PMFlow/backend/app/api/ai_pmo.py

601 lines
22 KiB
Python
Raw 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 决策中枢 API。"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal, 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)
class PMOWorkItemDecisionRequest(BaseModel):
action: Literal["approve", "reject"] = Field(..., description="审批动作:approve=继续执行,reject=标记失效")
project_id: Optional[int] = Field(default=None)
source_message_id: Optional[str] = Field(default=None, max_length=128)
reason: Optional[str] = Field(default=None, max_length=500)
@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)
raw_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),
)
result = _normalize_resume_result(raw=raw_result, target_work_item=target)
return success_response(result)
@router.post("/work-items/{work_item_id}/decision")
async def decide_work_item(
work_item_id: int,
request: PMOWorkItemDecisionRequest,
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")
if request.action == "reject":
result = await _reject_work_item(
db=db,
tenant_id=tenant_id,
target=target,
current_user=current_user,
reason=request.reason,
)
return success_response(result)
service = AIPMOKernelService(db, tenant_id=tenant_id)
raw_result = await service.process_ingress(
source_system="ai_pmo_ui",
source_message_id=request.source_message_id or f"decision-approve-{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),
)
result = _normalize_resume_result(raw=raw_result, target_work_item=target)
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,
}
def _normalize_resume_result(*, raw: dict[str, Any], target_work_item: WorkItem) -> dict[str, Any]:
if not bool(raw.get("handled")):
reason = str(raw.get("error") or raw.get("reason") or "继续执行失败").strip() or "继续执行失败"
return {
"handled": False,
"status": "failed_safe",
"followup_reply": reason,
"target_work_item_id": int(target_work_item.id),
"target_state": str(target_work_item.state or "").strip() or None,
"target_intent": str(target_work_item.intent or "").strip() or None,
"project_id": _safe_int(target_work_item.project_id),
"signal_id": _safe_int(target_work_item.signal_id),
"auto_tasks": [],
}
state = None
rows = raw.get("results") if isinstance(raw.get("results"), list) else []
if rows and isinstance(rows[0], dict):
state = str(rows[0].get("state") or "").strip() or None
if not state:
state = str(target_work_item.state or "").strip() or None
if state == "succeeded":
status = "succeeded"
elif state == "waiting_manual":
status = "pending_manual"
elif state == "ignored":
status = "invalidated"
else:
status = "failed_safe"
followup_reply = str(raw.get("direct_reply_text") or "").strip()
if not followup_reply:
if status == "succeeded":
followup_reply = "已继续执行并完成。"
elif status == "pending_manual":
followup_reply = "继续执行后仍需补充信息或人工确认。"
elif status == "invalidated":
followup_reply = "该工作项已失效,不再继续执行。"
else:
followup_reply = "继续执行失败,请查看详情。"
linked_project_ids = raw.get("linked_project_ids") if isinstance(raw.get("linked_project_ids"), list) else []
project_id = _safe_int(linked_project_ids[0]) if linked_project_ids else _safe_int(target_work_item.project_id)
return {
"handled": True,
"status": status,
"followup_reply": followup_reply,
"target_work_item_id": int(target_work_item.id),
"target_state": state,
"target_intent": str(target_work_item.intent or "").strip() or None,
"project_id": project_id,
"signal_id": _safe_int(raw.get("signal_id")) or _safe_int(target_work_item.signal_id),
"auto_tasks": raw.get("auto_tasks") if isinstance(raw.get("auto_tasks"), list) else [],
}
async def _reject_work_item(
*,
db: AsyncSession,
tenant_id: int,
target: WorkItem,
current_user: User,
reason: Optional[str],
) -> dict[str, Any]:
current_state = str(target.state or "").strip()
if current_state == "ignored":
return {
"handled": True,
"status": "invalidated",
"followup_reply": "该工作项已处于失效状态。",
"target_work_item_id": int(target.id),
"target_state": "ignored",
"target_intent": str(target.intent or "").strip() or None,
"project_id": _safe_int(target.project_id),
"signal_id": _safe_int(target.signal_id),
"auto_tasks": [],
}
reject_reason = str(reason or "").strip() or "人工审批拒绝,工作项已失效。"
existing_execution = target.execution_result if isinstance(target.execution_result, dict) else {}
target.state = "ignored"
target.execution_result = {
**existing_execution,
"status": "invalidated",
"manual_decision": "reject",
"manual_reason": reject_reason,
"operator_user_id": _safe_int(current_user.id),
"processed_at": datetime.now().isoformat(),
}
target.review_summary = {
"verified": False,
"issues": ["rejected_by_user"],
"next_step": "该工作项已进入失效状态",
"operator": str(current_user.name or current_user.username or "").strip() or None,
"reason": reject_reason,
}
target.reply_template_key = "action_rejected"
decision_row = (
await db.execute(
select(DecisionRecord)
.where(
and_(
DecisionRecord.tenant_id == tenant_id,
DecisionRecord.work_item_id == int(target.id),
)
)
.order_by(DecisionRecord.id.desc())
.limit(1)
)
).scalar_one_or_none()
if decision_row is not None:
decision_row.execution_result = target.execution_result
decision_row.final_outcome = "ignored"
await db.commit()
return {
"handled": True,
"status": "invalidated",
"followup_reply": reject_reason,
"target_work_item_id": int(target.id),
"target_state": "ignored",
"target_intent": str(target.intent or "").strip() or None,
"project_id": _safe_int(target.project_id),
"signal_id": _safe_int(target.signal_id),
"auto_tasks": [],
}