PMFlow/backend/app/services/ai_pmo_kernel_mixins_ingres...

173 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""AI PMO kernel ingress render mixin."""
from __future__ import annotations
from . import ai_pmo_kernel_common as _common
for _name, _value in vars(_common).items():
if not _name.startswith("__"):
globals()[_name] = _value
class KernelIngressRenderMixin:
def _build_capture_result(
self,
*,
work_item: WorkItem,
intent: CanonicalIntent,
execution_result: dict[str, Any],
render: dict[str, Any],
direct_reply_required: bool,
status: str,
classification: str,
) -> dict[str, Any]:
linked_project_ids = [int(work_item.project_id)] if work_item.project_id is not None else []
direct_reply_text = str(render.get("text") or "").strip()
if _looks_like_upstream_timeout_message(direct_reply_text):
logger.warning(
"ai_pmo_upstream_timeout_reply_detected",
tenant_id=int(self.tenant_id),
work_item_id=int(work_item.id),
intent=str(intent.name or ""),
source_message_id=str(work_item.source_message_id or "")[:128],
observed_at=now_cn_naive().isoformat(),
reply_preview=direct_reply_text[:260],
)
signal_ids = _normalize_signal_id_list(execution_result.get("signal_ids"))
primary_signal_id = _safe_int(execution_result.get("signal_id")) or _safe_int(work_item.signal_id)
if primary_signal_id is not None and primary_signal_id not in signal_ids:
signal_ids = [primary_signal_id, *signal_ids]
if signal_ids and primary_signal_id is None:
primary_signal_id = signal_ids[0]
result = {
"handled": True,
"status": status,
"classification": classification,
"linked_project_ids": linked_project_ids,
"signal_id": primary_signal_id,
"signal_ids": signal_ids,
"created_project_id": _safe_int(execution_result.get("project_id")) if intent.name == INTENT_PROJECT_CREATE else None,
"auto_tasks": execution_result.get("auto_tasks") if isinstance(execution_result.get("auto_tasks"), list) else execution_result.get("tasks", []),
"ai_trace_id": f"ai-pmo-{int(work_item.id)}",
"results": [
{
"work_item_id": int(work_item.id),
"state": work_item.state,
"intent": intent.name,
"route_status": "queued_for_decision" if work_item.state == STATE_WAITING_MANUAL else "auto_done",
}
],
"intent": intent.name,
"workflow_route": str(work_item.intent or execution_result.get("workflow_route") or "").strip() or None,
"direct_reply_required": bool(direct_reply_required),
"direct_reply_text": direct_reply_text,
"reply_template_key": str(render.get("template_key") or "").strip(),
"work_item_id": int(work_item.id),
"decision_level": work_item.decision_level,
"review_summary": work_item.review_summary,
"decision_trace": (
execution_result.get("decision_trace")
if isinstance(execution_result, dict)
else ((intent.metadata or {}).get("decision_trace") if isinstance(intent.metadata, dict) else None)
),
}
if intent.name == INTENT_QUERY and isinstance(execution_result.get("rag_result"), dict):
result["rag_result"] = execution_result["rag_result"]
if intent.name == INTENT_PROJECT_CREATE:
raw_payload = execution_result.get("raw_payload") if isinstance(execution_result.get("raw_payload"), dict) else {}
result["project"] = raw_payload.get("project")
if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD:
result["signal_source_workflow"] = execution_result.get("signal_source_workflow")
result["signal_source_orchestration"] = execution_result.get("signal_source_orchestration")
result["ai_execution"] = execution_result.get("signal_source_orchestration")
return result
@staticmethod
def _primary_operation_code(intent_name: str) -> Optional[str]:
normalized_route = RouteResolver.normalize_route_key(intent_name)
if normalized_route == ROUTE_QUERY_ANSWER:
return "query.answer"
if normalized_route == ROUTE_PROJECT_CREATE:
return "project.create"
if normalized_route in {ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER, ROUTE_PROJECT_SIGNAL_COMMIT}:
if normalized_route == ROUTE_PROJECT_SIGNAL_DISCOVER:
return ROUTE_PROJECT_SIGNAL_DISCOVER
if normalized_route == ROUTE_PROJECT_SIGNAL_COMMIT:
return ROUTE_PROJECT_SIGNAL_COMMIT
return "signal.enter"
if normalized_route == ROUTE_PROJECT_SIGNAL_REFRESH:
return ROUTE_PROJECT_SIGNAL_REFRESH
if normalized_route == ROUTE_PROJECT_SIGNAL_CANCEL:
return ROUTE_PROJECT_SIGNAL_CANCEL
if normalized_route == ROUTE_SIGNAL_SOURCE_ONBOARD:
return "signal_source.onboard"
if normalized_route in {ROUTE_FOLLOWUP_CONFIRM, ROUTE_FOLLOWUP_CONTINUE}:
return "work_item.resume"
if intent_name == INTENT_QUERY:
return "query.answer"
if intent_name == INTENT_PROJECT_CREATE:
return "project.create"
if intent_name == INTENT_SIGNAL_CREATE:
return "signal.enter"
if intent_name == INTENT_SIGNAL_SOURCE_ONBOARD:
return "signal_source.onboard"
if intent_name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
return "work_item.resume"
return None
@staticmethod
def _risk_level_for_intent(intent_name: str) -> str:
if intent_name in {INTENT_PROJECT_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD}:
return "high"
if intent_name in {INTENT_SIGNAL_CREATE, INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
return "medium"
return "low"
@staticmethod
def _build_source_message_id(
*,
source_system: str,
source_message_id: Optional[str],
channel_ref: Optional[str],
sender: str,
message_text: str,
) -> str:
explicit = str(source_message_id or "").strip()
if explicit:
return explicit[:128]
basis = f"{source_system}|{channel_ref or ''}|{sender}|{message_text}"
digest = hashlib.sha1(basis.encode("utf-8"), usedforsecurity=False).hexdigest()
return f"{source_system}-{digest[:32]}"
@staticmethod
def _normalize_text(text: str) -> str:
cleaned = re.sub(r"</?at[^>]*>", " ", str(text or ""), flags=re.IGNORECASE)
cleaned = re.sub(r"^@PMFlow\s*", "", cleaned, flags=re.IGNORECASE)
cleaned = re.sub(r"\s+", " ", cleaned).strip()
return cleaned[:4000]
@staticmethod
def _build_transport_mode_reply(message_text: str) -> Optional[str]:
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
has_long_connection = "长连接" in normalized or "longconnection" in normalized
has_webhook = "webhook" in normalized
if not (has_long_connection and has_webhook):
return None
if not any(token in normalized for token in TRANSPORT_MODE_CORRECTION_HINTS):
return None
return "已按长连接模式处理飞书接入,当前不会走 webhook;这条说明不会再被当成信号源类型变更。"
@staticmethod
def _serialize_project(project: Optional[Project]) -> Optional[ProjectSnapshot]:
if project is None:
return None
return {
"id": int(project.id),
"name": str(project.name or ""),
"title": str(project.title or project.name or ""),
"description": str(project.description or ""),
"status": str(project.status or ""),
"owner_id": _safe_int(project.owner_id),
}