"""AI PMO review engine。""" from __future__ import annotations from typing import Any from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from ..models import PortfolioSnapshot, ProjectSignalLink, ReplyReceipt, SignalSourceRegistry, WorkItem def _safe_int(value: Any) -> int | None: try: if value is None or value == "": return None return int(value) except (TypeError, ValueError): return None class AIPMOReviewService: """复盘层:结果核验、偏差检测、风险再评分、规则建议。""" def __init__(self, db: AsyncSession, tenant_id: int) -> None: self.db = db self.tenant_id = int(tenant_id) async def review_work_item( self, *, work_item: WorkItem, intent_name: str, execution_result: dict[str, Any], world_model: dict[str, Any], verification: dict[str, Any], simulation: dict[str, Any], ) -> dict[str, Any]: divergences: list[dict[str, Any]] = [] rule_updates: list[dict[str, Any]] = [] signal_id = _safe_int(execution_result.get("signal_id")) or _safe_int(work_item.signal_id) project_id = _safe_int(work_item.project_id) if signal_id is not None and project_id is not None: link_exists = ( await self.db.execute( select(ProjectSignalLink.id).where( and_( ProjectSignalLink.tenant_id == self.tenant_id, ProjectSignalLink.project_id == int(project_id), ProjectSignalLink.signal_id == int(signal_id), ) ).limit(1) ) ).scalar_one_or_none() if link_exists is None: divergences.append( { "code": "project_signal_link_missing", "severity": "high", "detail": f"signal#{signal_id} 未关联 project#{project_id}", } ) rule_updates.append( { "rule_key": "signal_link_persistence_guard", "reason": "信号已写入但 link 缺失,后续查询会出现空白板。", } ) workflow = execution_result.get("signal_source_workflow") if isinstance(execution_result.get("signal_source_workflow"), dict) else {} raw_orchestration = execution_result.get("signal_source_orchestration") if isinstance(execution_result.get("signal_source_orchestration"), dict) else {} registry_id = _safe_int(workflow.get("registry_id")) if registry_id is not None: registry = ( await self.db.execute( select(SignalSourceRegistry).where( and_( SignalSourceRegistry.tenant_id == self.tenant_id, SignalSourceRegistry.id == int(registry_id), ) ).limit(1) ) ).scalar_one_or_none() if registry is not None: normalized_status = str(workflow.get("status") or "").strip().lower() registry_onboarding = str(registry.onboarding_status or "").strip().lower() if normalized_status == "pending_manual" and registry_onboarding == "completed": divergences.append( { "code": "registry_workflow_divergence", "severity": "high", "detail": f"registry#{registry_id} 已 completed,但 domain workflow 为 pending_manual", } ) rule_updates.append( { "rule_key": "signal_source_finalize_single_source", "reason": "registry 与 workflow 终态不一致,必须收口到 domain workflow。", } ) if workflow and str(workflow.get("status") or "").strip().lower() != "completed": rule_updates.append( { "rule_key": "onboarding_requires_material_actions", "reason": "signal source onboarding 只有 material actions + 非 fallback 才能标记 completed。", } ) duplicate_reply_groups = ( await self.db.execute( select(ReplyReceipt.receipt_phase, func.count(ReplyReceipt.id)) .where( and_( ReplyReceipt.tenant_id == self.tenant_id, ReplyReceipt.work_item_id == int(work_item.id), ) ) .group_by(ReplyReceipt.receipt_phase) ) ).all() duplicate_phases = [str(phase) for phase, count in duplicate_reply_groups if int(count or 0) > 1] if duplicate_phases: divergences.append( { "code": "duplicate_reply_receipts", "severity": "medium", "detail": ",".join(duplicate_phases), } ) rule_updates.append( { "rule_key": "reply_receipt_single_phase", "reason": "同一 source_message 不得生成多条同 phase 回执。", } ) issues = verification.get("issues") if isinstance(verification.get("issues"), list) else [] manual_issue_codes = {"pending_manual", "signal_source_pending_manual", "waiting_manual"} base_score = 0.92 if verification.get("verified") else (0.56 if any(issue in manual_issue_codes for issue in issues) else 0.32) fallback_penalty = 0.15 if bool(workflow.get("fallback_used")) else 0.0 divergence_penalty = min(0.45, len(divergences) * 0.12) agent_quality_score = max(0.0, round(base_score - fallback_penalty - divergence_penalty, 4)) risk_before = float((world_model.get("risk_summary") or {}).get("open_risk_count") or 0.0) projected_risk = float(simulation.get("projected_risk_score") or 0.0) risk_after = min(1.0, projected_risk + len(divergences) * 0.08 + (0.1 if not verification.get("verified") else 0.0)) next_actions: list[str] = [] if divergences: next_actions.append("优先修正 projection / registry / reply 收口偏差。") if not verification.get("verified"): next_actions.append("当前工作项未形成可验证终态,进入风险例会或人工确认。") if risk_after >= 0.7: next_actions.append("风险重评分偏高,建议纳入风险例会升级。") if not next_actions: next_actions.append("复盘通过,进入下一轮节奏调度。") quality_grade = "A" if agent_quality_score >= 0.85 else ("B" if agent_quality_score >= 0.7 else ("C" if agent_quality_score >= 0.5 else "D")) review_payload = { "verified": bool(verification.get("verified")), "issues": issues, "divergences": divergences, "risk_score_before": round(risk_before, 4), "risk_score_after": round(risk_after, 4), "agent_quality_score": agent_quality_score, "agent_quality_grade": quality_grade, "rule_updates": rule_updates, "next_actions": next_actions, "simulation_summary": simulation, "workflow_summary": workflow or raw_orchestration, } self.db.add( PortfolioSnapshot( tenant_id=self.tenant_id, scope_type="review_cycle", scope_ref=f"work_item:{int(work_item.id)}", summary={ "work_item_id": int(work_item.id), "intent": intent_name, "verified": bool(verification.get("verified")), "agent_quality_score": agent_quality_score, "risk_score_after": round(risk_after, 4), }, payload=review_payload, ) ) await self.db.flush() return review_payload