"""AI PMO 决策中枢 API。""" from __future__ import annotations from datetime import datetime from typing import Any, Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession from ..core.response import success_response from ..database import get_db from ..dependencies.auth import get_current_user from ..dependencies.tenant import get_tenant_id from ..models import DecisionRecord, IngestEvent, PlanBundle, PortfolioSnapshot, Project, ReplyReceipt, User, WorkItem from ..services.ai_pmo_cadence_service import AIPMOCadenceService from ..services.ai_pmo_kernel_service import AIPMOKernelService router = APIRouter(prefix="/ai-pmo", tags=["AI PMO"]) class PMOProcessTextRequest(BaseModel): message_text: str = Field(..., min_length=1, max_length=4000) sender: Optional[str] = Field(default=None, max_length=120) source_message_id: Optional[str] = Field(default=None, max_length=128) project_id: Optional[int] = Field(default=None) class PMOCadenceRunRequest(BaseModel): cadence_type: str = Field(..., min_length=1, max_length=32) project_id: Optional[int] = Field(default=None) class PMOResumeWorkItemRequest(BaseModel): project_id: Optional[int] = Field(default=None) source_message_id: Optional[str] = Field(default=None, max_length=128) @router.post("/process-text") async def process_text( request: PMOProcessTextRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): service = AIPMOKernelService(db, tenant_id=tenant_id) result = await service.process_ingress( source_system="pmflow_manual", source_message_id=request.source_message_id, message_text=request.message_text, sender=str(request.sender or current_user.name or current_user.username or "PMFlow用户"), channel_ref="pmflow_manual", source_sender_open_id=None, captured_at=None, chat_type="manual", project_hint_id=request.project_id, ) if not result.get("handled"): raise HTTPException(status_code=400, detail=str(result.get("reason") or "AI PMO kernel 未处理该输入")) return success_response(result) @router.get("/work-items") async def list_work_items( limit: int = 20, state: Optional[str] = None, project_id: Optional[int] = None, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): _ = current_user stmt = ( select(WorkItem, IngestEvent, Project) .join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id) .outerjoin(Project, and_(Project.tenant_id == tenant_id, Project.id == WorkItem.project_id)) .where(WorkItem.tenant_id == tenant_id) .order_by(WorkItem.updated_at.desc(), WorkItem.id.desc()) ) if state: stmt = stmt.where(WorkItem.state == state) if project_id is not None: stmt = stmt.where(WorkItem.project_id == int(project_id)) rows = (await db.execute(stmt.limit(max(1, min(limit, 100))))).all() items = [ _serialize_work_item_summary(row=row, ingest=ingest, project=project) for row, ingest, project in rows ] return success_response({"items": items}) @router.get("/work-items/{work_item_id}") async def get_work_item( work_item_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): _ = current_user row = ( await db.execute( select(WorkItem).where( and_( WorkItem.tenant_id == tenant_id, WorkItem.id == int(work_item_id), ) ) ) ).scalar_one_or_none() if row is None: raise HTTPException(status_code=404, detail="work_item_not_found") decision_rows = ( await db.execute( select(DecisionRecord).where( and_( DecisionRecord.tenant_id == tenant_id, DecisionRecord.work_item_id == int(work_item_id), ) ).order_by(DecisionRecord.id.asc()) ) ).scalars().all() plan_rows = ( await db.execute( select(PlanBundle).where( and_( PlanBundle.tenant_id == tenant_id, PlanBundle.work_item_id == int(work_item_id), ) ).order_by(PlanBundle.id.asc()) ) ).scalars().all() reply_rows = ( await db.execute( select(ReplyReceipt).where( and_( ReplyReceipt.tenant_id == tenant_id, ReplyReceipt.work_item_id == int(work_item_id), ) ).order_by(ReplyReceipt.id.asc()) ) ).scalars().all() payload: dict[str, Any] = { "id": int(row.id), "source_system": row.source_system, "source_message_id": row.source_message_id, "state": row.state, "intent": row.intent, "decision_level": row.decision_level, "project_id": _safe_int(row.project_id), "signal_id": _safe_int(row.signal_id), "context_snapshot": row.context_snapshot, "action_bundle": row.action_bundle, "execution_result": row.execution_result, "review_summary": row.review_summary, "reply_template_key": row.reply_template_key, "decision_records": [ { "id": int(item.id), "decision_level": item.decision_level, "decision_object_type": item.decision_object_type, "decision_object_id": _safe_int(item.decision_object_id), "rationale": item.rationale, "risk_score": item.risk_score, "budget_impact": item.budget_impact, "resource_impact": item.resource_impact, "simulation_result": item.simulation_result, "policy_gate_hit": bool(item.policy_gate_hit), "policy_gate_code": item.policy_gate_code, "approval_requirement": item.approval_requirement, "execution_result": item.execution_result, "final_outcome": item.final_outcome, } for item in decision_rows ], "plan_bundles": [ { "id": int(item.id), "plan_type": item.plan_type, "summary": item.summary, "payload": item.payload, } for item in plan_rows ], "reply_receipts": [ { "id": int(item.id), "receipt_phase": item.receipt_phase, "template_key": item.template_key, "payload": item.payload, "status": item.status, } for item in reply_rows ], } return success_response(payload) @router.post("/work-items/{work_item_id}/resume") async def resume_work_item( work_item_id: int, request: PMOResumeWorkItemRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): target = ( await db.execute( select(WorkItem).where( and_( WorkItem.tenant_id == tenant_id, WorkItem.id == int(work_item_id), ) ).limit(1) ) ).scalar_one_or_none() if target is None: raise HTTPException(status_code=404, detail="work_item_not_found") service = AIPMOKernelService(db, tenant_id=tenant_id) result = await service.process_ingress( source_system="ai_pmo_ui", source_message_id=request.source_message_id or f"resume-{int(work_item_id)}-{int(datetime.now().timestamp() * 1000)}", message_text=f"继续处理 work_item #{int(work_item_id)}", sender=str(current_user.name or current_user.username or "PMFlow用户"), channel_ref="ai_pmo_ui", source_sender_open_id=None, captured_at=None, chat_type="manual", project_hint_id=request.project_id or _safe_int(target.project_id), ) if not result.get("handled"): raise HTTPException(status_code=400, detail=str(result.get("reason") or "resume_failed")) return success_response(result) @router.post("/cadence/run") async def run_cadence( request: PMOCadenceRunRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): _ = current_user service = AIPMOCadenceService(db, tenant_id=tenant_id) try: snapshot = await service.generate_snapshot( cadence_type=request.cadence_type, project_id=request.project_id, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc await db.commit() return success_response(snapshot) @router.get("/cadence/latest") async def get_latest_cadence_snapshot( cadence_type: str, project_id: Optional[int] = None, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): _ = current_user service = AIPMOCadenceService(db, tenant_id=tenant_id) snapshot = await service.get_latest_snapshot( cadence_type=cadence_type, project_id=project_id, ) return success_response(snapshot) @router.get("/portfolio/program-view") async def get_program_view( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): _ = current_user service = AIPMOCadenceService(db, tenant_id=tenant_id) return success_response(await service.build_program_view()) @router.get("/portfolio/latest") async def get_latest_portfolio_snapshot( scope_type: str = "tenant", project_id: Optional[int] = None, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): _ = current_user scope_ref = f"project:{int(project_id)}" if project_id is not None else f"tenant:{tenant_id}" row = ( await db.execute( select(PortfolioSnapshot).where( and_( PortfolioSnapshot.tenant_id == tenant_id, PortfolioSnapshot.scope_type == scope_type, PortfolioSnapshot.scope_ref == scope_ref, ) ).order_by(PortfolioSnapshot.id.desc()).limit(1) ) ).scalar_one_or_none() if row is None: return success_response(None) return success_response( { "id": int(row.id), "scope_type": row.scope_type, "scope_ref": row.scope_ref, "summary": row.summary, "payload": row.payload, "created_at": row.created_at.isoformat() if row.created_at else None, } ) def _safe_int(value: Any) -> Optional[int]: try: if value is None or value == "": return None return int(value) except (TypeError, ValueError): return None def _compact_text(value: Any, limit: int) -> str: text = " ".join(str(value or "").split()).strip() if len(text) <= limit: return text return f"{text[: max(limit - 1, 1)].rstrip()}…" def _priority_from_review(review_summary: Any) -> tuple[str, int]: payload = review_summary if isinstance(review_summary, dict) else {} risk_score = float(payload.get("risk_score_after") or 0.0) if risk_score >= 0.75: return "high", 3 if risk_score >= 0.45: return "medium", 2 return "low", 1 def _blocking_reason(row: WorkItem) -> Optional[str]: execution_result = row.execution_result if isinstance(row.execution_result, dict) else {} review_summary = row.review_summary if isinstance(row.review_summary, dict) else {} reason = str(execution_result.get("reason") or execution_result.get("error_message") or "").strip() if reason: return reason divergences = review_summary.get("divergences") if isinstance(review_summary.get("divergences"), list) else [] if divergences and isinstance(divergences[0], dict): return str(divergences[0].get("detail") or "").strip() or None return None def _next_step_text(row: WorkItem) -> Optional[str]: review_summary = row.review_summary if isinstance(row.review_summary, dict) else {} next_actions = review_summary.get("next_actions") if isinstance(review_summary.get("next_actions"), list) else [] if next_actions: return str(next_actions[0]).strip() action_bundle = row.action_bundle if isinstance(row.action_bundle, dict) else {} summary = str(action_bundle.get("summary") or "").strip() return summary or None def _stage_text(state: str) -> str: mapping = { "received": "已接收", "modeled": "已建模", "decided": "已决策", "planned": "已规划", "executing": "执行中", "waiting_manual": "待人工确认", "succeeded": "执行完成", "failed": "执行失败", "ignored": "已忽略", } return mapping.get(str(state or "").strip(), str(state or "").strip() or "-") def _serialize_work_item_summary(*, row: WorkItem, ingest: IngestEvent, project: Optional[Project]) -> dict[str, Any]: payload = ingest.payload if isinstance(ingest.payload, dict) else {} message_text = str(payload.get("message_text") or "").strip() summary = _compact_text(message_text or _next_step_text(row) or row.intent or "AI PMO 工作项", 180) title = _compact_text(message_text or f"AI PMO · {row.intent or 'work_item'}", 64) priority, priority_rank = _priority_from_review(row.review_summary) project_name = None if project is not None: project_name = str(project.title or project.name or "").strip() or None project_id = _safe_int(row.project_id) return { "id": int(row.id), "source_system": row.source_system, "source_message_id": row.source_message_id, "state": row.state, "intent": row.intent, "decision_level": row.decision_level, "project_id": project_id, "project_name": project_name, "signal_id": _safe_int(row.signal_id), "reply_template_key": row.reply_template_key, "title": title or f"工作项#{int(row.id)}", "summary": summary, "ai_plan": _compact_text(((row.action_bundle or {}) if isinstance(row.action_bundle, dict) else {}).get("summary") or "", 180) or None, "blocking_reason": _blocking_reason(row), "next_step_text": _next_step_text(row), "stage_text": _stage_text(str(row.state or "")), "priority": priority, "priority_rank": priority_rank, "target_url": f"/projects/{project_id}/board" if project_id is not None else None, "review_summary": row.review_summary, "execution_result": row.execution_result, "created_at": row.created_at.isoformat() if row.created_at else None, "updated_at": row.updated_at.isoformat() if row.updated_at else None, }