Files
2026-07-13 13:39:38 +08:00

320 lines
9.2 KiB
Python

from __future__ import annotations
import json
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from typing import Any, Protocol
from livekit.agents.voice.events import CloseEvent
from .log import logger
_CALL_ID_METADATA_KEY = "call_id"
CALL_ID_STRATEGY_ROOM_NAME = "room_name"
CALL_ID_STRATEGY_PARTICIPANT_IDENTITY = "participant_identity"
CALL_ID_STRATEGY_PARTICIPANT_METADATA = "participant_metadata"
CALL_ID_STRATEGY_CUSTOM = "custom"
class SessionReportLike(Protocol):
@property
def room(self) -> str: ...
@property
def started_at(self) -> float | None: ...
@property
def timestamp(self) -> float: ...
@property
def events(self) -> list[Any]: ...
@property
def audio_recording_path(self) -> Any | None: ...
def to_dict(self) -> dict[str, Any]: ...
@dataclass(frozen=True)
class CallIdResolutionContext:
room_name: str
participant_identity: str | None
participant_metadata_raw: str | None
external_agent_id: str
CallIdResolver = Callable[[CallIdResolutionContext], str | None]
@dataclass(frozen=True)
class PayloadBuildConfig:
external_agent_id: str
plugin_api_version: str
plugin_version: str
payload_schema_version: str
call_id_strategy: str = CALL_ID_STRATEGY_ROOM_NAME
call_id_metadata_key: str = _CALL_ID_METADATA_KEY
resolve_call_id: CallIdResolver | None = None
capture_manifest: Mapping[str, Any] | None = None
customer_metadata: Mapping[str, Any] | None = None
external_links: Sequence[Mapping[str, Any]] | None = None
session_mode: str | None = None
def build_livekit_monitoring_envelope(
*,
config: PayloadBuildConfig,
report: SessionReportLike,
participant_identity: str | None,
participant_metadata_raw: str | None,
recording_context: Mapping[str, Any] | None,
close_event: CloseEvent | None,
) -> dict[str, Any]:
call_id = _resolve_call_id(
room_name=report.room,
participant_identity=participant_identity,
participant_metadata_raw=participant_metadata_raw,
external_agent_id=config.external_agent_id,
strategy=config.call_id_strategy,
metadata_key=config.call_id_metadata_key,
resolve_call_id=config.resolve_call_id,
)
test_case_run_id = _resolve_test_case_run_id(
participant_metadata_raw=participant_metadata_raw,
recording_context=recording_context,
)
payload = {
"call_id": call_id,
"call_type": "web",
"livekit_room_name": report.room,
"start_timestamp": int((report.started_at or report.timestamp) * 1000),
"end_timestamp": int(report.timestamp * 1000),
"status": _resolve_status(close_event),
"livekit_capture": _build_livekit_capture(
report=report,
participant_identity=participant_identity,
participant_metadata_raw=participant_metadata_raw,
close_event=close_event,
),
}
if test_case_run_id:
payload["test_case_run_id"] = test_case_run_id
metadata: dict[str, Any] = {
"integration": "livekit-plugin-hamming",
"mode": "call_review",
"call_id_strategy": config.call_id_strategy,
}
if config.capture_manifest:
metadata["capture_manifest"] = dict(config.capture_manifest)
return {
"provider": "custom",
"external_agent_id": config.external_agent_id,
"payload_schema_version": config.payload_schema_version,
"plugin_api_version": config.plugin_api_version,
"plugin_version": config.plugin_version,
"payload": payload,
"metadata": metadata,
**_optional_envelope_fields(config),
}
def _optional_envelope_fields(config: PayloadBuildConfig) -> dict[str, Any]:
optional_fields: dict[str, Any] = {}
if config.customer_metadata:
optional_fields["customer_metadata"] = dict(config.customer_metadata)
if config.external_links:
optional_fields["external_links"] = [dict(link) for link in config.external_links]
if config.session_mode:
optional_fields["session_mode"] = config.session_mode
return optional_fields
def _build_livekit_capture(
*,
report: SessionReportLike,
participant_identity: str | None,
participant_metadata_raw: str | None,
close_event: CloseEvent | None,
) -> dict[str, Any]:
capture = report.to_dict()
capture["started_at"] = report.started_at
capture["timestamp"] = report.timestamp
capture["participant_identity"] = participant_identity
if participant_metadata_raw:
capture["participant_metadata"] = participant_metadata_raw
capture["close_reason"] = _serialize_close_reason(close_event)
capture["events"] = _serialize_events(report.events)
return capture
def _serialize_events(events: list[Any]) -> list[dict[str, Any]]:
events_out: list[dict[str, Any]] = []
for event in events:
try:
raw = event.model_dump(mode="json", exclude_none=False)
except Exception:
logger.debug(
"failed to serialize session event for livekit capture",
extra={"event_type": type(event).__name__},
exc_info=True,
)
continue
if isinstance(raw, dict) and isinstance(raw.get("type"), str):
events_out.append(raw)
return events_out
def _resolve_call_id(
*,
room_name: str,
participant_identity: str | None,
participant_metadata_raw: str | None,
external_agent_id: str,
strategy: str = CALL_ID_STRATEGY_ROOM_NAME,
metadata_key: str = _CALL_ID_METADATA_KEY,
resolve_call_id: CallIdResolver | None = None,
) -> str:
fallback = room_name
if strategy == CALL_ID_STRATEGY_ROOM_NAME:
return fallback
if strategy == CALL_ID_STRATEGY_PARTICIPANT_IDENTITY:
return _resolved_string_or_fallback(participant_identity, fallback)
if strategy == CALL_ID_STRATEGY_PARTICIPANT_METADATA:
metadata = _parse_metadata(participant_metadata_raw)
return _resolved_string_or_fallback(metadata.get(metadata_key), fallback)
if strategy == CALL_ID_STRATEGY_CUSTOM and resolve_call_id is not None:
return _resolve_custom_call_id(
room_name=room_name,
participant_identity=participant_identity,
participant_metadata_raw=participant_metadata_raw,
external_agent_id=external_agent_id,
resolve_call_id=resolve_call_id,
fallback=fallback,
)
return fallback
def _resolve_custom_call_id(
*,
room_name: str,
participant_identity: str | None,
participant_metadata_raw: str | None,
external_agent_id: str,
resolve_call_id: CallIdResolver,
fallback: str,
) -> str:
try:
resolved_call_id = resolve_call_id(
CallIdResolutionContext(
room_name=room_name,
participant_identity=participant_identity,
participant_metadata_raw=participant_metadata_raw,
external_agent_id=external_agent_id,
)
)
except Exception:
logger.warning(
"custom call_id resolver failed; falling back to room name",
extra={"room_name": room_name},
exc_info=True,
)
return fallback
return _resolved_string_or_fallback(resolved_call_id, fallback)
def _resolved_string_or_fallback(value: object, fallback: str) -> str:
if value is None:
return fallback
candidate = str(value).strip()
return candidate or fallback
def _parse_metadata(metadata_raw: str | None) -> dict[str, Any]:
if not metadata_raw:
return {}
try:
parsed = json.loads(metadata_raw)
except json.JSONDecodeError:
return {}
if isinstance(parsed, dict):
return parsed
return {}
def _resolve_test_case_run_id(
*,
participant_metadata_raw: str | None,
recording_context: Mapping[str, Any] | None,
) -> str | None:
metadata = _parse_metadata(participant_metadata_raw)
for key in (
"test_case_run_id",
"testCaseRunId",
"conversation_id",
"conversationId",
):
value = metadata.get(key)
if value is None:
continue
candidate = str(value).strip()
if candidate:
return candidate
if not recording_context:
return None
for key in (
"customer_conversation_id",
"test_case_run_id",
"testCaseRunId",
"conversation_id",
"conversationId",
):
value = recording_context.get(key)
if value is None:
continue
candidate = str(value).strip()
if candidate:
return candidate
return None
def _resolve_status(close_event: CloseEvent | None) -> str:
return "error" if _serialize_close_reason(close_event) == "error" else "ended"
def _serialize_close_reason(close_event: CloseEvent | None) -> str | None:
if close_event is None:
return None
reason = close_event.reason
if reason is None:
return None
if isinstance(reason, str):
return reason
enum_value = getattr(reason, "value", None)
if isinstance(enum_value, str):
return enum_value
return str(reason)