241 lines
11 KiB
Python
241 lines
11 KiB
Python
"""AI PMO kernel followup routing 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 KernelFollowupRoutingMixin:
|
|
async def _project_waiting_manual(
|
|
self,
|
|
*,
|
|
work_item: WorkItem,
|
|
message_text: str,
|
|
render: dict[str, Any],
|
|
intent: CanonicalIntent,
|
|
) -> None:
|
|
await NotificationService(self.db).create_notifications(
|
|
tenant_id=self.tenant_id,
|
|
user_ids=await NotificationService(self.db)._resolve_recipients(tenant_id=self.tenant_id),
|
|
title="AI PMO 待人工确认",
|
|
content=str(render.get("text") or message_text)[:4000],
|
|
category="decision",
|
|
source="ai_pmo",
|
|
entity_type="work_item",
|
|
entity_id=int(work_item.id),
|
|
target_url=f"/inbox?workItemId={int(work_item.id)}",
|
|
kind="action_required",
|
|
priority="high",
|
|
status="pending_manual",
|
|
recommended_action="view_detail",
|
|
dedupe_key=f"ai-pmo-waiting:{int(work_item.id)}",
|
|
payload={
|
|
"work_item_id": int(work_item.id),
|
|
"intent": intent.name,
|
|
"project_id": intent.project_id,
|
|
"message_text": message_text[:500],
|
|
},
|
|
commit=False,
|
|
)
|
|
|
|
async def _project_success(self, *, intent: CanonicalIntent, execution_result: dict[str, Any]) -> None:
|
|
if intent.name == INTENT_SIGNAL_CREATE:
|
|
signal_ids = _normalize_signal_id_list(execution_result.get("signal_ids"))
|
|
signal_id = _safe_int(execution_result.get("signal_id"))
|
|
if signal_id is not None and signal_id not in signal_ids:
|
|
signal_ids = [signal_id, *signal_ids]
|
|
if not signal_ids:
|
|
return
|
|
summary_pool = execution_result.get("summaries") if isinstance(execution_result.get("summaries"), list) else []
|
|
for index, item_signal_id in enumerate(signal_ids):
|
|
signal_content = str(summary_pool[index] if index < len(summary_pool) else "")[:300]
|
|
if not signal_content:
|
|
signal_content = str(execution_result.get("summary") or item_signal_id)
|
|
await NotificationService(self.db).notify_signal_created(
|
|
tenant_id=self.tenant_id,
|
|
signal_id=int(item_signal_id),
|
|
signal_content=signal_content,
|
|
source="ai_pmo",
|
|
target_url=(
|
|
f"/projects/{int(execution_result['project_id'])}/board"
|
|
if _safe_int(execution_result.get("project_id")) is not None
|
|
else None
|
|
),
|
|
)
|
|
|
|
async def _execute_followup_intent(
|
|
self,
|
|
*,
|
|
work_item: WorkItem,
|
|
intent: CanonicalIntent,
|
|
message_text: str,
|
|
sender: str,
|
|
operator_user_id: Optional[int],
|
|
source_system: str,
|
|
source_message_id: str,
|
|
channel_ref: Optional[str],
|
|
source_sender_open_id: Optional[str],
|
|
world_model: dict[str, Any],
|
|
workflow_route: WorkflowRoute,
|
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
explicit_target_id = self._extract_followup_work_item_id(message_text)
|
|
followup_action = RouteResolver.parse_followup_action(
|
|
message_text=message_text,
|
|
canonical_intent=intent.name,
|
|
)
|
|
candidates = await self._load_recent_followup_candidates(
|
|
current_work_item_id=int(work_item.id),
|
|
source_system=source_system,
|
|
channel_ref=channel_ref,
|
|
sender=sender,
|
|
source_sender_open_id=source_sender_open_id,
|
|
explicit_target_id=explicit_target_id,
|
|
)
|
|
actionable = [
|
|
(target, ingest)
|
|
for target, ingest in candidates
|
|
if str(target.state or "") == STATE_WAITING_MANUAL
|
|
and (
|
|
str(target.intent or "") in FOLLOWUP_TARGET_INTENTS
|
|
or self._stored_intent_family(target.intent) in {
|
|
ROUTE_FAMILY_PROJECT_SIGNAL,
|
|
ROUTE_FAMILY_SIGNAL_SOURCE,
|
|
ROUTE_FAMILY_PROJECT_CREATE,
|
|
}
|
|
)
|
|
]
|
|
if explicit_target_id is not None and not actionable:
|
|
matched = next((target for target, _ in candidates if int(target.id) == int(explicit_target_id)), None)
|
|
if matched is not None:
|
|
reply_text = f"工作项#{int(matched.id)} 当前状态为 {matched.state},无需继续处理。"
|
|
return {
|
|
"status": "succeeded",
|
|
"followup_reply": reply_text,
|
|
"target_work_item_id": int(matched.id),
|
|
"target_state": matched.state,
|
|
"target_intent": matched.intent,
|
|
"project_id": _safe_int(matched.project_id),
|
|
"signal_id": _safe_int(matched.signal_id),
|
|
"resolved_route": workflow_route.key,
|
|
}, {"verified": True, "issues": []}
|
|
|
|
if not actionable:
|
|
reply_text = "当前群最近没有可继续处理的待确认工作项。"
|
|
return {
|
|
"status": "pending_manual",
|
|
"followup_reply": reply_text,
|
|
"candidate_work_item_ids": [],
|
|
"resolved_route": workflow_route.key,
|
|
}, {"verified": False, "issues": ["waiting_manual"]}
|
|
|
|
if len(actionable) > 1:
|
|
reply_text = self._build_followup_candidate_clarification([target for target, _ in actionable])
|
|
return {
|
|
"status": "pending_manual",
|
|
"followup_reply": reply_text,
|
|
"candidate_work_item_ids": [int(target.id) for target, _ in actionable[:5]],
|
|
"resolved_route": workflow_route.key,
|
|
}, {"verified": False, "issues": ["waiting_manual", "followup_ambiguous"]}
|
|
|
|
target_work_item, target_ingest = actionable[0]
|
|
target_route_key = self._normalize_route_from_stored_intent(target_work_item.intent)
|
|
target_family, target_action = RouteResolver._split_route_key(target_route_key)
|
|
resolved_target_route = workflow_route
|
|
if target_family == ROUTE_FAMILY_PROJECT_SIGNAL:
|
|
projected_action = ROUTE_ACTION_COMMIT
|
|
if followup_action == ROUTE_ACTION_REFRESH:
|
|
projected_action = ROUTE_ACTION_REFRESH
|
|
elif followup_action == ROUTE_ACTION_CANCEL:
|
|
projected_action = ROUTE_ACTION_CANCEL
|
|
resolved_target_route = WorkflowRoute(
|
|
family=ROUTE_FAMILY_PROJECT_SIGNAL,
|
|
action=projected_action,
|
|
project_id=_safe_int(target_work_item.project_id),
|
|
requested_count=(
|
|
_safe_int(
|
|
((target_work_item.execution_result or {}) if isinstance(target_work_item.execution_result, dict) else {}).get("requested_count")
|
|
)
|
|
or 1
|
|
),
|
|
requires_confirmation=projected_action in {ROUTE_ACTION_COMMIT, ROUTE_ACTION_CANCEL, ROUTE_ACTION_REFRESH},
|
|
metadata={
|
|
"workflow_route": f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{projected_action}",
|
|
"followup_action": followup_action,
|
|
"target_work_item_id": int(target_work_item.id),
|
|
},
|
|
)
|
|
elif target_family in {ROUTE_FAMILY_SIGNAL_SOURCE, ROUTE_FAMILY_PROJECT_CREATE}:
|
|
resolved_target_route = WorkflowRoute(
|
|
family=target_family,
|
|
action=target_action or (ROUTE_ACTION_ONBOARD if target_family == ROUTE_FAMILY_SIGNAL_SOURCE else ROUTE_ACTION_CREATE),
|
|
project_id=_safe_int(target_work_item.project_id),
|
|
requested_count=1,
|
|
requires_confirmation=True,
|
|
metadata={
|
|
"workflow_route": target_route_key,
|
|
"followup_action": followup_action,
|
|
"target_work_item_id": int(target_work_item.id),
|
|
},
|
|
)
|
|
handler = self.workflow_registry.get_handler(route=resolved_target_route)
|
|
return await handler.resume(
|
|
route=resolved_target_route,
|
|
target_work_item=target_work_item,
|
|
target_ingest=target_ingest,
|
|
followup_sender=sender,
|
|
operator_user_id=operator_user_id,
|
|
source_system=source_system,
|
|
source_message_id=source_message_id,
|
|
message_text=message_text,
|
|
world_model=world_model,
|
|
)
|
|
|
|
async def _load_recent_followup_candidates(
|
|
self,
|
|
*,
|
|
current_work_item_id: int,
|
|
source_system: str,
|
|
channel_ref: Optional[str],
|
|
sender: str,
|
|
source_sender_open_id: Optional[str],
|
|
explicit_target_id: Optional[int],
|
|
limit: int = 12,
|
|
) -> list[tuple[WorkItem, IngestEvent]]:
|
|
rows = (
|
|
await self.db.execute(
|
|
select(WorkItem, IngestEvent)
|
|
.join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id)
|
|
.where(
|
|
and_(
|
|
WorkItem.tenant_id == self.tenant_id,
|
|
WorkItem.id != int(current_work_item_id),
|
|
)
|
|
)
|
|
.order_by(WorkItem.id.desc())
|
|
.limit(max(limit * 3, 18))
|
|
)
|
|
).all()
|
|
selected: list[tuple[WorkItem, IngestEvent]] = []
|
|
normalized_channel = str(channel_ref or "").strip()
|
|
normalized_sender = str(sender or "").strip()
|
|
normalized_open_id = str(source_sender_open_id or "").strip()
|
|
for target_work_item, ingest in rows:
|
|
if explicit_target_id is not None:
|
|
if int(target_work_item.id) == int(explicit_target_id):
|
|
selected.append((target_work_item, ingest))
|
|
continue
|
|
payload = ingest.payload if isinstance(ingest.payload, dict) else {}
|
|
same_channel = normalized_channel and str(payload.get("channel_ref") or "").strip() == normalized_channel
|
|
same_open_id = normalized_open_id and str(payload.get("source_sender_open_id") or "").strip() == normalized_open_id
|
|
same_sender = normalized_sender and str(payload.get("sender") or "").strip() == normalized_sender
|
|
if not (same_channel or same_open_id or same_sender):
|
|
continue
|
|
selected.append((target_work_item, ingest))
|
|
if len(selected) >= limit:
|
|
break
|
|
return selected
|