PMFlow/backend/app/services/mounted_ai_runtime_write_se...

798 lines
35 KiB
Python

from __future__ import annotations
import hashlib
from dataclasses import dataclass
from typing import Any, Optional
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import FeishuReplyOutbox, MountedAgentTrace, MountedConversationRun, MountedSourceActivation, MountedTopicWorkspace
from ..utils.thread_context import build_feishu_conversation_scope_key
from ..utils.timezone import now_cn_naive
@dataclass
class MountedIngressTracking:
topic_workspace: MountedTopicWorkspace
conversation_run: MountedConversationRun
context_layers_hit: dict[str, bool]
source_activation: Optional[MountedSourceActivation] = None
class MountedAIRuntimeWriteService:
def __init__(self, db: AsyncSession):
self.db = db
@staticmethod
def _compact(value: Any, *, max_len: int = 128) -> Optional[str]:
text = str(value or "").strip()
if not text:
return None
return text[:max_len]
@staticmethod
def _merge_json_dict(existing: Any, updates: dict[str, Any]) -> dict[str, Any]:
base = dict(existing) if isinstance(existing, dict) else {}
for key, value in updates.items():
if value is None:
continue
base[key] = value
return base
@classmethod
def _reconcile_semantic_packet_summary_requirement_card(
cls,
execution_outcome: Any,
requirement_plan_context: Any,
) -> dict[str, Any]:
base = dict(execution_outcome) if isinstance(execution_outcome, dict) else {}
context = dict(requirement_plan_context) if isinstance(requirement_plan_context, dict) else {}
if not context:
return base
packet_summary = (
dict(base.get("semantic_packet_summary") or {})
if isinstance(base.get("semantic_packet_summary"), dict)
else {}
)
draft_summary = (
dict(packet_summary.get("draft") or {})
if isinstance(packet_summary.get("draft"), dict)
else {}
)
requirement_card = (
dict(draft_summary.get("requirement_card") or {})
if isinstance(draft_summary.get("requirement_card"), dict)
else {}
)
requirement_card.update(context)
draft_summary["requirement_card"] = requirement_card
packet_summary["draft"] = draft_summary
base["semantic_packet_summary"] = packet_summary
return base
@classmethod
def build_topic_key(
cls,
*,
source_system: str,
channel_ref: Optional[str],
source_message_id: Optional[str],
sender: str,
message_text: str,
thread_context: Optional[dict[str, Any]],
) -> str:
normalized_source = cls._compact(source_system, max_len=32) or "unknown"
normalized_channel = cls._compact(channel_ref, max_len=128) or "direct"
root_message_id = cls._compact((thread_context or {}).get("root_message_id"), max_len=128)
if root_message_id:
return f"{normalized_source}:{normalized_channel}:root:{root_message_id}"
thread_id = cls._compact((thread_context or {}).get("thread_id"), max_len=128)
if thread_id:
return f"{normalized_source}:{normalized_channel}:thread:{thread_id}"
message_id = cls._compact(source_message_id, max_len=128)
if message_id:
return f"{normalized_source}:{normalized_channel}:message:{message_id}"
digest = hashlib.sha1(
f"{normalized_source}|{normalized_channel}|{sender}|{message_text}".encode("utf-8"),
usedforsecurity=False,
).hexdigest()
return f"{normalized_source}:{normalized_channel}:synthetic:{digest[:20]}"
@classmethod
def build_run_trace_id(
cls,
*,
source_system: str,
source_message_id: Optional[str],
topic_key: str,
) -> str:
normalized_source = cls._compact(source_system, max_len=32) or "unknown"
normalized_message_id = cls._compact(source_message_id, max_len=128)
if normalized_message_id:
return f"mounted:{normalized_source}:{normalized_message_id}"[:64]
digest = hashlib.sha1(topic_key.encode("utf-8"), usedforsecurity=False).hexdigest()
return f"mounted:{normalized_source}:{digest[:48]}"[:64]
@classmethod
def build_conversation_scope_key(
cls,
*,
source_system: str,
channel_ref: Optional[str],
sender: str,
source_sender_open_id: Optional[str] = None,
) -> Optional[str]:
normalized_source = cls._compact(source_system, max_len=32) or "unknown"
if normalized_source == "feishu":
return build_feishu_conversation_scope_key(
source_chat_id=channel_ref,
source_sender_open_id=source_sender_open_id,
sender=sender,
) or None
normalized_channel = cls._compact(channel_ref, max_len=128)
normalized_sender = cls._compact(source_sender_open_id or sender, max_len=128)
if normalized_channel and normalized_sender:
return f"{normalized_source}:{normalized_channel}:{normalized_sender}"[:160]
if normalized_sender:
return f"{normalized_source}:{normalized_sender}"[:160]
if normalized_channel:
return f"{normalized_source}:{normalized_channel}"[:160]
return None
@staticmethod
def _mode_for_route(
*,
workflow_route: str,
auto_execute: bool,
mode_override: Optional[str] = None,
) -> str:
if str(mode_override or "").strip():
return str(mode_override).strip()
normalized_route = str(workflow_route or "").strip().lower()
if auto_execute:
return "execute"
if normalized_route.startswith("query."):
return "chat"
if normalized_route.startswith("followup."):
return "plan"
return "plan"
@classmethod
def _topic_has_requirement_plan_context(cls, topic_row: MountedTopicWorkspace) -> bool:
metadata = topic_row.metadata_json if isinstance(getattr(topic_row, "metadata_json", None), dict) else {}
if bool(metadata.get("plan_mode")):
return True
if str(getattr(topic_row, "mode", "") or "").strip().lower() == "plan":
return True
if str(getattr(topic_row, "plan_status", "") or "").strip().lower() in {
"collecting",
"options_ready",
"plan_ready",
"awaiting_execute_confirm",
"draft_pending_confirm",
}:
return True
pending_actions = getattr(topic_row, "pending_actions", None)
if isinstance(pending_actions, list) and any(
str(action or "").strip() in {"continue_requirement_plan", "confirm_requirement_draft", "resume_requirement_plan"}
for action in pending_actions
):
return True
return False
@staticmethod
def _extract_result_topic_workspace_id(result: Optional[dict[str, Any]]) -> Optional[int]:
if not isinstance(result, dict):
return None
artifact = result.get("draft_artifact") if isinstance(result.get("draft_artifact"), dict) else {}
for candidate in (
artifact.get("topic_workspace_id"),
result.get("topic_workspace_id"),
):
try:
if candidate is None or candidate == "":
continue
return int(candidate)
except (TypeError, ValueError):
continue
return None
async def _resolve_source_activation(
self,
*,
tenant_id: int,
source_system: str,
channel_ref: Optional[str],
) -> tuple[Optional[MountedSourceActivation], bool]:
normalized_source = self._compact(source_system, max_len=32) or "unknown"
normalized_channel = self._compact(channel_ref, max_len=128)
candidates = (
await self.db.execute(
select(MountedSourceActivation).where(
MountedSourceActivation.tenant_id == int(tenant_id),
MountedSourceActivation.source_system == normalized_source,
MountedSourceActivation.status == "active",
)
)
).scalars().all()
activation_map = {
(str(row.scope_type or "").strip().lower(), str(row.scope_ref or "").strip()): row
for row in candidates
}
for key in (
("chat", normalized_channel or ""),
("source", normalized_source),
("tenant", str(int(tenant_id))),
):
match = activation_map.get(key)
if match is not None:
return match, True
return None, bool(candidates)
async def resolve_source_activation_scope(
self,
*,
tenant_id: int,
source_system: str,
channel_ref: Optional[str],
thread_context: Optional[dict[str, Any]] = None,
) -> tuple[Optional[MountedSourceActivation], bool]:
_ = thread_context
return await self._resolve_source_activation(
tenant_id=tenant_id,
source_system=source_system,
channel_ref=channel_ref,
)
async def canonicalize_thread_context(
self,
*,
tenant_id: int,
source_system: str,
channel_ref: Optional[str],
thread_context: Optional[dict[str, Any]],
) -> tuple[Optional[dict[str, Any]], dict[str, Optional[str]]]:
normalized_source = self._compact(source_system, max_len=32) or "unknown"
normalized_thread_context = dict(thread_context) if isinstance(thread_context, dict) else thread_context
if normalized_source != "feishu":
return normalized_thread_context, {}
return await self._canonicalize_feishu_thread_context(
tenant_id=tenant_id,
channel_ref=channel_ref,
thread_context=normalized_thread_context,
)
async def _canonicalize_feishu_thread_context(
self,
*,
tenant_id: int,
channel_ref: Optional[str],
thread_context: Optional[dict[str, Any]],
) -> tuple[Optional[dict[str, Any]], dict[str, Optional[str]]]:
if not isinstance(thread_context, dict):
return thread_context, {}
candidate_sent_ids: list[str] = []
for key in ("root_message_id", "reply_to_message_id"):
message_id = self._compact(thread_context.get(key), max_len=128)
if message_id and message_id not in candidate_sent_ids:
candidate_sent_ids.append(message_id)
if not candidate_sent_ids:
return dict(thread_context), {}
normalized_chat_id = self._compact(channel_ref, max_len=128)
stmt = select(FeishuReplyOutbox.sent_message_id, FeishuReplyOutbox.source_message_id).where(
FeishuReplyOutbox.tenant_id == int(tenant_id),
FeishuReplyOutbox.status == "sent",
FeishuReplyOutbox.sent_message_id.in_(candidate_sent_ids),
)
if normalized_chat_id:
stmt = stmt.where(FeishuReplyOutbox.chat_id == normalized_chat_id)
rows = (
await self.db.execute(stmt.order_by(FeishuReplyOutbox.id.desc()))
).all()
source_by_sent_id: dict[str, str] = {}
for sent_message_id, source_message_id in rows:
sent_key = self._compact(sent_message_id, max_len=128)
source_key = self._compact(source_message_id, max_len=128)
if sent_key and source_key and sent_key not in source_by_sent_id:
source_by_sent_id[sent_key] = source_key
if not source_by_sent_id:
return dict(thread_context), {}
canonical_thread_context = dict(thread_context)
original_root_message_id = self._compact(thread_context.get("root_message_id"), max_len=128)
original_reply_to_message_id = self._compact(thread_context.get("reply_to_message_id"), max_len=128)
canonical_root_message_id = source_by_sent_id.get(original_root_message_id or "")
canonical_reply_to_message_id = source_by_sent_id.get(original_reply_to_message_id or "")
if canonical_root_message_id:
canonical_thread_context["root_message_id"] = canonical_root_message_id
elif not original_root_message_id and canonical_reply_to_message_id:
canonical_thread_context["root_message_id"] = canonical_reply_to_message_id
if canonical_reply_to_message_id:
canonical_thread_context["reply_to_message_id"] = canonical_reply_to_message_id
return canonical_thread_context, {
"original_root_message_id": original_root_message_id,
"original_reply_to_message_id": original_reply_to_message_id,
"canonical_root_message_id": self._compact(canonical_thread_context.get("root_message_id"), max_len=128),
"canonical_reply_to_message_id": self._compact(canonical_thread_context.get("reply_to_message_id"), max_len=128),
}
async def begin_ingress_run(
self,
*,
tenant_id: int,
source_system: str,
source_message_id: Optional[str],
message_text: str,
sender: str,
channel_ref: Optional[str],
thread_context: Optional[dict[str, Any]],
source_sender_open_id: Optional[str] = None,
) -> Optional[MountedIngressTracking]:
normalized_source_system = self._compact(source_system, max_len=32) or "unknown"
effective_thread_context, canonicalization_metadata = await self.canonicalize_thread_context(
tenant_id=tenant_id,
source_system=normalized_source_system,
channel_ref=channel_ref,
thread_context=thread_context,
)
topic_key = self.build_topic_key(
source_system=source_system,
channel_ref=channel_ref,
source_message_id=source_message_id,
sender=sender,
message_text=message_text,
thread_context=effective_thread_context,
)
conversation_scope_key = self.build_conversation_scope_key(
source_system=source_system,
channel_ref=channel_ref,
sender=sender,
source_sender_open_id=source_sender_open_id,
)
activation, has_activation_rules = await self._resolve_source_activation(
tenant_id=tenant_id,
source_system=source_system,
channel_ref=channel_ref,
)
if has_activation_rules and activation is None:
return None
topic_row = (
await self.db.execute(
select(MountedTopicWorkspace).where(
MountedTopicWorkspace.tenant_id == int(tenant_id),
MountedTopicWorkspace.source_system == normalized_source_system,
MountedTopicWorkspace.topic_key == topic_key,
)
)
).scalar_one_or_none()
if topic_row is None:
topic_row = await self._resolve_existing_topic_for_reply_anchor(
tenant_id=tenant_id,
source_system=normalized_source_system,
channel_ref=channel_ref,
conversation_scope_key=conversation_scope_key,
thread_context=effective_thread_context,
)
topic_exists = topic_row is not None
now_at = now_cn_naive()
if topic_row is None:
topic_row = MountedTopicWorkspace(
tenant_id=int(tenant_id),
topic_key=topic_key,
source_system=normalized_source_system,
channel_ref=self._compact(channel_ref, max_len=128),
conversation_scope_key=self._compact(conversation_scope_key, max_len=160),
thread_key=self._compact((effective_thread_context or {}).get("thread_id"), max_len=255),
root_message_id=self._compact((effective_thread_context or {}).get("root_message_id"), max_len=128),
mode="chat",
status="active",
plan_status="idle",
metadata_json={},
)
self.db.add(topic_row)
await self.db.flush()
if activation is not None:
activation.last_seen_at = now_at
topic_row.channel_ref = self._compact(channel_ref, max_len=128)
topic_row.conversation_scope_key = self._compact(conversation_scope_key, max_len=160)
topic_row.thread_key = self._compact((effective_thread_context or {}).get("thread_id"), max_len=255)
topic_row.root_message_id = self._compact((effective_thread_context or {}).get("root_message_id"), max_len=128)
topic_row.last_message_id = self._compact(source_message_id, max_len=128)
topic_row.last_triggered_at = now_at
if not str(topic_row.plan_status or "").strip():
topic_row.plan_status = "idle"
topic_row.metadata_json = {
**(topic_row.metadata_json if isinstance(topic_row.metadata_json, dict) else {}),
"last_sender": self._compact(sender, max_len=128),
"last_message_preview": self._compact(message_text, max_len=200),
"last_reply_to_message_id": self._compact((effective_thread_context or {}).get("reply_to_message_id"), max_len=128),
"thread_context_canonicalization": canonicalization_metadata or None,
"source_activation": (
{
"scope_type": activation.scope_type,
"scope_ref": activation.scope_ref,
"cutover_mode": activation.cutover_mode,
"auto_reply_enabled": bool(activation.auto_reply_enabled),
}
if activation is not None
else None
),
}
context_layers_hit = {
"current_message": True,
"thread": bool((effective_thread_context or {}).get("root_message_id") or (effective_thread_context or {}).get("thread_id")),
"topic": bool(topic_exists),
"source_activation": bool(activation is not None),
}
trace_id = self.build_run_trace_id(
source_system=source_system,
source_message_id=source_message_id,
topic_key=topic_key,
)
run_row = (
await self.db.execute(
select(MountedConversationRun).where(
MountedConversationRun.tenant_id == int(tenant_id),
MountedConversationRun.trace_id == trace_id,
)
)
).scalar_one_or_none()
if run_row is None:
run_row = MountedConversationRun(
tenant_id=int(tenant_id),
trace_id=trace_id,
topic_workspace_id=int(topic_row.id),
source_system=self._compact(source_system, max_len=32) or "unknown",
source_message_id=self._compact(source_message_id, max_len=128),
mode_before=self._compact(topic_row.mode, max_len=24) if topic_exists else None,
active_agent="ConversationOrchestrator",
status="running",
trigger_reason=(
f"ingress:{self._compact(source_system, max_len=32) or 'unknown'}:{activation.cutover_mode}"
if activation is not None
else f"ingress:{self._compact(source_system, max_len=32) or 'unknown'}"
),
context_layers_hit=context_layers_hit,
started_at=now_at,
)
self.db.add(run_row)
await self.db.flush()
else:
run_row.topic_workspace_id = int(topic_row.id)
run_row.status = "running"
run_row.active_agent = "ConversationOrchestrator"
run_row.context_layers_hit = context_layers_hit
if run_row.started_at is None:
run_row.started_at = now_at
return MountedIngressTracking(
topic_workspace=topic_row,
conversation_run=run_row,
context_layers_hit=context_layers_hit,
source_activation=activation,
)
async def _resolve_existing_topic_for_reply_anchor(
self,
*,
tenant_id: int,
source_system: str,
channel_ref: Optional[str],
conversation_scope_key: Optional[str],
thread_context: Optional[dict[str, Any]],
) -> Optional[MountedTopicWorkspace]:
if str(source_system or "").strip().lower() != "feishu":
return None
anchor_ids: list[str] = []
for key in ("root_message_id", "reply_to_message_id"):
candidate = self._compact((thread_context or {}).get(key), max_len=128)
if candidate and candidate not in anchor_ids:
anchor_ids.append(candidate)
if not anchor_ids:
return None
normalized_channel = self._compact(channel_ref, max_len=128)
normalized_scope = self._compact(conversation_scope_key, max_len=160)
topic_key_candidates: list[str] = []
for anchor_id in anchor_ids:
if normalized_channel:
topic_key_candidates.append(f"feishu:{normalized_channel}:message:{anchor_id}")
topic_key_candidates.append(f"feishu:{normalized_channel}:root:{anchor_id}")
stmt = select(MountedTopicWorkspace).where(
MountedTopicWorkspace.tenant_id == int(tenant_id),
MountedTopicWorkspace.source_system == "feishu",
or_(
MountedTopicWorkspace.root_message_id.in_(anchor_ids),
MountedTopicWorkspace.last_message_id.in_(anchor_ids),
MountedTopicWorkspace.topic_key.in_(topic_key_candidates),
),
)
if normalized_channel:
stmt = stmt.where(MountedTopicWorkspace.channel_ref == normalized_channel)
rows = (
await self.db.execute(
stmt.order_by(MountedTopicWorkspace.updated_at.desc(), MountedTopicWorkspace.id.desc())
)
).scalars().all()
if not rows:
return None
scored_rows: list[tuple[int, MountedTopicWorkspace]] = []
for row in rows:
score = 0
row_topic_key = str(getattr(row, "topic_key", "") or "").strip()
row_root_message_id = str(getattr(row, "root_message_id", "") or "").strip()
row_last_message_id = str(getattr(row, "last_message_id", "") or "").strip()
row_scope = str(getattr(row, "conversation_scope_key", "") or "").strip()
if row_topic_key in topic_key_candidates:
score += 120
if row_last_message_id in anchor_ids:
score += 90
if row_root_message_id in anchor_ids:
score += 80
if normalized_scope and row_scope == normalized_scope:
score += 15
scored_rows.append((score, row))
scored_rows.sort(key=lambda item: (item[0], int(getattr(item[1], "id", 0) or 0)), reverse=True)
best_score, best_row = scored_rows[0]
return best_row if best_score > 0 else None
async def update_topic_plan_state(
self,
*,
tracking: MountedIngressTracking,
plan_status: str,
topic_status: Optional[str] = None,
pending_actions: Optional[list[str]] = None,
metadata: Optional[dict[str, Any]] = None,
) -> None:
topic_row = tracking.topic_workspace
topic_row.plan_status = self._compact(plan_status, max_len=32) or str(topic_row.plan_status or "idle")
if str(topic_status or "").strip():
topic_row.status = self._compact(topic_status, max_len=24) or topic_row.status
if pending_actions is not None:
topic_row.pending_actions = [str(item).strip() for item in pending_actions if str(item or "").strip()]
if isinstance(metadata, dict) and metadata:
topic_row.metadata_json = {
**(topic_row.metadata_json if isinstance(topic_row.metadata_json, dict) else {}),
**metadata,
}
await self.db.flush()
async def record_orchestrator_decision(
self,
*,
tracking: MountedIngressTracking,
workflow_route: str,
intent_name: str,
intent_confidence: float,
trigger_reason: str,
auto_execute: bool,
work_item_id: Optional[int],
route_metadata: Optional[dict[str, Any]],
mode_override: Optional[str] = None,
) -> str:
mode_after = self._mode_for_route(
workflow_route=workflow_route,
auto_execute=auto_execute,
mode_override=mode_override,
)
topic_row = tracking.topic_workspace
run_row = tracking.conversation_run
if mode_after == "chat" and self._topic_has_requirement_plan_context(topic_row):
mode_after = "plan"
topic_row.mode = mode_after
topic_row.metadata_json = {
**(topic_row.metadata_json if isinstance(topic_row.metadata_json, dict) else {}),
"last_workflow_route": str(workflow_route or "").strip() or None,
"last_intent_name": str(intent_name or "").strip() or None,
"last_intent_confidence": float(intent_confidence or 0.0),
"last_work_item_id": int(work_item_id) if work_item_id is not None else None,
"plan_mode": True if mode_after == "plan" and self._topic_has_requirement_plan_context(topic_row) else (
topic_row.metadata_json.get("plan_mode")
if isinstance(topic_row.metadata_json, dict)
else None
),
}
run_row.mode_after = mode_after
run_row.active_agent = "ConversationOrchestrator"
run_row.trigger_reason = self._compact(trigger_reason, max_len=128)
self.db.add(
MountedAgentTrace(
tenant_id=int(topic_row.tenant_id),
conversation_run_id=int(run_row.id),
agent_name="ConversationOrchestrator",
status="completed",
input_summary={
"mode_before": run_row.mode_before,
"context_layers_hit": tracking.context_layers_hit,
},
output_summary={
"mode_after": mode_after,
"workflow_route": str(workflow_route or "").strip() or None,
"intent_name": str(intent_name or "").strip() or None,
"auto_execute": bool(auto_execute),
"route_metadata": dict(route_metadata or {}),
},
)
)
await self.db.flush()
return mode_after
async def record_agent_trace(
self,
*,
tracking: MountedIngressTracking,
agent_name: str,
status: str = "completed",
latency_ms: Optional[int] = None,
input_summary: Optional[Any] = None,
output_summary: Optional[Any] = None,
tool_trace: Optional[Any] = None,
failure_reason: Optional[str] = None,
) -> None:
topic_row = tracking.topic_workspace
run_row = tracking.conversation_run
normalized_agent = self._compact(agent_name, max_len=64) or "SpecialistAgent"
normalized_status = self._compact(status, max_len=24) or "completed"
if normalized_status != "failed":
run_row.active_agent = normalized_agent
self.db.add(
MountedAgentTrace(
tenant_id=int(topic_row.tenant_id),
conversation_run_id=int(run_row.id),
agent_name=normalized_agent,
status=normalized_status,
latency_ms=int(latency_ms) if latency_ms is not None else None,
input_summary=input_summary if isinstance(input_summary, (dict, list)) else None,
output_summary=output_summary if isinstance(output_summary, (dict, list)) else None,
tool_trace=tool_trace if isinstance(tool_trace, (dict, list)) else None,
failure_reason=self._compact(failure_reason, max_len=500),
)
)
await self.db.flush()
async def record_semantic_shadow(
self,
*,
tracking: MountedIngressTracking,
source_message_preview: Optional[str],
semantic_packet_summary: Optional[dict[str, Any]],
semantic_decision: Optional[dict[str, Any]],
semantic_gate_result: Optional[dict[str, Any]],
semantic_diff: Optional[dict[str, Any]],
status: str = "completed",
failure_reason: Optional[str] = None,
) -> None:
topic_row = tracking.topic_workspace
run_row = tracking.conversation_run
normalized_status = self._compact(status, max_len=24) or "completed"
run_row.execution_outcome = self._merge_json_dict(
run_row.execution_outcome,
{
"source_message_preview": self._compact(source_message_preview, max_len=280),
"semantic_packet_summary": semantic_packet_summary if isinstance(semantic_packet_summary, dict) else None,
"semantic_decision": semantic_decision if isinstance(semantic_decision, dict) else None,
"semantic_gate_result": semantic_gate_result if isinstance(semantic_gate_result, dict) else None,
"semantic_diff": semantic_diff if isinstance(semantic_diff, dict) else None,
},
)
self.db.add(
MountedAgentTrace(
tenant_id=int(topic_row.tenant_id),
conversation_run_id=int(run_row.id),
agent_name="SemanticShadowPlanner",
status=normalized_status,
input_summary={
"topic_workspace_id": int(topic_row.id),
"mode_before": run_row.mode_before,
},
output_summary={
"semantic_packet_summary": semantic_packet_summary if isinstance(semantic_packet_summary, dict) else None,
"semantic_decision": semantic_decision if isinstance(semantic_decision, dict) else None,
"semantic_gate_result": semantic_gate_result if isinstance(semantic_gate_result, dict) else None,
"semantic_diff": semantic_diff if isinstance(semantic_diff, dict) else None,
},
tool_trace=[
{
"tool_name": "semantic.shadow",
"status": "completed",
}
],
failure_reason=self._compact(failure_reason, max_len=500),
)
)
await self.db.flush()
async def finalize_run(
self,
*,
tracking: MountedIngressTracking,
result: Optional[dict[str, Any]] = None,
error_text: Optional[str] = None,
) -> None:
topic_row = tracking.topic_workspace
run_row = tracking.conversation_run
now_at = now_cn_naive()
normalized_error = str(error_text or "").strip()
final_reply = str((result or {}).get("direct_reply_text") or "").strip() if isinstance(result, dict) else ""
if normalized_error:
run_row.status = "failed"
run_row.active_agent = "ConversationOrchestrator"
run_row.error_text = normalized_error
else:
persisted_result_fields: dict[str, Any] = {
"handled": bool((result or {}).get("handled")) if isinstance(result, dict) else False,
"status": str((result or {}).get("status") or "").strip() if isinstance(result, dict) else "",
"classification": str((result or {}).get("classification") or "").strip() if isinstance(result, dict) else "",
"workflow_route": str((result or {}).get("workflow_route") or "").strip() if isinstance(result, dict) else "",
"work_item_id": (result or {}).get("work_item_id") if isinstance(result, dict) else None,
"direct_reply_text": final_reply or None,
}
if isinstance(result, dict):
for field in (
"answer",
"rag_result",
"reply_template_key",
"draft_version",
"draft_artifact",
"requirement_plan_context",
"semantic_bundle",
"semantic_bundle_id",
"semantic_version",
"semantic_source",
):
value = result.get(field)
if value is None:
continue
if isinstance(value, str) and not value.strip():
continue
persisted_result_fields[field] = value
run_row.status = "completed"
run_row.active_agent = "ReplyComposer"
run_row.final_reply = final_reply or None
run_row.execution_outcome = self._merge_json_dict(
run_row.execution_outcome,
persisted_result_fields,
)
run_row.execution_outcome = self._reconcile_semantic_packet_summary_requirement_card(
run_row.execution_outcome,
persisted_result_fields.get("requirement_plan_context"),
)
canonical_topic_workspace_id = self._extract_result_topic_workspace_id(result)
if canonical_topic_workspace_id is not None and canonical_topic_workspace_id != int(topic_row.id):
canonical_topic = (
await self.db.execute(
select(MountedTopicWorkspace).where(
MountedTopicWorkspace.tenant_id == int(topic_row.tenant_id),
MountedTopicWorkspace.id == int(canonical_topic_workspace_id),
)
)
).scalar_one_or_none()
if canonical_topic is not None:
topic_row = canonical_topic
tracking.topic_workspace = canonical_topic
run_row.topic_workspace_id = int(canonical_topic.id)
run_row.completed_at = now_at
self.db.add(
MountedAgentTrace(
tenant_id=int(topic_row.tenant_id),
conversation_run_id=int(run_row.id),
agent_name="ReplyComposer" if not normalized_error else "ConversationOrchestrator",
status="failed" if normalized_error else "completed",
output_summary=(
{"final_reply": final_reply or None, "status": str((result or {}).get("status") or "").strip() or None}
if not normalized_error
else None
),
failure_reason=normalized_error or None,
)
)
await self.db.flush()