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

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()