PMFlow/backend/app/services/ai_pmo_kernel_mixins_follow...

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