- Remove duplicate function definitions with hardcoded "AI-оператор" strings (ai_voice_runtime, ai_orchestrator, voice_name_config, voice.py) - Remove unreachable dead code after return in ai_voice_runtime - Add SQL LIMIT to 17 unbounded queries across 12 services to prevent OOM - Move Python-side filtering to SQL WHERE in reporting_service - Downgrade 19 logger.warning to logger.info for normal-flow events in media_runtime Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1553 lines
59 KiB
Python
1553 lines
59 KiB
Python
from __future__ import annotations
|
|
|
|
from collections import Counter
|
|
import csv
|
|
from datetime import datetime, timedelta, timezone
|
|
import io
|
|
import json
|
|
import threading
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
from fastapi import Depends, FastAPI, HTTPException, Query
|
|
from fastapi.responses import PlainTextResponse
|
|
from sqlalchemy import inspect, select, text
|
|
|
|
from services.shared.core import Role, utc_now_iso
|
|
from services.shared.db import engine, get_session
|
|
from services.shared.event_bus import (
|
|
consume_one_message,
|
|
consumer_enabled,
|
|
event_bus_enabled,
|
|
event_bus_reporting_queue,
|
|
inbox_seen,
|
|
poll_forever,
|
|
record_inbox,
|
|
)
|
|
from services.shared.models import (
|
|
HealthResponse,
|
|
KpiEventIn,
|
|
ReportingAgentAnalyticsBreakdownsOut,
|
|
ReportingDrilldownFiltersOut,
|
|
ReportingDrilldownItemOut,
|
|
ReportingDrilldownOut,
|
|
ReportingAgentAnalyticsFiltersOut,
|
|
ReportingAgentAnalyticsOverviewOut,
|
|
ReportingAgentAnalyticsRowOut,
|
|
ReportingAgentAnalyticsShiftRowOut,
|
|
ReportingAgentAnalyticsTeamRowOut,
|
|
ReportingAgentAnalyticsTimeseriesOut,
|
|
ReportingAgentAnalyticsTrendFiltersOut,
|
|
ReportingAgentAnalyticsTrendPointOut,
|
|
ReportingAgentAnalyticsTotalsOut,
|
|
ReportingInteractionFactIn,
|
|
ReportingKpiCoverageOut,
|
|
ReportingAgentStateSnapshotOut,
|
|
ReportingMetricCoverageOut,
|
|
ReportingSavedViewIn,
|
|
ReportingSavedViewOut,
|
|
ReportingSavedViewSnapshot,
|
|
ReportingTimeseriesFiltersOut,
|
|
ReportingTimeseriesOut,
|
|
ReportingTimeseriesPointOut,
|
|
)
|
|
from services.shared.reporting_facts import upsert_reporting_interaction_fact
|
|
from services.shared.security import require_roles
|
|
from services.shared.sql_init import init_sql_schema
|
|
from services.shared.sql_models import (
|
|
Interaction,
|
|
ReportingEventLogRow,
|
|
ReportingEventRow,
|
|
ReportingInteractionFactRow,
|
|
ReportingSavedViewRow,
|
|
SupervisorAgentStateRow,
|
|
)
|
|
|
|
app = FastAPI(title="reporting-service", version="1.2.0")
|
|
|
|
_DRILLDOWN_METRICS = {"total", "answered", "SL", "ASA", "AHT", "Abandon", "FCR", "DigitalShare"}
|
|
_TIMESERIES_METRICS = {
|
|
"volume",
|
|
"total",
|
|
"answered",
|
|
"SL",
|
|
"ASA",
|
|
"AHT",
|
|
"Abandon",
|
|
"FCR",
|
|
"AnswerRate",
|
|
"WaitP95",
|
|
"HandleP95",
|
|
"Occupancy",
|
|
"DigitalShare",
|
|
}
|
|
_AGENT_SORT_FIELDS = {
|
|
"interactions_total",
|
|
"answered_total",
|
|
"avg_handle_seconds",
|
|
"fcr_rate",
|
|
"last_activity_at",
|
|
"agent_id",
|
|
}
|
|
_AGENT_TREND_METRICS = {"agents_with_activity", "interactions_per_agent", "avg_handle_seconds", "fcr_rate"}
|
|
_SORT_DIRECTIONS = {"asc", "desc"}
|
|
_VOICE_FIRST_METRICS = {"answered", "SL", "ASA", "AHT", "Abandon", "FCR", "AnswerRate", "WaitP95", "HandleP95", "Occupancy"}
|
|
_METRIC_SUPPORTED_CHANNELS = {
|
|
"total": ["voice", "telegram", "whatsapp", "webchat", "email"],
|
|
"answered": ["voice"],
|
|
"SL": ["voice"],
|
|
"ASA": ["voice"],
|
|
"AHT": ["voice"],
|
|
"Abandon": ["voice"],
|
|
"FCR": ["voice"],
|
|
"AnswerRate": ["voice"],
|
|
"WaitP95": ["voice"],
|
|
"HandleP95": ["voice"],
|
|
"Occupancy": ["voice"],
|
|
"DigitalShare": ["voice", "telegram", "whatsapp", "webchat", "email"],
|
|
}
|
|
_AGENT_SHIFT_LABELS = {
|
|
"night": "Ночная смена",
|
|
"day": "Дневная смена",
|
|
"evening": "Вечерняя смена",
|
|
}
|
|
|
|
|
|
def _ensure_reporting_columns() -> None:
|
|
init_sql_schema()
|
|
inspector = inspect(engine)
|
|
if "reporting_events" not in inspector.get_table_names():
|
|
return
|
|
|
|
columns = {item["name"] for item in inspector.get_columns("reporting_events")}
|
|
dialect = engine.url.get_backend_name()
|
|
|
|
statements: list[str] = []
|
|
if "channel" not in columns:
|
|
if dialect == "postgresql":
|
|
statements.append("ALTER TABLE reporting_events ADD COLUMN channel VARCHAR(32) NOT NULL DEFAULT 'voice'")
|
|
else:
|
|
statements.append("ALTER TABLE reporting_events ADD COLUMN channel TEXT NOT NULL DEFAULT 'voice'")
|
|
statements.append("CREATE INDEX IF NOT EXISTS ix_reporting_events_channel ON reporting_events (channel)")
|
|
if "agent_id" not in columns:
|
|
if dialect == "postgresql":
|
|
statements.append("ALTER TABLE reporting_events ADD COLUMN agent_id VARCHAR(128) NULL")
|
|
else:
|
|
statements.append("ALTER TABLE reporting_events ADD COLUMN agent_id TEXT NULL")
|
|
statements.append("CREATE INDEX IF NOT EXISTS ix_reporting_events_agent_id ON reporting_events (agent_id)")
|
|
|
|
if not statements:
|
|
return
|
|
|
|
with engine.begin() as conn:
|
|
for stmt in statements:
|
|
conn.execute(text(stmt))
|
|
|
|
|
|
_ensure_reporting_columns()
|
|
|
|
|
|
def _parse_filter_timestamp(raw: str | None, field_name: str) -> datetime | None:
|
|
if not raw:
|
|
return None
|
|
normalized = raw.strip()
|
|
if normalized.endswith("Z"):
|
|
normalized = f"{normalized[:-1]}+00:00"
|
|
try:
|
|
value = datetime.fromisoformat(normalized)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=f"Invalid {field_name}") from exc
|
|
if value.tzinfo is None:
|
|
value = value.replace(tzinfo=timezone.utc)
|
|
return value.astimezone(timezone.utc)
|
|
|
|
|
|
def _normalize_window(from_ts: str | None, to_ts: str | None) -> tuple[datetime | None, datetime | None]:
|
|
dt_from = _parse_filter_timestamp(from_ts, "from_ts")
|
|
dt_to = _parse_filter_timestamp(to_ts, "to_ts")
|
|
if dt_from and dt_to and dt_to <= dt_from:
|
|
raise HTTPException(status_code=400, detail="to_ts must be greater than from_ts")
|
|
return dt_from, dt_to
|
|
|
|
|
|
def _in_range(ts: str | None, dt_from: datetime | None, dt_to: datetime | None) -> bool:
|
|
if not ts:
|
|
return False
|
|
try:
|
|
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
except ValueError:
|
|
return False
|
|
if dt_from and dt < dt_from:
|
|
return False
|
|
if dt_to and dt >= dt_to:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _percentile(values: list[int], pct: float) -> float:
|
|
if not values:
|
|
return 0.0
|
|
ordered = sorted(values)
|
|
if len(ordered) == 1:
|
|
return float(ordered[0])
|
|
rank = (len(ordered) - 1) * pct
|
|
lower = int(rank)
|
|
upper = min(lower + 1, len(ordered) - 1)
|
|
weight = rank - lower
|
|
return float(ordered[lower] + (ordered[upper] - ordered[lower]) * weight)
|
|
|
|
|
|
def _safe_percent(numerator: int | float, denominator: int | float) -> float:
|
|
if not denominator:
|
|
return 0.0
|
|
return float(numerator) / float(denominator) * 100.0
|
|
|
|
|
|
def _serialize_legacy_row(row: ReportingEventRow) -> dict[str, Any]:
|
|
return {
|
|
"queue_id": row.queue_id,
|
|
"channel": row.channel,
|
|
"agent_id": row.agent_id,
|
|
"answered": row.answered,
|
|
"wait_seconds": row.wait_seconds,
|
|
"handle_seconds": row.handle_seconds,
|
|
"abandoned": row.abandoned,
|
|
"resolved_first_contact": row.resolved_first_contact,
|
|
"created_at": row.created_at,
|
|
}
|
|
|
|
|
|
def _serialize_fact_row(row: ReportingInteractionFactRow) -> dict[str, Any]:
|
|
return {
|
|
"interaction_id": row.interaction_id,
|
|
"channel": row.channel or "voice",
|
|
"queue_id": row.queue_id,
|
|
"agent_id": row.agent_id,
|
|
"status": row.status,
|
|
"created_at": row.created_at,
|
|
"updated_at": row.updated_at,
|
|
"closed_at": row.closed_at,
|
|
"answered": row.answered,
|
|
"abandoned": row.abandoned,
|
|
"wait_seconds": row.wait_seconds,
|
|
"handle_seconds": row.handle_seconds,
|
|
"resolved_first_contact": row.resolved_first_contact,
|
|
"source": row.source,
|
|
}
|
|
|
|
|
|
def _load_fact_records(
|
|
session,
|
|
*,
|
|
dt_from: datetime | None,
|
|
dt_to: datetime | None,
|
|
queue_id: str | None,
|
|
channel: str | None,
|
|
) -> list[dict[str, Any]]:
|
|
stmt = select(ReportingInteractionFactRow).order_by(ReportingInteractionFactRow.id.asc())
|
|
if queue_id:
|
|
stmt = stmt.where(ReportingInteractionFactRow.queue_id == queue_id)
|
|
if channel:
|
|
stmt = stmt.where(ReportingInteractionFactRow.channel == channel)
|
|
if dt_from:
|
|
stmt = stmt.where(ReportingInteractionFactRow.created_at >= dt_from.isoformat())
|
|
if dt_to:
|
|
stmt = stmt.where(ReportingInteractionFactRow.created_at < dt_to.isoformat())
|
|
rows = session.execute(stmt).scalars().all()
|
|
return [_serialize_fact_row(row) for row in rows]
|
|
|
|
|
|
def _load_interactions_map(session, interaction_ids: list[str]) -> dict[str, Interaction]:
|
|
ids = [item for item in interaction_ids if item]
|
|
if not ids:
|
|
return {}
|
|
rows = session.execute(
|
|
select(Interaction).where(Interaction.interaction_id.in_(ids))
|
|
).scalars().all()
|
|
return {row.interaction_id: row for row in rows}
|
|
|
|
|
|
def _supported_channels(metric: str) -> list[str]:
|
|
return list(_METRIC_SUPPORTED_CHANNELS.get(metric, ["voice"]))
|
|
|
|
|
|
def _metric_known(row: dict[str, Any], metric: str, sl_threshold_seconds: int) -> bool:
|
|
if metric in {"total", "DigitalShare"}:
|
|
return True
|
|
answered = row.get("answered")
|
|
abandoned = row.get("abandoned")
|
|
wait_seconds = row.get("wait_seconds")
|
|
handle_seconds = row.get("handle_seconds")
|
|
resolved_first_contact = row.get("resolved_first_contact")
|
|
if metric in {"answered", "AnswerRate"}:
|
|
return answered is not None
|
|
if metric == "Abandon":
|
|
return abandoned is not None
|
|
if metric == "SL":
|
|
return answered is not None and (answered is False or wait_seconds is not None)
|
|
if metric in {"ASA", "WaitP95"}:
|
|
return answered is not None and (answered is False or wait_seconds is not None)
|
|
if metric in {"AHT", "HandleP95"}:
|
|
return answered is not None and (answered is False or handle_seconds is not None)
|
|
if metric == "Occupancy":
|
|
return answered is not None and (answered is False or (wait_seconds is not None and handle_seconds is not None))
|
|
if metric == "FCR":
|
|
return answered is not None and (answered is False or resolved_first_contact is not None)
|
|
return False
|
|
|
|
|
|
def _metric_coverage(metric: str, records: list[dict[str, Any]], sl_threshold_seconds: int) -> ReportingMetricCoverageOut:
|
|
supported_channels = _supported_channels(metric)
|
|
total_rows = len(records)
|
|
supported_rows = [row for row in records if row["channel"] in supported_channels]
|
|
known_rows = [row for row in supported_rows if _metric_known(row, metric, sl_threshold_seconds)]
|
|
note: str | None = None
|
|
status = "unavailable"
|
|
|
|
if total_rows == 0:
|
|
note = "No exact fact rows found for the selected window."
|
|
elif not supported_rows:
|
|
note = "Exact KPI in V5 is not available for the selected channels yet."
|
|
elif len(known_rows) == total_rows and len(supported_rows) == total_rows:
|
|
status = "exact"
|
|
note = "Exact interaction-linked fact coverage is available for this metric."
|
|
elif known_rows:
|
|
status = "partial"
|
|
if len(supported_rows) < total_rows:
|
|
note = "The selected window includes channels outside the exact V5 scope."
|
|
else:
|
|
note = f"Exact facts are available for {len(known_rows)} of {len(supported_rows)} supported interactions."
|
|
else:
|
|
note = "Exact fact rows for this metric are not available yet in the selected window."
|
|
|
|
return ReportingMetricCoverageOut(
|
|
status=status, # type: ignore[arg-type]
|
|
supported_channels=supported_channels,
|
|
exact_rows=len(known_rows),
|
|
total_rows=total_rows,
|
|
note=note,
|
|
)
|
|
|
|
|
|
def _known_rows_for_metric(metric: str, records: list[dict[str, Any]], sl_threshold_seconds: int) -> list[dict[str, Any]]:
|
|
supported_channels = _supported_channels(metric)
|
|
return [
|
|
row
|
|
for row in records
|
|
if row["channel"] in supported_channels and _metric_known(row, metric, sl_threshold_seconds)
|
|
]
|
|
|
|
|
|
def _metric_slice(metric: str, records: list[dict[str, Any]], sl_threshold_seconds: int) -> list[dict[str, Any]]:
|
|
known_rows = _known_rows_for_metric(metric, records, sl_threshold_seconds)
|
|
if metric == "total":
|
|
return known_rows
|
|
if metric == "answered":
|
|
return [row for row in known_rows if row.get("answered") is True]
|
|
if metric == "SL":
|
|
return [
|
|
row
|
|
for row in known_rows
|
|
if row.get("answered") is True and row.get("wait_seconds") is not None and int(row["wait_seconds"]) <= sl_threshold_seconds
|
|
]
|
|
if metric == "ASA":
|
|
return [
|
|
row
|
|
for row in known_rows
|
|
if row.get("answered") is True and row.get("wait_seconds") is not None
|
|
]
|
|
if metric == "AHT":
|
|
return [
|
|
row
|
|
for row in known_rows
|
|
if row.get("answered") is True and row.get("handle_seconds") is not None
|
|
]
|
|
if metric == "Abandon":
|
|
return [row for row in known_rows if row.get("abandoned") is True]
|
|
if metric == "FCR":
|
|
return [
|
|
row
|
|
for row in known_rows
|
|
if row.get("answered") is True and row.get("resolved_first_contact") is True
|
|
]
|
|
if metric == "DigitalShare":
|
|
return [row for row in known_rows if row.get("channel") != "voice"]
|
|
return []
|
|
|
|
|
|
def _build_channel_breakdown(records: list[dict[str, Any]]) -> dict[str, dict[str, int]]:
|
|
bucket: dict[str, dict[str, int]] = {}
|
|
for item in records:
|
|
key = item.get("channel") or "voice"
|
|
current = bucket.setdefault(key, {"total": 0, "answered": 0, "abandoned": 0})
|
|
current["total"] += 1
|
|
current["answered"] += 1 if item.get("answered") is True else 0
|
|
current["abandoned"] += 1 if item.get("abandoned") is True else 0
|
|
return bucket
|
|
|
|
|
|
def _metric_catalog() -> list[dict[str, Any]]:
|
|
return [
|
|
{"code": "SL", "label": "Service Level", "formula": "answered_within_threshold / total * 100", "status": "implemented"},
|
|
{"code": "ASA", "label": "Average Speed of Answer", "formula": "sum(wait_seconds for answered) / answered", "status": "implemented"},
|
|
{"code": "AHT", "label": "Average Handle Time", "formula": "sum(handle_seconds for answered) / answered", "status": "implemented"},
|
|
{"code": "Abandon", "label": "Abandon Rate", "formula": "abandoned / total * 100", "status": "implemented"},
|
|
{"code": "FCR", "label": "First Contact Resolution", "formula": "resolved_first_contact / answered * 100", "status": "implemented"},
|
|
{"code": "AnswerRate", "label": "Answer Rate", "formula": "answered / total * 100", "status": "implemented"},
|
|
{"code": "WaitP95", "label": "Wait Time P95", "formula": "p95(wait_seconds for answered)", "status": "implemented"},
|
|
{"code": "HandleP95", "label": "Handle Time P95", "formula": "p95(handle_seconds for answered)", "status": "implemented"},
|
|
{"code": "Occupancy", "label": "Occupancy", "formula": "sum(handle_seconds) / (sum(handle_seconds) + sum(wait_seconds)) * 100", "status": "implemented"},
|
|
{"code": "DigitalShare", "label": "Digital Share", "formula": "non_voice / total * 100", "status": "implemented"},
|
|
]
|
|
|
|
|
|
def _coverage_snapshot(records: list[dict[str, Any]], sl_threshold_seconds: int) -> ReportingKpiCoverageOut:
|
|
metrics = ["total", "answered", "SL", "ASA", "AHT", "Abandon", "FCR", "AnswerRate", "WaitP95", "HandleP95", "Occupancy", "DigitalShare"]
|
|
metric_details = {
|
|
metric: _metric_coverage(metric, records, sl_threshold_seconds)
|
|
for metric in metrics
|
|
}
|
|
total_detail = metric_details["total"]
|
|
return ReportingKpiCoverageOut(
|
|
metric_status={metric: detail.status for metric, detail in metric_details.items()},
|
|
supported_channels={metric: detail.supported_channels for metric, detail in metric_details.items()},
|
|
exact_rows=total_detail.exact_rows,
|
|
total_rows=total_detail.total_rows,
|
|
note=total_detail.note,
|
|
metric_details=metric_details,
|
|
)
|
|
|
|
|
|
def _metric_note(metric: str) -> str | None:
|
|
if metric in _VOICE_FIRST_METRICS:
|
|
return "Exact V5 coverage is currently voice-first. Digital channels stay partial until they emit the same fact fields."
|
|
if metric == "DigitalShare":
|
|
return "DigitalShare is exact for any interaction with known channel in the selected window."
|
|
if metric == "total":
|
|
return "Total interactions use exact interaction-linked facts across all currently instrumented channels."
|
|
return None
|
|
|
|
|
|
def _build_kpi_from_facts(records: list[dict[str, Any]], sl_threshold_seconds: int, *, from_ts: str | None, to_ts: str | None, queue_id: str | None, channel: str | None) -> dict[str, Any]:
|
|
coverage = _coverage_snapshot(records, sl_threshold_seconds)
|
|
total_rows = _metric_slice("total", records, sl_threshold_seconds)
|
|
answered_known_rows = _known_rows_for_metric("answered", records, sl_threshold_seconds)
|
|
abandon_known_rows = _known_rows_for_metric("Abandon", records, sl_threshold_seconds)
|
|
sl_known_rows = _known_rows_for_metric("SL", records, sl_threshold_seconds)
|
|
asa_rows = _metric_slice("ASA", records, sl_threshold_seconds)
|
|
aht_rows = _metric_slice("AHT", records, sl_threshold_seconds)
|
|
fcr_known_rows = _known_rows_for_metric("FCR", records, sl_threshold_seconds)
|
|
digital_rows = _metric_slice("DigitalShare", records, sl_threshold_seconds)
|
|
wait_values = [int(row["wait_seconds"]) for row in asa_rows if row.get("wait_seconds") is not None]
|
|
handle_values = [int(row["handle_seconds"]) for row in aht_rows if row.get("handle_seconds") is not None]
|
|
answered_total = sum(1 for row in answered_known_rows if row.get("answered") is True)
|
|
abandoned_total = sum(1 for row in abandon_known_rows if row.get("abandoned") is True)
|
|
sl_hits = sum(
|
|
1
|
|
for row in sl_known_rows
|
|
if row.get("answered") is True and row.get("wait_seconds") is not None and int(row["wait_seconds"]) <= sl_threshold_seconds
|
|
)
|
|
fcr_hits = sum(
|
|
1
|
|
for row in fcr_known_rows
|
|
if row.get("answered") is True and row.get("resolved_first_contact") is True
|
|
)
|
|
occupancy = (
|
|
(sum(handle_values) / (sum(handle_values) + sum(wait_values)) * 100.0)
|
|
if (sum(handle_values) + sum(wait_values))
|
|
else 0.0
|
|
)
|
|
return {
|
|
"window": {"from": from_ts, "to": to_ts},
|
|
"queue_id": queue_id,
|
|
"channel": channel,
|
|
"volume": {
|
|
"total": len(total_rows),
|
|
"answered": answered_total,
|
|
"abandoned": abandoned_total,
|
|
},
|
|
"kpi": {
|
|
"SL": round(_safe_percent(sl_hits, len(sl_known_rows)), 2),
|
|
"ASA": round(sum(wait_values) / len(wait_values), 2) if wait_values else 0.0,
|
|
"AHT": round(sum(handle_values) / len(handle_values), 2) if handle_values else 0.0,
|
|
"Abandon": round(_safe_percent(abandoned_total, len(abandon_known_rows)), 2),
|
|
"FCR": round(_safe_percent(fcr_hits, sum(1 for row in fcr_known_rows if row.get("answered") is True)), 2),
|
|
"AnswerRate": round(_safe_percent(answered_total, len(answered_known_rows)), 2),
|
|
"WaitP95": round(_percentile(wait_values, 0.95), 2),
|
|
"HandleP95": round(_percentile(handle_values, 0.95), 2),
|
|
"Occupancy": round(occupancy, 2),
|
|
"DigitalShare": round(_safe_percent(len(digital_rows), len(total_rows)), 2),
|
|
},
|
|
"breakdowns": {
|
|
"by_channel": _build_channel_breakdown(records),
|
|
},
|
|
"filters": {
|
|
"queue_id": queue_id,
|
|
"channel": channel,
|
|
"sl_threshold_seconds": sl_threshold_seconds,
|
|
},
|
|
"coverage": coverage.model_dump(),
|
|
}
|
|
|
|
|
|
def _compose_drilldown_item(
|
|
row: dict[str, Any],
|
|
interaction: Interaction | None,
|
|
*,
|
|
sl_threshold_seconds: int,
|
|
) -> ReportingDrilldownItemOut:
|
|
interaction_id = row["interaction_id"]
|
|
created_at = row.get("created_at") or (interaction.created_at if interaction else utc_now_iso())
|
|
updated_at = (
|
|
(interaction.updated_at if interaction else None)
|
|
or row.get("closed_at")
|
|
or row.get("updated_at")
|
|
or created_at
|
|
)
|
|
wait_seconds = row.get("wait_seconds")
|
|
return ReportingDrilldownItemOut(
|
|
interaction_id=interaction_id,
|
|
channel=(row.get("channel") or (interaction.channel if interaction else "voice") or "voice"), # type: ignore[arg-type]
|
|
subject=(interaction.subject if interaction else f"Interaction {interaction_id}"),
|
|
customer_id=(interaction.customer_id if interaction else None),
|
|
queue_id=(row.get("queue_id") or (interaction.queue_id if interaction else None)),
|
|
priority=(interaction.priority if interaction else 3),
|
|
status=(row.get("status") or (interaction.status if interaction else "new") or "new"), # type: ignore[arg-type]
|
|
assigned_to=(row.get("agent_id") or (interaction.assigned_to if interaction else None)),
|
|
created_at=created_at,
|
|
updated_at=updated_at,
|
|
answered=row.get("answered"),
|
|
abandoned=row.get("abandoned"),
|
|
wait_seconds=wait_seconds,
|
|
handle_seconds=row.get("handle_seconds"),
|
|
within_sla=(wait_seconds is not None and int(wait_seconds) <= sl_threshold_seconds) if row.get("answered") is True else None,
|
|
resolved_first_contact=row.get("resolved_first_contact"),
|
|
)
|
|
|
|
|
|
def _timeseries_sample_size(metric: str, records: list[dict[str, Any]], sl_threshold_seconds: int) -> int:
|
|
if metric in {"volume", "total", "DigitalShare"}:
|
|
return len(_metric_slice("total" if metric != "DigitalShare" else "DigitalShare", records, sl_threshold_seconds))
|
|
if metric == "answered":
|
|
return len(_metric_slice("answered", records, sl_threshold_seconds))
|
|
if metric == "SL":
|
|
return len(_known_rows_for_metric("SL", records, sl_threshold_seconds))
|
|
if metric == "ASA":
|
|
return len(_metric_slice("ASA", records, sl_threshold_seconds))
|
|
if metric == "AHT":
|
|
return len(_metric_slice("AHT", records, sl_threshold_seconds))
|
|
if metric == "Abandon":
|
|
return len(_known_rows_for_metric("Abandon", records, sl_threshold_seconds))
|
|
if metric == "FCR":
|
|
return sum(
|
|
1 for row in _known_rows_for_metric("FCR", records, sl_threshold_seconds) if row.get("answered") is True
|
|
)
|
|
if metric == "AnswerRate":
|
|
return len(_known_rows_for_metric("answered", records, sl_threshold_seconds))
|
|
if metric == "WaitP95":
|
|
return len(_metric_slice("ASA", records, sl_threshold_seconds))
|
|
if metric == "HandleP95":
|
|
return len(_metric_slice("AHT", records, sl_threshold_seconds))
|
|
if metric == "Occupancy":
|
|
return len(_known_rows_for_metric("Occupancy", records, sl_threshold_seconds))
|
|
return len(records)
|
|
|
|
|
|
def _timeseries_metric_value(metric: str, records: list[dict[str, Any]], sl_threshold_seconds: int) -> float:
|
|
envelope = _build_kpi_from_facts(
|
|
records,
|
|
sl_threshold_seconds,
|
|
from_ts=None,
|
|
to_ts=None,
|
|
queue_id=None,
|
|
channel=None,
|
|
)
|
|
if metric in {"volume", "total"}:
|
|
return float(envelope["volume"]["total"])
|
|
if metric == "answered":
|
|
return float(envelope["volume"]["answered"])
|
|
return float(envelope["kpi"].get(metric, 0.0))
|
|
|
|
|
|
def _safe_average(values: list[int]) -> float | None:
|
|
if not values:
|
|
return None
|
|
return round(sum(values) / len(values), 2)
|
|
|
|
|
|
def _normalize_agent_sort(sort_by: str | None, sort_dir: str | None) -> tuple[str, str]:
|
|
normalized_sort_by = (sort_by or "interactions_total").strip() or "interactions_total"
|
|
normalized_sort_dir = (sort_dir or "desc").strip().lower() or "desc"
|
|
if normalized_sort_by not in _AGENT_SORT_FIELDS:
|
|
raise HTTPException(status_code=400, detail="Unsupported agent sort")
|
|
if normalized_sort_dir not in _SORT_DIRECTIONS:
|
|
raise HTTPException(status_code=400, detail="Unsupported sort_dir")
|
|
return normalized_sort_by, normalized_sort_dir
|
|
|
|
|
|
def _load_agent_state_rows(session, queue_id: str | None = None) -> list[SupervisorAgentStateRow]:
|
|
rows = session.execute(
|
|
select(SupervisorAgentStateRow).order_by(SupervisorAgentStateRow.id.asc())
|
|
).scalars().all()
|
|
if queue_id:
|
|
rows = [row for row in rows if row.queue_id == queue_id]
|
|
return rows
|
|
|
|
|
|
def _agent_last_activity(records: list[dict[str, Any]], state_row: SupervisorAgentStateRow | None) -> str | None:
|
|
candidates = [
|
|
row.get("updated_at") or row.get("closed_at") or row.get("created_at")
|
|
for row in records
|
|
if row.get("updated_at") or row.get("closed_at") or row.get("created_at")
|
|
]
|
|
if state_row and state_row.updated_at:
|
|
candidates.append(state_row.updated_at)
|
|
return max(candidates) if candidates else None
|
|
|
|
|
|
def _build_agent_row(
|
|
agent_id: str,
|
|
records: list[dict[str, Any]],
|
|
state_row: SupervisorAgentStateRow | None,
|
|
) -> ReportingAgentAnalyticsRowOut:
|
|
answered_total = sum(1 for row in records if row.get("answered") is True)
|
|
closed_total = sum(1 for row in records if row.get("status") == "closed")
|
|
abandoned_total = sum(1 for row in records if row.get("abandoned") is True)
|
|
wait_values = [int(row["wait_seconds"]) for row in records if row.get("answered") is True and row.get("wait_seconds") is not None]
|
|
handle_values = [int(row["handle_seconds"]) for row in records if row.get("answered") is True and row.get("handle_seconds") is not None]
|
|
fcr_known_rows = [
|
|
row
|
|
for row in records
|
|
if row.get("answered") is True and row.get("resolved_first_contact") is not None
|
|
]
|
|
fcr_hits = sum(1 for row in fcr_known_rows if row.get("resolved_first_contact") is True)
|
|
queue_counter = Counter(row.get("queue_id") for row in records if row.get("queue_id"))
|
|
dominant_queue_id = queue_counter.most_common(1)[0][0] if queue_counter else (state_row.queue_id if state_row else None)
|
|
channels = sorted({str(row.get("channel") or "voice") for row in records if row.get("channel")})
|
|
return ReportingAgentAnalyticsRowOut(
|
|
agent_id=agent_id,
|
|
current_state=(state_row.state if state_row else None),
|
|
current_queue_id=(state_row.queue_id if state_row else None),
|
|
current_state_updated_at=(state_row.updated_at if state_row else None),
|
|
dominant_queue_id=dominant_queue_id,
|
|
last_activity_at=_agent_last_activity(records, state_row),
|
|
interactions_total=len(records),
|
|
answered_total=answered_total,
|
|
closed_total=closed_total,
|
|
abandoned_total=abandoned_total,
|
|
avg_wait_seconds=_safe_average(wait_values),
|
|
avg_handle_seconds=_safe_average(handle_values),
|
|
answer_rate=round(_safe_percent(answered_total, len(records)), 2),
|
|
fcr_rate=round(_safe_percent(fcr_hits, len(fcr_known_rows)), 2) if fcr_known_rows else None,
|
|
channels=channels,
|
|
)
|
|
|
|
|
|
def _agent_sort_key(row: ReportingAgentAnalyticsRowOut, sort_by: str) -> tuple[Any, str]:
|
|
if sort_by == "agent_id":
|
|
return (row.agent_id.lower(), row.agent_id.lower())
|
|
if sort_by == "last_activity_at":
|
|
return (row.last_activity_at or "", row.agent_id.lower())
|
|
if sort_by == "avg_handle_seconds":
|
|
return ((row.avg_handle_seconds if row.avg_handle_seconds is not None else -1.0), row.agent_id.lower())
|
|
if sort_by == "fcr_rate":
|
|
return ((row.fcr_rate if row.fcr_rate is not None else -1.0), row.agent_id.lower())
|
|
return (getattr(row, sort_by, 0) or 0, row.agent_id.lower())
|
|
|
|
|
|
def _agent_team_key(row: ReportingAgentAnalyticsRowOut) -> str:
|
|
return row.dominant_queue_id or row.current_queue_id or "unassigned"
|
|
|
|
|
|
def _agent_team_label(team_key: str) -> str:
|
|
return "Без очереди" if team_key == "unassigned" else team_key
|
|
|
|
|
|
def _shift_key_for_timestamp(ts: str | None) -> str | None:
|
|
if not ts:
|
|
return None
|
|
try:
|
|
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
except ValueError:
|
|
return None
|
|
hour = dt.hour
|
|
if hour < 8:
|
|
return "night"
|
|
if hour < 16:
|
|
return "day"
|
|
return "evening"
|
|
|
|
|
|
def _shift_label(shift_key: str) -> str:
|
|
return _AGENT_SHIFT_LABELS.get(shift_key, shift_key)
|
|
|
|
|
|
def _normalize_agent_trend_metric(metric: str | None) -> str:
|
|
normalized = (metric or "interactions_per_agent").strip() or "interactions_per_agent"
|
|
if normalized not in _AGENT_TREND_METRICS:
|
|
raise HTTPException(status_code=400, detail="Unsupported agent metric")
|
|
return normalized
|
|
|
|
|
|
def _agent_interval_for_window(dt_from: datetime, dt_to: datetime, requested: str | None) -> str:
|
|
if requested in {"hour", "day"}:
|
|
return requested
|
|
duration = max((dt_to - dt_from).total_seconds(), 0)
|
|
return "hour" if duration <= 36 * 60 * 60 else "day"
|
|
|
|
|
|
def _bucket_start(dt: datetime, interval: str) -> datetime:
|
|
if interval == "hour":
|
|
return dt.replace(minute=0, second=0, microsecond=0)
|
|
return dt.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
|
|
|
|
def _next_bucket(dt: datetime, interval: str) -> datetime:
|
|
return dt + (timedelta(hours=1) if interval == "hour" else timedelta(days=1))
|
|
|
|
|
|
def _build_agent_breakdowns(
|
|
rows: list[ReportingAgentAnalyticsRowOut],
|
|
records_by_agent: dict[str, list[dict[str, Any]]],
|
|
state_by_agent: dict[str, SupervisorAgentStateRow],
|
|
) -> ReportingAgentAnalyticsBreakdownsOut:
|
|
team_agents: dict[str, list[ReportingAgentAnalyticsRowOut]] = {}
|
|
team_records: dict[str, list[dict[str, Any]]] = {}
|
|
team_states: dict[str, Counter[str]] = {}
|
|
for row in rows:
|
|
team_key = _agent_team_key(row)
|
|
team_agents.setdefault(team_key, []).append(row)
|
|
team_records.setdefault(team_key, []).extend(records_by_agent.get(row.agent_id, []))
|
|
team_states.setdefault(team_key, Counter())
|
|
state_value = (state_by_agent.get(row.agent_id).state if row.agent_id in state_by_agent else "OFFLINE") or "OFFLINE"
|
|
team_states[team_key][state_value] += 1
|
|
|
|
team_rows: list[ReportingAgentAnalyticsTeamRowOut] = []
|
|
for team_key, agent_rows in team_agents.items():
|
|
records = team_records.get(team_key, [])
|
|
answered_total = sum(1 for row in records if row.get("answered") is True)
|
|
handle_values = [
|
|
int(row["handle_seconds"])
|
|
for row in records
|
|
if row.get("answered") is True and row.get("handle_seconds") is not None
|
|
]
|
|
fcr_known_rows = [
|
|
row
|
|
for row in records
|
|
if row.get("answered") is True and row.get("resolved_first_contact") is not None
|
|
]
|
|
fcr_hits = sum(1 for row in fcr_known_rows if row.get("resolved_first_contact") is True)
|
|
states = team_states.get(team_key, Counter())
|
|
team_rows.append(
|
|
ReportingAgentAnalyticsTeamRowOut(
|
|
team_key=team_key,
|
|
label=_agent_team_label(team_key),
|
|
agents_total=len(agent_rows),
|
|
agents_with_activity=sum(1 for row in agent_rows if row.interactions_total > 0),
|
|
interactions_total=sum(row.interactions_total for row in agent_rows),
|
|
answered_total=answered_total,
|
|
avg_handle_seconds=_safe_average(handle_values),
|
|
fcr_rate=round(_safe_percent(fcr_hits, len(fcr_known_rows)), 2) if fcr_known_rows else None,
|
|
ready_now=int(states.get("READY", 0)),
|
|
busy_now=int(states.get("BUSY", 0)),
|
|
break_now=int(states.get("BREAK", 0)),
|
|
offline_now=int(states.get("OFFLINE", 0)),
|
|
)
|
|
)
|
|
team_rows.sort(key=lambda item: (item.interactions_total, item.agents_total, item.label.lower()), reverse=True)
|
|
|
|
shift_records: dict[str, list[dict[str, Any]]] = {"night": [], "day": [], "evening": []}
|
|
for agent_records in records_by_agent.values():
|
|
for row in agent_records:
|
|
shift_key = _shift_key_for_timestamp(row.get("created_at"))
|
|
if shift_key:
|
|
shift_records[shift_key].append(row)
|
|
|
|
shift_rows: list[ReportingAgentAnalyticsShiftRowOut] = []
|
|
for shift_key in ("night", "day", "evening"):
|
|
records = shift_records.get(shift_key, [])
|
|
answered_total = sum(1 for row in records if row.get("answered") is True)
|
|
handle_values = [
|
|
int(row["handle_seconds"])
|
|
for row in records
|
|
if row.get("answered") is True and row.get("handle_seconds") is not None
|
|
]
|
|
fcr_known_rows = [
|
|
row
|
|
for row in records
|
|
if row.get("answered") is True and row.get("resolved_first_contact") is not None
|
|
]
|
|
fcr_hits = sum(1 for row in fcr_known_rows if row.get("resolved_first_contact") is True)
|
|
shift_rows.append(
|
|
ReportingAgentAnalyticsShiftRowOut(
|
|
shift_key=shift_key, # type: ignore[arg-type]
|
|
label=_shift_label(shift_key),
|
|
agents_with_activity=len({str(row.get("agent_id")) for row in records if row.get("agent_id")}),
|
|
interactions_total=len(records),
|
|
answered_total=answered_total,
|
|
avg_handle_seconds=_safe_average(handle_values),
|
|
fcr_rate=round(_safe_percent(fcr_hits, len(fcr_known_rows)), 2) if fcr_known_rows else None,
|
|
)
|
|
)
|
|
|
|
return ReportingAgentAnalyticsBreakdownsOut(
|
|
by_team=team_rows,
|
|
by_shift=shift_rows,
|
|
)
|
|
|
|
|
|
def _build_agent_timeseries(
|
|
records: list[dict[str, Any]],
|
|
*,
|
|
from_ts: datetime,
|
|
to_ts: datetime,
|
|
queue_id: str | None,
|
|
channel: str | None,
|
|
metric: str,
|
|
interval: str,
|
|
) -> ReportingAgentAnalyticsTimeseriesOut:
|
|
points: list[ReportingAgentAnalyticsTrendPointOut] = []
|
|
bucket = _bucket_start(from_ts, interval)
|
|
if bucket < from_ts:
|
|
bucket = _next_bucket(bucket, interval)
|
|
while bucket < to_ts:
|
|
bucket_end = _next_bucket(bucket, interval)
|
|
bucket_rows = [
|
|
row for row in records
|
|
if _in_range(row.get("created_at"), bucket, bucket_end)
|
|
and row.get("agent_id")
|
|
]
|
|
active_agents = len({str(row.get("agent_id")) for row in bucket_rows if row.get("agent_id")})
|
|
handle_values = [
|
|
int(row["handle_seconds"])
|
|
for row in bucket_rows
|
|
if row.get("answered") is True and row.get("handle_seconds") is not None
|
|
]
|
|
fcr_known_rows = [
|
|
row
|
|
for row in bucket_rows
|
|
if row.get("answered") is True and row.get("resolved_first_contact") is not None
|
|
]
|
|
fcr_hits = sum(1 for row in fcr_known_rows if row.get("resolved_first_contact") is True)
|
|
if metric == "agents_with_activity":
|
|
value = float(active_agents)
|
|
elif metric == "avg_handle_seconds":
|
|
value = float(_safe_average(handle_values) or 0.0)
|
|
elif metric == "fcr_rate":
|
|
value = round(_safe_percent(fcr_hits, len(fcr_known_rows)), 2) if fcr_known_rows else 0.0
|
|
else:
|
|
value = round((len(bucket_rows) / active_agents), 2) if active_agents else 0.0
|
|
points.append(
|
|
ReportingAgentAnalyticsTrendPointOut(
|
|
ts=bucket.isoformat(),
|
|
value=value,
|
|
agents_with_activity=active_agents,
|
|
interactions_total=len(bucket_rows),
|
|
)
|
|
)
|
|
bucket = bucket_end
|
|
|
|
return ReportingAgentAnalyticsTimeseriesOut(
|
|
metric=metric, # type: ignore[arg-type]
|
|
interval=interval, # type: ignore[arg-type]
|
|
filters=ReportingAgentAnalyticsTrendFiltersOut(
|
|
from_ts=from_ts.isoformat(),
|
|
to_ts=to_ts.isoformat(),
|
|
queue_id=queue_id,
|
|
channel=channel,
|
|
metric=metric, # type: ignore[arg-type]
|
|
interval=interval, # type: ignore[arg-type]
|
|
),
|
|
points=points,
|
|
)
|
|
|
|
|
|
def _build_agent_analytics_overview(
|
|
records: list[dict[str, Any]],
|
|
state_rows: list[SupervisorAgentStateRow],
|
|
*,
|
|
from_ts: str,
|
|
to_ts: str,
|
|
queue_id: str | None,
|
|
channel: str | None,
|
|
sort_by: str,
|
|
sort_dir: str,
|
|
limit: int,
|
|
) -> ReportingAgentAnalyticsOverviewOut:
|
|
fact_records = [row for row in records if row.get("agent_id")]
|
|
grouped: dict[str, list[dict[str, Any]]] = {}
|
|
for row in fact_records:
|
|
grouped.setdefault(str(row.get("agent_id")), []).append(row)
|
|
|
|
state_by_agent = {row.agent_id: row for row in state_rows if row.agent_id}
|
|
included_agent_ids = set(grouped.keys())
|
|
if not channel:
|
|
included_agent_ids.update(state_by_agent.keys())
|
|
|
|
rows = [
|
|
_build_agent_row(agent_id, grouped.get(agent_id, []), state_by_agent.get(agent_id))
|
|
for agent_id in sorted(included_agent_ids)
|
|
]
|
|
rows.sort(
|
|
key=lambda row: _agent_sort_key(row, sort_by),
|
|
reverse=(sort_dir == "desc"),
|
|
)
|
|
breakdowns = _build_agent_breakdowns(rows, grouped, state_by_agent)
|
|
|
|
visible_rows = rows[:limit]
|
|
state_counts = Counter(
|
|
(state_by_agent[agent_id].state if agent_id in state_by_agent else "OFFLINE")
|
|
for agent_id in included_agent_ids
|
|
)
|
|
handle_values = [int(row["handle_seconds"]) for row in fact_records if row.get("answered") is True and row.get("handle_seconds") is not None]
|
|
fcr_known_rows = [
|
|
row
|
|
for row in fact_records
|
|
if row.get("answered") is True and row.get("resolved_first_contact") is not None
|
|
]
|
|
fcr_hits = sum(1 for row in fcr_known_rows if row.get("resolved_first_contact") is True)
|
|
latest_state_update = max((row.updated_at for row in state_rows if row.updated_at), default=None)
|
|
agents_with_activity = sum(1 for row in rows if row.interactions_total > 0)
|
|
|
|
return ReportingAgentAnalyticsOverviewOut(
|
|
window={"from_ts": from_ts, "to_ts": to_ts},
|
|
filters=ReportingAgentAnalyticsFiltersOut(
|
|
from_ts=from_ts,
|
|
to_ts=to_ts,
|
|
queue_id=queue_id,
|
|
channel=channel,
|
|
sort_by=sort_by, # type: ignore[arg-type]
|
|
sort_dir=sort_dir, # type: ignore[arg-type]
|
|
limit=limit,
|
|
),
|
|
totals=ReportingAgentAnalyticsTotalsOut(
|
|
agents_total=len(rows),
|
|
agents_with_activity=agents_with_activity,
|
|
interactions_total=sum(row.interactions_total for row in rows),
|
|
answered_total=sum(row.answered_total for row in rows),
|
|
closed_total=sum(row.closed_total for row in rows),
|
|
ready_now=int(state_counts.get("READY", 0)),
|
|
busy_now=int(state_counts.get("BUSY", 0)),
|
|
break_now=int(state_counts.get("BREAK", 0)),
|
|
offline_now=int(state_counts.get("OFFLINE", 0)),
|
|
avg_interactions_per_agent=round(
|
|
(sum(row.interactions_total for row in rows) / agents_with_activity),
|
|
2,
|
|
) if agents_with_activity else 0.0,
|
|
avg_handle_seconds=_safe_average(handle_values),
|
|
avg_fcr_rate=round(_safe_percent(fcr_hits, len(fcr_known_rows)), 2) if fcr_known_rows else None,
|
|
),
|
|
state_snapshot=ReportingAgentStateSnapshotOut(
|
|
by_state={key: int(value) for key, value in state_counts.items()},
|
|
updated_at=latest_state_update,
|
|
),
|
|
breakdowns=breakdowns,
|
|
items=visible_rows,
|
|
)
|
|
|
|
|
|
def _bucket_step(interval: str) -> timedelta:
|
|
if interval == "hour":
|
|
return timedelta(hours=1)
|
|
if interval == "day":
|
|
return timedelta(days=1)
|
|
raise HTTPException(status_code=400, detail="Unsupported interval")
|
|
|
|
|
|
def _serialize_saved_view(row: ReportingSavedViewRow) -> ReportingSavedViewOut:
|
|
try:
|
|
snapshot = ReportingSavedViewSnapshot.model_validate(json.loads(row.snapshot_json or "{}"))
|
|
except Exception:
|
|
snapshot = ReportingSavedViewSnapshot()
|
|
return ReportingSavedViewOut(
|
|
id=row.view_id,
|
|
name=row.name,
|
|
snapshot=snapshot,
|
|
created_at=row.created_at,
|
|
updated_at=row.updated_at,
|
|
)
|
|
|
|
|
|
def _csv_response(filename: str, headers: list[str], rows: list[dict[str, Any]]) -> PlainTextResponse:
|
|
buff = io.StringIO()
|
|
writer = csv.DictWriter(buff, fieldnames=headers)
|
|
writer.writeheader()
|
|
for row in rows:
|
|
writer.writerow({key: row.get(key, "") for key in headers})
|
|
return PlainTextResponse(
|
|
content=buff.getvalue(),
|
|
media_type="text/csv",
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
|
|
def _legacy_export_csv(queue_id: str | None = None, channel: str | None = None) -> PlainTextResponse:
|
|
headers = [
|
|
"queue_id",
|
|
"channel",
|
|
"agent_id",
|
|
"answered",
|
|
"wait_seconds",
|
|
"handle_seconds",
|
|
"abandoned",
|
|
"resolved_first_contact",
|
|
"created_at",
|
|
]
|
|
session = get_session()
|
|
try:
|
|
stmt = select(ReportingEventRow).order_by(ReportingEventRow.id.asc())
|
|
if queue_id:
|
|
stmt = stmt.where(ReportingEventRow.queue_id == queue_id)
|
|
if channel:
|
|
stmt = stmt.where(ReportingEventRow.channel == channel)
|
|
rows = [_serialize_legacy_row(row) for row in session.execute(stmt).scalars().all()]
|
|
return _csv_response("konturcc-reporting-legacy.csv", headers, rows)
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.get("/health", response_model=HealthResponse)
|
|
def health() -> HealthResponse:
|
|
return HealthResponse(status="ok", service="reporting-service", version="v1.2")
|
|
|
|
|
|
@app.post("/reports/events")
|
|
def ingest_event(payload: KpiEventIn) -> dict[str, Any]:
|
|
session = get_session()
|
|
try:
|
|
item = payload.model_dump()
|
|
row = ReportingEventRow(
|
|
queue_id=item["queue_id"],
|
|
channel=item.get("channel") or "voice",
|
|
agent_id=item.get("agent_id"),
|
|
answered=bool(item["answered"]),
|
|
wait_seconds=int(item["wait_seconds"]),
|
|
handle_seconds=int(item["handle_seconds"]),
|
|
abandoned=bool(item["abandoned"]),
|
|
resolved_first_contact=bool(item["resolved_first_contact"]),
|
|
created_at=item.get("created_at") or utc_now_iso(),
|
|
)
|
|
session.add(row)
|
|
session.commit()
|
|
count = session.execute(select(ReportingEventRow)).scalars().all()
|
|
return {"accepted": True, "count": len(count), "source": "legacy"}
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.post("/reports/facts/interactions")
|
|
def upsert_interaction_fact(
|
|
payload: ReportingInteractionFactIn,
|
|
_: dict = Depends(require_roles(Role.ADMIN)),
|
|
) -> dict[str, Any]:
|
|
session = get_session()
|
|
try:
|
|
row = upsert_reporting_interaction_fact(session, payload.model_dump(exclude_none=True))
|
|
session.commit()
|
|
return {"accepted": True, "interaction_id": row.interaction_id, "updated_at": row.updated_at}
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.get("/reports/kpi")
|
|
def kpi(
|
|
from_ts: str | None = None,
|
|
to_ts: str | None = None,
|
|
queue_id: str | None = None,
|
|
channel: str | None = None,
|
|
sl_threshold_seconds: int = 30,
|
|
) -> dict[str, Any]:
|
|
dt_from, dt_to = _normalize_window(from_ts, to_ts)
|
|
session = get_session()
|
|
try:
|
|
records = _load_fact_records(
|
|
session,
|
|
dt_from=dt_from,
|
|
dt_to=dt_to,
|
|
queue_id=queue_id,
|
|
channel=channel,
|
|
)
|
|
return _build_kpi_from_facts(
|
|
records,
|
|
sl_threshold_seconds,
|
|
from_ts=from_ts,
|
|
to_ts=to_ts,
|
|
queue_id=queue_id,
|
|
channel=channel,
|
|
)
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.get("/reports/drilldown", response_model=ReportingDrilldownOut)
|
|
def drilldown(
|
|
from_ts: str = Query(...),
|
|
to_ts: str = Query(...),
|
|
metric: str = Query(...),
|
|
queue_id: str | None = None,
|
|
channel: str | None = None,
|
|
sl_threshold_seconds: int = 30,
|
|
limit: int = Query(default=25, ge=1, le=100),
|
|
offset: int = Query(default=0, ge=0),
|
|
) -> ReportingDrilldownOut:
|
|
if metric not in _DRILLDOWN_METRICS:
|
|
raise HTTPException(status_code=400, detail="Unsupported metric")
|
|
dt_from, dt_to = _normalize_window(from_ts, to_ts)
|
|
session = get_session()
|
|
try:
|
|
records = _load_fact_records(
|
|
session,
|
|
dt_from=dt_from,
|
|
dt_to=dt_to,
|
|
queue_id=queue_id,
|
|
channel=channel,
|
|
)
|
|
coverage = _metric_coverage(metric, records, sl_threshold_seconds)
|
|
metric_rows = sorted(
|
|
_metric_slice(metric, records, sl_threshold_seconds),
|
|
key=lambda row: (row.get("created_at") or "", row["interaction_id"]),
|
|
reverse=True,
|
|
)
|
|
total = len(metric_rows)
|
|
paged_rows = metric_rows[offset: offset + limit]
|
|
interactions = _load_interactions_map(session, [row["interaction_id"] for row in paged_rows])
|
|
items = [
|
|
_compose_drilldown_item(
|
|
row,
|
|
interactions.get(row["interaction_id"]),
|
|
sl_threshold_seconds=sl_threshold_seconds,
|
|
)
|
|
for row in paged_rows
|
|
]
|
|
if coverage.note is None:
|
|
coverage.note = _metric_note(metric)
|
|
return ReportingDrilldownOut(
|
|
items=items,
|
|
total=total,
|
|
limit=limit,
|
|
offset=offset,
|
|
metric=metric,
|
|
coverage=coverage,
|
|
filters=ReportingDrilldownFiltersOut(
|
|
from_ts=dt_from.isoformat() if dt_from else from_ts,
|
|
to_ts=dt_to.isoformat() if dt_to else to_ts,
|
|
metric=metric,
|
|
queue_id=queue_id,
|
|
channel=channel,
|
|
sl_threshold_seconds=sl_threshold_seconds,
|
|
),
|
|
)
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.get("/reports/timeseries", response_model=ReportingTimeseriesOut)
|
|
def timeseries(
|
|
from_ts: str = Query(...),
|
|
to_ts: str = Query(...),
|
|
metric: str = Query(...),
|
|
interval: str = Query(default="day"),
|
|
queue_id: str | None = None,
|
|
channel: str | None = None,
|
|
sl_threshold_seconds: int = 30,
|
|
) -> ReportingTimeseriesOut:
|
|
if metric not in _TIMESERIES_METRICS:
|
|
raise HTTPException(status_code=400, detail="Unsupported metric")
|
|
if interval not in {"hour", "day"}:
|
|
raise HTTPException(status_code=400, detail="Unsupported interval")
|
|
dt_from, dt_to = _normalize_window(from_ts, to_ts)
|
|
if dt_from is None or dt_to is None:
|
|
raise HTTPException(status_code=400, detail="from_ts and to_ts are required")
|
|
|
|
session = get_session()
|
|
try:
|
|
records = _load_fact_records(
|
|
session,
|
|
dt_from=dt_from,
|
|
dt_to=dt_to,
|
|
queue_id=queue_id,
|
|
channel=channel,
|
|
)
|
|
step = _bucket_step(interval)
|
|
cursor = dt_from
|
|
points: list[ReportingTimeseriesPointOut] = []
|
|
while cursor < dt_to:
|
|
bucket_to = min(cursor + step, dt_to)
|
|
bucket_rows = [
|
|
row
|
|
for row in records
|
|
if _in_range(row.get("created_at"), cursor, bucket_to)
|
|
]
|
|
points.append(
|
|
ReportingTimeseriesPointOut(
|
|
ts=cursor.isoformat(),
|
|
value=round(_timeseries_metric_value(metric, bucket_rows, sl_threshold_seconds), 2),
|
|
sample_size=_timeseries_sample_size(metric, bucket_rows, sl_threshold_seconds),
|
|
)
|
|
)
|
|
cursor = bucket_to
|
|
return ReportingTimeseriesOut(
|
|
metric=metric,
|
|
interval=interval, # type: ignore[arg-type]
|
|
filters=ReportingTimeseriesFiltersOut(
|
|
from_ts=dt_from.isoformat(),
|
|
to_ts=dt_to.isoformat(),
|
|
metric=metric,
|
|
interval=interval, # type: ignore[arg-type]
|
|
queue_id=queue_id,
|
|
channel=channel,
|
|
sl_threshold_seconds=sl_threshold_seconds,
|
|
),
|
|
points=points,
|
|
)
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.get("/reports/agents/overview", response_model=ReportingAgentAnalyticsOverviewOut)
|
|
def agent_overview(
|
|
from_ts: str = Query(...),
|
|
to_ts: str = Query(...),
|
|
queue_id: str | None = None,
|
|
channel: str | None = None,
|
|
sort_by: str = Query(default="interactions_total"),
|
|
sort_dir: str = Query(default="desc"),
|
|
limit: int = Query(default=25, ge=1, le=100),
|
|
) -> ReportingAgentAnalyticsOverviewOut:
|
|
dt_from, dt_to = _normalize_window(from_ts, to_ts)
|
|
if dt_from is None or dt_to is None:
|
|
raise HTTPException(status_code=400, detail="from_ts and to_ts are required")
|
|
normalized_sort_by, normalized_sort_dir = _normalize_agent_sort(sort_by, sort_dir)
|
|
session = get_session()
|
|
try:
|
|
records = _load_fact_records(
|
|
session,
|
|
dt_from=dt_from,
|
|
dt_to=dt_to,
|
|
queue_id=queue_id,
|
|
channel=channel,
|
|
)
|
|
state_rows = _load_agent_state_rows(session, queue_id=queue_id)
|
|
return _build_agent_analytics_overview(
|
|
records,
|
|
state_rows,
|
|
from_ts=dt_from.isoformat(),
|
|
to_ts=dt_to.isoformat(),
|
|
queue_id=queue_id,
|
|
channel=channel,
|
|
sort_by=normalized_sort_by,
|
|
sort_dir=normalized_sort_dir,
|
|
limit=limit,
|
|
)
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.get("/reports/agents/timeseries", response_model=ReportingAgentAnalyticsTimeseriesOut)
|
|
def agent_timeseries(
|
|
from_ts: str = Query(...),
|
|
to_ts: str = Query(...),
|
|
queue_id: str | None = None,
|
|
channel: str | None = None,
|
|
metric: str = Query(default="interactions_per_agent"),
|
|
interval: str | None = Query(default=None),
|
|
) -> ReportingAgentAnalyticsTimeseriesOut:
|
|
dt_from, dt_to = _normalize_window(from_ts, to_ts)
|
|
if dt_from is None or dt_to is None:
|
|
raise HTTPException(status_code=400, detail="from_ts and to_ts are required")
|
|
normalized_metric = _normalize_agent_trend_metric(metric)
|
|
normalized_interval = _agent_interval_for_window(dt_from, dt_to, interval)
|
|
session = get_session()
|
|
try:
|
|
records = _load_fact_records(
|
|
session,
|
|
dt_from=dt_from,
|
|
dt_to=dt_to,
|
|
queue_id=queue_id,
|
|
channel=channel,
|
|
)
|
|
return _build_agent_timeseries(
|
|
records,
|
|
from_ts=dt_from,
|
|
to_ts=dt_to,
|
|
queue_id=queue_id,
|
|
channel=channel,
|
|
metric=normalized_metric,
|
|
interval=normalized_interval,
|
|
)
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.get("/reports/views", response_model=list[ReportingSavedViewOut])
|
|
def list_saved_views(
|
|
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)),
|
|
) -> list[ReportingSavedViewOut]:
|
|
owner_user = str(actor.get("user") or actor.get("username") or "analyst")
|
|
session = get_session()
|
|
try:
|
|
rows = session.execute(
|
|
select(ReportingSavedViewRow)
|
|
.where(ReportingSavedViewRow.owner_user == owner_user)
|
|
.order_by(ReportingSavedViewRow.updated_at.desc(), ReportingSavedViewRow.id.desc())
|
|
).scalars().all()
|
|
return [_serialize_saved_view(row) for row in rows]
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.post("/reports/views", response_model=ReportingSavedViewOut)
|
|
def save_view(
|
|
payload: ReportingSavedViewIn,
|
|
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)),
|
|
) -> ReportingSavedViewOut:
|
|
owner_user = str(actor.get("user") or actor.get("username") or "analyst")
|
|
owner_role = str(actor.get("role") or "")
|
|
view_id = str(payload.id or f"view-{uuid4().hex[:12]}").strip()
|
|
if not view_id:
|
|
raise HTTPException(status_code=400, detail="View id is required")
|
|
name = payload.name.strip()
|
|
if not name:
|
|
raise HTTPException(status_code=400, detail="View name is required")
|
|
|
|
session = get_session()
|
|
try:
|
|
row = session.execute(
|
|
select(ReportingSavedViewRow).where(
|
|
ReportingSavedViewRow.owner_user == owner_user,
|
|
ReportingSavedViewRow.view_id == view_id,
|
|
)
|
|
).scalar_one_or_none()
|
|
now = utc_now_iso()
|
|
if row is None:
|
|
row = ReportingSavedViewRow(
|
|
owner_user=owner_user,
|
|
owner_role=owner_role or None,
|
|
view_id=view_id,
|
|
name=name,
|
|
snapshot_json=json.dumps(payload.snapshot.model_dump(), ensure_ascii=False),
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
session.add(row)
|
|
else:
|
|
row.owner_role = owner_role or row.owner_role
|
|
row.name = name
|
|
row.snapshot_json = json.dumps(payload.snapshot.model_dump(), ensure_ascii=False)
|
|
row.updated_at = now
|
|
session.commit()
|
|
return _serialize_saved_view(row)
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.delete("/reports/views/{view_id}")
|
|
def delete_view(
|
|
view_id: str,
|
|
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)),
|
|
) -> dict[str, Any]:
|
|
owner_user = str(actor.get("user") or actor.get("username") or "analyst")
|
|
session = get_session()
|
|
try:
|
|
row = session.execute(
|
|
select(ReportingSavedViewRow).where(
|
|
ReportingSavedViewRow.owner_user == owner_user,
|
|
ReportingSavedViewRow.view_id == view_id,
|
|
)
|
|
).scalar_one_or_none()
|
|
if row is None:
|
|
raise HTTPException(status_code=404, detail="Saved view not found")
|
|
session.delete(row)
|
|
session.commit()
|
|
return {"accepted": True, "id": view_id}
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.get("/reports/coverage")
|
|
def coverage() -> dict[str, Any]:
|
|
return {
|
|
"implemented_metrics": _metric_catalog(),
|
|
"supported_filters": ["queue_id", "channel", "from_ts", "to_ts", "sl_threshold_seconds", "metric", "interval"],
|
|
"dimensions": ["queue_id", "channel", "agent_id", "window", "interaction_id"],
|
|
}
|
|
|
|
|
|
@app.get("/reports/export", response_class=PlainTextResponse)
|
|
def export_csv(
|
|
from_ts: str | None = None,
|
|
to_ts: str | None = None,
|
|
queue_id: str | None = None,
|
|
channel: str | None = None,
|
|
metric: str | None = None,
|
|
sl_threshold_seconds: int = 30,
|
|
) -> PlainTextResponse:
|
|
if not from_ts and not to_ts and not metric:
|
|
return _legacy_export_csv(queue_id=queue_id, channel=channel)
|
|
|
|
dt_from, dt_to = _normalize_window(from_ts, to_ts)
|
|
session = get_session()
|
|
try:
|
|
records = _load_fact_records(
|
|
session,
|
|
dt_from=dt_from,
|
|
dt_to=dt_to,
|
|
queue_id=queue_id,
|
|
channel=channel,
|
|
)
|
|
date_stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
if metric:
|
|
if metric not in _DRILLDOWN_METRICS:
|
|
raise HTTPException(status_code=400, detail="Unsupported metric")
|
|
metric_rows = sorted(
|
|
_metric_slice(metric, records, sl_threshold_seconds),
|
|
key=lambda row: (row.get("created_at") or "", row["interaction_id"]),
|
|
reverse=True,
|
|
)
|
|
interactions = _load_interactions_map(session, [row["interaction_id"] for row in metric_rows])
|
|
rows = [
|
|
_compose_drilldown_item(
|
|
row,
|
|
interactions.get(row["interaction_id"]),
|
|
sl_threshold_seconds=sl_threshold_seconds,
|
|
).model_dump()
|
|
for row in metric_rows
|
|
]
|
|
return _csv_response(
|
|
f"konturcc-drilldown-{metric.lower()}-{date_stamp}.csv",
|
|
[
|
|
"interaction_id",
|
|
"subject",
|
|
"channel",
|
|
"status",
|
|
"queue_id",
|
|
"assigned_to",
|
|
"created_at",
|
|
"updated_at",
|
|
"answered",
|
|
"abandoned",
|
|
"wait_seconds",
|
|
"handle_seconds",
|
|
"within_sla",
|
|
"resolved_first_contact",
|
|
],
|
|
rows,
|
|
)
|
|
|
|
rows = sorted(
|
|
records,
|
|
key=lambda row: (row.get("created_at") or "", row["interaction_id"]),
|
|
reverse=True,
|
|
)
|
|
return _csv_response(
|
|
f"konturcc-analytics-{date_stamp}.csv",
|
|
[
|
|
"interaction_id",
|
|
"channel",
|
|
"queue_id",
|
|
"agent_id",
|
|
"status",
|
|
"created_at",
|
|
"updated_at",
|
|
"closed_at",
|
|
"answered",
|
|
"abandoned",
|
|
"wait_seconds",
|
|
"handle_seconds",
|
|
"resolved_first_contact",
|
|
"source",
|
|
],
|
|
rows,
|
|
)
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
def _handle_event(envelope: dict[str, Any]) -> None:
|
|
session = get_session()
|
|
try:
|
|
event_id = str(envelope.get("event_id") or "").strip()
|
|
event_type = str(envelope.get("event_type") or "").strip()
|
|
if not event_id or not event_type:
|
|
raise ValueError("Invalid event envelope")
|
|
if inbox_seen(session, "reporting-service", event_id):
|
|
return
|
|
|
|
payload = envelope.get("payload") if isinstance(envelope.get("payload"), dict) else {}
|
|
session.add(
|
|
ReportingEventLogRow(
|
|
event_id=event_id,
|
|
event_type=event_type,
|
|
queue_id=payload.get("queue_id") or payload.get("resolved_queue_id"),
|
|
channel=payload.get("channel"),
|
|
interaction_id=payload.get("interaction_id"),
|
|
payload_json=json.dumps(envelope, ensure_ascii=False),
|
|
created_at=utc_now_iso(),
|
|
)
|
|
)
|
|
|
|
if event_type == "interaction.closed":
|
|
session.add(
|
|
ReportingEventRow(
|
|
queue_id=str(payload.get("queue_id") or "queue_unknown"),
|
|
channel=str(payload.get("channel") or "voice"),
|
|
agent_id=payload.get("assignee"),
|
|
answered=True,
|
|
wait_seconds=0,
|
|
handle_seconds=30,
|
|
abandoned=False,
|
|
resolved_first_contact=bool(payload.get("resolved_first_contact", True)),
|
|
created_at=utc_now_iso(),
|
|
)
|
|
)
|
|
if payload.get("interaction_id"):
|
|
upsert_reporting_interaction_fact(
|
|
session,
|
|
{
|
|
"interaction_id": payload.get("interaction_id"),
|
|
"channel": payload.get("channel") or "voice",
|
|
"queue_id": payload.get("queue_id"),
|
|
"agent_id": payload.get("assignee"),
|
|
"status": payload.get("status") or "closed",
|
|
"closed_at": payload.get("updated_at") or utc_now_iso(),
|
|
"resolved_first_contact": payload.get("resolved_first_contact"),
|
|
"source": "event-bus",
|
|
},
|
|
)
|
|
elif event_type == "ivr.completed":
|
|
session.add(
|
|
ReportingEventRow(
|
|
queue_id=str(payload.get("resolved_queue_id") or payload.get("queue_id") or "queue_unknown"),
|
|
channel="voice",
|
|
agent_id=None,
|
|
answered=True,
|
|
wait_seconds=0,
|
|
handle_seconds=5,
|
|
abandoned=False,
|
|
resolved_first_contact=True,
|
|
created_at=utc_now_iso(),
|
|
)
|
|
)
|
|
|
|
record_inbox(
|
|
session,
|
|
consumer_name="reporting-service",
|
|
event_id=event_id,
|
|
event_type=event_type,
|
|
status="processed",
|
|
)
|
|
session.commit()
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
def _consume_once() -> None:
|
|
consume_one_message(event_bus_reporting_queue(), _handle_event)
|
|
|
|
|
|
@app.on_event("startup")
|
|
def _startup() -> None:
|
|
if not event_bus_enabled() or not consumer_enabled():
|
|
return
|
|
threading.Thread(target=lambda: poll_forever(_consume_once), daemon=True).start()
|