from __future__ import annotations from typing import Any, Optional from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from ....models import MountedConversationRun, MountedDraftWorkspace, MountedTopicWorkspace from .mode_card_service import build_mode_card from .packet_schema import ( SemanticContextDraftSnapshot, SemanticContextPacket, SemanticContextThread, SemanticContextTopic, SemanticContextTurn, ) class SemanticContextAssembler: def __init__(self, db: AsyncSession, *, recent_turn_limit: int = 6): self.db = db self.recent_turn_limit = max(int(recent_turn_limit), 2) @staticmethod def _normalize_list(value: Any) -> list[str]: if isinstance(value, list): return [str(item).strip() for item in value if str(item or "").strip()] return [] @staticmethod def _normalize_mapping(value: Any) -> Optional[Any]: if isinstance(value, (dict, list)): return value return None @staticmethod def _extract_source_preview(row: MountedConversationRun) -> Optional[str]: execution_outcome = row.execution_outcome if isinstance(row.execution_outcome, dict) else {} preview = str(execution_outcome.get("source_message_preview") or "").strip() return preview or None async def _load_topic( self, *, tenant_id: int, topic_workspace_id: int, ) -> MountedTopicWorkspace: topic_row = ( await self.db.execute( select(MountedTopicWorkspace).where( MountedTopicWorkspace.tenant_id == int(tenant_id), MountedTopicWorkspace.id == int(topic_workspace_id), ) ) ).scalar_one_or_none() if topic_row is None: raise ValueError("mounted topic workspace not found") return topic_row async def _load_draft( self, *, tenant_id: int, topic_workspace_id: int, ) -> MountedDraftWorkspace | None: return ( await self.db.execute( select(MountedDraftWorkspace).where( MountedDraftWorkspace.tenant_id == int(tenant_id), MountedDraftWorkspace.topic_workspace_id == int(topic_workspace_id), ) ) ).scalar_one_or_none() async def _load_recent_turns( self, *, tenant_id: int, topic_workspace_id: int, ) -> list[SemanticContextTurn]: run_rows = ( await self.db.execute( select(MountedConversationRun) .where( MountedConversationRun.tenant_id == int(tenant_id), MountedConversationRun.topic_workspace_id == int(topic_workspace_id), MountedConversationRun.status == "completed", ) .order_by(MountedConversationRun.updated_at.desc(), MountedConversationRun.id.desc()) .limit(max(self.recent_turn_limit, 1)) ) ).scalars().all() turns: list[SemanticContextTurn] = [] for row in reversed(run_rows): source_preview = self._extract_source_preview(row) if source_preview: turns.append(SemanticContextTurn(role="user", text=source_preview)) final_reply = str(row.final_reply or "").strip() if final_reply: turns.append(SemanticContextTurn(role="assistant", text=final_reply)) if len(turns) <= self.recent_turn_limit: return turns return turns[-self.recent_turn_limit :] async def assemble( self, *, tenant_id: int, operator_user_id: int, topic_workspace_id: int, latest_user_message: str, ) -> SemanticContextPacket: topic_row = await self._load_topic( tenant_id=tenant_id, topic_workspace_id=topic_workspace_id, ) draft_row = await self._load_draft( tenant_id=tenant_id, topic_workspace_id=topic_workspace_id, ) recent_turns = await self._load_recent_turns( tenant_id=tenant_id, topic_workspace_id=topic_workspace_id, ) topic = SemanticContextTopic( topic_workspace_id=int(topic_row.id), topic_key=topic_row.topic_key, source_system=topic_row.source_system, channel_ref=topic_row.channel_ref, conversation_scope_key=topic_row.conversation_scope_key, thread_key=topic_row.thread_key, mode=topic_row.mode, status=topic_row.status, plan_status=topic_row.plan_status, facts=self._normalize_mapping(topic_row.facts_json), goals=self._normalize_mapping(topic_row.goals_json), constraints=self._normalize_mapping(topic_row.constraints_json), metadata=self._normalize_mapping(topic_row.metadata_json), ) draft = None if draft_row is not None: draft = SemanticContextDraftSnapshot( draft_workspace_id=int(draft_row.id), state=draft_row.state, draft_version=int(draft_row.draft_version or 0), requirement_card=self._normalize_mapping(draft_row.requirement_card), draft_payload=self._normalize_mapping(draft_row.draft_payload), required_confirmations=self._normalize_list(draft_row.required_confirmations), ) metadata = topic_row.metadata_json if isinstance(topic_row.metadata_json, dict) else {} thread = SemanticContextThread( thread_key=topic_row.thread_key, conversation_scope_key=topic_row.conversation_scope_key, thread_summary=str( metadata.get("last_message_preview") or (topic_row.facts_json or {}).get("title") or "" ).strip() or None, recent_turns=recent_turns, open_questions=self._normalize_list(topic_row.open_questions), ) return SemanticContextPacket( packet_version="v1", tenant_id=int(tenant_id), operator_user_id=int(operator_user_id), topic_workspace_id=int(topic_row.id), latest_user_message=str(latest_user_message or "").strip(), topic=topic, draft=draft, thread=thread, mode_card=build_mode_card( topic_mode=topic_row.mode, plan_status=topic_row.plan_status, ), )