Files
call-center/services/interaction_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

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