530 lines
22 KiB
Python
530 lines
22 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
from dataclasses import dataclass
|
|
from typing import Any, Optional
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from ..models import 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 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"
|
|
|
|
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 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]:
|
|
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=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 == (self._compact(source_system, max_len=32) or "unknown"),
|
|
MountedTopicWorkspace.topic_key == topic_key,
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
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=self._compact(source_system, max_len=32) or "unknown",
|
|
channel_ref=self._compact(channel_ref, max_len=128),
|
|
conversation_scope_key=self._compact(conversation_scope_key, max_len=160),
|
|
thread_key=self._compact((thread_context or {}).get("thread_id"), max_len=255),
|
|
root_message_id=self._compact((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((thread_context or {}).get("thread_id"), max_len=255)
|
|
topic_row.root_message_id = self._compact((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((thread_context or {}).get("reply_to_message_id"), max_len=128),
|
|
"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((thread_context or {}).get("root_message_id") or (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 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
|
|
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,
|
|
}
|
|
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,
|
|
},
|
|
)
|
|
next_plan_status = None
|
|
if isinstance(semantic_gate_result, dict):
|
|
next_plan_status = self._compact(semantic_gate_result.get("next_plan_status"), max_len=32)
|
|
if next_plan_status:
|
|
topic_row.plan_status = next_plan_status
|
|
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:
|
|
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,
|
|
{
|
|
"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,
|
|
},
|
|
)
|
|
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()
|