995 lines
38 KiB
Python
995 lines
38 KiB
Python
"""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": [],
|
||
}
|