PMFlow/backend/app/services/ai_pmo_review_service.py

190 lines
8.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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