Files
call-center/services/reporting_service/app.py
T
Yera AllandClaude Opus 4.6 6798320209 fix: remove dead code duplicates, add SQL LIMIT across all services
- 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>
2026-04-13 12:04:26 +05:00

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()