173 lines
7.9 KiB
Python
173 lines
7.9 KiB
Python
"""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),
|
||
}
|
||
|