"""对话历史持久化。 只依赖管线已经发给客户端的最终文本事件,不侵入 Pipecat。媒体历史以后写入 conversation_artifacts,并把对象存储地址关联到会话或消息。 """ import asyncio from copy import deepcopy from datetime import UTC, datetime from uuid import uuid4 from db.models import ConversationArtifact, ConversationMessage, ConversationSession from db.session import SessionLocal from loguru import logger from services.object_storage import delete_object, put_object, storage_uri MAX_WORKFLOW_TRACE_EVENTS = 1000 MAX_PENDING_HISTORY_MESSAGES = 256 HISTORY_FLUSH_TIMEOUT_SECONDS = 3.0 def _parse_timestamp(value: object) -> datetime: if not isinstance(value, str) or not value: return datetime.now(UTC) try: parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) except ValueError: return datetime.now(UTC) class ConversationRecorder: """按事件顺序写入一通会话;写库失败不应中断实时通话。""" def __init__(self, session_id: str, analysis_plan: dict | None = None): self.session_id = session_id self._analysis_plan = deepcopy(analysis_plan or {}) self._sequence = 0 self._trace_sequence = 0 self._lock = asyncio.Lock() self._seen_events: set[str] = set() self._pending_artifacts: set[asyncio.Task[None]] = set() self._pending_messages: asyncio.Queue[dict] = asyncio.Queue( maxsize=MAX_PENDING_HISTORY_MESSAGES, ) self._message_writer: asyncio.Task[None] | None = None self._accepting_messages = True def record_transport_message_later(self, message: object) -> None: """Keep database latency off the media path, with bounded buffering.""" if ( not self._accepting_messages or not isinstance(message, dict) or message.get("type") not in { "transcript", "assistant-text-end", "workflow-event", } ): return try: self._pending_messages.put_nowait(deepcopy(message)) except asyncio.QueueFull: logger.warning(f"对话历史写入积压,跳过本条记录: session={self.session_id}") return if self._message_writer is None: self._message_writer = asyncio.create_task( self._write_pending_messages(), name=f"conversation-history-writer:{self.session_id}", ) async def _write_pending_messages(self) -> None: while True: message = await self._pending_messages.get() try: await self.record_transport_message(message) except Exception as exc: logger.warning(f"对话历史写入失败: {type(exc).__name__}") finally: self._pending_messages.task_done() async def flush_pending_messages(self) -> None: """Drain final transcripts before finishing the conversation row.""" self._accepting_messages = False writer = self._message_writer if writer is None: return try: await asyncio.wait_for( self._pending_messages.join(), timeout=HISTORY_FLUSH_TIMEOUT_SECONDS, ) except TimeoutError: logger.warning(f"对话历史收尾超时,丢弃剩余缓冲: session={self.session_id}") finally: writer.cancel() await asyncio.gather(writer, return_exceptions=True) self._message_writer = None while not self._pending_messages.empty(): self._pending_messages.get_nowait() self._pending_messages.task_done() @classmethod async def start( cls, *, assistant_id: str | None, assistant_name: str, channel: str, runtime_mode: str, session_id: str | None = None, extra: dict | None = None, analysis_plan: dict | None = None, ) -> "ConversationRecorder | None": session_id = session_id or f"conv_{uuid4().hex[:20]}" try: async with SessionLocal() as db: db.add( ConversationSession( id=session_id, assistant_id=assistant_id, assistant_name=assistant_name, channel=channel, runtime_mode=runtime_mode, status="active", message_count=0, analysis_status="none", analysis_data=( {"plan": deepcopy(analysis_plan)} if analysis_plan and analysis_plan.get("enabled") else {} ), extra=deepcopy(extra or {}), ) ) await db.commit() return cls(session_id, analysis_plan) except Exception as exc: logger.error(f"创建对话历史会话失败,不影响本次通话: {exc}") return None async def record_transport_message(self, message: object) -> None: if not isinstance(message, dict): return event_type = message.get("type") if event_type == "workflow-event": await self._append_workflow_event(message) return role = "" content = "" extra: dict = {} timestamp = message.get("timestamp") event_key = "" if event_type == "transcript": role = str(message.get("role") or "") content = str(message.get("content") or "").strip() event_key = f"transcript:{role}:{timestamp}:{content}" if message.get("source"): extra["source"] = str(message["source"]) if message.get("nodeId"): extra["node_id"] = str(message["nodeId"]) elif event_type == "assistant-text-end": role = "assistant" content = str(message.get("content") or "").strip() turn_id = str(message.get("turn_id") or "") event_key = f"assistant:{turn_id}" extra = { "turn_id": turn_id, "interrupted": bool(message.get("interrupted", False)), } else: return if role not in {"user", "assistant"} or not content or event_key in self._seen_events: return self._seen_events.add(event_key) await self._append(role, content, timestamp, extra) async def _append_workflow_event(self, message: dict) -> None: """Persist bounded control-plane history without creating chat rows.""" if message.get("internalStage") is True: return event_id = str(message.get("eventId") or "") event_name = str(message.get("event") or "") event_key = f"workflow:{event_id}" if not event_id or not event_name or event_key in self._seen_events: return self._seen_events.add(event_key) async with self._lock: next_sequence = self._trace_sequence + 1 payload = deepcopy(message) payload["sessionId"] = self.session_id payload["sequence"] = next_sequence try: async with SessionLocal() as db: conversation = await db.get(ConversationSession, self.session_id) if not conversation: return extra = dict(conversation.extra or {}) trace = list(extra.get("workflowTrace") or []) trace.append(payload) extra["workflowTrace"] = trace[-MAX_WORKFLOW_TRACE_EVENTS:] conversation.extra = extra await db.commit() self._trace_sequence = next_sequence except Exception as exc: logger.error(f"保存 Workflow 轨迹失败,不影响本次通话: {exc}") async def _append( self, role: str, content: str, timestamp: object, extra: dict, ) -> None: async with self._lock: next_sequence = self._sequence + 1 try: async with SessionLocal() as db: db.add( ConversationMessage( id=f"msg_{uuid4().hex[:20]}", session_id=self.session_id, sequence=next_sequence, role=role, content_type="text", content=content, occurred_at=_parse_timestamp(timestamp), extra=extra, ) ) conversation = await db.get(ConversationSession, self.session_id) if conversation: conversation.message_count = next_sequence await db.commit() self._sequence = next_sequence except Exception as exc: logger.error(f"保存对话文本失败,不影响本次通话: {exc}") def record_image_later( self, data: bytes, *, input_id: str, timestamp: object, content: str = "", source: str = "camera_capture", mime_type: str = "image/jpeg", ) -> None: """Persist a captured frame without delaying the realtime model turn.""" task = asyncio.create_task( self._record_image( data, input_id=input_id, timestamp=timestamp, content=content, source=source, mime_type=mime_type, ), name=f"conversation-image:{self.session_id}:{input_id}", ) self._pending_artifacts.add(task) task.add_done_callback(self._pending_artifacts.discard) async def _record_image( self, data: bytes, *, input_id: str, timestamp: object, content: str, source: str, mime_type: str, ) -> None: message_id = f"msg_{uuid4().hex[:20]}" artifact_id = f"artifact_{uuid4().hex[:20]}" extension = ".jpg" if mime_type == "image/jpeg" else ".bin" key = f"conversations/{self.session_id}/{artifact_id}{extension}" try: await asyncio.to_thread(put_object, key, data, mime_type) except Exception as exc: logger.error(f"保存会话图片到对象存储失败,不影响本次通话: {exc}") return async with self._lock: next_sequence = self._sequence + 1 try: async with SessionLocal() as db: db.add( ConversationMessage( id=message_id, session_id=self.session_id, sequence=next_sequence, role="user", content_type="image", content=content.strip(), occurred_at=_parse_timestamp(timestamp), extra={ "input_id": input_id, "source": source, }, ) ) # Flush before artifact insert: db.get() below autoflushes and # can otherwise write conversation_artifacts first (FK violation). await db.flush() db.add( ConversationArtifact( id=artifact_id, session_id=self.session_id, message_id=message_id, kind="image", storage_uri=storage_uri(key), mime_type=mime_type, size_bytes=len(data), extra={"input_id": input_id, "source": source}, ) ) conversation = await db.get(ConversationSession, self.session_id) if conversation: conversation.message_count = next_sequence await db.commit() self._sequence = next_sequence except Exception as exc: logger.error(f"保存会话图片索引失败,不影响本次通话: {exc}") try: await asyncio.to_thread(delete_object, key) except Exception: logger.warning(f"清理未关联的会话图片失败: {key}") async def finish(self, *, status: str = "completed") -> None: """Finish the session even if the owning pipeline is being cancelled.""" finish_task = asyncio.create_task( self._finish(status=status), name=f"conversation-recorder-finish:{self.session_id}", ) try: await asyncio.shield(finish_task) except asyncio.CancelledError: # Shield prevents an interrupted pipeline cleanup from abandoning a # checked-out asyncpg connection. Preserve cancellation only after # the short database cleanup has returned the connection. await asyncio.shield(finish_task) raise async def _finish(self, *, status: str) -> None: await self.flush_pending_messages() pending_artifacts = list(self._pending_artifacts) if pending_artifacts: await asyncio.gather(*pending_artifacts, return_exceptions=True) async with self._lock: try: async with SessionLocal() as db: conversation = await db.get(ConversationSession, self.session_id) if conversation: conversation.status = status conversation.ended_at = datetime.now(UTC) conversation.message_count = self._sequence if ( status == "completed" and self._sequence > 0 and self._analysis_plan.get("enabled") ): conversation.analysis_status = "pending" conversation.analysis_error = "" conversation.analysis_data = { "plan": deepcopy(self._analysis_plan) } await db.commit() except Exception as exc: logger.error(f"结束对话历史会话失败: {exc}")