PMFlow/backend/app/api/ai_pmo.py

995 lines
38 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
import asyncio
from datetime import datetime
import re
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.logging import get_logger
from ..core.response import success_response
from ..database import AsyncSessionLocal, 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"])
logger = get_logger(__name__)
FRONT_NOISE_INTENT = "query.read_only"
FRONT_NOISE_EXACT = {
"收到",
"收到啦",
"好的",
"ok",
"okk",
"yes",
"no",
"谢谢",
"thanks",
"thx",
"在吗",
"在不在",
"怎么样了",
"咋样了",
"辛苦了",
}
FRONT_NOISE_HINTS = ("怎么样了", "咋样了", "在吗", "辛苦了", "ok", "谢谢", "收到")
FRONT_NOISE_STRUCTURED_HINTS = ("项目", "信号", "任务", "风险", "进度", "状态", "work_item", "工作项", "#")
GENERIC_SOURCE_TEXT_RE = re.compile(
r"^(?:请)?(?:按|按照)?(?:这个|上述|这条)?(?:分步|步骤)?(?:骤)?(?:把|将)?(?:它|该需求|这条需求)?(?:加入|接入|录入).{0,24}信号源(?:中)?$",
re.IGNORECASE,
)
LOW_VALUE_CLOSURE_HINTS = ("缺少项目归属", "必须先确认项目", "无法创建", "不能创建")
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()
work_item_ids = [int(row.id) for row, _, _ in rows]
latest_decision_projection = await _build_latest_decision_projection(
db=db,
tenant_id=tenant_id,
work_item_ids=work_item_ids,
)
closure_payload_overrides = await _build_closure_payload_overrides(
db=db,
tenant_id=tenant_id,
rows=rows,
)
items = [
_serialize_work_item_summary(
row=row,
ingest=ingest,
project=project,
latest_decision_projection=latest_decision_projection.get(int(row.id)),
payload_override=closure_payload_overrides.get(int(row.id)),
)
for row, ingest, project in rows
if not _should_hide_work_item_from_inbox(row=row, ingest=ingest)
]
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)
if str(target.state or "").strip() == "waiting_manual" and request.project_id is not None:
target.project_id = int(request.project_id)
target.state = "executing"
target.reply_template_key = "action_processing"
current_execution = target.execution_result if isinstance(target.execution_result, dict) else {}
target.execution_result = {
**current_execution,
"status": "executing",
"manual_decision": "approve",
"resume_trigger": "queued_background",
"project_id": int(request.project_id),
"queued_at": datetime.now().isoformat(),
}
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 and str(decision_row.policy_gate_code or "").strip() == "missing_project_scope":
decision_row.policy_gate_hit = False
decision_row.policy_gate_code = None
decision_row.approval_requirement = {
"required": False,
"reason": "project_scope_confirmed",
"message": "项目归属已确认,正在后台继续执行。",
}
decision_row.final_outcome = "executing"
await db.commit()
_queue_resume_work_item(
tenant_id=tenant_id,
work_item_id=int(work_item_id),
source_message_id=request.source_message_id,
sender=str(current_user.name or current_user.username or "PMFlow用户"),
project_id=int(request.project_id),
)
return success_response(
{
"handled": True,
"status": "pending_manual",
"followup_reply": "已确认关联项目,正在后台继续执行。",
"target_work_item_id": int(target.id),
"target_state": str(target.state or "").strip() or "executing",
"target_intent": str(target.intent or "").strip() or None,
"project_id": int(request.project_id),
"signal_id": _safe_int(target.signal_id),
"auto_tasks": [],
}
)
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)
def _queue_resume_work_item(
*,
tenant_id: int,
work_item_id: int,
source_message_id: Optional[str],
sender: str,
project_id: int,
) -> None:
message_id = (
str(source_message_id or "").strip()
or f"decision-approve-bg-{int(work_item_id)}-{int(datetime.now().timestamp() * 1000)}"
)[:128]
task = asyncio.create_task(
_run_resume_work_item_in_background(
tenant_id=int(tenant_id),
work_item_id=int(work_item_id),
source_message_id=message_id,
sender=str(sender or "PMFlow用户"),
project_id=int(project_id),
),
name=f"ai-pmo-resume-{int(work_item_id)}",
)
def _on_done(done_task: asyncio.Task[None]) -> None:
if done_task.cancelled():
return
exc = done_task.exception()
if exc is not None:
logger.warning(
"ai_pmo_background_resume_failed",
tenant_id=int(tenant_id),
work_item_id=int(work_item_id),
error=str(exc)[:400],
)
task.add_done_callback(_on_done)
async def _run_resume_work_item_in_background(
*,
tenant_id: int,
work_item_id: int,
source_message_id: str,
sender: str,
project_id: int,
) -> None:
async with AsyncSessionLocal() as bg_db:
service = AIPMOKernelService(bg_db, tenant_id=int(tenant_id))
await service.process_ingress(
source_system="ai_pmo_ui",
source_message_id=source_message_id,
message_text=f"继续处理 work_item #{int(work_item_id)}",
sender=str(sender or "PMFlow用户"),
channel_ref="ai_pmo_ui",
source_sender_open_id=None,
captured_at=None,
chat_type="manual",
project_hint_id=int(project_id),
)
def _normalize_noise_text(text: str) -> str:
return re.sub(r"[\\s\\??!!,,。~~、]+", "", str(text or "")).strip().lower()
def _should_hide_work_item_from_inbox(*, row: WorkItem, ingest: IngestEvent) -> bool:
intent_name = str(row.intent or "").strip().lower()
if intent_name != FRONT_NOISE_INTENT:
return False
payload = ingest.payload if isinstance(ingest.payload, dict) else {}
message_text = str(payload.get("message_text") or "").strip()
normalized = _normalize_noise_text(message_text)
if not normalized:
return True
if any(keyword in normalized for keyword in FRONT_NOISE_STRUCTURED_HINTS):
return False
if normalized in FRONT_NOISE_EXACT:
return True
if len(normalized) <= 8 and any(keyword in normalized for keyword in FRONT_NOISE_HINTS):
return True
return False
@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 _normalize_module_list(value: Any) -> list[str]:
if not isinstance(value, list):
return []
modules: list[str] = []
for row in value:
item = str(row or "").strip()
if not item or item in modules:
continue
modules.append(item[:50])
return modules[:8]
def _is_low_value_closure_payload(payload: dict[str, Any]) -> bool:
summary = str(payload.get("closure_summary") or "").strip()
dialogue = str(payload.get("closure_dialogue") or "").strip()
modules = _normalize_module_list(payload.get("closure_modules"))
turns = payload.get("closure_turns") if isinstance(payload.get("closure_turns"), list) else []
has_closure = bool(summary or dialogue or modules)
if not has_closure:
return True
compact_summary = re.sub(r"\s+", "", summary).lower() if summary else ""
summary_is_generic = bool(compact_summary and GENERIC_SOURCE_TEXT_RE.match(compact_summary))
weak_summary = bool(summary and any(token in summary for token in LOW_VALUE_CLOSURE_HINTS))
has_assistant_turn = any(
str(item.get("role") or "").strip().lower() == "assistant" and str(item.get("text") or "").strip()
for item in turns
if isinstance(item, dict)
)
if weak_summary and not has_assistant_turn:
return True
if summary_is_generic and not modules and not has_assistant_turn:
return True
if dialogue and "机器人:" not in dialogue and len(turns) <= 1 and weak_summary:
return True
return False
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 _normalize_approval_requirement(value: Any) -> Optional[dict[str, Any]]:
if not isinstance(value, dict):
return None
reason = str(value.get("reason") or value.get("policy_gate_code") or "").strip() or None
message = str(value.get("message") or value.get("hint") or value.get("detail") or "").strip() or None
return {
**value,
"required": bool(value.get("required")),
"reason": reason,
"message": message,
}
def _extract_gate_projection_from_work_item(row: WorkItem) -> dict[str, Any]:
execution_result = row.execution_result if isinstance(row.execution_result, dict) else {}
review_summary = row.review_summary if isinstance(row.review_summary, dict) else {}
policy_gate_code = (
str(
execution_result.get("policy_gate_code")
or execution_result.get("gate_code")
or review_summary.get("policy_gate_code")
or review_summary.get("gate_code")
or ""
).strip()
or None
)
approval_requirement = _normalize_approval_requirement(
execution_result.get("approval_requirement")
if isinstance(execution_result.get("approval_requirement"), dict)
else review_summary.get("approval_requirement")
)
return {
"latest_policy_gate_code": policy_gate_code,
"approval_requirement": approval_requirement,
}
async def _build_latest_decision_projection(
*,
db: AsyncSession,
tenant_id: int,
work_item_ids: list[int],
) -> dict[int, dict[str, Any]]:
if not work_item_ids:
return {}
rows = (
await db.execute(
select(DecisionRecord)
.where(
and_(
DecisionRecord.tenant_id == tenant_id,
DecisionRecord.work_item_id.in_(work_item_ids),
)
)
.order_by(DecisionRecord.work_item_id.asc(), DecisionRecord.id.desc())
)
).scalars().all()
projection: dict[int, dict[str, Any]] = {}
for row in rows:
work_item_id = _safe_int(row.work_item_id)
if work_item_id is None or work_item_id in projection:
continue
projection[work_item_id] = {
"latest_policy_gate_code": str(row.policy_gate_code or "").strip() or None,
"approval_requirement": _normalize_approval_requirement(row.approval_requirement),
}
return projection
async def _build_closure_payload_overrides(
*,
db: AsyncSession,
tenant_id: int,
rows: list[tuple[WorkItem, IngestEvent, Optional[Project]]],
) -> dict[int, dict[str, Any]]:
if not rows:
return {}
service = AIPMOKernelService(db, tenant_id=tenant_id)
overrides: dict[int, dict[str, Any]] = {}
for row, ingest, _project in rows:
payload = ingest.payload if isinstance(ingest.payload, dict) else {}
if not isinstance(payload, dict):
continue
has_closure = bool(
str(payload.get("closure_summary") or "").strip()
or str(payload.get("closure_dialogue") or "").strip()
or (isinstance(payload.get("closure_modules"), list) and payload.get("closure_modules"))
)
if has_closure and not _is_low_value_closure_payload(payload):
continue
message_text = str(payload.get("message_text") or "").strip()
if not message_text:
continue
source_system = str(row.source_system or "").strip() or "pmflow_manual"
captured_at_seed: Optional[str] = str(payload.get("captured_at") or "").strip() or None
if not captured_at_seed and isinstance(row.created_at, datetime):
captured_at_seed = row.created_at.isoformat()
closure_payload = await service._build_message_closure_payload(
source_system=source_system,
channel_ref=str(payload.get("channel_ref") or "").strip() or None,
sender=str(payload.get("sender") or "").strip(),
source_sender_open_id=str(payload.get("source_sender_open_id") or "").strip() or None,
message_text=message_text,
source_message_id=str(row.source_message_id or "").strip() or None,
owner_user_id=_safe_int(row.owner_user_id),
project_id=_safe_int(row.project_id),
captured_at=captured_at_seed,
)
if not any(
(
str(closure_payload.get("closure_summary") or "").strip(),
str(closure_payload.get("closure_dialogue") or "").strip(),
isinstance(closure_payload.get("closure_modules"), list) and closure_payload.get("closure_modules"),
)
):
continue
overrides[int(row.id)] = {
**payload,
"closure_summary": closure_payload.get("closure_summary"),
"closure_dialogue": closure_payload.get("closure_dialogue"),
"closure_modules": closure_payload.get("closure_modules"),
"closure_turns": closure_payload.get("closure_turns"),
}
return overrides
def _serialize_work_item_summary(
*,
row: WorkItem,
ingest: IngestEvent,
project: Optional[Project],
latest_decision_projection: Optional[dict[str, Any]] = None,
payload_override: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
payload = payload_override if isinstance(payload_override, dict) else (
ingest.payload if isinstance(ingest.payload, dict) else {}
)
message_text = str(payload.get("message_text") or "").strip()
closure_dialogue = str(payload.get("closure_dialogue") or "").strip() or None
closure_summary = str(payload.get("closure_summary") or "").strip() or None
closure_modules = _normalize_module_list(payload.get("closure_modules"))
closure_turns = payload.get("closure_turns") if isinstance(payload.get("closure_turns"), list) else []
source_captured_at = str(payload.get("captured_at") or "").strip() or None
source_sender = str(payload.get("sender") or "").strip() or None
source_channel_ref = str(payload.get("channel_ref") or "").strip() or None
summary_seed = closure_summary or message_text or _next_step_text(row) or row.intent or "AI PMO 工作项"
summary = _compact_text(summary_seed, 180)
compact_message = re.sub(r"\s+", "", message_text).lower() if message_text else ""
prefer_closure_title = bool(compact_message and GENERIC_SOURCE_TEXT_RE.match(compact_message))
title_seed = (
(closure_summary or message_text)
if prefer_closure_title
else (message_text or closure_summary or f"AI PMO · {row.intent or 'work_item'}")
)
title = _compact_text(title_seed, 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)
decision_projection = latest_decision_projection if isinstance(latest_decision_projection, dict) else {}
latest_policy_gate_code = str(decision_projection.get("latest_policy_gate_code") or "").strip() or None
approval_requirement = _normalize_approval_requirement(decision_projection.get("approval_requirement"))
if latest_policy_gate_code is None or approval_requirement is None:
fallback_projection = _extract_gate_projection_from_work_item(row)
if latest_policy_gate_code is None:
latest_policy_gate_code = str(fallback_projection.get("latest_policy_gate_code") or "").strip() or None
if approval_requirement is None:
approval_requirement = _normalize_approval_requirement(fallback_projection.get("approval_requirement"))
return {
"id": int(row.id),
"source_system": row.source_system,
"source_message_id": row.source_message_id,
"source_message_text": _compact_text(message_text, 600) or None,
"source_conversation_closure": _compact_text(closure_dialogue, 2000) or None,
"source_captured_at": source_captured_at,
"source_sender": source_sender,
"source_channel_ref": source_channel_ref,
"closure_summary": closure_summary,
"implementation_modules": closure_modules,
"source_closure_turns": closure_turns[:4],
"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,
"latest_policy_gate_code": latest_policy_gate_code,
"approval_requirement": approval_requirement,
"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": [],
}