217 lines
8.6 KiB
Python
217 lines
8.6 KiB
Python
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,
|
|
SemanticContextIngress,
|
|
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,
|
|
ingress_context: Optional[dict[str, Any]] = None,
|
|
) -> 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),
|
|
)
|
|
mode_card = build_mode_card(
|
|
topic_mode=topic_row.mode,
|
|
plan_status=topic_row.plan_status,
|
|
)
|
|
ingress = None
|
|
if isinstance(ingress_context, dict):
|
|
ingress = SemanticContextIngress(
|
|
candidate_kind=str(ingress_context.get("candidate_kind") or "").strip() or None,
|
|
source_system=str(ingress_context.get("source_system") or "").strip() or None,
|
|
reply_anchor_present=bool(ingress_context.get("reply_anchor_present")),
|
|
active_context_kind=str(ingress_context.get("active_context_kind") or "").strip() or None,
|
|
allowed_followup_actions=self._normalize_list(ingress_context.get("allowed_followup_actions")),
|
|
allowed_context_scopes=self._normalize_list(ingress_context.get("allowed_context_scopes")),
|
|
thread_context=self._normalize_mapping(ingress_context.get("thread_context")),
|
|
)
|
|
if ingress.candidate_kind == "draft_followup":
|
|
merged_allowed = list(mode_card.allowed_actions)
|
|
for action in (
|
|
"draft.followup.confirm",
|
|
"draft.followup.revise",
|
|
"draft.followup.cancel",
|
|
"draft.followup.answer_only",
|
|
):
|
|
if action not in merged_allowed:
|
|
merged_allowed.append(action)
|
|
mode_card.allowed_actions = merged_allowed
|
|
if ingress.candidate_kind == "active_context_followup":
|
|
merged_allowed = list(mode_card.allowed_actions)
|
|
for action in (
|
|
"context.followup.current_requirement",
|
|
"context.followup.current_draft",
|
|
"context.followup.independent_workflow",
|
|
"context.followup.clarify",
|
|
):
|
|
if action not in merged_allowed:
|
|
merged_allowed.append(action)
|
|
mode_card.allowed_actions = merged_allowed
|
|
|
|
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=mode_card,
|
|
ingress=ingress,
|
|
)
|