"""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_INTENTS = {"query.read_only", "query.answer"} 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): action: Literal["continue", "commit", "refresh", "cancel"] = Field(default="continue") 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") action_text_map = { "continue": "继续处理", "commit": "确认并提交候选", "refresh": "换一批候选", "cancel": "取消本次候选", } action_message = action_text_map.get(str(request.action or "").strip().lower(), "继续处理") 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"{action_message} 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 not in FRONT_NOISE_INTENTS: 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": [], }