PMFlow/backend/app/services/ai_pmo/semantic_context/assembler.py

218 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.regenerate",
"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,
)