190 lines
8.3 KiB
Python
190 lines
8.3 KiB
Python
"""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
|