418 lines
15 KiB
Python
418 lines
15 KiB
Python
"""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,
|
|
}
|