from __future__ import annotations import asyncio import json import re import time from datetime import datetime from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import JSONResponse, StreamingResponse from sqlalchemy import and_, desc, exists, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from ...core.database import get_db from ...core.response import error_response, success_response from ...dependencies import get_current_user from ...models import ( PMFlowAgentArtifact, PMFlowAgentMemory, PMFlowAIMessage, PMFlowAIThread, PMFlowAgentObservation, PMFlowAgentRun, PMFlowAgentStep, PMFlowAgentVerification, Project, QARecord, QASessionState, User, UserProject, ) from ...schemas.pmflow_agent_runtime import new_pmflow_agent_id from ...schemas.pmflow_ai_operating_runtime import PMFlowAIConfirmationExecutionRequest from ...schemas.pmflow_os import PMFlowExecutionHandoffRequest from ...schemas.rag import QASessionListQuery, RAGAskRequest from ...schemas.space import SavedIdeaCreateRequest, SavedIdeaListResponse, SavedIdeaPromoteResponse, SavedIdeaUpdateRequest from ...schemas.context_workspace import normalize_research_workspace_payload from ...schemas.pmflow_ai_agent_runtime import normalize_agent_workspace_payload from ...schemas.pmflow_ai_turn_runtime import ( PMFlowAITurnDelta, PMFlowAITurnDeltaAnswer, ) from ...services.adaptive_rag_service import AdaptiveRAGService from ...services.customer_answer_presentation_service import CustomerAnswerPresentationService from ...services.pmflow_ai_turn_object_ledger import ( build_object_ledger_from_payload, merge_object_ledger_into_context_packet, object_ledger_from_context_capsules, upsert_object_ledger_capsule, ) from ...services.pmflow_ai_agent_runtime.agent_payload_gate import ( build_agent_turn_presentation_contract, ) from ...services.pmflow_ai_agent_runtime.agent_workspace import ( agent_workspace_from_context_capsules, upsert_agent_workspace_capsule, ) from ...services.pmflow_agent_runtime import PMFlowAgentRuntimeService from ...services.pmflow_os_projection_service import PMFlowOSProjectionService from ...services.pmflow_launch_service import PMFlowLaunchError from ...services.space_saved_idea_service import SavedIdeaError, SpaceSavedIdeaService from ...services.space_runtime_context_builder import SpaceRuntimeContextBuilder from .. import rag_ask_contract router = APIRouter(prefix="/space", tags=["space"]) @router.get("/ai-concierge/status") async def get_ai_concierge_status( current_user: User = Depends(get_current_user), ): return success_response( { "chat": "available", "history": "available", "actions": "available", "tenant_id": int(current_user.tenant_id), } ) @router.post("/ai-concierge/ask") async def ask_ai_concierge( body: RAGAskRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): started = time.perf_counter() try: payload = await _run_pmflow_agent_turn_payload( body=body, db=db, current_user=current_user, started=started, ) except Exception: await db.rollback() return JSONResponse(status_code=500, content=error_response(500, "PMFlow AI 暂时没有生成可展示回答,请稍后重试。")) if not str(payload.get("answer") or "").strip() and not str(payload.get("reply_text") or "").strip(): return JSONResponse(status_code=500, content=error_response(500, "AI 管家没有返回可用回答。")) return success_response(payload) @router.get("/pmflow-ai/threads") async def list_pmflow_ai_threads( page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=100), q: str | None = Query(default=None, max_length=120), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): filters = [ PMFlowAIThread.tenant_id == int(current_user.tenant_id), PMFlowAIThread.user_id == int(current_user.id), PMFlowAIThread.surface == "space", ] search_text = (q or "").strip() if search_text: pattern = f"%{search_text}%" filters.append( or_( PMFlowAIThread.title.ilike(pattern), PMFlowAIThread.last_message_preview.ilike(pattern), exists( select(PMFlowAIMessage.id).where( and_( PMFlowAIMessage.tenant_id == int(current_user.tenant_id), PMFlowAIMessage.thread_id == PMFlowAIThread.id, PMFlowAIMessage.content.ilike(pattern), ) ) ), ) ) stmt = ( select(PMFlowAIThread) .where(and_(*filters)) .order_by(desc(PMFlowAIThread.last_message_at), desc(PMFlowAIThread.id)) .offset((page - 1) * page_size) .limit(page_size + 1) ) rows = list((await db.execute(stmt)).scalars().all()) has_more = len(rows) > page_size return success_response( { "items": [_pmflow_ai_thread_summary(row) for row in rows[:page_size]], "page": page, "page_size": page_size, "has_more": has_more, } ) @router.post("/pmflow-ai/threads") async def create_pmflow_ai_thread( body: RAGAskRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): started = time.perf_counter() try: payload = await _run_pmflow_agent_turn_payload( body=body.model_copy(update={"session_id": None, "new_session": True}), db=db, current_user=current_user, started=started, ) thread = await _persist_pmflow_ai_thread_turn( db=db, tenant_id=int(current_user.tenant_id), current_user=current_user, thread=None, request=body, payload=payload, ) detail = await _pmflow_ai_thread_detail(db=db, thread=thread) except Exception: await db.rollback() return JSONResponse(status_code=500, content=error_response(500, "PMFlow AI 暂时没有生成可展示回答,请稍后重试。")) return success_response(detail) @router.post("/pmflow-ai/threads/stream") async def create_pmflow_ai_thread_stream( body: RAGAskRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): return _pmflow_ai_thread_stream_response( body=body.model_copy(update={"session_id": None, "new_session": True}), db=db, current_user=current_user, thread=None, ) @router.get("/pmflow-ai/threads/{thread_id}") async def get_pmflow_ai_thread( thread_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): thread = await _load_pmflow_ai_thread(db=db, tenant_id=int(current_user.tenant_id), user_id=int(current_user.id), thread_id=thread_id) if thread is None: raise HTTPException(status_code=404, detail="PMFlow AI thread not found") return success_response(await _pmflow_ai_thread_detail(db=db, thread=thread)) @router.post("/pmflow-ai/threads/{thread_id}/messages/stream") async def append_pmflow_ai_thread_message_stream( thread_id: str, body: RAGAskRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): thread = await _load_pmflow_ai_thread(db=db, tenant_id=int(current_user.tenant_id), user_id=int(current_user.id), thread_id=thread_id) if thread is None: raise HTTPException(status_code=404, detail="PMFlow AI thread not found") return _pmflow_ai_thread_stream_response( body=body.model_copy(update={"session_id": thread.qa_session_id, "new_session": False}), db=db, current_user=current_user, thread=thread, ) @router.post("/pmflow-ai/threads/{thread_id}/messages") async def append_pmflow_ai_thread_message( thread_id: str, body: RAGAskRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): thread = await _load_pmflow_ai_thread(db=db, tenant_id=int(current_user.tenant_id), user_id=int(current_user.id), thread_id=thread_id) if thread is None: raise HTTPException(status_code=404, detail="PMFlow AI thread not found") started = time.perf_counter() try: payload = await _run_pmflow_agent_turn_payload( body=body.model_copy(update={"session_id": thread.qa_session_id, "new_session": False}), db=db, current_user=current_user, started=started, ) thread = await _persist_pmflow_ai_thread_turn( db=db, tenant_id=int(current_user.tenant_id), current_user=current_user, thread=thread, request=body, payload=payload, ) detail = await _pmflow_ai_thread_detail(db=db, thread=thread) except Exception: await db.rollback() return JSONResponse(status_code=500, content=error_response(500, "PMFlow AI 暂时没有生成可展示回答,请稍后重试。")) return success_response(detail) @router.post("/pmflow-ai/threads/{thread_id}/confirmations/{confirmation_id}/execute") async def execute_pmflow_ai_thread_confirmation( thread_id: str, confirmation_id: str, body: PMFlowAIConfirmationExecutionRequest | None = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): thread = await _load_pmflow_ai_thread(db=db, tenant_id=int(current_user.tenant_id), user_id=int(current_user.id), thread_id=thread_id) if thread is None: raise HTTPException(status_code=404, detail="PMFlow AI thread not found") request = body or PMFlowAIConfirmationExecutionRequest() contract = await _load_pmflow_ai_confirmation_contract(db=db, thread=thread, confirmation_id=confirmation_id) if contract is None: return JSONResponse(status_code=404, content=error_response(404, "confirmation_not_found")) if _confirmation_already_executed(thread=thread, confirmation_id=confirmation_id): return success_response(await _pmflow_ai_thread_detail(db=db, thread=thread)) try: action_type = str(contract.get("action_type") or "").strip() if action_type == "continue_research": detail = await _execute_pmflow_ai_research_confirmation( db=db, current_user=current_user, thread=thread, confirmation=contract, confirmation_id=confirmation_id, request=request, ) return success_response(detail) if action_type == "save_candidate": thread = await _execute_pmflow_ai_save_candidate_confirmation( db=db, current_user=current_user, thread=thread, confirmation=contract, confirmation_id=confirmation_id, request=request, ) return success_response(await _pmflow_ai_thread_detail(db=db, thread=thread)) if action_type == "start_execution_handoff": thread = await _execute_pmflow_ai_execution_handoff_confirmation( db=db, current_user=current_user, thread=thread, confirmation=contract, confirmation_id=confirmation_id, request=request, ) return success_response(await _pmflow_ai_thread_detail(db=db, thread=thread)) thread = await _persist_pmflow_ai_action_turn( db=db, tenant_id=int(current_user.tenant_id), current_user=current_user, thread=thread, user_action_text=_confirmation_user_action_text(contract), answer="这个确认项当前只能作为候选草案展示,还没有满足直接执行条件。请先进入对应项目对象完成审查或补齐缺口。", confirmation_id=confirmation_id, action_type=action_type or "review_prepared_action", action_payload=_confirmation_action_payload_from_contract(contract), action_result={ "status": "blocked", "reason": "confirmation_action_not_executable", "confirmation_id": confirmation_id, }, ) except SavedIdeaError as exc: await db.rollback() return JSONResponse(status_code=exc.status_code, content=error_response(exc.status_code, f"{exc.code}: {exc.message}")) except ValueError as exc: await db.rollback() code = str(exc) status_code = 404 if code in {"project_not_found", "task_not_found"} else 422 return JSONResponse(status_code=status_code, content=error_response(status_code, code)) except Exception: await db.rollback() return JSONResponse(status_code=500, content=error_response(500, "确认动作没有执行成功,请稍后重试。")) return success_response(await _pmflow_ai_thread_detail(db=db, thread=thread)) @router.post("/pmflow-ai/runs") async def create_pmflow_ai_run( body: RAGAskRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): started = time.perf_counter() try: payload = await _run_pmflow_agent_turn_payload( body=body, db=db, current_user=current_user, started=started, ) except Exception: await db.rollback() return JSONResponse(status_code=500, content=error_response(500, "PMFlow AI 暂时没有生成可展示回答,请稍后重试。")) return success_response(payload) @router.get("/pmflow-ai/runs") async def list_pmflow_ai_runs( page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=100), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): stmt = ( select(PMFlowAgentRun) .where( and_( PMFlowAgentRun.tenant_id == int(current_user.tenant_id), PMFlowAgentRun.user_id == int(current_user.id), PMFlowAgentRun.presentation_mode == "pmflow_ai", ) ) .order_by(desc(PMFlowAgentRun.started_at), desc(PMFlowAgentRun.id)) .offset((page - 1) * page_size) .limit(page_size + 1) ) rows = list((await db.execute(stmt)).scalars().all()) has_more = len(rows) > page_size items = [_agent_run_summary(row) for row in rows[:page_size]] return success_response({"items": items, "page": page, "page_size": page_size, "has_more": has_more}) @router.get("/pmflow-ai/runs/{run_id}") async def get_pmflow_ai_run( run_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): row = ( await db.execute( select(PMFlowAgentRun) .where( and_( PMFlowAgentRun.tenant_id == int(current_user.tenant_id), PMFlowAgentRun.user_id == int(current_user.id), PMFlowAgentRun.public_run_id == str(run_id), ) ) .limit(1) ) ).scalar_one_or_none() if row is None: raise HTTPException(status_code=404, detail="PMFlow AI run not found") return success_response(await _agent_run_detail(db=db, run=row)) @router.post("/pmflow-ai/runs/{run_id}/resume") async def resume_pmflow_ai_run( run_id: str, body: RAGAskRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): # Resume currently creates the next durable run in the same QA session. The # prior run remains immutable evidence; continuation state is carried by the # QA session and Agent OS capsules. _ = run_id started = time.perf_counter() try: payload = await _run_pmflow_agent_turn_payload( body=body, db=db, current_user=current_user, started=started, ) except Exception: await db.rollback() return JSONResponse(status_code=500, content=error_response(500, "PMFlow AI 暂时没有生成可展示回答,请稍后重试。")) return success_response(payload) def _pmflow_ai_thread_stream_response( *, body: RAGAskRequest, db: AsyncSession, current_user: User, thread: PMFlowAIThread | None, ) -> StreamingResponse: async def event_generator(): queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue() started = time.perf_counter() async def emit(event: dict[str, Any]) -> None: if not isinstance(event, dict): return event = _customer_process_stream_event(event) event_type = str(event.get("type") or event.get("event") or "message").strip() or "message" await queue.put({"type": event_type, **event}) async def run_turn() -> None: try: await emit( { "type": "run.started", "thread_id": getattr(thread, "public_thread_id", None), "message": "PMFlow AI 已开始处理本轮对话。", } ) payload = await _run_pmflow_agent_turn_payload( body=body, db=db, current_user=current_user, started=started, event_sink=emit, ) persisted_thread = await _persist_pmflow_ai_thread_turn( db=db, tenant_id=int(current_user.tenant_id), current_user=current_user, thread=thread, request=body, payload=payload, ) detail = await _pmflow_ai_thread_detail(db=db, thread=persisted_thread) answer = str(payload.get("answer") or payload.get("reply_text") or "").strip() await emit( { "type": "answer.started", "thread_id": detail.get("thread_id"), "message": "最终回答已通过客户安全门控,开始输出。", } ) for chunk in _stream_text_chunks(answer): await emit({"type": "answer.delta", "thread_id": detail.get("thread_id"), "delta": chunk}) await asyncio.sleep(0.04) await emit({"type": "answer.completed", "thread_id": detail.get("thread_id"), "answer": answer}) await emit({"type": "thread.completed", "thread": detail}) except Exception: await db.rollback() await emit({"type": "error", "message": "PMFlow AI 暂时没有生成可展示回答,请稍后重试。"}) finally: await queue.put(None) task = asyncio.create_task(run_turn()) try: while True: event = await queue.get() if event is None: break yield _sse_encode(event) finally: if not task.done(): task.cancel() try: await task except asyncio.CancelledError: pass return StreamingResponse( event_generator(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "X-Accel-Buffering": "no", }, ) def _sse_encode(event: dict[str, Any]) -> str: event_type = str(event.get("type") or "message").strip() or "message" payload = json.dumps(event, ensure_ascii=False, separators=(",", ":")) return f"event: {event_type}\ndata: {payload}\n\n" def _stream_text_chunks(text: str, *, size: int = 6) -> list[str]: clean = str(text or "") if not clean: return [] chunks: list[str] = [] buffer = "" for char in clean: buffer += char if len(buffer) >= size or char in {"。", "!", "?", "\n"}: chunks.append(buffer) buffer = "" if buffer: chunks.append(buffer) return chunks async def _run_pmflow_agent_turn_payload( *, body: RAGAskRequest, db: AsyncSession, current_user: User, started: float, event_sink: Any | None = None, ) -> dict: body = _request_with_interaction_submission(body) conversation_context = await _load_space_conversation_context( db=db, tenant_id=int(current_user.tenant_id), session_id=body.session_id, ) runtime_context_builder = SpaceRuntimeContextBuilder(db, tenant_id=int(current_user.tenant_id)) space_runtime_context = await runtime_context_builder.build( current_user=current_user, surface_context=body.surface_context, current_project_id=conversation_context.get("current_project_id") or body.project_id, ) body = body.model_copy( update={ "surface_context": runtime_context_builder.merge_surface_context( surface_context=body.surface_context, runtime_context=space_runtime_context, ) } ) tenant_project_context = rag_ask_contract.build_tenant_project_context_packet( request=body, current_user=current_user, current_project_id_override=conversation_context.get("current_project_id"), ) runtime_payload = await PMFlowAgentRuntimeService( db, tenant_id=int(current_user.tenant_id), ).run_space_turn( request=body, current_user=current_user, conversation_context=conversation_context, tenant_project_context=tenant_project_context, started_at=started, event_sink=event_sink, ) persisted_payload = await _persist_space_direct_payload( db=db, tenant_id=int(current_user.tenant_id), current_user=current_user, request=body, payload=runtime_payload, started_at=started, ) payload = rag_ask_contract.sanitize_customer_rag_payload( _attach_space_operating_conversation(persisted_payload, request=body), request=body, current_user=current_user, apply_presentation_contract=False, ) return payload def _attach_space_operating_conversation(payload: dict, *, request: RAGAskRequest) -> dict: """Attach the Space PMFlow AI conversation-mode contract to customer payloads.""" out = dict(payload or {}) out["pmflow_ai_mode"] = "space_operating_partner" out["conversation_frame"] = { "frame_version": "pmflow_ai_space_conversation.v1", "mode": "space_operating_partner", "frame_type": _space_frame_type_from_agent_payload(out), "primary_question": "你现在最应该关注什么", "object_scope": "cross_project_space", "default_posture": "chief_of_staff", "automation_mode": "suggest_only", } canvas = dict(out.get("answer_canvas") or {}) canvas["conversation_mode"] = "space_operating_partner" canvas.pop("conversation_blocks", None) out["answer_canvas"] = canvas return out def _space_frame_type_from_agent_payload(payload: dict) -> str: packet = payload.get("customer_evidence_packet") if isinstance(payload.get("customer_evidence_packet"), dict) else {} answer_type = str(packet.get("answer_type") or "").strip() if answer_type: return answer_type[:80] terminal_state = payload.get("terminal_answer_state") if isinstance(payload.get("terminal_answer_state"), dict) else {} terminal_status = str(terminal_state.get("terminal_status") or "").strip() if terminal_status: return terminal_status[:80] agent_run = payload.get("agent_run_packet") if isinstance(payload.get("agent_run_packet"), dict) else {} agent_status = str(agent_run.get("status") or "").strip() return f"agent_{agent_status}"[:80] if agent_status else "agent_run" def _request_with_interaction_submission(body: RAGAskRequest) -> RAGAskRequest: surface_context = dict(body.surface_context or {}) packet = _interaction_submission_packet(surface_context) if not packet: return body turn_delta = _turn_delta_from_interaction_submission( raw_question=str(body.question or "").strip(), packet=packet, ) surface_context["interaction_submission_packet"] = packet surface_context["pmflow_ai_turn_delta"] = turn_delta.model_dump(mode="json", exclude_none=True) question = turn_delta.effective_question or str(body.question or "").strip() or "确认并继续" return body.model_copy(update={"question": question, "surface_context": surface_context}) def _interaction_submission_packet(surface_context: dict | None) -> dict: surface = dict(surface_context or {}) submission = surface.get("interaction_submission") if not isinstance(submission, dict): return {} answers: list[dict] = [] raw_answers = submission.get("answers") if isinstance(raw_answers, list): for item in raw_answers[:12]: if not isinstance(item, dict): continue labels = [str(entry).strip() for entry in list(item.get("labels") or []) if str(entry).strip()] value = item.get("value") if isinstance(value, list): values = [str(entry).strip() for entry in value if str(entry).strip()] else: values = [str(value).strip()] if str(value or "").strip() else [] question_id = str(item.get("question_id") or "").strip() question_label = str(item.get("question_label") or "").strip() display_value = "、".join(labels or values) if question_id or question_label or display_value: answers.append( { "question_id": question_id[:80], "question_label": question_label[:180], "values": values[:8], "labels": labels[:8], "display_value": display_value[:240], } ) supplement = str(submission.get("supplement_text") or "").strip() if not answers and not supplement: return {} return { "packet_version": "interaction_submission_packet.v1", "answers": answers, "supplement_text": supplement[:1200], } def _turn_delta_from_interaction_submission(*, raw_question: str, packet: dict) -> PMFlowAITurnDelta: answers: list[PMFlowAITurnDeltaAnswer] = [] for item in list(packet.get("answers") or [])[:12]: if not isinstance(item, dict): continue answer = PMFlowAITurnDeltaAnswer.model_validate(item) answers.append(answer) supplement = str(packet.get("supplement_text") or "").strip() answer_lines: list[str] = [] for answer in answers[:8]: label = str(answer.question_label or answer.question_id or "").strip() value = str(answer.display_value or "、".join(answer.labels or answer.values) or "").strip() if label and value: answer_lines.append(f"{label}:{value}") elif value: answer_lines.append(value) effective_parts = [str(raw_question or "").strip()] if answer_lines: effective_parts.append("结构化补充:" + ";".join(answer_lines)) if supplement: effective_parts.append("补充说明:" + supplement) effective = "\n".join(part for part in effective_parts if part).strip() or "确认并继续" return PMFlowAITurnDelta( source="interaction_submission", raw_question=raw_question[:1000], effective_question=effective[:1000], answers=answers, supplement_text=supplement, ) async def _load_space_conversation_context( *, db: AsyncSession, tenant_id: int, session_id: int | None, ) -> dict: if session_id is None: return {} state_row = ( await db.execute( select(QASessionState).where( and_( QASessionState.tenant_id == int(tenant_id), QASessionState.session_id == int(session_id), ) ).limit(1) ) ).scalar_one_or_none() if state_row is None: return {} raw_workspace = getattr(state_row, "active_workspace", None) if isinstance(getattr(state_row, "active_workspace", None), dict) else {} active_workspace = normalize_research_workspace_payload(raw_workspace) if not active_workspace and raw_workspace: active_workspace = dict(raw_workspace) current_project_id = rag_ask_contract._project_id_from_conversation_context( { "active_workspace": active_workspace, "last_entity_ids": list(getattr(state_row, "last_entity_ids", []) or []), } ) context_capsules = list(getattr(state_row, "context_capsules", []) or []) object_ledger = object_ledger_from_context_capsules(context_capsules) agent_workspace = agent_workspace_from_context_capsules(context_capsules) return { "session_id": int(session_id), "last_subject": str(getattr(state_row, "last_subject", "") or "").strip() or None, "last_entity_ids": list(getattr(state_row, "last_entity_ids", []) or []), "active_workspace": active_workspace, "thread_workspace": active_workspace, "context_capsules": context_capsules, "object_ledger": object_ledger, "agent_workspace": agent_workspace, "current_project_id": current_project_id, } async def _persist_space_direct_payload( *, db: AsyncSession, tenant_id: int, current_user: User, request: RAGAskRequest, payload: dict, started_at: float, ) -> dict: rag_service = AdaptiveRAGService(db, tenant_id=int(tenant_id)) payload = dict(payload or {}) answer = str(payload.get("answer") or payload.get("reply_text") or "").strip() elapsed = int((time.perf_counter() - started_at) * 1000) processing_stage = str(payload.get("processing_stage") or "surface_direct").strip() or "surface_direct" payload["response_time_ms"] = elapsed presentation_service = CustomerAnswerPresentationService(db, tenant_id=int(tenant_id)) packet = payload.get("customer_evidence_packet") if isinstance(payload.get("customer_evidence_packet"), dict) else None if packet is None: packet = presentation_service.build_customer_evidence_packet( request=request, current_user=current_user, payload=payload, ) payload["customer_evidence_packet"] = packet research_workspace = payload.get("research_workspace") if isinstance(payload.get("research_workspace"), dict) else {} if not isinstance(payload.get("customer_interaction_context_packet"), dict): payload["customer_interaction_context_packet"] = presentation_service.build_customer_interaction_context_packet( packet, research_workspace=research_workspace, ) if not isinstance(payload.get("answer_canvas"), dict): payload["answer_canvas"] = presentation_service.build_answer_canvas( packet=packet, answer=answer, research_workspace=research_workspace, ) payload["answer_process_packet"] = rag_ask_contract.attach_answer_process_timing( payload.get("answer_process_packet"), total_duration_ms=elapsed, route_key=processing_stage, ) if not isinstance(payload.get("turn_presentation_contract"), dict): payload["turn_presentation_contract"] = build_agent_turn_presentation_contract(payload) answer = str(payload.get("answer") or payload.get("reply_text") or "").strip() payload = _attach_space_operating_conversation(payload, request=request) preliminary_ledger = build_object_ledger_from_payload(payload=payload) if preliminary_ledger: payload["object_ledger"] = preliminary_ledger payload["customer_interaction_context_packet"] = merge_object_ledger_into_context_packet( context_packet=payload.get("customer_interaction_context_packet") if isinstance(payload.get("customer_interaction_context_packet"), dict) else {}, object_ledger=preliminary_ledger, ) focus = ( payload.get("customer_interaction_context_packet", {}).get("current_focus_object") if isinstance(payload.get("customer_interaction_context_packet"), dict) else None ) focus_project_id = None if isinstance(focus, dict): try: focus_project_id = int(focus.get("object_id")) if focus.get("object_id") is not None else None except Exception: focus_project_id = None workspace_project_id = None try: workspace_project_id = ( int(research_workspace.get("primary_object_id")) if research_workspace.get("primary_object_id") is not None else None ) except Exception: workspace_project_id = None project_id = request.project_id or focus_project_id or workspace_project_id session_id, turn_index = await rag_service._touch_session_for_new_record( asked_by=int(current_user.id), asked_via=request.asked_via, project_id=project_id, question=request.question, preferred_session_id=request.session_id, force_new_session=bool(request.new_session), external_conversation_key=request.source_chat_id, ) state_row = await rag_service._ensure_session_state(session_id=int(session_id) if session_id is not None else None) agent_os_run_id = str(payload.get("agent_os_run_id") or "").strip() if agent_os_run_id and session_id is not None: await db.execute( update(PMFlowAgentRun) .where( and_( PMFlowAgentRun.tenant_id == int(tenant_id), PMFlowAgentRun.public_run_id == agent_os_run_id, ) ) .values(qa_session_id=int(session_id)) ) object_ledger = build_object_ledger_from_payload( payload=payload, previous_ledger=object_ledger_from_context_capsules(getattr(state_row, "context_capsules", None)), ) if object_ledger: payload["object_ledger"] = object_ledger payload["customer_interaction_context_packet"] = merge_object_ledger_into_context_packet( context_packet=payload.get("customer_interaction_context_packet") if isinstance(payload.get("customer_interaction_context_packet"), dict) else {}, object_ledger=object_ledger, ) customer_payload_for_storage = rag_ask_contract.sanitize_customer_rag_payload( payload, request=request, current_user=current_user, apply_presentation_contract=False, ) answer = str(customer_payload_for_storage.get("answer") or customer_payload_for_storage.get("reply_text") or "").strip() payload["answer"] = answer payload["reply_text"] = answer for key in ( "answer_canvas", "customer_evidence_packet", "customer_interaction_context_packet", "answer_process_packet", "deliverable_packet", "research_workspace", "turn_presentation_contract", ): if key in customer_payload_for_storage: payload[key] = customer_payload_for_storage.get(key) research_contract = payload.get("research_contract") if isinstance(payload.get("research_contract"), dict) else {} effective_source_policy = str( payload.get("source_policy") or research_contract.get("effective_lane") or "internal" ).strip().lower() if effective_source_policy not in {"internal", "external", "hybrid"}: effective_source_policy = "internal" route_decision = { "route_mode": effective_source_policy, "effective_source_policy": effective_source_policy, "decision_origin": processing_stage, "query_profile": { "needs_private_context": effective_source_policy in {"internal", "hybrid"}, "needs_freshness": effective_source_policy in {"external", "hybrid"}, "confidence": 1.0, }, } routing_snapshot = { "query_route": route_decision, "response_payload": { "operating_mission": customer_payload_for_storage.get("operating_mission"), "project_world_state_packet": customer_payload_for_storage.get("project_world_state_packet"), "evidence_ledger": customer_payload_for_storage.get("evidence_ledger"), "operating_run_packet": customer_payload_for_storage.get("operating_run_packet"), "agent_assignments": customer_payload_for_storage.get("agent_assignments"), "agent_work_products": customer_payload_for_storage.get("agent_work_products"), "agent_council_review": customer_payload_for_storage.get("agent_council_review"), "conflict_ledger": customer_payload_for_storage.get("conflict_ledger"), "autonomy_gate": customer_payload_for_storage.get("autonomy_gate"), "prepared_action_packets": customer_payload_for_storage.get("prepared_action_packets"), "confirmation_requests": customer_payload_for_storage.get("confirmation_requests"), "execution_preparations": customer_payload_for_storage.get("execution_preparations"), "product_object_projections": customer_payload_for_storage.get("product_object_projections"), "operating_cycle": customer_payload_for_storage.get("operating_cycle"), "consumed_learning_ledger": customer_payload_for_storage.get("consumed_learning_ledger"), "outcome_observations": customer_payload_for_storage.get("outcome_observations"), "learning_ledger": customer_payload_for_storage.get("learning_ledger"), "learning_updates": customer_payload_for_storage.get("learning_updates"), "next_cycle_triggers": customer_payload_for_storage.get("next_cycle_triggers"), "operating_plan": customer_payload_for_storage.get("operating_plan"), "command_payload": customer_payload_for_storage.get("command_payload"), "customer_evidence_packet": customer_payload_for_storage.get("customer_evidence_packet"), "customer_interaction_context_packet": customer_payload_for_storage.get("customer_interaction_context_packet"), "answer_process_packet": customer_payload_for_storage.get("answer_process_packet"), "terminal_answer_state": customer_payload_for_storage.get("terminal_answer_state"), "deliverable_packet": customer_payload_for_storage.get("deliverable_packet"), "answer_canvas": customer_payload_for_storage.get("answer_canvas"), "agent_os_environment": customer_payload_for_storage.get("agent_os_environment"), "agent_os_specialists": customer_payload_for_storage.get("agent_os_specialists"), "pmflow_agent_run": customer_payload_for_storage.get("pmflow_agent_run"), "agent_os_run_id": customer_payload_for_storage.get("agent_os_run_id"), "agent_os_steps": customer_payload_for_storage.get("agent_os_steps"), "agent_os_observations": customer_payload_for_storage.get("agent_os_observations"), "agent_os_artifacts": customer_payload_for_storage.get("agent_os_artifacts"), "agent_os_verifications": customer_payload_for_storage.get("agent_os_verifications"), "agent_os_memories": customer_payload_for_storage.get("agent_os_memories"), "agent_os_specialist_assignments": customer_payload_for_storage.get("agent_os_specialist_assignments"), "agent_os_work_products": customer_payload_for_storage.get("agent_os_work_products"), "agent_os_agent_council_review": customer_payload_for_storage.get("agent_os_agent_council_review"), "agent_os_operating_plan": customer_payload_for_storage.get("agent_os_operating_plan"), "agent_os_autonomy_gate": customer_payload_for_storage.get("agent_os_autonomy_gate"), "agent_os_operating_cycle": customer_payload_for_storage.get("agent_os_operating_cycle"), "agent_os_consumed_learning_ledger": customer_payload_for_storage.get("agent_os_consumed_learning_ledger"), "agent_os_outcome_observations": customer_payload_for_storage.get("agent_os_outcome_observations"), "agent_os_learning_ledger": customer_payload_for_storage.get("agent_os_learning_ledger"), "agent_os_learning_updates": customer_payload_for_storage.get("agent_os_learning_updates"), "agent_os_next_cycle_triggers": customer_payload_for_storage.get("agent_os_next_cycle_triggers"), "agent_os_prepared_action_packets": customer_payload_for_storage.get("agent_os_prepared_action_packets"), "agent_os_confirmation_requests": customer_payload_for_storage.get("agent_os_confirmation_requests"), "agent_os_execution_preparations": customer_payload_for_storage.get("agent_os_execution_preparations"), "agent_os_product_object_projections": customer_payload_for_storage.get("agent_os_product_object_projections"), "agent_os_command_payload": customer_payload_for_storage.get("agent_os_command_payload"), "agent_run_packet": customer_payload_for_storage.get("agent_run_packet"), "object_ledger": customer_payload_for_storage.get("object_ledger"), "research_workspace": customer_payload_for_storage.get("research_workspace"), "research_contract": customer_payload_for_storage.get("research_contract"), "interaction_request": customer_payload_for_storage.get("interaction_request"), "turn_presentation_contract": customer_payload_for_storage.get("turn_presentation_contract"), }, } qa = QARecord( tenant_id=int(tenant_id), question=str(request.question or ""), answer=answer, sources=list(customer_payload_for_storage.get("sources") or []), intent="question", domains_used=["surface_context"], response_time_ms=elapsed, asked_by=int(current_user.id), asked_via=(request.asked_via or "pmflow")[:20], project_id=project_id, session_id=int(session_id) if session_id is not None else None, turn_index=int(turn_index), source_message_id=f"space-direct-{int(time.time() * 1000)}", source_chat_id=request.source_chat_id, routing_snapshot=routing_snapshot, answer_mode=str(payload.get("answer_mode") or "surface_direct")[:32], grounding_status=str(payload.get("grounding_status") or "grounded")[:32], policy_version=str(payload.get("policy_version") or "surface-direct-v1")[:160], citations=[], sources_used=list(customer_payload_for_storage.get("sources_used") or []), evidence_pack=customer_payload_for_storage.get("customer_evidence_packet"), tool_trace=[ { "step": 0, "tool": processing_stage, "route_mode": effective_source_policy, "duration_ms": elapsed, } ], retrieval_metrics={ "surface_direct": processing_stage == "surface_direct", "total_duration_ms": elapsed, "model_calls": int((payload.get("answer_process_packet") or {}).get("model_calls") or 0) if isinstance(payload.get("answer_process_packet"), dict) else 0, "source": processing_stage, }, ) db.add(qa) await rag_service._update_session_state_after_turn( session_state=state_row, question=str(request.question or ""), effective_question=str(request.question or ""), answer=answer, query_type=str(payload.get("query_type") or ("project_inventory" if focus_project_id else "space_status")), ambiguity="clear", rewrite_meta={"source": processing_stage}, sources=list(customer_payload_for_storage.get("sources") or []), response_payload=routing_snapshot.get("response_payload") if isinstance(routing_snapshot, dict) else None, route_decision=route_decision, ) if object_ledger: state_row.context_capsules = upsert_object_ledger_capsule( capsules=getattr(state_row, "context_capsules", None), object_ledger=object_ledger, ) agent_workspace = payload.get("agent_workspace") if isinstance(payload.get("agent_workspace"), dict) else None if agent_workspace: state_row.context_capsules = upsert_agent_workspace_capsule( capsules=getattr(state_row, "context_capsules", None), workspace=normalize_agent_workspace_payload(agent_workspace), ) operating_run_packet = payload.get("operating_run_packet") if isinstance(payload.get("operating_run_packet"), dict) else None if operating_run_packet: state_row.context_capsules = _upsert_operating_run_capsule( capsules=getattr(state_row, "context_capsules", None), operating_run_packet=operating_run_packet, ) pmflow_agent_run = payload.get("pmflow_agent_run") if isinstance(payload.get("pmflow_agent_run"), dict) else None if pmflow_agent_run: state_row.context_capsules = _upsert_pmflow_agent_run_capsule( capsules=getattr(state_row, "context_capsules", None), pmflow_agent_run=pmflow_agent_run, ) learning_ledger = payload.get("learning_ledger") if isinstance(payload.get("learning_ledger"), dict) else None if learning_ledger: state_row.context_capsules = _upsert_operating_learning_ledger_capsule( capsules=getattr(state_row, "context_capsules", None), learning_ledger=learning_ledger, ) await db.flush() await db.commit() await db.refresh(qa) await rag_service.context_cache.append_session_messages( tenant_id=int(tenant_id), session_id=int(session_id) if session_id is not None else None, question=str(request.question or ""), answer=answer, ) payload["qa_id"] = int(qa.id) payload["session_id"] = int(session_id) if session_id is not None else None payload["turn_index"] = int(turn_index) payload["tool_trace"] = qa.tool_trace payload["retrieval_metrics"] = qa.retrieval_metrics return payload def _upsert_operating_run_capsule(*, capsules: list | None, operating_run_packet: dict) -> list[dict]: rows = [dict(item) for item in list(capsules or []) if isinstance(item, dict)] packet = dict(operating_run_packet or {}) mission = packet.get("mission") if isinstance(packet.get("mission"), dict) else {} capsule = { "capsule_version": "pmflow_ai_operating_run_capsule.v1", "capsule_type": "operating_run", "run_id": str(packet.get("run_id") or "")[:80], "mission_id": str(mission.get("mission_id") or "")[:80], "mission_goal": str(mission.get("mission_goal") or "")[:500], "status": str(packet.get("status") or "")[:80], "updated_at": datetime.utcnow().isoformat(), "operating_run_packet": packet, } out = [item for item in rows if item.get("capsule_type") != "operating_run"] out.append(capsule) return out[-12:] def _upsert_pmflow_agent_run_capsule(*, capsules: list | None, pmflow_agent_run: dict) -> list[dict]: rows = [dict(item) for item in list(capsules or []) if isinstance(item, dict)] packet = dict(pmflow_agent_run or {}) capsule = { "capsule_version": "pmflow_agent_run_capsule.v1", "capsule_type": "pmflow_agent_run", "run_id": str(packet.get("run_id") or "")[:80], "goal": str(packet.get("goal") or "")[:500], "status": str(packet.get("status") or "")[:80], "updated_at": datetime.utcnow().isoformat(), "pmflow_agent_run": packet, } out = [item for item in rows if item.get("capsule_type") != "pmflow_agent_run"] out.append(capsule) return out[-12:] def _upsert_operating_learning_ledger_capsule(*, capsules: list | None, learning_ledger: dict) -> list[dict]: rows = [dict(item) for item in list(capsules or []) if isinstance(item, dict)] ledger = dict(learning_ledger or {}) capsule = { "capsule_version": "pmflow_ai_learning_ledger_capsule.v1", "capsule_type": "operating_learning_ledger", "scope_ref": str(ledger.get("scope_ref") or "")[:160], "ledger_id": str(ledger.get("ledger_id") or "")[:80], "updated_at": datetime.utcnow().isoformat(), "learning_ledger": ledger, } out = [ item for item in rows if not ( item.get("capsule_type") == "operating_learning_ledger" and str(item.get("scope_ref") or "") == str(capsule.get("scope_ref") or "") ) ] out.append(capsule) return out[-12:] async def _load_pmflow_ai_thread( *, db: AsyncSession, tenant_id: int, user_id: int, thread_id: str, ) -> PMFlowAIThread | None: return ( await db.execute( select(PMFlowAIThread) .where( and_( PMFlowAIThread.tenant_id == int(tenant_id), PMFlowAIThread.user_id == int(user_id), PMFlowAIThread.public_thread_id == str(thread_id), ) ) .limit(1) ) ).scalar_one_or_none() async def _load_pmflow_ai_confirmation_contract( *, db: AsyncSession, thread: PMFlowAIThread, confirmation_id: str, ) -> dict[str, Any] | None: target_id = str(confirmation_id or "").strip() if not target_id: return None rows = list( ( await db.execute( select(PMFlowAIMessage) .where(and_(PMFlowAIMessage.thread_id == int(thread.id), PMFlowAIMessage.role == "assistant")) .order_by(PMFlowAIMessage.message_index.desc(), PMFlowAIMessage.id.desc()) .limit(40) ) ) .scalars() .all() ) for message in rows: payload = dict(message.message_payload or {}) if isinstance(message.message_payload, dict) else {} confirmations = payload.get("confirmations") if isinstance(payload.get("confirmations"), list) else [] for item in confirmations: if isinstance(item, dict) and str(item.get("id") or "").strip() == target_id: return { **item, "source_message_id": message.public_message_id, "source_run_id": message.run_public_id, } return None async def _execute_pmflow_ai_research_confirmation( *, db: AsyncSession, current_user: User, thread: PMFlowAIThread, confirmation: dict[str, Any], confirmation_id: str, request: PMFlowAIConfirmationExecutionRequest, ) -> dict: started = time.perf_counter() action_payload = _confirmation_action_payload_from_contract(confirmation) prompt = _research_recovery_prompt_from_confirmation(confirmation=confirmation, request=request) runtime_request = RAGAskRequest( question=prompt, asked_via="pmflow", session_id=thread.qa_session_id, new_session=False, thinking_mode="deep", action_mode="read", message_intent="pmflow_ai_confirm", surface_context={ "pmflow_ai_confirmation_action": { "confirmation_id": confirmation_id, "action_type": "continue_research", "action_payload": action_payload, "source_message_id": confirmation.get("source_message_id"), } }, ) payload = await _run_pmflow_agent_turn_payload( body=runtime_request, db=db, current_user=current_user, started=started, ) thread = await _persist_pmflow_ai_thread_turn( db=db, tenant_id=int(current_user.tenant_id), current_user=current_user, thread=thread, request=runtime_request, payload=payload, ) await _mark_pmflow_ai_confirmation_executed( db=db, thread=thread, confirmation_id=confirmation_id, action_type="continue_research", action_payload=action_payload, result={"status": "completed", "run_id": payload.get("agent_os_run_id") or payload.get("run_id")}, ) return await _pmflow_ai_thread_detail(db=db, thread=thread) async def _execute_pmflow_ai_save_candidate_confirmation( *, db: AsyncSession, current_user: User, thread: PMFlowAIThread, confirmation: dict[str, Any], confirmation_id: str, request: PMFlowAIConfirmationExecutionRequest, ) -> PMFlowAIThread: action_payload = _confirmation_action_payload_from_contract(confirmation) body = _saved_idea_request_from_confirmation( confirmation=confirmation, action_payload=action_payload, thread=thread, confirmation_id=confirmation_id, supplement_text=request.supplement_text, ) public_thread_id = str(thread.public_thread_id) service = SpaceSavedIdeaService(db, tenant_id=int(current_user.tenant_id)) item = await service.create_idea(body=body, user_id=int(current_user.id)) refreshed_thread = await _load_pmflow_ai_thread( db=db, tenant_id=int(current_user.tenant_id), user_id=int(current_user.id), thread_id=public_thread_id, ) thread = refreshed_thread or thread answer = f"已保存为暂存想法:{item.title}。它会进入个人空间的想法池,后续可以继续整理为项目草案或验证计划。" return await _persist_pmflow_ai_action_turn( db=db, tenant_id=int(current_user.tenant_id), current_user=current_user, thread=thread, user_action_text=_confirmation_user_action_text(confirmation), answer=answer, confirmation_id=confirmation_id, action_type="save_candidate", action_payload=action_payload, action_result={ "status": "completed", "saved_idea_id": item.id, "saved_idea_title": item.title, "href": item.href, }, ) async def _execute_pmflow_ai_execution_handoff_confirmation( *, db: AsyncSession, current_user: User, thread: PMFlowAIThread, confirmation: dict[str, Any], confirmation_id: str, request: PMFlowAIConfirmationExecutionRequest, ) -> PMFlowAIThread: action_payload = _confirmation_action_payload_from_contract(confirmation) resolved = _execution_handoff_request_from_confirmation(action_payload=action_payload, supplement_text=request.supplement_text) if resolved.get("blocked"): return await _persist_pmflow_ai_action_turn( db=db, tenant_id=int(current_user.tenant_id), current_user=current_user, thread=thread, user_action_text=_confirmation_user_action_text(confirmation), answer=str(resolved.get("customer_message") or "执行交接缺少正式任务或项目锚点,当前不能直接创建 handoff。"), confirmation_id=confirmation_id, action_type="start_execution_handoff", action_payload=action_payload, action_result={"status": "blocked", **resolved}, ) project_id = int(resolved["project_id"]) await _ensure_pmflow_ai_project_access( db=db, tenant_id=int(current_user.tenant_id), user_id=int(current_user.id), project_id=project_id, ) public_thread_id = str(thread.public_thread_id) service = PMFlowOSProjectionService(db, tenant_id=int(current_user.tenant_id)) summary = await service.create_execution_handoff( project_id=project_id, body=PMFlowExecutionHandoffRequest( task_id=str(resolved["task_id"]), repo_binding_id=resolved.get("repo_binding_id"), runtime_target=resolved.get("runtime_target") or "local_bridge", note=resolved.get("note"), ), current_user_id=int(current_user.id), ) refreshed_thread = await _load_pmflow_ai_thread( db=db, tenant_id=int(current_user.tenant_id), user_id=int(current_user.id), thread_id=public_thread_id, ) thread = refreshed_thread or thread summary_payload = summary.model_dump(mode="json") prompt_package = summary_payload.get("promptPackage") if isinstance(summary_payload.get("promptPackage"), dict) else {} package_id = str(prompt_package.get("id") or prompt_package.get("promptPackageId") or "").strip() answer = "已创建执行交接包。执行环境可以拉取 prompt package,并按回写协议把进度、测试和交付结果写回 PMFlow。" if package_id: answer += f" 当前交接包编号:{package_id}。" return await _persist_pmflow_ai_action_turn( db=db, tenant_id=int(current_user.tenant_id), current_user=current_user, thread=thread, user_action_text=_confirmation_user_action_text(confirmation), answer=answer, confirmation_id=confirmation_id, action_type="start_execution_handoff", action_payload=action_payload, action_result={ "status": "completed", "project_id": project_id, "task_id": str(resolved["task_id"]), "runtime_target": resolved.get("runtime_target") or "local_bridge", "prompt_package": prompt_package, "result_writeback": { "endpoint": "/api/pmflow-os/runtime/execution-results", "protocol": "execution_result_protocol_v1", "observed_by_next_cycle": True, }, }, ) async def _persist_pmflow_ai_thread_turn( *, db: AsyncSession, tenant_id: int, current_user: User, thread: PMFlowAIThread | None, request: RAGAskRequest, payload: dict, ) -> PMFlowAIThread: now = datetime.utcnow() answer = str(payload.get("answer") or payload.get("reply_text") or "").strip() run_public_id = str(payload.get("agent_os_run_id") or payload.get("run_id") or "").strip()[:80] or None qa_record_id = _safe_int(payload.get("qa_id")) session_id = _safe_int(payload.get("session_id")) if thread is None: thread = PMFlowAIThread( tenant_id=int(tenant_id), public_thread_id=new_pmflow_agent_id("thread"), user_id=int(current_user.id), qa_session_id=session_id, workspace_scope="space", surface="space", title=_title_from_goal(str(request.question or "")), status="active", current_object_refs=list(_active_object_refs_from_payload(payload)), last_run_public_id=run_public_id, last_message_preview=answer[:500] or str(request.question or "")[:500], message_count=0, thread_metadata={ "presentation": "pmflow_ai_thread", "model": "thread_message_agent_run", }, started_at=now, last_message_at=now, ) db.add(thread) await db.flush() else: thread.qa_session_id = thread.qa_session_id or session_id if not str(thread.title or "").strip() or thread.title == "PMFlow AI": thread.title = _title_from_goal(str(request.question or "")) object_refs = list(_active_object_refs_from_payload(payload)) if object_refs: thread.current_object_refs = object_refs thread.last_run_public_id = run_public_id thread.last_message_preview = answer[:500] or str(request.question or "")[:500] thread.last_message_at = now base_index = int(thread.message_count or 0) user_message = PMFlowAIMessage( tenant_id=int(tenant_id), thread_id=int(thread.id), public_message_id=new_pmflow_agent_id("msg"), role="user", message_index=base_index + 1, run_public_id=run_public_id, qa_record_id=qa_record_id, content=str(request.question or "").strip(), message_payload={"message_type": "user_input"}, sent_at=now, ) assistant_message = PMFlowAIMessage( tenant_id=int(tenant_id), thread_id=int(thread.id), public_message_id=new_pmflow_agent_id("msg"), role="assistant", message_index=base_index + 2, run_public_id=run_public_id, qa_record_id=qa_record_id, content=answer, message_payload=_pmflow_ai_assistant_message_payload(payload, question=str(request.question or "")), sent_at=now, ) db.add(user_message) db.add(assistant_message) thread.message_count = base_index + 2 if run_public_id: await db.execute( update(PMFlowAgentRun) .where( and_( PMFlowAgentRun.tenant_id == int(tenant_id), PMFlowAgentRun.public_run_id == run_public_id, ) ) .values(thread_id=int(thread.id), qa_session_id=session_id) ) await db.flush() await db.commit() await db.refresh(thread) return thread async def _persist_pmflow_ai_action_turn( *, db: AsyncSession, tenant_id: int, current_user: User, thread: PMFlowAIThread, user_action_text: str, answer: str, confirmation_id: str, action_type: str, action_payload: dict[str, Any], action_result: dict[str, Any], ) -> PMFlowAIThread: now = datetime.utcnow() base_index = int(thread.message_count or 0) clean_user_action = _customer_safe_display_text(user_action_text, fallback="确认执行该动作", limit=500) clean_answer = _customer_safe_display_text(answer, fallback="确认动作已处理。", limit=2000) user_message = PMFlowAIMessage( tenant_id=int(tenant_id), thread_id=int(thread.id), public_message_id=new_pmflow_agent_id("msg"), role="user", message_index=base_index + 1, run_public_id=None, qa_record_id=None, content=clean_user_action, message_payload={"message_type": "user_confirmation_action", "confirmation_id": str(confirmation_id)[:80]}, sent_at=now, ) assistant_message = PMFlowAIMessage( tenant_id=int(tenant_id), thread_id=int(thread.id), public_message_id=new_pmflow_agent_id("msg"), role="assistant", message_index=base_index + 2, run_public_id=None, qa_record_id=None, content=clean_answer, message_payload={ "message_type": "assistant_action_result", "status_label": "已处理", "work_log": [ { "id": "confirmation_action", "title": "执行确认动作", "summary": _customer_safe_display_text( action_result.get("status") or action_type, fallback="已处理确认动作。", limit=240, ), "status": "completed" if action_result.get("status") == "completed" else "blocked", } ], "artifacts": [], "actions": [], "confirmations": [], "answer_canvas": { "headline": "确认动作结果", "direct_answer": clean_answer, "evidence_blocks": [], "action_candidates": [], "object_refs": [], }, "action_execution_result": _customer_safe_action_result(action_result), }, sent_at=now, ) db.add(user_message) db.add(assistant_message) thread.message_count = base_index + 2 thread.last_message_preview = clean_answer[:500] thread.last_message_at = now _apply_confirmation_execution_metadata( thread=thread, confirmation_id=confirmation_id, action_type=action_type, action_payload=action_payload, result=action_result, ) await db.flush() await db.commit() await db.refresh(thread) return thread async def _mark_pmflow_ai_confirmation_executed( *, db: AsyncSession, thread: PMFlowAIThread, confirmation_id: str, action_type: str, action_payload: dict[str, Any], result: dict[str, Any], ) -> None: _apply_confirmation_execution_metadata( thread=thread, confirmation_id=confirmation_id, action_type=action_type, action_payload=action_payload, result=result, ) await db.flush() await db.commit() await db.refresh(thread) def _apply_confirmation_execution_metadata( *, thread: PMFlowAIThread, confirmation_id: str, action_type: str, action_payload: dict[str, Any], result: dict[str, Any], ) -> None: metadata = dict(thread.thread_metadata or {}) if isinstance(thread.thread_metadata, dict) else {} rows = [item for item in list(metadata.get("confirmation_execution_log") or []) if isinstance(item, dict)] rows = [item for item in rows if str(item.get("confirmation_id") or "") != str(confirmation_id)] rows.append( { "confirmation_id": str(confirmation_id)[:80], "action_type": str(action_type or "")[:120], "action_family": str(action_payload.get("action_family") or "")[:120], "status": str(result.get("status") or "completed")[:40], "executed_at": datetime.utcnow().isoformat(), "result_ref": _confirmation_result_ref(result), } ) metadata["confirmation_execution_log"] = rows[-20:] thread.thread_metadata = metadata def _confirmation_already_executed(*, thread: PMFlowAIThread, confirmation_id: str) -> bool: metadata = dict(thread.thread_metadata or {}) if isinstance(thread.thread_metadata, dict) else {} for item in list(metadata.get("confirmation_execution_log") or []): if isinstance(item, dict) and str(item.get("confirmation_id") or "") == str(confirmation_id): return True return False async def _pmflow_ai_thread_detail(*, db: AsyncSession, thread: PMFlowAIThread) -> dict: messages = list( ( await db.execute( select(PMFlowAIMessage) .where(PMFlowAIMessage.thread_id == int(thread.id)) .order_by(PMFlowAIMessage.message_index.asc(), PMFlowAIMessage.id.asc()) ) ) .scalars() .all() ) step_payloads_by_run = await _pmflow_ai_run_step_payloads_by_public_id(db=db, thread=thread, messages=messages) latest_assistant = next((item for item in reversed(messages) if item.role == "assistant"), None) return { **_pmflow_ai_thread_summary(thread), "messages": [ _pmflow_ai_message_view(item, run_steps=step_payloads_by_run.get(str(item.run_public_id or ""))) for item in messages ], "assistant_message": _pmflow_ai_message_view( latest_assistant, run_steps=step_payloads_by_run.get(str(latest_assistant.run_public_id or "")) if latest_assistant else None, ) if latest_assistant else None, } async def _pmflow_ai_run_step_payloads_by_public_id( *, db: AsyncSession, thread: PMFlowAIThread, messages: list[PMFlowAIMessage], ) -> dict[str, list[dict[str, Any]]]: public_run_ids = { str(item.run_public_id or "").strip() for item in messages if item.role == "assistant" and str(item.run_public_id or "").strip() } if not public_run_ids: return {} rows = list( ( await db.execute( select(PMFlowAgentRun.public_run_id, PMFlowAgentStep) .join(PMFlowAgentStep, PMFlowAgentStep.run_id == PMFlowAgentRun.id) .where( PMFlowAgentRun.thread_id == int(thread.id), PMFlowAgentRun.public_run_id.in_(public_run_ids), PMFlowAgentRun.tenant_id == int(thread.tenant_id), PMFlowAgentStep.tenant_id == int(thread.tenant_id), ) .order_by(PMFlowAgentRun.id, PMFlowAgentStep.step_index, PMFlowAgentStep.id) ) ) .all() ) out: dict[str, list[dict[str, Any]]] = {} for public_run_id, step in rows: payload = dict(step.step_payload or {}) if isinstance(step.step_payload, dict) else {} payload.setdefault("id", step.public_step_id) payload.setdefault("key", step.public_step_id) payload.setdefault("label", payload.get("label") or step.step_kind) payload.setdefault("status", step.status) payload.setdefault("summary", payload.get("summary") or step.output_summary or step.input_summary or "") payload.setdefault("tool_name", step.tool_name) payload.setdefault("duration_ms", step.duration_ms) out.setdefault(str(public_run_id), []).append(payload) return out def _pmflow_ai_thread_summary(row: PMFlowAIThread) -> dict: return { "thread_id": row.public_thread_id, "title": row.title or "PMFlow AI", "status": row.status, "status_label": _customer_status_label(row.status), "last_message_preview": row.last_message_preview, "message_count": int(row.message_count or 0), "last_run_id": row.last_run_public_id, "current_object_refs": list(row.current_object_refs or []), "started_at": _dt_iso(row.started_at), "last_message_at": _dt_iso(row.last_message_at), } def _pmflow_ai_message_view(row: PMFlowAIMessage | None, *, run_steps: list[dict[str, Any]] | None = None) -> dict | None: if row is None: return None payload = dict(row.message_payload or {}) if isinstance(row.message_payload, dict) else {} if row.role == "assistant": payload = _normalize_assistant_message_payload_for_view(payload, answer=row.content) content = _canonical_customer_answer_from_payload({**payload, "answer": payload.get("answer") or row.content}) payload = _merge_run_steps_into_work_log_payload(payload, run_steps=run_steps, answer=content) else: content = row.content return { "message_id": row.public_message_id, "role": row.role, "content": content, "run_id": row.run_public_id, "qa_record_id": row.qa_record_id, "sent_at": _dt_iso(row.sent_at), "work_log": list(payload.get("work_log") or []), "work_log_full": list(payload.get("work_log_full") or payload.get("work_log") or []), "work_log_meta": payload.get("work_log_meta") if isinstance(payload.get("work_log_meta"), dict) else _work_log_meta(payload.get("work_log_full") or payload.get("work_log")), "artifacts": list(payload.get("artifacts") or []), "actions": list(payload.get("actions") or []), "confirmations": list(payload.get("confirmations") or []), "answer_canvas": payload.get("answer_canvas") if isinstance(payload.get("answer_canvas"), dict) else None, "deliverable_packet": None, "status_label": str(payload.get("status_label") or ""), } def _merge_run_steps_into_work_log_payload( payload: dict, *, run_steps: list[dict[str, Any]] | None, answer: str = "", ) -> dict: if not run_steps: return payload existing_full = list(payload.get("work_log_full") or payload.get("work_log") or []) run_work_log = _customer_process_work_log(run_steps, answer=answer, max_items=80) if len(run_work_log) <= len(existing_full): return payload out = dict(payload) out["work_log_full"] = run_work_log out["work_log"] = run_work_log[:12] out["work_log_meta"] = _work_log_meta(run_work_log) return out def _pmflow_ai_assistant_message_payload(payload: dict, *, question: str = "") -> dict: canonical_answer = _canonical_customer_answer_from_payload(payload) return { "message_type": "assistant_answer", "run_id": str(payload.get("agent_os_run_id") or payload.get("run_id") or "")[:80], "answer": canonical_answer, "reply_text": canonical_answer, "processing_stage": _customer_payload_token(payload.get("processing_stage"), limit=80), "answer_mode": _customer_payload_token(payload.get("answer_mode"), limit=80), "grounding_status": _customer_payload_token(payload.get("grounding_status"), limit=80), "policy_version": _customer_payload_token(payload.get("policy_version"), limit=120), "status_label": _customer_status_label( (payload.get("pmflow_agent_run") if isinstance(payload.get("pmflow_agent_run"), dict) else {}).get("status") ), **_customer_work_log_payload_fields(payload, question=question), "artifacts": _customer_artifacts_from_payload(payload), "actions": _customer_actions_from_payload(payload), "confirmations": _customer_confirmations_from_payload(payload), "answer_canvas": _customer_answer_canvas_from_payload(payload), "deliverable_packet": None, } def _normalize_assistant_message_payload_for_view(payload: dict, *, answer: str = "") -> dict: out = dict(payload or {}) canonical_answer = _canonical_customer_answer_from_payload({**out, "answer": out.get("answer") or answer, "reply_text": out.get("reply_text") or answer}) if canonical_answer: out["answer"] = canonical_answer out["reply_text"] = canonical_answer full_source = out.get("work_log_full") if isinstance(out.get("work_log_full"), list) else out.get("work_log") full_work_log = _customer_work_log_from_existing(full_source, answer=answer, max_items=64) out["work_log_full"] = full_work_log out["work_log"] = full_work_log[:12] out["work_log_meta"] = _work_log_meta(full_work_log) canvas = _customer_answer_canvas_from_payload(out) if canvas is not None: out["answer_canvas"] = canvas out["artifacts"] = [item for item in list(out.get("artifacts") or []) if not _contains_internal_display_terms(item)] out["actions"] = [ item for item in list(out.get("actions") or []) if isinstance(item, dict) and item.get("href") and _is_customer_visible_action_label(item.get("label")) and not _contains_internal_display_terms(item.get("summary")) ] return out def _customer_answer_canvas_from_payload(payload: dict) -> dict | None: canvas = payload.get("answer_canvas") if isinstance(payload.get("answer_canvas"), dict) else None if canvas is None: return None out = dict(canvas) payload_answer = _canonical_customer_answer_from_payload(payload) if _is_agent_runtime_payload(payload) and payload_answer: direct_answer = payload_answer else: direct_answer = str(out.get("direct_answer") or payload_answer or "").strip() action_candidates = _customer_canvas_actions(out.get("action_candidates")) if _contains_internal_display_terms(direct_answer): direct_answer = "" if _is_agent_runtime_payload(payload) else _fallback_answer_from_actions(action_candidates) out["direct_answer"] = direct_answer out["headline"] = _customer_safe_display_text(out.get("headline"), fallback="PMFlow AI", limit=120) out["object_refs"] = _customer_canvas_object_refs(out.get("object_refs")) out["action_candidates"] = action_candidates out["next_actions"] = [] out["deliverable_sections"] = _customer_canvas_sections(out.get("deliverable_sections")) out["limitations"] = [ _customer_safe_display_text(item, fallback="", limit=240) for item in list(out.get("limitations") or [])[:6] if _customer_safe_display_text(item, fallback="", limit=240) ] return out def _is_agent_runtime_payload(payload: dict) -> bool: stage = str(payload.get("processing_stage") or "").strip().lower() return stage in {"agent_runtime", "pmflow_agent_runtime", "multi_agent_operating_runtime"} def _canonical_customer_answer_from_payload(payload: dict) -> str: canvas = payload.get("answer_canvas") if isinstance(payload.get("answer_canvas"), dict) else {} deliverable = payload.get("deliverable_packet") if isinstance(payload.get("deliverable_packet"), dict) else {} evidence = payload.get("customer_evidence_packet") if isinstance(payload.get("customer_evidence_packet"), dict) else {} values = ( payload.get("answer"), payload.get("reply_text"), deliverable.get("answer_text"), deliverable.get("summary"), canvas.get("direct_answer"), evidence.get("fallback_summary"), ) for value in values: text = rag_ask_contract._sanitize_customer_answer_text(value) # canonical customer-safe projection; do not regenerate. if text and not _contains_internal_display_terms(text): return text[:4000] return "" def _customer_canvas_actions(value: object) -> list[dict]: rows = value if isinstance(value, list) else [] out: list[dict] = [] for index, item in enumerate(rows[:8]): if not isinstance(item, dict): continue href = str(item.get("href") or "").strip()[:300] title = _customer_safe_display_text(item.get("title"), fallback="", limit=120) if not href or not _is_customer_visible_action_label(title): continue summary = _customer_safe_display_text(item.get("summary") or item.get("reason"), fallback="", limit=240) reason = _customer_safe_display_text(item.get("reason") or item.get("summary"), fallback="", limit=240) completion = _customer_safe_display_text(item.get("completion_criteria"), fallback="", limit=240) out.append( { "title": title, "summary": summary, "target_title": _customer_safe_display_text(item.get("target_title"), fallback="", limit=160) or None, "href": href, "reason": reason or None, "completion_criteria": completion or None, "safety": str(item.get("safety") or "suggest_only")[:80], "requires_confirmation": bool(item.get("requires_confirmation") or item.get("requires_user_confirm")), "action_id": str(item.get("action_id") or f"canvas_action_{index + 1}")[:80], } ) return out def _customer_canvas_object_refs(value: object) -> list[dict]: rows = value if isinstance(value, list) else [] out: list[dict] = [] for index, item in enumerate(rows[:8]): if not isinstance(item, dict): continue object_ref = str(item.get("object_ref") or f"object:{index + 1}")[:160] title = _customer_safe_display_text(item.get("title"), fallback="", limit=120) if not title: prefix = object_ref.split(":", 1)[0] title = {"project": "项目", "approval": "待确认项", "change_set": "变更集", "task": "任务"}.get(prefix, "业务对象") summary = _customer_safe_display_text(item.get("summary"), fallback="", limit=180) out.append( { "object_ref": object_ref, "title": title, "type": str(item.get("type") or item.get("object_type") or "object")[:80], "status": _customer_safe_display_text(item.get("status"), fallback="", limit=80) or None, "summary": summary or None, "href": str(item.get("href") or "").strip()[:300] or None, } ) return out def _customer_canvas_sections(value: object) -> list[dict]: rows = value if isinstance(value, list) else [] out: list[dict] = [] for section in rows[:6]: if not isinstance(section, dict): continue if str(section.get("section_kind") or "") == "actions": continue items: list[dict] = [] for item in list(section.get("items") or [])[:6]: if not isinstance(item, dict): continue summary = _customer_safe_display_text(item.get("summary") or item.get("title"), fallback="", limit=320) if not summary: continue cleaned = dict(item) cleaned["summary"] = summary cleaned.pop("object_ref", None) cleaned.pop("target_ref", None) items.append(cleaned) if items: cleaned_section = dict(section) cleaned_section["display_title"] = _customer_safe_display_text(section.get("display_title"), fallback="本轮结果", limit=120) cleaned_section["items"] = items out.append(cleaned_section) return out def _fallback_answer_from_actions(actions: list[dict]) -> str: if actions: first = actions[0] title = str(first.get("title") or "").strip() reason = str(first.get("reason") or first.get("summary") or "").strip() if reason: return f"你当前最该处理的是「{title}」。{reason}" return f"你当前最该处理的是「{title}」,进入对应页面后完成确认或退回。" return "这轮没有形成可展示的回答。" def _customer_work_log_from_payload(payload: dict, *, question: str = "") -> list[dict]: return _customer_work_log_payload_fields(payload, question=question)["work_log"] def _customer_work_log_payload_fields(payload: dict, *, question: str = "") -> dict: process_packet = payload.get("answer_process_packet") if isinstance(payload.get("answer_process_packet"), dict) else {} raw_steps = process_packet.get("steps") if isinstance(process_packet.get("steps"), list) else None if raw_steps is None: raw_steps = payload.get("agent_os_steps") if isinstance(payload.get("agent_os_steps"), list) else [] answer = str(payload.get("answer") or payload.get("reply_text") or "").strip() full_work_log = _customer_process_work_log(raw_steps, question=question, answer=answer, max_items=64) return { "work_log": full_work_log[:12], "work_log_full": full_work_log, "work_log_meta": _work_log_meta(full_work_log), } def _work_log_meta(value: object) -> dict: rows = value if isinstance(value, list) else [] total = len(rows) visible = min(total, 12) return { "total_step_count": total, "visible_step_count": visible, "hidden_step_count": max(total - visible, 0), "truncated": total > visible, } def _customer_work_log_from_existing(value: object, *, answer: str = "", max_items: int = 12) -> list[dict]: rows = value if isinstance(value, list) else [] out: list[dict] = [] for index, item in enumerate(rows[:max(1, int(max_items or 12))]): if not isinstance(item, dict): continue label = _customer_safe_display_text(item.get("label"), fallback="", limit=120) summary = _customer_process_safe_summary( item.get("summary") or item.get("display_summary"), ) if not label and not summary: continue if _contains_internal_display_terms(label) or _contains_internal_display_terms(summary): continue status_code, status_label = _customer_status_pair(item.get("status")) out.append( { "id": str(item.get("id") or item.get("key") or f"work_{index + 1}")[:80], "label": label or f"推进这轮问题的第 {index + 1} 步", "summary": summary, "status": status_code, "status_label": _customer_safe_display_text(item.get("status_label"), fallback="", limit=40) or status_label, "duration_ms": _safe_int(item.get("duration_ms")), } ) return out def _customer_process_stream_event(event: dict) -> dict: event_type = str(event.get("type") or event.get("event") or "").strip() if event_type not in {"step.started", "step.completed"}: return event raw_step = event.get("step") if isinstance(event.get("step"), dict) else {} raw_step_id = str(raw_step.get("key") or raw_step.get("id") or "").strip()[:80] projected = _customer_process_work_log([raw_step]) if not projected: return {"type": event_type, "step": None} step = dict(projected[0]) step_id = raw_step_id or str(step.get("id") or "step").strip()[:80] return { **event, "type": event_type, "step": { "id": step_id, "key": step_id, "label": step.get("label"), "status": step.get("status"), "status_label": step.get("status_label"), "display_summary": step.get("summary"), "summary": step.get("summary"), "duration_ms": step.get("duration_ms"), }, } def _customer_process_work_log(raw_steps: object, *, question: str = "", answer: str = "", max_items: int = 64) -> list[dict]: rows = raw_steps if isinstance(raw_steps, list) else [] projected: list[dict] = [] seen: dict[str, int] = {} target = max(1, min(int(max_items or 64), 80)) for index, step in enumerate(list(rows or [])[:80]): if not isinstance(step, dict): continue item = _customer_process_item_from_step(step, index=index) if item is None: continue raw_step_id = str(step.get("key") or step.get("id") or item.get("id") or f"work_{index + 1}").strip()[:80] item.pop("bucket", None) item["id"] = raw_step_id or f"work_{index + 1}" existing_index = seen.get(str(item["id"])) if existing_index is not None: existing = projected[existing_index] existing["status"] = _merge_process_status(str(existing.get("status") or ""), str(item.get("status") or "")) existing["status_label"] = _customer_status_pair(existing["status"])[1] existing_summary = str(existing.get("summary") or "").strip() item_summary = str(item.get("summary") or "").strip() if item_summary and item_summary != existing_summary: existing["summary"] = item_summary existing["duration_ms"] = _sum_optional_int(existing.get("duration_ms"), item.get("duration_ms")) continue seen[str(item["id"])] = len(projected) projected.append(item) if len(projected) >= target: break if projected: return projected return _enrich_customer_process_work_log([], question=question, answer=answer) def _enrich_customer_process_work_log(work_log: list[dict], *, question: str = "", answer: str = "") -> list[dict]: goal = _customer_process_goal(question=question, answer=answer) insight = _customer_process_answer_insight(answer) boundary = _customer_process_boundary(answer) if not goal and not insight and not boundary: return work_log[:5] enriched: list[dict] = [] if goal: enriched.append( { "id": "work_goal", "label": f"确认你要解决的是:{goal}", "summary": "先把这轮问题收口到一个可回答的目标,避免答成泛泛建议。", "status": "done", "status_label": "已完成", "duration_ms": None, } ) if insight: enriched.append( { "id": "work_evidence", "label": _customer_process_evidence_label(answer), "summary": insight, "status": "done", "status_label": "已完成", "duration_ms": None, } ) if boundary: enriched.append( { "id": "work_boundary", "label": _customer_process_boundary_label(boundary), "summary": boundary, "status": "blocked" if _looks_like_boundary(boundary) else "done", "status_label": "仍有边界" if _looks_like_boundary(boundary) else "已完成", "duration_ms": None, } ) for item in work_log: label = str(item.get("label") or "").strip() if any(label == str(existing.get("label") or "").strip() for existing in enriched): continue if label in {"确认这轮要回答什么", "判断现在能不能负责任地回答"} and (goal or insight or boundary): continue enriched.append(item) if len(enriched) >= 5: break return enriched[:12] def _customer_process_goal(*, question: str = "", answer: str = "") -> str: source = str(question or "").strip() source = _customer_safe_display_text(source, fallback="", limit=80) if not source: return "" return source.rstrip("。?!?!") def _customer_process_answer_insight(answer: str) -> str: for sentence in _customer_process_sentences(answer): if any(token in sentence for token in ("公开", "来源", "材料", "项目", "任务", "待办", "证据", "支撑", "可展示事实")): safe = _customer_safe_display_text(sentence, fallback="", limit=220) if safe: return safe return "" def _customer_process_boundary(answer: str) -> str: for sentence in _customer_process_sentences(answer): if any(token in sentence for token in ("不能", "缺口", "缺少", "不足", "不确定", "需要", "还没有", "尚未", "无法")): safe = _customer_safe_display_text(sentence, fallback="", limit=220) if safe: return safe return "" def _customer_process_evidence_label(answer: str) -> str: text = str(answer or "") if any(token in text for token in ("公开", "来源", "材料")): return "查看公开材料能支撑到哪一步" if any(token in text for token in ("项目", "任务", "待办")): return "查看当前项目和待办里的依据" return "查看能支撑回答的依据" def _customer_process_boundary_label(boundary: str) -> str: if any(token in boundary for token in ("不能", "不足", "不确定", "缺口", "缺少", "尚未", "无法")): return "标出现在还不能下强结论的地方" return "整理需要继续确认的部分" def _looks_like_boundary(value: str) -> bool: return any(token in str(value or "") for token in ("不能", "不足", "不确定", "缺口", "缺少", "尚未", "无法")) def _first_sentence(value: str) -> str: sentences = _customer_process_sentences(value) return sentences[0] if sentences else "" def _customer_process_sentences(value: str) -> list[str]: text = " ".join(str(value or "").strip().split()) if not text: return [] parts = re.split(r"(?<=[。!?!?])\s+|[;;]\s*|\n+", text) return [part.strip() for part in parts if part.strip()][:12] def _customer_process_item_from_step(step: dict, *, index: int) -> dict | None: raw_key = str(step.get("key") or step.get("id") or "").strip().lower() step_type = str(step.get("step_type") or "").strip().lower() raw_label = str(step.get("label") or step.get("title") or "").strip() raw_summary = str(step.get("display_summary") or step.get("summary") or step.get("description") or "").strip() status_code, status_label = _customer_status_pair(step.get("status")) safe_label = _customer_safe_display_text(raw_label, fallback="", limit=120) bucket = _customer_process_bucket(raw_key=raw_key, step_type=step_type, label=raw_label) if not bucket: return None return { "id": str(step.get("key") or step.get("id") or f"work_{index + 1}")[:80], "bucket": bucket, "label": _customer_process_label(bucket=bucket, raw_label=safe_label, index=index), "summary": _customer_process_summary(bucket=bucket, raw_summary=raw_summary), "status": status_code, "status_label": status_label, "duration_ms": _safe_int(step.get("duration_ms")), } def _customer_process_bucket(*, raw_key: str, step_type: str, label: str) -> str: structural = f"{raw_key} {step_type}".lower() if any(token in structural for token in ("workspace", "context", "snapshot", "bind")): return "context" if any(token in structural for token in ("tool", "observe", "inspect", "read", "search", "fetch", "external")): return "read" if any(token in structural for token in ("normalize", "extract", "claim", "ledger", "evidence")): return "organize" if any(token in structural for token in ("critic", "sufficiency", "verify", "check", "assess")): return "check" if any(token in structural for token in ("answer", "compose", "terminal")): return "answer" if any(token in structural for token in ("plan", "planner", "next_action", "action_selector")): return "plan" text = f"{structural} {label}".lower() if any(token in text for token in ("understand", "goal", "intent", "目标", "意图")): return "understand" if any(token in text for token in ("answer", "compose", "terminal", "回答", "生成")): return "answer" if any(token in text for token in ("plan", "planner", "next_action", "action_selector", "规划", "计划", "下一步")): return "plan" if any(token in text for token in ("tool", "observe", "inspect", "read", "search", "fetch", "external")): return "read" if any(token in text for token in ("读取", "查询")): return "read" if any(token in text for token in ("workspace", "context", "snapshot", "bind", "上下文", "空间", "工作区", "绑定")): return "context" if any(token in text for token in ("normalize", "extract", "claim", "ledger", "evidence")): return "organize" if any(token in text for token in ("critic", "sufficiency", "verify", "check", "assess")): return "check" return "progress" def _customer_process_label(*, bucket: str, raw_label: str, index: int) -> str: safe_label = _customer_safe_display_text(raw_label, fallback="", limit=120) if safe_label: return safe_label return f"推进这轮问题的第 {index + 1} 步" def _customer_process_summary(*, bucket: str, raw_summary: str) -> str: safe_summary = _customer_process_safe_summary(raw_summary) if safe_summary: return safe_summary summaries = { "understand": "先确认这轮问题要回答到什么程度,避免答成泛泛建议。", "context": "查看当前可安全引用的项目、待办、状态和对话上下文。", "plan": "先判断下一步该补哪类材料,不把计划当成结论。", "read": "读取和本轮问题直接相关的信息,不做写入或自动执行。", "organize": "把读到的内容分成可用判断、不能确认的边界和还缺的材料。", "check": "确认当前材料能不能支撑回答,避免把不确定说成确定。", "answer": "把可确认的信息、边界和下一步整理成用户能直接看的回复。", "progress": "已推进本轮必要处理。", } return summaries.get(bucket, "已完成本轮必要处理。") def _customer_process_safe_summary(value: object) -> str: text = _customer_safe_display_text(value, fallback="", limit=220) if not text: return "" if len(text) > 90 and not re.search(r"[\u4e00-\u9fff]", text): return "" return text def _merge_process_status(left: str, right: str) -> str: order = {"failed": 4, "blocked": 3, "running": 2, "active": 2, "done": 1, "completed": 1} left_value = str(left or "").lower() right_value = str(right or "").lower() return left if order.get(left_value, 1) >= order.get(right_value, 1) else right def _sum_optional_int(left: object, right: object) -> int | None: left_int = _safe_int(left) right_int = _safe_int(right) if left_int is None: return right_int if right_int is None: return left_int return left_int + right_int def _customer_artifacts_from_payload(payload: dict) -> list[dict]: answer = str(payload.get("answer") or payload.get("reply_text") or "").strip() rows = payload.get("agent_os_artifacts") if isinstance(payload.get("agent_os_artifacts"), list) else [] out: list[dict] = [] for index, item in enumerate(rows[:12]): if not isinstance(item, dict): continue kind = str(item.get("kind") or "").strip() title = _customer_safe_display_text(item.get("title"), fallback="") summary = _customer_safe_display_text(item.get("summary"), fallback="", limit=320) if kind in {"final_answer", "agent_deliverable"} or title == "PMFlow AI 回答" or (answer and summary and summary == answer[:1000]): continue if _contains_internal_display_terms(title) or _contains_internal_display_terms(summary): continue if not title and not summary: continue out.append( { "id": str(item.get("artifact_id") or f"artifact_{index + 1}")[:80], "title": title or "本轮产物", "summary": summary, "status_label": _customer_status_label(item.get("status")), } ) return out def _customer_actions_from_payload(payload: dict) -> list[dict]: rows = payload.get("agent_os_prepared_action_packets") if isinstance(payload.get("agent_os_prepared_action_packets"), list) else [] out: list[dict] = [] for index, item in enumerate(rows[:8]): if not isinstance(item, dict): continue if str(item.get("source") or "") != "deliverable_action_candidate": continue href = str(item.get("href") or "").strip()[:300] if not href: continue label = _customer_safe_display_text( item.get("title") or item.get("action_title"), fallback="", limit=120, ) if not _is_customer_visible_action_label(label): continue summary = _customer_safe_display_text( item.get("summary") or item.get("description") or item.get("customer_safe_summary"), fallback="", limit=240, ) out.append( { "id": str(item.get("action_id") or f"action_{index + 1}")[:80], "label": label, "summary": summary, "href": href, } ) return out def _customer_confirmations_from_payload(payload: dict) -> list[dict]: rows = payload.get("agent_os_confirmation_requests") if isinstance(payload.get("agent_os_confirmation_requests"), list) else [] prepared_by_id: dict[str, dict] = {} for packet in payload.get("agent_os_prepared_action_packets") if isinstance(payload.get("agent_os_prepared_action_packets"), list) else []: if not isinstance(packet, dict): continue prepared_id = str(packet.get("prepared_action_id") or "").strip() if prepared_id: prepared_by_id[prepared_id] = packet out: list[dict] = [] for index, item in enumerate(rows[:8]): if not isinstance(item, dict): continue requested_for = [str(value).strip() for value in list(item.get("requested_for") or []) if str(value).strip()] prepared = next((prepared_by_id[value] for value in requested_for if value in prepared_by_id), {}) title = _customer_safe_display_text( item.get("title") or item.get("question") or item.get("summary"), fallback=f"需要确认 {index + 1}", limit=160, ) summary = _customer_safe_display_text( item.get("summary") or item.get("reason") or item.get("customer_safe_summary"), fallback="确认后 PMFlow AI 才会继续准备后续动作。", limit=260, ) action_payload = item.get("action_payload") if isinstance(item.get("action_payload"), dict) else {} if not action_payload and prepared: action_payload = { "prepared_action_id": prepared.get("prepared_action_id"), "action_family": prepared.get("action_family"), "title": prepared.get("title"), "target_ref": prepared.get("target_ref"), "payload": prepared.get("payload") if isinstance(prepared.get("payload"), dict) else {}, } out.append( { "id": str(item.get("confirmation_id") or item.get("confirmation_request_id") or f"confirmation_{index + 1}")[:80], "title": title, "summary": summary, "action_label": _customer_safe_display_text(item.get("action_label"), fallback="", limit=80), "action_type": _customer_action_type_token(item.get("action_type")), "action_payload": action_payload, "can_execute": bool(item.get("can_execute_after_confirmation")), } ) return out def _customer_action_type_token(value: Any) -> str: text = str(value or "").strip() return text[:80] if re.fullmatch(r"[a-z][a-z0-9_]{0,79}", text) else "" def _customer_payload_token(value: Any, *, limit: int) -> str: text = str(value or "").strip() return text[:limit] if re.fullmatch(r"[A-Za-z0-9_.:-]{1,160}", text) else "" def _confirmation_action_payload_from_contract(confirmation: dict[str, Any]) -> dict[str, Any]: payload = confirmation.get("action_payload") if isinstance(confirmation.get("action_payload"), dict) else {} return dict(payload) def _confirmation_user_action_text(confirmation: dict[str, Any]) -> str: label = str(confirmation.get("action_label") or "确认").strip() title = str(confirmation.get("title") or "").strip() return f"{label}:{title}" if title else label def _research_recovery_prompt_from_confirmation( *, confirmation: dict[str, Any], request: PMFlowAIConfirmationExecutionRequest, ) -> str: payload = _confirmation_action_payload_from_contract(confirmation) recovery = _research_recovery_payload(payload) question = str(recovery.get("research_question") or confirmation.get("title") or "").strip() queries = _unique_non_empty_strings( _string_list(recovery.get("queries")) + [ str(item.get("query") or "").strip() for item in list(recovery.get("source_plan") or []) if isinstance(item, dict) ] )[:5] gaps = _unique_non_empty_strings(_string_list(recovery.get("research_gaps")))[:5] supplement = str(request.supplement_text or "").strip() parts = [ "继续执行上一轮确认的只读研究补证。必须重新观察外部公开来源,补足证据后重新经过 specialist/council 汇总;不要写 Project Memory、不要创建任务、不要发起执行交接。", f"研究问题:{question}" if question else "", f"优先查询:{';'.join(queries)}" if queries else "优先覆盖:需求/活动市场、工具/竞品、商业化/增长三类来源。", f"当前缺口:{';'.join(gaps)}" if gaps else "", f"用户补充:{supplement}" if supplement else "", "输出 ResearchNote/OpportunityBrief 的最新判断、来源边界和下一步确认动作。", ] return "\n".join([item for item in parts if item])[:4000] def _research_recovery_payload(action_payload: dict[str, Any]) -> dict[str, Any]: nested = action_payload.get("payload") if isinstance(action_payload.get("payload"), dict) else {} recovery = nested.get("research_recovery") if isinstance(nested.get("research_recovery"), dict) else {} if recovery: return dict(recovery) direct = action_payload.get("research_recovery") if isinstance(action_payload.get("research_recovery"), dict) else {} return dict(direct or action_payload) def _saved_idea_request_from_confirmation( *, confirmation: dict[str, Any], action_payload: dict[str, Any], thread: PMFlowAIThread, confirmation_id: str, supplement_text: str, ) -> SavedIdeaCreateRequest: nested = action_payload.get("payload") if isinstance(action_payload.get("payload"), dict) else {} projection = nested.get("product_object_projection") if isinstance(nested.get("product_object_projection"), dict) else {} fields = projection.get("fields") if isinstance(projection.get("fields"), dict) else {} title = ( str(action_payload.get("title") or "").strip() or str(confirmation.get("title") or "").replace("确认准备动作:", "").strip() or str(fields.get("title") or fields.get("name") or "").strip() or "PMFlow AI 候选对象" ) summary = str(confirmation.get("summary") or fields.get("summary") or fields.get("customer_safe_summary") or "").strip() object_type = str(projection.get("object_type") or fields.get("object_type") or "").strip() raw_lines = [ f"候选对象:{title}", f"对象类型:{object_type}" if object_type else "", f"当前依据:{summary}" if summary else "", _compact_json_for_customer(fields, label="结构化草案") if fields else "", f"用户补充:{supplement_text.strip()}" if supplement_text.strip() else "", ] raw_text = "\n".join([line for line in raw_lines if line]).strip()[:4000] or title return SavedIdeaCreateRequest( title=title[:255], raw_text=raw_text, summary=summary[:1000] or None, source_type="ai_concierge", source_ref=f"pmflow_ai_thread:{thread.public_thread_id}:confirmation:{confirmation_id}"[:255], input_mode="idea", ) def _execution_handoff_request_from_confirmation( *, action_payload: dict[str, Any], supplement_text: str, ) -> dict[str, Any]: nested = action_payload.get("payload") if isinstance(action_payload.get("payload"), dict) else {} package = nested.get("handoff_package") if isinstance(nested.get("handoff_package"), dict) else {} task_candidates = [item for item in list(package.get("task_candidates") or []) if isinstance(item, dict)] first_task = task_candidates[0] if task_candidates else {} target_ref = str(action_payload.get("target_ref") or package.get("target_ref") or "").strip() project_id = ( _safe_int(action_payload.get("project_id")) or _safe_int(package.get("project_id")) or _extract_ref_int(target_ref, accepted_prefixes={"project", "pmflow_project"}) ) task_id = ( _safe_int(action_payload.get("task_id")) or _safe_int(package.get("task_id")) or _safe_int(first_task.get("task_id")) or _safe_int(first_task.get("id")) or _extract_ref_int(str(first_task.get("target_ref") or first_task.get("ref") or ""), accepted_prefixes={"task", "pmflow_task"}) ) repo_binding_id = ( str(action_payload.get("repo_binding_id") or package.get("repo_binding_id") or first_task.get("repo_binding_id") or "").strip() or None ) runtime_target = str(action_payload.get("runtime_target") or package.get("runtime_target") or "local_bridge").strip() if runtime_target not in {"local_bridge", "codex", "github_agent", "manual"}: runtime_target = "local_bridge" if project_id is None or task_id is None: missing = [] if project_id is None: missing.append("项目锚点") if task_id is None: missing.append("正式任务") return { "blocked": True, "missing": missing, "customer_message": f"还不能创建执行交接:缺少{'、'.join(missing)}。需要先把候选动作转成正式项目任务,再交给执行桥。", } note = supplement_text.strip() or str(package.get("note") or "PMFlow AI 确认后创建执行交接包。").strip() return { "project_id": project_id, "task_id": task_id, "repo_binding_id": repo_binding_id, "runtime_target": runtime_target, "note": note[:4000], } async def _ensure_pmflow_ai_project_access( *, db: AsyncSession, tenant_id: int, user_id: int, project_id: int, ) -> None: row = ( await db.execute( select(Project.id) .outerjoin( UserProject, and_( UserProject.project_id == Project.id, UserProject.tenant_id == int(tenant_id), UserProject.user_id == int(user_id), ), ) .where( and_( Project.tenant_id == int(tenant_id), Project.id == int(project_id), or_(Project.owner_id == int(user_id), UserProject.user_id == int(user_id)), ) ) .limit(1) ) ).scalar_one_or_none() if row is None: raise ValueError("project_not_found") def _customer_safe_action_result(result: dict[str, Any]) -> dict[str, Any]: allowed = { "status", "saved_idea_id", "saved_idea_title", "href", "project_id", "task_id", "runtime_target", "prompt_package", "result_writeback", "reason", "missing", } return {key: value for key, value in dict(result or {}).items() if key in allowed} def _confirmation_result_ref(result: dict[str, Any]) -> str: for key in ("saved_idea_id", "task_id", "project_id"): value = str(result.get(key) or "").strip() if value: return f"{key}:{value}"[:160] prompt_package = result.get("prompt_package") if isinstance(result.get("prompt_package"), dict) else {} value = str(prompt_package.get("id") or prompt_package.get("promptPackageId") or "").strip() return f"prompt_package:{value}"[:160] if value else "" def _extract_ref_int(value: str, *, accepted_prefixes: set[str]) -> int | None: text = str(value or "").strip().lower() if not text: return None match = re.search(r"([a-z_]+)[:#/-](\d+)", text) if match and match.group(1) in accepted_prefixes: return _safe_int(match.group(2)) return None def _string_list(value: object) -> list[str]: if not isinstance(value, list): return [] return [str(item).strip() for item in value if str(item or "").strip()] def _unique_non_empty_strings(values: list[str]) -> list[str]: out: list[str] = [] for value in values: text = str(value or "").strip() if text and text not in out: out.append(text) return out def _compact_json_for_customer(value: dict[str, Any], *, label: str) -> str: clean = { str(key): item for key, item in dict(value or {}).items() if key not in {"trace", "debug", "raw", "prompt_key", "source_label"} and item not in (None, "", [], {}) } if not clean: return "" return f"{label}:{json.dumps(clean, ensure_ascii=False)[:1200]}" def _active_object_refs_from_payload(payload: dict) -> list[str]: agent_run = payload.get("pmflow_agent_run") if isinstance(payload.get("pmflow_agent_run"), dict) else {} refs = agent_run.get("active_object_refs") if isinstance(agent_run.get("active_object_refs"), list) else [] if not refs: context_packet = payload.get("customer_interaction_context_packet") if isinstance(payload.get("customer_interaction_context_packet"), dict) else {} focus = context_packet.get("current_focus_object") if isinstance(context_packet.get("current_focus_object"), dict) else {} object_ref = str(focus.get("object_ref") or "").strip() refs = [object_ref] if object_ref else [] return [str(item).strip()[:160] for item in refs if str(item).strip()][:12] _INTERNAL_DISPLAY_TOKENS = { "goalframe", "goal_frame", "top_next_actions", "project_object_required_for_world_state", "world_state", "route_plan", "function", "prompt", "trace", "packet", "final_answer", "target_ref", "object_ref", "object_anchor", "verification_id", "turnstate", "turn_state", "claim ledger", "claim_ledger", "boundary ledger", "boundary_ledger", "source_quality", "terminal_repair", "terminal_state", "workbench.", "workbench_", "agent_os", "verifier", "runtime", } def _customer_safe_display_text(value: object, *, fallback: str, limit: int = 180) -> str: text = " ".join(str(value or "").strip().split())[:limit] if not text: return fallback lowered = text.lower() if _contains_internal_display_terms(text): return fallback if re.search(r"\b(step|run|packet|trace|prompt|function)_[a-z0-9_]+\b", lowered): return fallback return text def _contains_internal_display_terms(value: object) -> bool: text = " ".join(str(value or "").strip().split()) if not text: return False lowered = text.lower() if any(token in lowered for token in _INTERNAL_DISPLAY_TOKENS): return True if re.search(r"\b[a-z]+(?:_[a-z0-9]+){1,}\b", lowered): return True if re.search(r"\b(project|approval|change_set|task):\d+\b", lowered): return True if re.search(r"\b(step|run|packet|trace|prompt|function)_[a-z0-9_]+\b", lowered): return True return False def _is_customer_visible_action_label(value: object) -> bool: text = " ".join(str(value or "").strip().split()) if not text or len(text) > 120: return False if _contains_internal_display_terms(text): return False lowered = text.lower() if ";对象" in text or ";依据" in text or "; object" in lowered: return False latin_count = sum(1 for ch in text if ("a" <= ch.lower() <= "z")) cjk_count = sum(1 for ch in text if "\u4e00" <= ch <= "\u9fff") return not (latin_count > 20 and cjk_count == 0) def _customer_status_pair(value: object) -> tuple[str, str]: raw = str(value or "").strip().lower() if raw in {"blocked", "waiting_confirmation", "waiting_for_user_confirmation"}: return "blocked", "需要确认" if raw in {"running", "active", "in_progress"}: return "running", "进行中" if raw in {"failed", "error"}: return "failed", "未完成" return "done", "已完成" def _customer_status_label(value: object) -> str: return _customer_status_pair(value)[1] def _safe_int(value: object) -> int | None: try: return int(value) if value is not None else None except Exception: return None def _agent_run_summary(row: PMFlowAgentRun) -> dict: return { "run_id": row.public_run_id, "title": row.title or _title_from_goal(row.goal), "goal": row.goal, "status": row.status, "final_answer": row.final_answer, "surface": row.surface, "presentation_mode": row.presentation_mode, "active_object_refs": list(row.active_object_refs or []), "started_at": _dt_iso(row.started_at), "completed_at": _dt_iso(row.completed_at), } async def _agent_run_detail(*, db: AsyncSession, run: PMFlowAgentRun) -> dict: steps = list( ( await db.execute( select(PMFlowAgentStep) .where(PMFlowAgentStep.run_id == int(run.id)) .order_by(PMFlowAgentStep.step_index, PMFlowAgentStep.id) ) ) .scalars() .all() ) observations = list( ( await db.execute( select(PMFlowAgentObservation) .where(PMFlowAgentObservation.run_id == int(run.id)) .order_by(PMFlowAgentObservation.observed_at, PMFlowAgentObservation.id) ) ) .scalars() .all() ) artifacts = list( ( await db.execute( select(PMFlowAgentArtifact) .where(PMFlowAgentArtifact.run_id == int(run.id)) .order_by(PMFlowAgentArtifact.id) ) ) .scalars() .all() ) verifications = list( ( await db.execute( select(PMFlowAgentVerification) .where(PMFlowAgentVerification.run_id == int(run.id)) .order_by(PMFlowAgentVerification.verified_at, PMFlowAgentVerification.id) ) ) .scalars() .all() ) memories = list( ( await db.execute( select(PMFlowAgentMemory) .where(PMFlowAgentMemory.run_id == int(run.id)) .order_by(PMFlowAgentMemory.id) ) ) .scalars() .all() ) return { **_agent_run_summary(run), "steps": [ { "step_id": item.public_step_id, "index": item.step_index, "kind": item.step_kind, "status": item.status, "label": (item.step_payload or {}).get("label") if isinstance(item.step_payload, dict) else item.step_kind, "summary": item.output_summary, "tool_name": item.tool_name, "duration_ms": item.duration_ms, } for item in steps ], "observations": [ { "observation_id": item.public_observation_id, "step_id": _public_step_id_for_row(steps, item.step_id), "source_kind": item.source_kind, "summary": item.customer_safe_summary, "confidence": item.confidence, "facts": list(item.facts or []), "gaps": list(item.gaps or []), } for item in observations ], "artifacts": [dict(item.customer_safe_payload or {}) for item in artifacts], "verifications": [ { "verification_id": item.public_verification_id, "target_ref": item.target_ref, "result": item.result, "retry_would_change_state": item.retry_would_change_state, "findings": list(item.findings or []), } for item in verifications ], "memories": [dict(item.memory_payload or {}) for item in memories], } def _public_step_id_for_row(steps: list[PMFlowAgentStep], row_id: int | None) -> str | None: if row_id is None: return None for step in steps: if int(step.id) == int(row_id): return step.public_step_id return None def _title_from_goal(goal: str | None) -> str: return " ".join(str(goal or "").strip().split())[:80] or "PMFlow AI Run" def _dt_iso(value: datetime | None) -> str | None: return value.isoformat() if value is not None else None @router.get("/ai-concierge/sessions") async def list_ai_concierge_sessions( date_from: datetime | None = Query(default=None), date_to: datetime | None = Query(default=None), page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=100), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): query = QASessionListQuery( date_from=date_from, date_to=date_to, page=page, page_size=page_size, ) service = AdaptiveRAGService(db, tenant_id=int(current_user.tenant_id)) result = await service.list_qa_sessions( user_id=int(current_user.id), date_from=query.date_from, date_to=query.date_to, page=query.page, page_size=query.page_size, ) return success_response(result) @router.get("/ai-concierge/sessions/{session_id}") async def get_ai_concierge_session( session_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): service = AdaptiveRAGService(db, tenant_id=int(current_user.tenant_id)) try: result = await service.get_qa_session_detail(session_id=session_id, user_id=int(current_user.id)) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return success_response(result) @router.get("/saved-ideas") async def list_saved_ideas( limit: int = Query(default=20, ge=1, le=100), include_archived: bool = False, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): service = SpaceSavedIdeaService(db, tenant_id=int(current_user.tenant_id)) items = await service.list_ideas( user_id=int(current_user.id), limit=limit, include_archived=include_archived, ) return success_response(SavedIdeaListResponse(items=items).model_dump(mode="json")) @router.post("/saved-ideas") async def create_saved_idea( body: SavedIdeaCreateRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): service = SpaceSavedIdeaService(db, tenant_id=int(current_user.tenant_id)) try: item = await service.create_idea(body=body, user_id=int(current_user.id)) except SavedIdeaError as exc: return JSONResponse(status_code=exc.status_code, content=error_response(exc.status_code, f"{exc.code}: {exc.message}")) return success_response(item.model_dump(mode="json")) @router.patch("/saved-ideas/{idea_id}") async def update_saved_idea( idea_id: str, body: SavedIdeaUpdateRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): service = SpaceSavedIdeaService(db, tenant_id=int(current_user.tenant_id)) try: item = await service.update_idea(idea_id=idea_id, body=body, user_id=int(current_user.id)) except SavedIdeaError as exc: return JSONResponse(status_code=exc.status_code, content=error_response(exc.status_code, f"{exc.code}: {exc.message}")) return success_response(item.model_dump(mode="json")) @router.post("/saved-ideas/{idea_id}/archive") async def archive_saved_idea( idea_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): service = SpaceSavedIdeaService(db, tenant_id=int(current_user.tenant_id)) try: item = await service.archive_idea(idea_id=idea_id, user_id=int(current_user.id)) except SavedIdeaError as exc: return JSONResponse(status_code=exc.status_code, content=error_response(exc.status_code, f"{exc.code}: {exc.message}")) return success_response(item.model_dump(mode="json")) @router.post("/saved-ideas/{idea_id}/promote") async def promote_saved_idea( idea_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): service = SpaceSavedIdeaService(db, tenant_id=int(current_user.tenant_id)) try: idea, draft = await service.promote_to_launch_draft(idea_id=idea_id, user_id=int(current_user.id)) except SavedIdeaError as exc: return JSONResponse(status_code=exc.status_code, content=error_response(exc.status_code, f"{exc.code}: {exc.message}")) except PMFlowLaunchError as exc: return JSONResponse(status_code=exc.status_code, content=error_response(exc.status_code, f"{exc.code}: {exc.message}")) payload = SavedIdeaPromoteResponse( idea=idea, draft=draft, redirect_url="/project-launch?state=human_review", ) return success_response(payload.model_dump(mode="json")) __all__ = ["router"]