- 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>
129 lines
4.3 KiB
Python
129 lines
4.3 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
|
|
from fastapi import Depends, FastAPI, HTTPException
|
|
from sqlalchemy import select
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
from services.shared.core import Role, new_id, utc_now_iso
|
|
from services.shared.db import get_session
|
|
from services.shared.models import HealthResponse, VoiceEventIn, VoiceEventOut
|
|
from services.shared.security import get_actor
|
|
from services.shared.sql_init import init_sql_schema
|
|
from services.shared.sql_models import VoiceEventRow
|
|
|
|
app = FastAPI(title="voice-adapter-service", version="1.0.0")
|
|
|
|
init_sql_schema()
|
|
|
|
|
|
def _trusted_service_subjects() -> set[str]:
|
|
raw = os.getenv("VOICE_ADAPTER_TRUSTED_SERVICE_SUBJECTS", "svc:asterisk-bridge,svc:ivr-service")
|
|
return {item.strip() for item in raw.split(",") if item.strip()}
|
|
|
|
|
|
def _is_trusted_service_actor(actor: dict) -> bool:
|
|
return (
|
|
actor.get("auth_source") == "service"
|
|
and str(actor.get("sub") or "").strip() in _trusted_service_subjects()
|
|
)
|
|
|
|
|
|
def _require_voice_event_ingest(actor: dict = Depends(get_actor)) -> dict:
|
|
if actor.get("auth_source") == "service":
|
|
if _is_trusted_service_actor(actor):
|
|
return actor
|
|
raise HTTPException(status_code=403, detail="Untrusted service subject")
|
|
|
|
if actor.get("role") in {Role.ADMIN.value, Role.SUPERVISOR.value, Role.OPERATOR.value}:
|
|
return actor
|
|
raise HTTPException(status_code=403, detail="Insufficient role")
|
|
|
|
|
|
def _require_voice_event_read(actor: dict = Depends(get_actor)) -> dict:
|
|
if actor.get("auth_source") == "service":
|
|
if _is_trusted_service_actor(actor):
|
|
return actor
|
|
raise HTTPException(status_code=403, detail="Untrusted service subject")
|
|
|
|
if actor.get("role") in {Role.ADMIN.value, Role.SUPERVISOR.value, Role.ANALYST.value}:
|
|
return actor
|
|
raise HTTPException(status_code=403, detail="Insufficient role")
|
|
|
|
|
|
def _to_out(row: VoiceEventRow) -> VoiceEventOut:
|
|
return VoiceEventOut(
|
|
event_id=row.event_id,
|
|
event_type=row.event_type,
|
|
call_id=row.call_id,
|
|
interaction_id=row.interaction_id,
|
|
source_event_id=row.source_event_id,
|
|
payload=json.loads(row.payload_json or "{}"),
|
|
created_at=row.created_at,
|
|
)
|
|
|
|
|
|
def _load_existing_by_source_event(session, source_event_id: str | None) -> VoiceEventRow | None:
|
|
if not source_event_id:
|
|
return None
|
|
return session.execute(
|
|
select(VoiceEventRow).where(VoiceEventRow.source_event_id == source_event_id)
|
|
).scalar_one_or_none()
|
|
|
|
|
|
@app.get("/health", response_model=HealthResponse)
|
|
def health() -> HealthResponse:
|
|
return HealthResponse(status="ok", service="voice-adapter-service")
|
|
|
|
|
|
@app.post("/integrations/voice/events", response_model=VoiceEventOut)
|
|
def ingest_voice_event(
|
|
payload: VoiceEventIn,
|
|
_: dict = Depends(_require_voice_event_ingest),
|
|
) -> VoiceEventOut:
|
|
session = get_session()
|
|
try:
|
|
existing = _load_existing_by_source_event(session, payload.source_event_id)
|
|
if existing is not None:
|
|
return _to_out(existing)
|
|
|
|
row = VoiceEventRow(
|
|
event_id=new_id("vev"),
|
|
event_type=payload.event_type,
|
|
call_id=payload.call_id,
|
|
interaction_id=payload.interaction_id,
|
|
source_event_id=payload.source_event_id,
|
|
payload_json=json.dumps(payload.payload, ensure_ascii=False),
|
|
created_at=utc_now_iso(),
|
|
)
|
|
session.add(row)
|
|
try:
|
|
session.commit()
|
|
except IntegrityError:
|
|
session.rollback()
|
|
existing = _load_existing_by_source_event(session, payload.source_event_id)
|
|
if existing is not None:
|
|
return _to_out(existing)
|
|
raise
|
|
session.refresh(row)
|
|
return _to_out(row)
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.get("/integrations/voice/events", response_model=list[VoiceEventOut])
|
|
def list_voice_events(
|
|
limit: int = 100,
|
|
_: dict = Depends(_require_voice_event_read),
|
|
) -> list[VoiceEventOut]:
|
|
session = get_session()
|
|
try:
|
|
rows = session.execute(
|
|
select(VoiceEventRow).order_by(VoiceEventRow.id.desc()).limit(max(limit, 1))
|
|
).scalars().all()
|
|
return [_to_out(r) for r in reversed(rows)]
|
|
finally:
|
|
session.close()
|