Files
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

667 lines
23 KiB
Python

from __future__ import annotations
import json
import os
from fastapi import Depends, FastAPI, HTTPException, Query
from sqlalchemy import 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 (
HealthResponse,
IvrDtmfIn,
IvrFlowCreate,
IvrFlowOut,
IvrFlowUpdate,
IvrSessionOut,
IvrSessionStartIn,
)
from services.shared.security import get_actor, require_roles
from services.shared.sql_init import init_sql_schema
from services.shared.sql_models import InteractionTimeline, IvrFlowRow, IvrSessionRow, VoiceEventRow
app = FastAPI(title="ivr-service", version="1.0.0")
init_sql_schema()
def _load_flow_doc(raw: str) -> dict:
return json.loads(raw or "{}")
def _to_flow_out(row: IvrFlowRow) -> IvrFlowOut:
return IvrFlowOut(
flow_id=row.flow_id,
name=row.name,
description=row.description,
channel="voice",
queue_id=row.queue_id,
version=row.version,
is_active=row.is_active,
entry_node_id=row.entry_node_id,
flow_json=_load_flow_doc(row.flow_json),
created_at=row.created_at,
updated_at=row.updated_at,
)
def _to_session_out(row: IvrSessionRow) -> IvrSessionOut:
return IvrSessionOut(
session_id=row.session_id,
call_id=row.call_id,
interaction_id=row.interaction_id,
flow_id=row.flow_id,
queue_id=row.queue_id,
current_node_id=row.current_node_id,
entered_digits=json.loads(row.entered_digits_json or "[]"),
status=row.status,
outcome_code=row.outcome_code,
resolved_queue_id=row.resolved_queue_id,
resolved_queue_code=row.resolved_queue_code,
completed_at=row.completed_at,
created_at=row.created_at,
updated_at=row.updated_at,
)
def _trusted_runtime_subjects() -> set[str]:
raw = os.getenv("IVR_RUNTIME_TRUSTED_SERVICE_SUBJECTS", "svc:asterisk-bridge")
return {item.strip() for item in raw.split(",") if item.strip()}
def _is_trusted_runtime_actor(actor: dict) -> bool:
return (
actor.get("auth_source") == "service"
and str(actor.get("sub") or "").strip() in _trusted_runtime_subjects()
)
def _require_runtime_actor(actor: dict = Depends(get_actor)) -> dict:
if actor.get("auth_source") == "service":
if _is_trusted_runtime_actor(actor):
return actor
raise HTTPException(status_code=403, detail="Untrusted service subject")
if actor.get("role") in {Role.ADMIN.value, Role.SUPERVISOR.value}:
return actor
raise HTTPException(status_code=403, detail="Insufficient role")
def _validate_digit(value: str) -> str:
digit = value.strip()
if len(digit) != 1 or digit not in "0123456789":
raise HTTPException(status_code=400, detail="Digit must be a single character 0-9")
return digit
def _validate_flow_document(entry_node_id: str, flow_doc: dict) -> dict[str, dict]:
nodes = flow_doc.get("nodes")
if not isinstance(nodes, list) or not nodes:
raise HTTPException(status_code=400, detail="flow_json.nodes must be a non-empty list")
node_map: dict[str, dict] = {}
for node in nodes:
if not isinstance(node, dict):
raise HTTPException(status_code=400, detail="Each flow node must be an object")
node_id = str(node.get("node_id") or "").strip()
if not node_id:
raise HTTPException(status_code=400, detail="Each flow node requires node_id")
if node_id in node_map:
raise HTTPException(status_code=400, detail=f"Duplicate node_id: {node_id}")
node_map[node_id] = node
if entry_node_id not in node_map:
raise HTTPException(status_code=400, detail="entry_node_id must reference an existing node")
for node_id, node in node_map.items():
is_terminal = bool(node.get("is_terminal"))
options = node.get("options", [])
prompt_audio_key = node.get("prompt_audio_key")
prompt_sequence = node.get("prompt_sequence")
if not isinstance(options, list):
raise HTTPException(status_code=400, detail=f"Node {node_id} options must be a list")
if prompt_audio_key is not None and not str(prompt_audio_key).strip():
raise HTTPException(status_code=400, detail=f"Node {node_id} prompt_audio_key must be non-empty when set")
if prompt_sequence is not None:
if not isinstance(prompt_sequence, list) or not prompt_sequence:
raise HTTPException(status_code=400, detail=f"Node {node_id} prompt_sequence must be a non-empty list")
for index, prompt in enumerate(prompt_sequence, start=1):
if not isinstance(prompt, dict):
raise HTTPException(
status_code=400,
detail=f"Node {node_id} prompt_sequence item {index} must be an object",
)
prompt_key = str(prompt.get("prompt_audio_key") or "").strip()
if not prompt_key:
raise HTTPException(
status_code=400,
detail=f"Node {node_id} prompt_sequence item {index} requires prompt_audio_key",
)
prompt_text = prompt.get("prompt_text")
if prompt_text is not None and not str(prompt_text).strip():
raise HTTPException(
status_code=400,
detail=f"Node {node_id} prompt_sequence item {index} prompt_text must be non-empty when set",
)
prompt_language = prompt.get("language")
if prompt_language is not None and not str(prompt_language).strip():
raise HTTPException(
status_code=400,
detail=f"Node {node_id} prompt_sequence item {index} language must be non-empty when set",
)
seen_digits: set[str] = set()
for option in options:
if not isinstance(option, dict):
raise HTTPException(status_code=400, detail=f"Node {node_id} option must be an object")
digit = _validate_digit(str(option.get("digit") or ""))
if digit in seen_digits:
raise HTTPException(status_code=400, detail=f"Duplicate digit {digit} in node {node_id}")
seen_digits.add(digit)
target_node_id = str(option.get("target_node_id") or "").strip()
if target_node_id not in node_map:
raise HTTPException(status_code=400, detail=f"Option target {target_node_id} is missing")
for ref_name in ("invalid_target_node_id", "no_input_target_node_id"):
target = node.get(ref_name)
if target is not None and str(target).strip() not in node_map:
raise HTTPException(status_code=400, detail=f"{ref_name} for node {node_id} is invalid")
if is_terminal:
if not str(node.get("outcome_code") or "").strip():
raise HTTPException(status_code=400, detail=f"Terminal node {node_id} requires outcome_code")
if not str(node.get("resolved_queue_id") or "").strip():
raise HTTPException(status_code=400, detail=f"Terminal node {node_id} requires resolved_queue_id")
if not str(node.get("resolved_queue_code") or "").strip():
raise HTTPException(status_code=400, detail=f"Terminal node {node_id} requires resolved_queue_code")
elif not options:
raise HTTPException(status_code=400, detail=f"Node {node_id} requires at least one option")
return node_map
def _deactivate_other_flows(session, queue_id: str, *, keep_flow_id: str | None = None) -> None:
rows = session.execute(
select(IvrFlowRow).where(
IvrFlowRow.queue_id == queue_id,
IvrFlowRow.channel == "voice",
IvrFlowRow.is_active.is_(True),
)
).scalars().all()
now = utc_now_iso()
for row in rows:
if keep_flow_id and row.flow_id == keep_flow_id:
continue
row.is_active = False
row.updated_at = now
def _get_flow(session, flow_id: str) -> IvrFlowRow:
row = session.execute(select(IvrFlowRow).where(IvrFlowRow.flow_id == flow_id)).scalar_one_or_none()
if not row:
raise HTTPException(status_code=404, detail="IVR flow not found")
return row
def _get_session_row(session, session_id: str) -> IvrSessionRow:
row = session.execute(select(IvrSessionRow).where(IvrSessionRow.session_id == session_id)).scalar_one_or_none()
if not row:
raise HTTPException(status_code=404, detail="IVR session not found")
return row
def _push_timeline(session, interaction_id: str | None, action: str, metadata: dict | None = None) -> None:
if not interaction_id:
return
session.add(
InteractionTimeline(
interaction_id=interaction_id,
timestamp=utc_now_iso(),
action=action,
metadata_json=json.dumps(metadata or {}, ensure_ascii=False),
)
)
def _emit_ivr_completed_event(session, row: IvrSessionRow, *, terminal_node_id: str) -> None:
session.add(
VoiceEventRow(
event_id=new_id("vev"),
event_type="ivr.completed",
call_id=row.call_id,
interaction_id=row.interaction_id,
payload_json=json.dumps(
{
"session_id": row.session_id,
"flow_id": row.flow_id,
"outcome_code": row.outcome_code,
"resolved_queue_id": row.resolved_queue_id,
"digits": json.loads(row.entered_digits_json or "[]"),
"terminal_node_id": terminal_node_id,
},
ensure_ascii=False,
),
created_at=utc_now_iso(),
)
)
def _load_flow_runtime(session, row: IvrSessionRow | IvrFlowRow) -> tuple[IvrFlowRow, dict[str, dict]]:
flow = row if isinstance(row, IvrFlowRow) else _get_flow(session, row.flow_id)
flow_doc = _load_flow_doc(flow.flow_json)
node_map = _validate_flow_document(flow.entry_node_id, flow_doc)
return flow, node_map
def _complete_session(
session,
row: IvrSessionRow,
*,
next_node: dict,
next_node_id: str,
entered_digits: list[str],
completed_at: str,
) -> None:
row.status = "completed"
row.outcome_code = str(next_node.get("outcome_code") or "").strip() or None
row.resolved_queue_id = str(next_node.get("resolved_queue_id") or "").strip() or None
row.resolved_queue_code = str(next_node.get("resolved_queue_code") or "").strip() or None
row.completed_at = completed_at
_emit_ivr_completed_event(session, row, terminal_node_id=next_node_id)
_push_timeline(
session,
row.interaction_id,
"ivr.completed",
{
"session_id": row.session_id,
"flow_id": row.flow_id,
"outcome_code": row.outcome_code,
"resolved_queue_id": row.resolved_queue_id,
},
)
if event_bus_enabled():
append_outbox_event(
session,
event_type="ivr.completed",
producer_service="ivr-service",
entity_type="ivr_session",
entity_id=row.session_id,
correlation_id=row.interaction_id or row.call_id,
payload={
"event": "ivr.completed",
"call_id": row.call_id,
"interaction_id": row.interaction_id,
"session_id": row.session_id,
"flow_id": row.flow_id,
"outcome_code": row.outcome_code,
"resolved_queue_id": row.resolved_queue_id,
"digits": entered_digits,
"terminal_node_id": next_node_id,
},
)
def _session_response(row: IvrSessionRow, current_node: dict | None) -> dict:
return {
"session": _to_session_out(row).model_dump(),
"current_node": current_node,
"completed": row.status == "completed",
}
@app.get("/health", response_model=HealthResponse)
def health() -> HealthResponse:
return HealthResponse(status="ok", service="ivr-service")
@app.post("/ivr/flows", response_model=IvrFlowOut)
def create_flow(
payload: IvrFlowCreate,
_: dict = Depends(require_roles(Role.ADMIN)),
) -> IvrFlowOut:
session = get_session()
try:
_validate_flow_document(payload.entry_node_id, payload.flow_json)
if payload.is_active:
_deactivate_other_flows(session, payload.queue_id)
now = utc_now_iso()
row = IvrFlowRow(
flow_id=new_id("ivr"),
name=payload.name,
description=payload.description,
channel="voice",
queue_id=payload.queue_id,
version=1,
is_active=payload.is_active,
entry_node_id=payload.entry_node_id,
flow_json=json.dumps(payload.flow_json, ensure_ascii=False),
created_at=now,
updated_at=now,
)
session.add(row)
session.commit()
session.refresh(row)
return _to_flow_out(row)
finally:
session.close()
@app.get("/ivr/flows", response_model=list[IvrFlowOut])
def list_flows(
queue_id: str | None = None,
active_only: bool = False,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)),
) -> list[IvrFlowOut]:
session = get_session()
try:
stmt = select(IvrFlowRow).order_by(IvrFlowRow.id.desc())
if queue_id:
stmt = stmt.where(IvrFlowRow.queue_id == queue_id)
if active_only:
stmt = stmt.where(IvrFlowRow.is_active.is_(True))
rows = session.execute(stmt).scalars().all()
return [_to_flow_out(row) for row in rows]
finally:
session.close()
@app.get("/ivr/flows/{flow_id}", response_model=IvrFlowOut)
def get_flow(
flow_id: str,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)),
) -> IvrFlowOut:
session = get_session()
try:
return _to_flow_out(_get_flow(session, flow_id))
finally:
session.close()
@app.patch("/ivr/flows/{flow_id}", response_model=IvrFlowOut)
def update_flow(
flow_id: str,
payload: IvrFlowUpdate,
_: dict = Depends(require_roles(Role.ADMIN)),
) -> IvrFlowOut:
session = get_session()
try:
row = _get_flow(session, flow_id)
flow_doc = _load_flow_doc(row.flow_json)
changed = False
if payload.name is not None and payload.name != row.name:
row.name = payload.name
changed = True
if payload.description is not None and payload.description != row.description:
row.description = payload.description
changed = True
candidate_entry = payload.entry_node_id if payload.entry_node_id is not None else row.entry_node_id
if payload.flow_json is not None:
flow_doc = payload.flow_json
changed = True
_validate_flow_document(candidate_entry, flow_doc)
if candidate_entry != row.entry_node_id:
row.entry_node_id = candidate_entry
changed = True
if payload.flow_json is not None:
row.flow_json = json.dumps(flow_doc, ensure_ascii=False)
if payload.is_active is not None and payload.is_active != row.is_active:
row.is_active = payload.is_active
changed = True
if row.is_active:
_deactivate_other_flows(session, row.queue_id, keep_flow_id=row.flow_id)
if changed:
row.version += 1
row.updated_at = utc_now_iso()
session.commit()
session.refresh(row)
return _to_flow_out(row)
finally:
session.close()
@app.post("/ivr/flows/{flow_id}/activate", response_model=IvrFlowOut)
def activate_flow(
flow_id: str,
_: dict = Depends(require_roles(Role.ADMIN)),
) -> IvrFlowOut:
session = get_session()
try:
row = _get_flow(session, flow_id)
_deactivate_other_flows(session, row.queue_id, keep_flow_id=row.flow_id)
row.is_active = True
row.updated_at = utc_now_iso()
session.commit()
session.refresh(row)
return _to_flow_out(row)
finally:
session.close()
@app.post("/ivr/sessions/start")
def start_session(
payload: IvrSessionStartIn,
_: dict = Depends(_require_runtime_actor),
) -> dict:
session = get_session()
try:
flow = session.execute(
select(IvrFlowRow).where(
IvrFlowRow.queue_id == payload.queue_id,
IvrFlowRow.channel == "voice",
IvrFlowRow.is_active.is_(True),
)
).scalar_one_or_none()
if not flow:
raise HTTPException(status_code=404, detail="No active IVR flow for queue")
_, node_map = _load_flow_runtime(session, flow)
now = utc_now_iso()
row = IvrSessionRow(
session_id=new_id("ivs"),
call_id=payload.call_id,
interaction_id=payload.interaction_id,
flow_id=flow.flow_id,
queue_id=payload.queue_id,
current_node_id=flow.entry_node_id,
entered_digits_json="[]",
status="active",
outcome_code=None,
resolved_queue_id=None,
resolved_queue_code=None,
completed_at=None,
created_at=now,
updated_at=now,
)
session.add(row)
_push_timeline(
session,
payload.interaction_id,
"ivr.session.started",
{"session_id": row.session_id, "flow_id": flow.flow_id, "queue_id": payload.queue_id},
)
session.commit()
session.refresh(row)
return _session_response(row, node_map[row.current_node_id])
finally:
session.close()
@app.post("/ivr/sessions/{session_id}/dtmf")
def process_dtmf(
session_id: str,
payload: IvrDtmfIn,
_: dict = Depends(_require_runtime_actor),
) -> dict:
session = get_session()
try:
row = _get_session_row(session, session_id)
if row.status != "active":
raise HTTPException(status_code=409, detail="IVR session is not active")
_, node_map = _load_flow_runtime(session, row)
current_node = node_map[row.current_node_id]
digit = _validate_digit(payload.digit)
entered_digits = json.loads(row.entered_digits_json or "[]")
next_node_id = None
matched = False
for option in current_node.get("options", []):
if str(option.get("digit")) == digit:
next_node_id = str(option["target_node_id"])
matched = True
entered_digits.append(digit)
break
if not matched:
fallback_target = current_node.get("invalid_target_node_id")
next_node_id = str(fallback_target).strip() if fallback_target else row.current_node_id
next_node = node_map[next_node_id]
now = utc_now_iso()
row.current_node_id = next_node_id
row.entered_digits_json = json.dumps(entered_digits, ensure_ascii=False)
row.updated_at = now
_push_timeline(
session,
row.interaction_id,
"ivr.step.completed",
{
"session_id": row.session_id,
"digit": digit,
"matched": matched,
"current_node_id": next_node_id,
},
)
if next_node.get("is_terminal"):
_complete_session(
session,
row,
next_node=next_node,
next_node_id=next_node_id,
entered_digits=entered_digits,
completed_at=now,
)
session.commit()
session.refresh(row)
return _session_response(row, next_node)
finally:
session.close()
@app.post("/ivr/sessions/{session_id}/no-input")
def process_no_input(
session_id: str,
_: dict = Depends(_require_runtime_actor),
) -> dict:
session = get_session()
try:
row = _get_session_row(session, session_id)
if row.status != "active":
raise HTTPException(status_code=409, detail="IVR session is not active")
_, node_map = _load_flow_runtime(session, row)
current_node = node_map[row.current_node_id]
next_node_id = str(current_node.get("no_input_target_node_id") or "").strip() or row.current_node_id
next_node = node_map[next_node_id]
entered_digits = json.loads(row.entered_digits_json or "[]")
now = utc_now_iso()
row.current_node_id = next_node_id
row.updated_at = now
_push_timeline(
session,
row.interaction_id,
"ivr.step.no_input",
{
"session_id": row.session_id,
"current_node_id": next_node_id,
},
)
if next_node.get("is_terminal"):
_complete_session(
session,
row,
next_node=next_node,
next_node_id=next_node_id,
entered_digits=entered_digits,
completed_at=now,
)
session.commit()
session.refresh(row)
return _session_response(row, next_node)
finally:
session.close()
@app.get("/ivr/sessions/{session_id}", response_model=IvrSessionOut)
def get_session_detail(
session_id: str,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)),
) -> IvrSessionOut:
session = get_session()
try:
return _to_session_out(_get_session_row(session, session_id))
finally:
session.close()
@app.get("/ivr/sessions", response_model=list[IvrSessionOut])
def list_sessions(
call_id: str | None = None,
interaction_id: str | None = None,
status: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)),
) -> list[IvrSessionOut]:
session = get_session()
try:
stmt = select(IvrSessionRow).order_by(IvrSessionRow.id.desc())
if call_id:
stmt = stmt.where(IvrSessionRow.call_id == call_id)
if interaction_id:
stmt = stmt.where(IvrSessionRow.interaction_id == interaction_id)
if status:
stmt = stmt.where(IvrSessionRow.status == status)
rows = session.execute(stmt.limit(max(limit, 1))).scalars().all()
return [_to_session_out(row) for row in rows]
finally:
session.close()
@app.post("/ivr/sessions/{session_id}/abandon", response_model=IvrSessionOut)
def abandon_session(
session_id: str,
_: dict = Depends(_require_runtime_actor),
) -> IvrSessionOut:
session = get_session()
try:
row = _get_session_row(session, session_id)
if row.status != "active":
raise HTTPException(status_code=409, detail="IVR session is not active")
row.status = "abandoned"
row.updated_at = utc_now_iso()
_push_timeline(
session,
row.interaction_id,
"ivr.abandoned",
{"session_id": row.session_id, "flow_id": row.flow_id},
)
session.commit()
session.refresh(row)
return _to_session_out(row)
finally:
session.close()