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

293 lines
12 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, MountedTopicWorkspace
from ..utils.timezone import now_cn_naive
@dataclass
class MountedIngressTracking:
topic_workspace: MountedTopicWorkspace
conversation_run: MountedConversationRun
context_layers_hit: dict[str, bool]
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]
@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]
@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 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]],
) -> 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,
)
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),
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",
metadata_json={},
)
self.db.add(topic_row)
await self.db.flush()
topic_row.channel_ref = self._compact(channel_ref, max_len=128)
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
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),
}
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),
}
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'}",
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,
)
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 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 = {
"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()