from __future__ import annotations import asyncio import json import logging import re import time from datetime import datetime from typing import Any from urllib.parse import unquote, urlparse 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"]) logger = logging.getLogger(__name__) @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, ) await emit( { "type": "answer.persisting", "thread_id": getattr(thread, "public_thread_id", None), "message": "正在保存本轮对话并准备输出。", "stream_mode": "final_answer_snapshot", } ) 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": "最终回答已准备好,开始流式输出。", "stream_mode": "final_answer_snapshot", } ) for chunk in _stream_markdown_answer_chunks(answer): await emit( { "type": "answer.delta", "thread_id": detail.get("thread_id"), "delta": chunk, "stream_mode": "final_answer_snapshot", } ) await asyncio.sleep(0.025) await emit( { "type": "answer.completed", "thread_id": detail.get("thread_id"), "answer": answer, "stream_mode": "final_answer_snapshot", } ) await emit({"type": "thread.completed", "thread": detail}) except Exception: logger.exception("pmflow_ai_thread_stream_failed") 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_markdown_answer_chunks(text: str, *, target_size: int = 18, max_size: int = 48) -> list[str]: clean = str(text or "") if not clean: return [] chunks: list[str] = [] buffer = "" link_depth = 0 inline_code = False pending_link_target = False for char in clean: if char == "`": inline_code = not inline_code if not inline_code: if char == "[": link_depth += 1 elif char == "]" and link_depth: link_depth -= 1 pending_link_target = True elif pending_link_target and char == "(": link_depth += 1 pending_link_target = False elif char == ")" and link_depth: link_depth -= 1 elif pending_link_target and not char.isspace(): pending_link_target = False buffer += char can_flush = not inline_code and link_depth == 0 semantic_break = char in {"。", "!", "?", ";", "\n"} soft_break = char in {",", "、", " ", "\t"} and len(buffer) >= target_size hard_break = len(buffer) >= max_size if can_flush and (semantic_break or soft_break or hard_break): 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 = _customer_markdown_answer_text( customer_payload_for_storage.get("answer") or customer_payload_for_storage.get("reply_text") ) customer_payload_for_storage["answer"] = answer customer_payload_for_storage["reply_text"] = answer 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": { "answer": customer_payload_for_storage.get("answer"), "reply_text": customer_payload_for_storage.get("reply_text"), "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"), "answer_runtime_packet": payload.get("answer_runtime_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 = _customer_markdown_answer_text(payload.get("answer") or payload.get("reply_text")) 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() ) observation_rows = list( ( await db.execute( select(PMFlowAgentRun.public_run_id, PMFlowAgentObservation) .join(PMFlowAgentObservation, PMFlowAgentObservation.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), PMFlowAgentObservation.tenant_id == int(thread.tenant_id), ) .order_by(PMFlowAgentRun.id, PMFlowAgentObservation.observed_at, PMFlowAgentObservation.id) ) ) .all() ) observations_by_step: dict[tuple[str, int], list[PMFlowAgentObservation]] = {} for public_run_id, observation in observation_rows: if observation.step_id is None: continue observations_by_step.setdefault((str(public_run_id), int(observation.step_id)), []).append(observation) 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) step_observations = observations_by_step.get((str(public_run_id), int(step.id))) or [] if step_observations: _attach_observation_process_projection(payload, step_observations) out.setdefault(str(public_run_id), []).append(payload) return out def _attach_observation_process_projection(payload: dict[str, Any], observations: list[PMFlowAgentObservation]) -> None: narratives: list[str] = [] activity_rows: list[dict] = [] evidence_rows: list[dict] = [] boundaries: list[str] = [] observation_ids: list[str] = [] for observation in observations[:4]: if observation.public_observation_id: observation_ids.append(str(observation.public_observation_id)) summary = _customer_process_safe_summary(observation.customer_safe_summary) if summary: narratives.append(summary) tool_rows, evidence, boundary = _customer_process_rows_from_observation(observation) activity_rows.extend(tool_rows) evidence_rows.extend(evidence) if boundary: boundaries.append(boundary) if observation_ids: payload.setdefault("observation_id", observation_ids[-1]) if narratives: payload["process_narrative"] = _dedupe_process_texts(narratives)[0] if activity_rows: payload["tool_transcript"] = _dedupe_process_detail_dicts( list(payload.get("tool_transcript") or []) + activity_rows )[:16] if evidence_rows: payload["evidence"] = _dedupe_process_detail_dicts(list(payload.get("evidence") or []) + evidence_rows)[:20] if boundaries: payload["boundary_summary"] = ";".join(_dedupe_process_texts(boundaries)[:3]) def _customer_process_rows_from_observation(observation: PMFlowAgentObservation) -> tuple[list[dict], list[dict], str]: facts = observation.facts if isinstance(observation.facts, list) else [] gaps = observation.gaps if isinstance(observation.gaps, list) else [] tool_rows: list[dict] = [] evidence_rows: list[dict] = [] boundary_rows: list[str] = [] visible_fact_count = 0 source_count = 0 for fact in facts[:48]: if not isinstance(fact, dict): continue fact_type = str(fact.get("type") or "").strip() if not fact_type or fact_type == "agent_tool_contract" or fact_type.startswith("_"): continue visible_fact_count += 1 if fact_type == "external_search_query": rows = [ _customer_safe_display_text(item, fallback="", limit=180) for item in list(fact.get("queries") or []) if _customer_safe_display_text(item, fallback="", limit=180) ][:6] query = _customer_safe_display_text(fact.get("query"), fallback="", limit=220) summary = ";".join(rows) if rows else query if summary: tool_rows.append({"kind": "search_query", "title": "搜索", "summary": summary}) continue if fact_type == "external_retrieval_status": count = _safe_int(fact.get("evidence_count")) status = _customer_safe_display_text(fact.get("status"), fallback="", limit=80) if count is not None: tool_rows.append({"kind": "tool_result", "title": "检索结果", "summary": f"返回 {count} 条候选来源" + (f",检索状态:{status}" if status else "")}) elif status: tool_rows.append({"kind": "tool_result", "title": "检索结果", "summary": status}) continue if fact_type == "visible_object_count": value = _safe_int(fact.get("value")) if value is not None: tool_rows.append({"kind": "space_read", "title": "读取当前空间", "summary": f"读取到 {value} 项可见内容。"}) continue if fact_type == "visible_object_samples": items = [] for child in list(fact.get("items") or [])[:8]: if not isinstance(child, dict): continue title = _customer_safe_display_text(child.get("title"), fallback="", limit=140) kind = _customer_safe_display_text(child.get("kind"), fallback="", limit=60) if title: items.append({"title": title, "kind": kind}) if items: evidence_rows.append({"kind": "space_objects", "title": "当前空间可见内容", "items": items}) continue if fact_type in {"external_evidence", "external_source_text"}: url = str(fact.get("url") or fact.get("source_url") or "").strip() summary_source = fact.get("text") if fact_type == "external_source_text" else fact.get("summary") or fact.get("snippet") summary = _customer_process_source_excerpt(summary_source) title = _customer_source_display_title(url=url, title=fact.get("title"), summary=summary) evidence_rows.append( { "kind": "opened_source" if fact_type == "external_source_text" else "search_result", "title": title, "summary": summary, "url": url if url.startswith(("http://", "https://")) else None, "domain": _customer_domain_label(url), } ) source_count += 1 continue if fact_type == "external_source_unreadable": url = str(fact.get("url") or "").strip() summary = _customer_safe_display_text(fact.get("summary") or url, fallback="来源不可读,未进入最终判断。", limit=220) tool_rows.append({"kind": "unavailable_source", "title": "来源不可读", "summary": summary, "url": url if url.startswith(("http://", "https://")) else None, "domain": _customer_domain_label(url)}) boundary_rows.append(summary) continue if source_count or visible_fact_count: parts = [] if source_count: parts.append(f"记录了 {source_count} 个可追溯来源") if visible_fact_count: parts.append(f"整理了 {visible_fact_count} 条可见观察") if parts: tool_rows.insert(0, {"kind": "observation_summary", "title": "本步观察", "summary": ";".join(parts)}) for gap in gaps[:8]: if isinstance(gap, dict): text = _customer_safe_display_text(gap.get("summary") or gap.get("description"), fallback="", limit=220) else: text = _customer_safe_display_text(gap, fallback="", limit=220) if text: boundary_rows.append(text) return ( _customer_process_detail_rows(tool_rows), _customer_process_detail_rows(evidence_rows), ";".join(_dedupe_process_texts(boundary_rows)[:3]), ) def _customer_process_source_excerpt(value: object) -> str: text = _customer_safe_display_text(value, fallback="", limit=700) if not text: return "" for marker in (" Skip to content ", " Agree & Join ", " Your browser is not supported. "): if marker in text: text = text.split(marker, 1)[0].strip() or text.split(marker, 1)[-1].strip() if len(text) <= 260: return text cut = text[:260].rstrip() boundary = max(cut.rfind("。"), cut.rfind("."), cut.rfind(";"), cut.rfind(";"), cut.rfind(","), cut.rfind(",")) if boundary >= 80: cut = cut[: boundary + 1].rstrip() return f"{cut}..." def _customer_source_display_title(*, url: object, title: object = "", summary: object = "") -> str: safe_title = _customer_safe_display_text(title, fallback="", limit=180) safe_url = str(url or "").strip() if safe_title and not safe_title.startswith(("http://", "https://")): return safe_title safe_summary = _customer_safe_display_text(summary, fallback="", limit=180) if safe_summary and not safe_summary.startswith(("http://", "https://")): first = re.split(r"[。.!?!?]\s*", safe_summary, maxsplit=1)[0].strip() if 8 <= len(first) <= 120: return first slug_title = _customer_title_from_url_path(safe_url) if slug_title: return slug_title return _customer_domain_label(safe_url) or "公开来源" def _customer_title_from_url_path(url: object) -> str: text = str(url or "").strip() if not text.startswith(("http://", "https://")): return "" try: parsed = urlparse(text) except Exception: return "" path_parts = [part for part in parsed.path.split("/") if part] if not path_parts: return "" candidate = unquote(path_parts[-1]).strip() candidate = re.sub(r"\.(html?|php|aspx?)$", "", candidate, flags=re.IGNORECASE) candidate = re.sub(r"[-_]+", " ", candidate) candidate = re.sub(r"\s+", " ", candidate).strip(" -_/") candidate = re.sub(r"\b[a-z0-9]{5,}$", "", candidate, flags=re.IGNORECASE).strip() if not candidate: return "" if len(candidate) > 120: candidate = candidate[:120].rsplit(" ", 1)[0].strip() or candidate[:120].strip() return candidate def _customer_domain_label(url: object) -> str: text = str(url or "").strip() if not text.startswith(("http://", "https://")): return "" try: return urlparse(text).netloc.replace("www.", "") except Exception: return "" def _dedupe_process_texts(rows: list[str]) -> list[str]: seen: set[str] = set() out: list[str] = [] for row in rows: text = _customer_safe_display_text(row, fallback="", limit=360) key = text.lower() if not text or key in seen: continue seen.add(key) out.append(text) return out def _dedupe_process_detail_dicts(rows: list[dict]) -> list[dict]: seen: set[str] = set() out: list[dict] = [] for row in rows: if not isinstance(row, dict): continue key = _process_detail_dedupe_key(row) if not key.strip("|") or key in seen: continue seen.add(key) out.append(row) return out def _process_detail_dedupe_key(row: dict) -> str: url_key = _normalized_source_url_key(row.get("url")) if url_key: return f"url:{url_key}" items = row.get("items") if isinstance(items, list) and items: item_key = "|".join( _customer_safe_display_text((item or {}).get("title"), fallback="", limit=120).lower() for item in items if isinstance(item, dict) and _customer_safe_display_text(item.get("title"), fallback="", limit=120) ) if item_key: return f"items:{item_key}" return "|".join(str(row.get(name) or "") for name in ("kind", "title", "summary")).lower() def _normalized_source_url_key(value: object) -> str: text = str(value or "").strip() if not text.startswith(("http://", "https://")): return "" try: parsed = urlparse(text) except Exception: return text.lower().rstrip("/") path = re.sub(r"/+$", "", parsed.path or "/") return f"{parsed.netloc.lower().replace('www.', '')}{path.lower()}" 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) merged_work_log = run_work_log if len(run_work_log) > len(existing_full) else _merge_customer_work_logs(existing_full, run_work_log) if merged_work_log == existing_full: return payload out = dict(payload) out["work_log_full"] = merged_work_log out["work_log"] = merged_work_log[:12] out["work_log_meta"] = _work_log_meta(merged_work_log) return out def _pmflow_ai_assistant_message_payload(payload: dict, *, question: str = "") -> dict: canonical_answer = _canonical_customer_answer_from_payload(payload) answer_process_packet = _customer_answer_process_packet_from_payload(payload, question=question, answer=canonical_answer) 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") ), "answer_process_packet": answer_process_packet, **_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 _customer_answer_process_packet_from_payload(payload: dict, *, question: str = "", answer: str = "") -> dict | None: process_packet = payload.get("answer_process_packet") if isinstance(payload.get("answer_process_packet"), dict) else None if process_packet is None: return None raw_steps = process_packet.get("steps") if isinstance(process_packet.get("steps"), list) else [] work_log = _customer_process_work_log(raw_steps, question=question, answer=answer, max_items=80) return { "packet_version": str(process_packet.get("packet_version") or "customer_answer_process.v2")[:80], "visibility": "customer", "answer_type": str(process_packet.get("answer_type") or "agent_run")[:80], "route_display": str(process_packet.get("route_display") or "agent_runtime")[:80], "steps": [ { "key": str(item.get("id") or f"work_{index + 1}")[:80], "label": item.get("label"), "status": item.get("status"), "display_summary": item.get("summary"), "duration_ms": item.get("duration_ms"), "process_narrative": item.get("process_narrative"), "activity_summary": item.get("activity_summary"), "tool_transcript": item.get("tool_transcript"), "evidence": item.get("evidence"), "boundary_summary": item.get("boundary_summary"), } for index, item in enumerate(work_log) ], "total_duration_ms": _safe_int(process_packet.get("total_duration_ms")), } 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"] = _customer_existing_artifacts_for_view(out.get("artifacts"), answer=canonical_answer or answer) 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")) ] if isinstance(out.get("confirmations"), list): out["confirmations"] = _customer_confirmation_rows(out.get("confirmations")) 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"] = _customer_markdown_answer_text(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 _customer_existing_artifacts_for_view(rows: object, *, answer: str = "") -> list[dict]: if not isinstance(rows, list): return [] out: list[dict] = [] for index, item in enumerate(rows[:12]): if not isinstance(item, dict) or _contains_internal_display_terms(item): continue title = _customer_safe_display_text(item.get("title"), fallback="", limit=120) summary = _customer_safe_display_text(item.get("summary"), fallback="", limit=320) if not _is_informative_customer_artifact(title=title, summary=summary, answer=answer): continue out.append( { "id": str(item.get("id") or item.get("artifact_id") or f"artifact_{index + 1}")[:80], "title": title, "summary": summary, "status_label": _customer_safe_display_text( item.get("status_label") or _customer_status_label(item.get("status")), fallback="", limit=40, ), } ) return out def _is_informative_customer_artifact(*, title: str, summary: str, answer: str = "") -> bool: title_key = _customer_artifact_compare_key(title) summary_key = _customer_artifact_compare_key(summary) answer_key = _customer_artifact_compare_key(answer) if not title_key and not summary_key: return False if not summary_key: return False if title_key and summary_key == title_key: return False if answer_key and summary_key == answer_key[: len(summary_key)]: return False return True def _customer_artifact_compare_key(value: object) -> str: return re.sub(r"\s+", "", str(value or "").strip()).lower() 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 _customer_markdown_answer_text(text)[:4000] return "" def _customer_markdown_answer_text(value: object) -> str: text = rag_ask_contract._sanitize_customer_answer_text(value) if not text: return "" return rag_ask_contract._normalize_customer_answer_markdown(text) 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) total_duration_ms = 0 has_duration = False for item in rows: if not isinstance(item, dict): continue duration = _safe_int(item.get("duration_ms")) if duration is None: continue total_duration_ms += int(duration) has_duration = True return { "total_step_count": total, "visible_step_count": visible, "hidden_step_count": max(total - visible, 0), "truncated": total > visible, "total_duration_ms": total_duration_ms if has_duration else None, } 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")), "process_narrative": _customer_process_safe_summary(item.get("process_narrative")), "activity_summary": _customer_process_safe_summary(item.get("activity_summary")), "tool_transcript": _customer_process_detail_rows(item.get("tool_transcript")), "evidence": _customer_process_detail_rows(item.get("evidence")), "boundary_summary": _customer_process_safe_summary(item.get("boundary_summary")), } ) 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"), "process_narrative": step.get("process_narrative"), "activity_summary": step.get("activity_summary"), "tool_transcript": step.get("tool_transcript"), "evidence": step.get("evidence"), "boundary_summary": step.get("boundary_summary"), }, } 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: projected[existing_index] = _merge_customer_work_log_item(projected[existing_index], item) continue seen[str(item["id"])] = len(projected) projected.append(item) if len(projected) >= target: break if projected: return projected return [] def _merge_customer_work_logs(left: list[dict], right: list[dict]) -> list[dict]: merged: list[dict] = [] index_by_id: dict[str, int] = {} for row in [*list(left or []), *list(right or [])]: if not isinstance(row, dict): continue row_id = str(row.get("id") or row.get("key") or "").strip()[:80] or f"work_{len(merged) + 1}" normalized = dict(row) normalized["id"] = row_id existing_index = index_by_id.get(row_id) if existing_index is None: index_by_id[row_id] = len(merged) merged.append(normalized) continue merged[existing_index] = _merge_customer_work_log_item(merged[existing_index], normalized) return merged[:80] def _merge_customer_work_log_item(left: dict, right: dict) -> dict: merged = dict(left or {}) incoming = dict(right or {}) merged["status"] = _merge_process_status(str(merged.get("status") or ""), str(incoming.get("status") or "")) merged["status_label"] = _customer_status_pair(merged["status"])[1] for key in ("label", "summary", "process_narrative", "activity_summary", "boundary_summary"): incoming_value = str(incoming.get(key) or "").strip() if incoming_value and len(incoming_value) >= len(str(merged.get(key) or "").strip()): merged[key] = incoming_value merged["duration_ms"] = _merge_optional_duration_ms(merged.get("duration_ms"), incoming.get("duration_ms")) merged["tool_transcript"] = _dedupe_process_detail_dicts( [*list(merged.get("tool_transcript") or []), *list(incoming.get("tool_transcript") or [])] ) merged["evidence"] = _dedupe_process_detail_dicts( [*list(merged.get("evidence") or []), *list(incoming.get("evidence") or [])] ) return merged def _customer_process_item_from_step(step: dict, *, index: int) -> dict | None: 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) safe_summary = _customer_process_safe_summary(raw_summary) label_action, label_detail = _split_process_label_detail(raw_label) if not (safe_label or safe_summary or label_detail): return None return { "id": str(step.get("key") or step.get("id") or f"work_{index + 1}")[:80], "label": _customer_process_label(raw_label=label_action or safe_label, has_detail=bool(label_detail), index=index), "summary": _customer_process_summary(safe_summary=safe_summary, label_detail=label_detail, label_action=label_action), "status": status_code, "status_label": status_label, "duration_ms": _safe_int(step.get("duration_ms")), "process_narrative": _customer_process_safe_summary(step.get("process_narrative")), "activity_summary": _customer_process_safe_summary(step.get("activity_summary")), "tool_transcript": _customer_process_detail_rows(step.get("tool_transcript")), "evidence": _customer_process_detail_rows(step.get("evidence")), "boundary_summary": _customer_process_safe_summary(step.get("boundary_summary")), } def _split_process_label_detail(value: object) -> tuple[str, str]: text = " ".join(str(value or "").strip().split()) if not text: return "", "" match = re.match(r"^([^::]{1,16})[::]\s*(.+)$", text) if not match: return text, "" action = _customer_safe_display_text(match.group(1), fallback="", limit=40) detail = _customer_safe_display_text(match.group(2), fallback="", limit=180) return action, detail def _customer_process_label(*, raw_label: str, has_detail: bool = False, index: int) -> str: safe_label = _customer_safe_display_text(raw_label, fallback="", limit=120) if safe_label and has_detail and len(safe_label) <= 8: return f"{safe_label}相关信息" if safe_label: return safe_label return f"推进第 {index + 1} 步" def _customer_process_summary(*, safe_summary: str, label_detail: str = "", label_action: str = "") -> str: safe_detail = _customer_process_detail_text(label_detail) detail_prefix = _customer_safe_display_text(label_action, fallback="内容", limit=12) if safe_summary and safe_detail and safe_detail in safe_summary: return safe_summary if safe_summary and safe_detail and safe_summary in safe_detail: return f"{detail_prefix}:{safe_detail}" if safe_detail and safe_summary and safe_detail not in safe_summary: return _customer_safe_display_text(f"{detail_prefix}:{safe_detail};{safe_summary}", fallback="", limit=420) if safe_detail: return f"{detail_prefix}:{safe_detail}" if safe_summary: return safe_summary return "" def _customer_process_detail_text(value: object) -> str: return _customer_safe_display_text(value, fallback="", limit=220) 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 _customer_process_detail_rows(value: object) -> list[dict]: rows = value if isinstance(value, list) else [] out: list[dict] = [] for index, item in enumerate(rows[:20]): if not isinstance(item, dict): continue title = _customer_safe_display_text(item.get("title"), fallback="", limit=180) summary = _customer_safe_display_text(item.get("summary"), fallback="", limit=360) url = str(item.get("url") or "").strip() if url and not url.startswith(("http://", "https://")): url = "" domain = _customer_safe_display_text(item.get("domain"), fallback="", limit=80) if url and (not title or title.startswith(("http://", "https://"))): title = _customer_source_display_title(url=url, title=title, summary=summary) kind = _customer_safe_display_text(item.get("kind"), fallback="detail", limit=60) or "detail" detail_items: list[dict] = [] for child in list(item.get("items") or [])[:8]: if not isinstance(child, dict): continue child_title = _customer_safe_display_text(child.get("title"), fallback="", limit=140) child_kind = _customer_safe_display_text(child.get("kind"), fallback="", limit=60) if child_title: detail_items.append({"title": child_title, "kind": child_kind}) if not title and not summary and not url and not detail_items: continue if _contains_internal_display_terms(title) or _contains_internal_display_terms(summary): continue out.append( { "id": str(item.get("id") or f"detail_{index + 1}")[:80], "kind": kind, "title": title or domain or "过程明细", "summary": summary, "url": url or None, "domain": domain or None, "items": detail_items, } ) return out 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 _merge_optional_duration_ms(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 max(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 _is_informative_customer_artifact(title=title, summary=summary, answer=answer): 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 return _customer_confirmation_rows(rows, prepared_by_id=prepared_by_id) def _customer_confirmation_rows(value: object, *, prepared_by_id: dict[str, dict] | None = None) -> list[dict]: rows = value if isinstance(value, list) else [] prepared_index = prepared_by_id or {} 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_index[value] for value in requested_for if value in prepared_index), {}) action_payload = item.get("action_payload") if isinstance(item.get("action_payload"), dict) else {} title = _customer_confirmation_title(item=item, prepared=prepared, action_payload=action_payload, index=index) summary = _customer_safe_display_text( item.get("summary") or item.get("reason") or item.get("customer_safe_summary"), fallback="确认前只展示候选,不会写入项目资料、改动任务或交给执行环境。", limit=260, ) 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_confirmation_title(*, item: dict, prepared: dict, action_payload: dict, index: int) -> str: candidates = [ item.get("title"), item.get("question"), prepared.get("title") if isinstance(prepared, dict) else None, action_payload.get("title") if isinstance(action_payload, dict) else None, item.get("action_label"), item.get("summary"), ] for candidate in candidates: text = _customer_safe_display_text(candidate, fallback="", limit=160) if not text: continue text = re.sub(r"^\s*确认[^::]{0,12}动作\s*[::]\s*", "", text).strip() text = re.sub(r"^\s*确认\s*[::]\s*", "", text).strip() if text: return text[:160] return f"需要确认的动作 {index + 1}" 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 _customer_confirmation_title(item=confirmation, prepared={}, action_payload={}, index=0) 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", "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", "evidence normalizer", "state critic", "statecritic", } 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(?:goal_frame|turn_state|claim_ledger|boundary_ledger|answer_runtime|agent_os|route_plan|source_quality|terminal_state|" r"step_plan|tool_trace|prompt_key|function_name|raw_source_label|pmflow_ai_turn_state|answer_process_packet|customer_evidence_packet)" r"(?:_[a-z0-9]+)*\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"]