"""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