- 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>
490 lines
17 KiB
Python
490 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
import json
|
|
|
|
from fastapi import Depends, FastAPI, HTTPException, Query
|
|
from sqlalchemy import func, or_, select
|
|
|
|
from services.shared.core import Role, new_id, utc_now_iso
|
|
from services.shared.db import get_session
|
|
from services.shared.event_bus import append_outbox_event, event_bus_enabled
|
|
from services.shared.models import (
|
|
AssignRequest,
|
|
EscalateRequest,
|
|
HealthResponse,
|
|
InteractionCreate,
|
|
InteractionDrilldownFiltersOut,
|
|
InteractionDrilldownOut,
|
|
InteractionTimelineAppendIn,
|
|
InteractionOut,
|
|
ReportingInteractionFactIn,
|
|
StatusRequest,
|
|
)
|
|
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, InteractionTimeline
|
|
|
|
app = FastAPI(title="interaction-service", version="1.0.0")
|
|
|
|
init_sql_schema()
|
|
|
|
_DRILLDOWN_SORT_COLUMNS = {
|
|
"created_at": Interaction.created_at,
|
|
"updated_at": Interaction.updated_at,
|
|
"subject": Interaction.subject,
|
|
}
|
|
_DRILLDOWN_SORT_DIRECTIONS = {"asc", "desc"}
|
|
|
|
|
|
def _to_out(row: Interaction) -> InteractionOut:
|
|
return InteractionOut(
|
|
interaction_id=row.interaction_id,
|
|
channel=row.channel,
|
|
subject=row.subject,
|
|
customer_id=row.customer_id,
|
|
queue_id=row.queue_id,
|
|
priority=row.priority,
|
|
status=row.status,
|
|
assigned_to=row.assigned_to,
|
|
created_at=row.created_at,
|
|
updated_at=row.updated_at,
|
|
)
|
|
|
|
|
|
def _push_event(session, interaction_id: str, action: str, metadata: dict | None = None) -> None:
|
|
session.add(
|
|
InteractionTimeline(
|
|
interaction_id=interaction_id,
|
|
timestamp=utc_now_iso(),
|
|
action=action,
|
|
metadata_json=json.dumps(metadata or {}, ensure_ascii=False),
|
|
)
|
|
)
|
|
|
|
|
|
def _reporting_fact_payload(
|
|
row: Interaction,
|
|
*,
|
|
source: str,
|
|
closed_at: str | None = None,
|
|
resolved_first_contact: bool | None = None,
|
|
) -> dict:
|
|
return ReportingInteractionFactIn(
|
|
interaction_id=row.interaction_id,
|
|
channel=row.channel, # type: ignore[arg-type]
|
|
queue_id=row.queue_id,
|
|
agent_id=row.assigned_to,
|
|
status=row.status, # type: ignore[arg-type]
|
|
created_at=row.created_at,
|
|
closed_at=closed_at,
|
|
resolved_first_contact=resolved_first_contact,
|
|
source=source,
|
|
).model_dump(exclude_none=True)
|
|
|
|
|
|
def _upsert_reporting_fact(
|
|
session,
|
|
row: Interaction,
|
|
*,
|
|
source: str,
|
|
closed_at: str | None = None,
|
|
resolved_first_contact: bool | None = None,
|
|
) -> None:
|
|
upsert_reporting_interaction_fact(
|
|
session,
|
|
_reporting_fact_payload(
|
|
row,
|
|
source=source,
|
|
closed_at=closed_at,
|
|
resolved_first_contact=resolved_first_contact,
|
|
),
|
|
)
|
|
|
|
|
|
def _parse_filter_timestamp(raw: str, field_name: str) -> datetime:
|
|
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_drilldown_sort(sort_by: str | None, sort_dir: str | None) -> tuple[str, str]:
|
|
normalized_sort_by = (sort_by or "created_at").strip() or "created_at"
|
|
normalized_sort_dir = (sort_dir or "desc").strip().lower() or "desc"
|
|
if normalized_sort_by not in _DRILLDOWN_SORT_COLUMNS:
|
|
raise HTTPException(status_code=400, detail="Invalid sort_by")
|
|
if normalized_sort_dir not in _DRILLDOWN_SORT_DIRECTIONS:
|
|
raise HTTPException(status_code=400, detail="Invalid sort_dir")
|
|
return normalized_sort_by, normalized_sort_dir
|
|
|
|
|
|
def _drilldown_order_by(sort_by: str, sort_dir: str):
|
|
column = _DRILLDOWN_SORT_COLUMNS[sort_by]
|
|
direction = column.asc() if sort_dir == "asc" else column.desc()
|
|
id_order = Interaction.id.asc() if sort_dir == "asc" else Interaction.id.desc()
|
|
if sort_by == "subject":
|
|
created_order = Interaction.created_at.asc() if sort_dir == "asc" else Interaction.created_at.desc()
|
|
return [direction, created_order, id_order]
|
|
return [direction, id_order]
|
|
|
|
|
|
@app.get("/health", response_model=HealthResponse)
|
|
def health() -> HealthResponse:
|
|
return HealthResponse(status="ok", service="interaction-service")
|
|
|
|
|
|
@app.post("/interactions", response_model=InteractionOut)
|
|
def create_interaction(
|
|
payload: InteractionCreate,
|
|
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
|
|
) -> InteractionOut:
|
|
session = get_session()
|
|
try:
|
|
now = utc_now_iso()
|
|
row = Interaction(
|
|
interaction_id=new_id("int"),
|
|
channel=payload.channel,
|
|
subject=payload.subject,
|
|
customer_id=payload.customer_id,
|
|
queue_id=payload.queue_id,
|
|
priority=payload.priority,
|
|
status="new",
|
|
assigned_to=None,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
session.add(row)
|
|
_push_event(session, row.interaction_id, "interaction.created", {"channel": payload.channel})
|
|
_upsert_reporting_fact(session, row, source="interaction-service")
|
|
if event_bus_enabled():
|
|
append_outbox_event(
|
|
session,
|
|
event_type="interaction.created",
|
|
producer_service="interaction-service",
|
|
entity_type="interaction",
|
|
entity_id=row.interaction_id,
|
|
payload={
|
|
"event": "interaction.created",
|
|
"interaction_id": row.interaction_id,
|
|
"channel": payload.channel,
|
|
"customer_id": payload.customer_id,
|
|
"queue_id": payload.queue_id,
|
|
"priority": payload.priority,
|
|
"status": "new",
|
|
"created_at": now,
|
|
},
|
|
)
|
|
session.commit()
|
|
session.refresh(row)
|
|
return _to_out(row)
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.get("/interactions/drilldown", response_model=InteractionDrilldownOut)
|
|
def drilldown_interactions(
|
|
from_ts: str = Query(...),
|
|
to_ts: str = Query(...),
|
|
queue_id: str | None = None,
|
|
channel: str | None = None,
|
|
agent_id: str | None = None,
|
|
status: str | None = None,
|
|
q: str | None = None,
|
|
sort_by: str = Query(default="created_at"),
|
|
sort_dir: str = Query(default="desc"),
|
|
limit: int = Query(default=25, ge=1, le=100),
|
|
offset: int = Query(default=0, ge=0),
|
|
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)),
|
|
) -> InteractionDrilldownOut:
|
|
range_from = _parse_filter_timestamp(from_ts, "from_ts")
|
|
range_to = _parse_filter_timestamp(to_ts, "to_ts")
|
|
if range_to <= range_from:
|
|
raise HTTPException(status_code=400, detail="to_ts must be greater than from_ts")
|
|
normalized_q = (q or "").strip()
|
|
normalized_sort_by, normalized_sort_dir = _normalize_drilldown_sort(sort_by, sort_dir)
|
|
|
|
filters = [
|
|
Interaction.created_at >= range_from.isoformat(),
|
|
Interaction.created_at < range_to.isoformat(),
|
|
]
|
|
if queue_id:
|
|
filters.append(Interaction.queue_id == queue_id)
|
|
if channel:
|
|
filters.append(Interaction.channel == channel)
|
|
if agent_id:
|
|
filters.append(Interaction.assigned_to == agent_id)
|
|
if status:
|
|
filters.append(Interaction.status == status)
|
|
if normalized_q:
|
|
search_pattern = f"%{normalized_q.lower()}%"
|
|
filters.append(
|
|
or_(
|
|
func.lower(func.coalesce(Interaction.interaction_id, "")).like(search_pattern),
|
|
func.lower(func.coalesce(Interaction.subject, "")).like(search_pattern),
|
|
func.lower(func.coalesce(Interaction.assigned_to, "")).like(search_pattern),
|
|
)
|
|
)
|
|
|
|
session = get_session()
|
|
try:
|
|
total = session.execute(select(func.count()).select_from(Interaction).where(*filters)).scalar_one()
|
|
rows = session.execute(
|
|
select(Interaction)
|
|
.where(*filters)
|
|
.order_by(*_drilldown_order_by(normalized_sort_by, normalized_sort_dir))
|
|
.offset(offset)
|
|
.limit(limit)
|
|
).scalars().all()
|
|
return InteractionDrilldownOut(
|
|
items=[_to_out(row) for row in rows],
|
|
total=int(total or 0),
|
|
limit=limit,
|
|
offset=offset,
|
|
filters=InteractionDrilldownFiltersOut(
|
|
from_ts=range_from.isoformat(),
|
|
to_ts=range_to.isoformat(),
|
|
queue_id=queue_id,
|
|
channel=channel,
|
|
agent_id=agent_id,
|
|
status=status,
|
|
q=normalized_q or None,
|
|
sort_by=normalized_sort_by,
|
|
sort_dir=normalized_sort_dir,
|
|
),
|
|
)
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.get("/interactions/{interaction_id}", response_model=InteractionOut)
|
|
def get_interaction(interaction_id: str) -> InteractionOut:
|
|
session = get_session()
|
|
try:
|
|
row = session.execute(
|
|
select(Interaction).where(Interaction.interaction_id == interaction_id)
|
|
).scalar_one_or_none()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Interaction not found")
|
|
return _to_out(row)
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.get("/interactions", response_model=list[InteractionOut])
|
|
def list_interactions(
|
|
status: str | None = None,
|
|
queue_id: str | None = None,
|
|
assigned_to: str | None = None,
|
|
limit: int = 100,
|
|
) -> list[InteractionOut]:
|
|
session = get_session()
|
|
try:
|
|
stmt = select(Interaction).order_by(Interaction.id.desc())
|
|
if status:
|
|
stmt = stmt.where(Interaction.status == status)
|
|
if queue_id:
|
|
stmt = stmt.where(Interaction.queue_id == queue_id)
|
|
if assigned_to:
|
|
stmt = stmt.where(Interaction.assigned_to == assigned_to)
|
|
|
|
rows = session.execute(stmt.limit(max(limit, 1))).scalars().all()
|
|
return [_to_out(r) for r in rows]
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.patch("/interactions/{interaction_id}/assign", response_model=InteractionOut)
|
|
def assign_interaction(
|
|
interaction_id: str,
|
|
payload: AssignRequest,
|
|
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)),
|
|
) -> InteractionOut:
|
|
session = get_session()
|
|
try:
|
|
row = session.execute(
|
|
select(Interaction).where(Interaction.interaction_id == interaction_id)
|
|
).scalar_one_or_none()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Interaction not found")
|
|
row.assigned_to = payload.assignee
|
|
row.status = "in_progress"
|
|
row.updated_at = utc_now_iso()
|
|
_push_event(session, row.interaction_id, "interaction.assigned", {"assignee": payload.assignee})
|
|
_upsert_reporting_fact(session, row, source="interaction-service")
|
|
if event_bus_enabled():
|
|
append_outbox_event(
|
|
session,
|
|
event_type="interaction.assigned",
|
|
producer_service="interaction-service",
|
|
entity_type="interaction",
|
|
entity_id=row.interaction_id,
|
|
payload={
|
|
"event": "interaction.assigned",
|
|
"interaction_id": row.interaction_id,
|
|
"assignee": payload.assignee,
|
|
"queue_id": row.queue_id,
|
|
"channel": row.channel,
|
|
"status": row.status,
|
|
"updated_at": row.updated_at,
|
|
},
|
|
)
|
|
session.commit()
|
|
return _to_out(row)
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.patch("/interactions/{interaction_id}/status", response_model=InteractionOut)
|
|
def update_status(
|
|
interaction_id: str,
|
|
payload: StatusRequest,
|
|
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
|
|
) -> InteractionOut:
|
|
session = get_session()
|
|
try:
|
|
row = session.execute(
|
|
select(Interaction).where(Interaction.interaction_id == interaction_id)
|
|
).scalar_one_or_none()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Interaction not found")
|
|
row.status = payload.status
|
|
row.updated_at = utc_now_iso()
|
|
metadata = {"status": payload.status}
|
|
if payload.resolved_first_contact is not None:
|
|
metadata["resolved_first_contact"] = payload.resolved_first_contact
|
|
_push_event(session, row.interaction_id, "interaction.status_changed", metadata)
|
|
_upsert_reporting_fact(
|
|
session,
|
|
row,
|
|
source="interaction-service",
|
|
closed_at=row.updated_at if payload.status == "closed" else None,
|
|
resolved_first_contact=payload.resolved_first_contact,
|
|
)
|
|
if payload.status == "closed" and event_bus_enabled():
|
|
append_outbox_event(
|
|
session,
|
|
event_type="interaction.closed",
|
|
producer_service="interaction-service",
|
|
entity_type="interaction",
|
|
entity_id=row.interaction_id,
|
|
payload={
|
|
"event": "interaction.closed",
|
|
"interaction_id": row.interaction_id,
|
|
"queue_id": row.queue_id,
|
|
"channel": row.channel,
|
|
"assignee": row.assigned_to,
|
|
"status": row.status,
|
|
"resolved_first_contact": payload.resolved_first_contact,
|
|
"updated_at": row.updated_at,
|
|
},
|
|
)
|
|
session.commit()
|
|
return _to_out(row)
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.post("/interactions/{interaction_id}/escalate", response_model=InteractionOut)
|
|
def escalate(
|
|
interaction_id: str,
|
|
payload: EscalateRequest,
|
|
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
|
|
) -> InteractionOut:
|
|
session = get_session()
|
|
try:
|
|
row = session.execute(
|
|
select(Interaction).where(Interaction.interaction_id == interaction_id)
|
|
).scalar_one_or_none()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Interaction not found")
|
|
row.queue_id = payload.target_queue_id
|
|
row.status = "escalated"
|
|
row.updated_at = utc_now_iso()
|
|
_push_event(
|
|
session,
|
|
row.interaction_id,
|
|
"interaction.escalated",
|
|
{"target_queue_id": payload.target_queue_id},
|
|
)
|
|
_upsert_reporting_fact(session, row, source="interaction-service")
|
|
if event_bus_enabled():
|
|
append_outbox_event(
|
|
session,
|
|
event_type="interaction.escalated",
|
|
producer_service="interaction-service",
|
|
entity_type="interaction",
|
|
entity_id=row.interaction_id,
|
|
payload={
|
|
"event": "interaction.escalated",
|
|
"interaction_id": row.interaction_id,
|
|
"target_queue_id": payload.target_queue_id,
|
|
"queue_id": row.queue_id,
|
|
"channel": row.channel,
|
|
"status": row.status,
|
|
"updated_at": row.updated_at,
|
|
},
|
|
)
|
|
session.commit()
|
|
return _to_out(row)
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.get("/interactions/{interaction_id}/timeline")
|
|
def timeline(interaction_id: str) -> dict:
|
|
session = get_session()
|
|
try:
|
|
row = session.execute(
|
|
select(Interaction).where(Interaction.interaction_id == interaction_id)
|
|
).scalar_one_or_none()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Interaction not found")
|
|
|
|
events = session.execute(
|
|
select(InteractionTimeline)
|
|
.where(InteractionTimeline.interaction_id == interaction_id)
|
|
.order_by(InteractionTimeline.id.asc())
|
|
).scalars().all()
|
|
return {
|
|
"interaction_id": interaction_id,
|
|
"events": [
|
|
{
|
|
"timestamp": e.timestamp,
|
|
"action": e.action,
|
|
"metadata": json.loads(e.metadata_json or "{}"),
|
|
}
|
|
for e in events
|
|
],
|
|
}
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.post("/interactions/{interaction_id}/timeline")
|
|
def append_timeline_event(
|
|
interaction_id: str,
|
|
payload: InteractionTimelineAppendIn,
|
|
_: dict = Depends(require_roles(Role.ADMIN)),
|
|
) -> dict:
|
|
session = get_session()
|
|
try:
|
|
row = session.execute(
|
|
select(Interaction).where(Interaction.interaction_id == interaction_id)
|
|
).scalar_one_or_none()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Interaction not found")
|
|
row.updated_at = utc_now_iso()
|
|
_push_event(session, interaction_id, payload.action, payload.metadata)
|
|
session.commit()
|
|
return {"ok": True, "interaction_id": interaction_id, "action": payload.action}
|
|
finally:
|
|
session.close()
|