3845 lines
161 KiB
Python
3845 lines
161 KiB
Python
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 AsyncSessionLocal, get_db
|
||
from ...core.response import error_response, success_response
|
||
from ...dependencies import get_current_user
|
||
from ...models import (
|
||
PMFlowAgentArtifact,
|
||
PMFlowAgentMemory,
|
||
PMFlowAIContextMemory,
|
||
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_ai_context_assembly_service import PMFlowAIContextAssemblyService
|
||
from ...services.pmflow_ai_context_memory_job_service import PMFlowAIContextMemoryCompactionJobService
|
||
from ...services.pmflow_ai_context_memory_service import PMFlowAIContextMemoryService
|
||
from ...services.pmflow_ai_context_pack_service import (
|
||
PMFlowAIContextPackBuilder,
|
||
context_pack_observability_packet,
|
||
evaluate_context_replay_case,
|
||
)
|
||
from ...services.pmflow_ai_context_replay_runner import PMFlowAIContextReplayRunner
|
||
from ...services.pmflow_ai_thread_context_service import PMFlowAIThreadContextService, update_thread_context_summary_metadata
|
||
from ...services.pmflow_ai_topic_workspace_service import PMFlowAITopicWorkspaceService
|
||
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.get("/pmflow-ai/context-debug/threads/{thread_id}")
|
||
async def get_pmflow_ai_context_debug(
|
||
thread_id: str,
|
||
expected_evidence: list[str] | None = Query(default=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")
|
||
return success_response(
|
||
await _pmflow_ai_context_debug_packet(
|
||
db=db,
|
||
current_user=current_user,
|
||
thread=thread,
|
||
expected_evidence=expected_evidence or [],
|
||
)
|
||
)
|
||
|
||
|
||
@router.get("/pmflow-ai/context-debug/replay-cases")
|
||
async def run_pmflow_ai_context_replay_cases(
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
_ = current_user
|
||
return success_response(PMFlowAIContextReplayRunner().run_cases())
|
||
|
||
|
||
@router.get("/pmflow-ai/context-debug/runs/{run_id}")
|
||
async def get_pmflow_ai_context_debug_run(
|
||
run_id: str,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
packet = await PMFlowAIContextAssemblyService(
|
||
db,
|
||
tenant_id=int(current_user.tenant_id),
|
||
).latest_for_run(run_public_id=run_id)
|
||
if packet is None:
|
||
raise HTTPException(status_code=404, detail="PMFlow AI context assembly not found")
|
||
return success_response(packet)
|
||
|
||
|
||
@router.get("/pmflow-ai/context-debug/worker")
|
||
async def get_pmflow_ai_context_worker_debug(
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
return success_response(
|
||
await PMFlowAIContextMemoryCompactionJobService(
|
||
db,
|
||
tenant_id=int(current_user.tenant_id),
|
||
).health_packet()
|
||
)
|
||
|
||
|
||
@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=thread,
|
||
)
|
||
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,
|
||
thread=thread,
|
||
)
|
||
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,
|
||
thread: PMFlowAIThread | 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,
|
||
)
|
||
thread_context_pack = await PMFlowAIThreadContextService(
|
||
db,
|
||
tenant_id=int(current_user.tenant_id),
|
||
).build_context_pack(
|
||
thread=thread,
|
||
user_question=str(body.question or ""),
|
||
)
|
||
if thread_context_pack:
|
||
conversation_context = {
|
||
**dict(conversation_context or {}),
|
||
"thread_context_pack": thread_context_pack,
|
||
"recent_thread_messages": list(thread_context_pack.get("recent_messages") or []),
|
||
"relevant_thread_messages": list(thread_context_pack.get("relevant_messages") or []),
|
||
}
|
||
topic_workspace_pack = await PMFlowAITopicWorkspaceService(
|
||
db,
|
||
tenant_id=int(current_user.tenant_id),
|
||
).build_topic_context_pack(
|
||
current_user=current_user,
|
||
user_question=str(body.question or ""),
|
||
thread_context_pack=thread_context_pack,
|
||
)
|
||
if topic_workspace_pack:
|
||
conversation_context = {
|
||
**dict(conversation_context or {}),
|
||
"topic_workspace_pack": topic_workspace_pack,
|
||
}
|
||
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"),
|
||
)
|
||
memory_context_pack = await PMFlowAIContextMemoryService(
|
||
db,
|
||
tenant_id=int(current_user.tenant_id),
|
||
).build_memory_context_pack(
|
||
thread=thread,
|
||
current_user=current_user,
|
||
conversation_context=conversation_context,
|
||
tenant_project_context=tenant_project_context,
|
||
user_question=str(body.question or ""),
|
||
)
|
||
context_pack = PMFlowAIContextPackBuilder().build(
|
||
user_question=str(body.question or ""),
|
||
thread_context_pack=thread_context_pack,
|
||
conversation_context=conversation_context,
|
||
tenant_project_context=tenant_project_context,
|
||
memory_context_pack=memory_context_pack,
|
||
)
|
||
conversation_context = {
|
||
**dict(conversation_context or {}),
|
||
"memory_context_pack": memory_context_pack,
|
||
"context_pack": context_pack,
|
||
"context_evidence_packet": context_pack.get("context_evidence_packet")
|
||
if isinstance(context_pack.get("context_evidence_packet"), dict)
|
||
else {},
|
||
}
|
||
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,
|
||
)
|
||
observability_packet = context_pack_observability_packet(context_pack)
|
||
if observability_packet:
|
||
runtime_payload["context_observability_packet"] = observability_packet
|
||
run_public_id = str(runtime_payload.get("agent_os_run_id") or runtime_payload.get("run_id") or "").strip()[:80] or None
|
||
assembly_packet = await PMFlowAIContextAssemblyService(
|
||
db,
|
||
tenant_id=int(current_user.tenant_id),
|
||
).persist_for_run(
|
||
thread=thread,
|
||
current_user=current_user,
|
||
context_pack=context_pack,
|
||
user_question=str(body.question or ""),
|
||
run_public_id=run_public_id,
|
||
grounding_packet=runtime_payload.get("context_grounding_packet")
|
||
if isinstance(runtime_payload.get("context_grounding_packet"), dict)
|
||
else {},
|
||
)
|
||
if assembly_packet:
|
||
runtime_payload["context_assembly_packet"] = assembly_packet
|
||
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,
|
||
)
|
||
if isinstance(runtime_payload.get("used_context_packet"), dict):
|
||
payload["used_context_packet"] = runtime_payload["used_context_packet"]
|
||
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)
|
||
existing_process_packet = payload.get("answer_process_packet") if isinstance(payload.get("answer_process_packet"), dict) else {}
|
||
durable_elapsed = (
|
||
_safe_int(existing_process_packet.get("wall_clock_duration_ms"))
|
||
or _safe_int(existing_process_packet.get("total_duration_ms"))
|
||
or elapsed
|
||
)
|
||
elapsed_for_display = max(elapsed, int(durable_elapsed or 0))
|
||
processing_stage = str(payload.get("processing_stage") or "surface_direct").strip() or "surface_direct"
|
||
payload["response_time_ms"] = elapsed_for_display
|
||
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_for_display,
|
||
wall_clock_duration_ms=elapsed_for_display,
|
||
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,
|
||
)
|
||
if isinstance(payload.get("used_context_packet"), dict):
|
||
customer_payload_for_storage["used_context_packet"] = payload["used_context_packet"]
|
||
if isinstance(payload.get("context_evidence_packet"), dict):
|
||
customer_payload_for_storage["context_evidence_packet"] = payload["context_evidence_packet"]
|
||
answer = _canonical_customer_answer_from_payload(customer_payload_for_storage) or _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
|
||
if isinstance(customer_payload_for_storage.get("answer_canvas"), dict):
|
||
customer_payload_for_storage["answer_canvas"] = {
|
||
**dict(customer_payload_for_storage["answer_canvas"]),
|
||
"direct_answer": answer,
|
||
}
|
||
payload["answer_canvas"] = customer_payload_for_storage["answer_canvas"]
|
||
if isinstance(customer_payload_for_storage.get("deliverable_packet"), dict):
|
||
customer_payload_for_storage["deliverable_packet"] = {
|
||
**dict(customer_payload_for_storage["deliverable_packet"]),
|
||
"answer_text": answer,
|
||
}
|
||
payload["deliverable_packet"] = customer_payload_for_storage["deliverable_packet"]
|
||
for key in (
|
||
"answer_canvas",
|
||
"customer_evidence_packet",
|
||
"customer_interaction_context_packet",
|
||
"answer_process_packet",
|
||
"used_context_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"),
|
||
"used_context_packet": customer_payload_for_storage.get("used_context_packet"),
|
||
"context_evidence_packet": customer_payload_for_storage.get("context_evidence_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_for_display,
|
||
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_for_display,
|
||
}
|
||
],
|
||
retrieval_metrics={
|
||
"surface_direct": processing_stage == "surface_direct",
|
||
"total_duration_ms": elapsed_for_display,
|
||
"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 _pmflow_ai_context_debug_packet(
|
||
*,
|
||
db: AsyncSession,
|
||
current_user: User,
|
||
thread: PMFlowAIThread,
|
||
expected_evidence: list[str],
|
||
) -> dict[str, Any]:
|
||
latest_run = (
|
||
await db.execute(
|
||
select(PMFlowAgentRun)
|
||
.where(
|
||
and_(
|
||
PMFlowAgentRun.tenant_id == int(current_user.tenant_id),
|
||
PMFlowAgentRun.thread_id == int(thread.id),
|
||
)
|
||
)
|
||
.order_by(desc(PMFlowAgentRun.completed_at), desc(PMFlowAgentRun.id))
|
||
.limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
run_metadata = dict(latest_run.run_metadata or {}) if latest_run is not None and isinstance(latest_run.run_metadata, dict) else {}
|
||
context_observability = (
|
||
run_metadata.get("context_observability") if isinstance(run_metadata.get("context_observability"), dict) else {}
|
||
)
|
||
used_context = run_metadata.get("used_context") if isinstance(run_metadata.get("used_context"), dict) else {}
|
||
scope_refs = _debug_memory_scope_refs(thread=thread, current_user=current_user)
|
||
memory_rows = await _debug_context_memory_rows(db=db, tenant_id=int(current_user.tenant_id), scope_refs=scope_refs, thread=thread)
|
||
replay_context = {
|
||
"packet_version": "pmflow_ai_context_debug_replay_source.v1",
|
||
"observability": context_observability,
|
||
"used_context": used_context,
|
||
"memories": [_debug_memory_packet(row) for row in memory_rows],
|
||
}
|
||
return {
|
||
"packet_version": "pmflow_ai_context_debug.v1",
|
||
"thread": {
|
||
"thread_id": str(thread.public_thread_id or "")[:80],
|
||
"title": str(thread.title or "")[:160],
|
||
"message_count": int(thread.message_count or 0),
|
||
"last_run_id": str(thread.last_run_public_id or "")[:80],
|
||
},
|
||
"latest_run": {
|
||
"run_id": str(latest_run.public_run_id or "")[:80] if latest_run is not None else "",
|
||
"status": str(latest_run.status or "")[:40] if latest_run is not None else "",
|
||
"completed_at": _iso(latest_run.completed_at) if latest_run is not None else None,
|
||
},
|
||
"context_observability": context_observability,
|
||
"used_context_packet": used_context,
|
||
"context_assemblies": await PMFlowAIContextAssemblyService(
|
||
db,
|
||
tenant_id=int(current_user.tenant_id),
|
||
).latest_for_thread(thread_id=int(thread.id), limit=8),
|
||
"memory_gate": [_debug_memory_gate_packet(row) for row in memory_rows],
|
||
"retrieval_critic": context_observability.get("retrieval_critic") if isinstance(context_observability, dict) else {},
|
||
"compaction_jobs": await PMFlowAIContextMemoryCompactionJobService(
|
||
db,
|
||
tenant_id=int(current_user.tenant_id),
|
||
).latest_jobs_for_thread(thread_id=int(thread.id), limit=8),
|
||
"worker_health": await PMFlowAIContextMemoryCompactionJobService(
|
||
db,
|
||
tenant_id=int(current_user.tenant_id),
|
||
).health_packet(),
|
||
"replay_evaluation": evaluate_context_replay_case(
|
||
context_pack=replay_context,
|
||
expected_evidence=[str(item) for item in expected_evidence if str(item or "").strip()],
|
||
),
|
||
}
|
||
|
||
|
||
def _debug_memory_scope_refs(*, thread: PMFlowAIThread, current_user: User) -> dict[str, list[str]]:
|
||
refs: dict[str, list[str]] = {
|
||
"thread": [f"thread:{thread.public_thread_id}"] if str(thread.public_thread_id or "").strip() else [],
|
||
"user": [f"user:{int(current_user.id)}"],
|
||
"workspace": [f"workspace:{str(thread.workspace_scope or thread.surface or 'space')[:120]}"],
|
||
"project": [],
|
||
}
|
||
for item in list(thread.current_object_refs or [])[:12] if isinstance(thread.current_object_refs, list) else []:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
project_id = _safe_int(item.get("project_id") or item.get("id") or item.get("object_id"))
|
||
object_type = str(item.get("object_type") or item.get("type") or "").strip()
|
||
if project_id and (not object_type or object_type == "project"):
|
||
ref = f"project:{project_id}"
|
||
if ref not in refs["project"]:
|
||
refs["project"].append(ref)
|
||
return {scope: values for scope, values in refs.items() if values}
|
||
|
||
|
||
async def _debug_context_memory_rows(
|
||
*,
|
||
db: AsyncSession,
|
||
tenant_id: int,
|
||
scope_refs: dict[str, list[str]],
|
||
thread: PMFlowAIThread,
|
||
) -> list[PMFlowAIContextMemory]:
|
||
filters = [
|
||
PMFlowAIContextMemory.tenant_id == int(tenant_id),
|
||
or_(
|
||
PMFlowAIContextMemory.source_thread_id == int(thread.id),
|
||
*[
|
||
and_(PMFlowAIContextMemory.memory_scope == scope, PMFlowAIContextMemory.scope_ref.in_(refs[:8]))
|
||
for scope, refs in scope_refs.items()
|
||
if refs
|
||
],
|
||
),
|
||
]
|
||
result = await db.execute(
|
||
select(PMFlowAIContextMemory)
|
||
.where(and_(*filters))
|
||
.order_by(desc(PMFlowAIContextMemory.last_compacted_at), desc(PMFlowAIContextMemory.updated_at))
|
||
.limit(30)
|
||
)
|
||
return list(result.scalars().all())
|
||
|
||
|
||
def _debug_memory_packet(row: PMFlowAIContextMemory) -> dict[str, Any]:
|
||
return {
|
||
"memory_id": str(row.public_memory_id or "")[:80],
|
||
"memory_scope": str(row.memory_scope or "")[:40],
|
||
"scope_ref": str(row.scope_ref or "")[:160],
|
||
"status": str(getattr(row, "status", "") or "")[:32],
|
||
"confidence_score": float(getattr(row, "confidence_score", 0.0) or 0.0),
|
||
"summary": str(row.summary or "")[:800],
|
||
"durable_facts": list(row.durable_facts or [])[:8] if isinstance(row.durable_facts, list) else [],
|
||
"open_loops": list(row.open_loops or [])[:6] if isinstance(row.open_loops, list) else [],
|
||
"last_compacted_at": _iso(row.last_compacted_at),
|
||
"last_validated_at": _iso(getattr(row, "last_validated_at", None)),
|
||
}
|
||
|
||
|
||
def _debug_memory_gate_packet(row: PMFlowAIContextMemory) -> dict[str, Any]:
|
||
payload = dict(row.compaction_payload or {}) if isinstance(row.compaction_payload, dict) else {}
|
||
gate = payload.get("write_gate") or payload.get("last_gate")
|
||
gate = gate if isinstance(gate, dict) else {}
|
||
return {
|
||
"memory_id": str(row.public_memory_id or "")[:80],
|
||
"memory_scope": str(row.memory_scope or "")[:40],
|
||
"scope_ref": str(row.scope_ref or "")[:160],
|
||
"status": str(getattr(row, "status", "") or "")[:32],
|
||
"confidence_score": float(getattr(row, "confidence_score", 0.0) or 0.0),
|
||
"gate": {
|
||
"action": str(gate.get("action") or "")[:40],
|
||
"reason": str(gate.get("reason") or "")[:120],
|
||
"status": str(gate.get("status") or "")[:32],
|
||
"confidence_score": gate.get("confidence_score"),
|
||
},
|
||
"last_validated_at": _iso(getattr(row, "last_validated_at", 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=thread,
|
||
)
|
||
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 = _canonical_customer_answer_from_payload(payload) or _customer_markdown_answer_text(
|
||
payload.get("answer") or payload.get("reply_text")
|
||
)
|
||
payload = {**dict(payload or {}), "answer": answer, "reply_text": answer}
|
||
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_sent_at = now
|
||
if run_public_id:
|
||
started_row = (
|
||
await db.execute(
|
||
select(PMFlowAgentRun.started_at).where(
|
||
and_(
|
||
PMFlowAgentRun.tenant_id == int(tenant_id),
|
||
PMFlowAgentRun.public_run_id == run_public_id,
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if started_row is not None:
|
||
user_sent_at = started_row
|
||
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=user_sent_at,
|
||
)
|
||
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,
|
||
)
|
||
thread.thread_metadata = update_thread_context_summary_metadata(
|
||
thread_metadata=thread.thread_metadata,
|
||
user_message=str(request.question or "").strip(),
|
||
assistant_answer=answer,
|
||
user_message_index=base_index + 1,
|
||
assistant_message_index=base_index + 2,
|
||
)
|
||
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 PMFlowAIContextAssemblyService(
|
||
db,
|
||
tenant_id=int(tenant_id),
|
||
).attach_thread_to_run(run_public_id=run_public_id, thread=thread)
|
||
await db.flush()
|
||
tenant_project_context = rag_ask_contract.build_tenant_project_context_packet(
|
||
request=request,
|
||
current_user=current_user,
|
||
current_project_id_override=None,
|
||
)
|
||
compaction_job = await PMFlowAIContextMemoryCompactionJobService(
|
||
db,
|
||
tenant_id=int(tenant_id),
|
||
).enqueue_after_turn(
|
||
thread=thread,
|
||
current_user=current_user,
|
||
user_message=str(request.question or "").strip(),
|
||
assistant_answer=answer,
|
||
context_pack={
|
||
"context_observability_packet": payload.get("context_observability_packet")
|
||
if isinstance(payload.get("context_observability_packet"), dict)
|
||
else {},
|
||
"used_context_packet": payload.get("used_context_packet") if isinstance(payload.get("used_context_packet"), dict) else {},
|
||
},
|
||
tenant_project_context=tenant_project_context,
|
||
run_public_id=run_public_id,
|
||
user_message_index=base_index + 1,
|
||
assistant_message_index=base_index + 2,
|
||
)
|
||
topic_workspace = await PMFlowAITopicWorkspaceService(
|
||
db,
|
||
tenant_id=int(tenant_id),
|
||
).upsert_from_turn(
|
||
thread=thread,
|
||
current_user=current_user,
|
||
user_question=str(request.question or "").strip(),
|
||
assistant_answer=answer,
|
||
context_evidence_packet=payload.get("context_evidence_packet")
|
||
if isinstance(payload.get("context_evidence_packet"), dict)
|
||
else (
|
||
(payload.get("used_context_packet") or {}).get("context_evidence")
|
||
if isinstance(payload.get("used_context_packet"), dict)
|
||
else {}
|
||
),
|
||
run_public_id=run_public_id,
|
||
)
|
||
if topic_workspace:
|
||
metadata = dict(thread.thread_metadata or {}) if isinstance(thread.thread_metadata, dict) else {}
|
||
metadata["last_topic_workspace"] = topic_workspace
|
||
thread.thread_metadata = metadata
|
||
if compaction_job:
|
||
metadata = dict(thread.thread_metadata or {}) if isinstance(thread.thread_metadata, dict) else {}
|
||
metadata["last_memory_compaction_job"] = compaction_job
|
||
thread.thread_metadata = metadata
|
||
await db.commit()
|
||
await db.refresh(thread)
|
||
if compaction_job.get("job_id"):
|
||
asyncio.create_task(
|
||
_run_context_memory_compaction_job_background(
|
||
tenant_id=int(tenant_id),
|
||
public_job_id=str(compaction_job.get("job_id")),
|
||
)
|
||
)
|
||
return thread
|
||
|
||
|
||
async def _run_context_memory_compaction_job_background(*, tenant_id: int, public_job_id: str) -> None:
|
||
try:
|
||
async with AsyncSessionLocal() as bg_db:
|
||
try:
|
||
await PMFlowAIContextMemoryCompactionJobService(
|
||
bg_db,
|
||
tenant_id=int(tenant_id),
|
||
).process_job(public_job_id=public_job_id)
|
||
await bg_db.commit()
|
||
except Exception:
|
||
await bg_db.rollback()
|
||
logger.exception("pmflow_ai_context_memory_compaction_job_failed", extra={"job_id": public_job_id})
|
||
except Exception:
|
||
logger.exception("pmflow_ai_context_memory_compaction_background_failed", extra={"job_id": public_job_id})
|
||
|
||
|
||
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":
|
||
has_persisted_full_work_log = bool(payload.get("work_log_full")) if isinstance(payload.get("work_log_full"), list) else False
|
||
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,
|
||
preserve_existing_full=has_persisted_full_work_log,
|
||
)
|
||
else:
|
||
content = row.content
|
||
process_packet = payload.get("answer_process_packet") if isinstance(payload.get("answer_process_packet"), dict) else None
|
||
existing_meta = payload.get("work_log_meta") if isinstance(payload.get("work_log_meta"), dict) else None
|
||
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": _work_log_meta(
|
||
payload.get("work_log_full") or payload.get("work_log"),
|
||
process_packet=process_packet,
|
||
existing_meta=existing_meta,
|
||
),
|
||
"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,
|
||
"used_context_packet": payload.get("used_context_packet") if isinstance(payload.get("used_context_packet"), 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 = "",
|
||
preserve_existing_full: bool = False,
|
||
) -> dict:
|
||
if not run_steps:
|
||
return payload
|
||
persisted_full = payload.get("work_log_full") if isinstance(payload.get("work_log_full"), list) else []
|
||
if preserve_existing_full and persisted_full:
|
||
return payload
|
||
existing_full = list(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,
|
||
process_packet=out.get("answer_process_packet") if isinstance(out.get("answer_process_packet"), dict) else None,
|
||
existing_meta=out.get("work_log_meta") if isinstance(out.get("work_log_meta"), dict) else None,
|
||
)
|
||
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,
|
||
"used_context_packet": payload.get("used_context_packet") if isinstance(payload.get("used_context_packet"), dict) else None,
|
||
"context_evidence_packet": payload.get("context_evidence_packet")
|
||
if isinstance(payload.get("context_evidence_packet"), dict)
|
||
else None,
|
||
**_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")),
|
||
"wall_clock_duration_ms": _safe_int(process_packet.get("wall_clock_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)
|
||
# Agent artifacts such as IdeaSeed, Operating Mission, specialist work products
|
||
# and claim ledgers are internal reasoning material. Customer-facing Space AI
|
||
# should surface the answer and concrete next actions, not an artifact packet viewer.
|
||
out["artifacts"] = []
|
||
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]:
|
||
# Historical payloads may already contain inline artifacts. Keep them available
|
||
# in stored/audit payloads, but do not render them in the user conversation.
|
||
# Useful transitions must be exposed through actions/confirmations/canvas CTAs.
|
||
_ = (rows, answer)
|
||
return []
|
||
|
||
|
||
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 {}
|
||
candidates = []
|
||
for priority, value in enumerate(
|
||
(
|
||
payload.get("answer"),
|
||
payload.get("reply_text"),
|
||
deliverable.get("answer_text"),
|
||
canvas.get("direct_answer"),
|
||
deliverable.get("summary"),
|
||
evidence.get("fallback_summary"),
|
||
)
|
||
):
|
||
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):
|
||
candidates.append((text, priority))
|
||
if not candidates:
|
||
return ""
|
||
best, _ = sorted(candidates, key=lambda item: (-_canonical_answer_quality_score(item[0]), item[1]))[0]
|
||
return _customer_markdown_answer_text(best)[:4000]
|
||
|
||
|
||
def _canonical_answer_quality_score(value: object) -> int:
|
||
text = _customer_markdown_answer_text(value)
|
||
if not text:
|
||
return -1000
|
||
score = min(800, len(text))
|
||
if _is_context_claim_scaffold_text(text):
|
||
score -= 700
|
||
if "\n" in text:
|
||
score += 80
|
||
if "|" in text and "---" in text:
|
||
score += 80
|
||
if len(text) < 40:
|
||
score -= 120
|
||
return score
|
||
|
||
|
||
def _is_context_claim_scaffold_text(value: object) -> bool:
|
||
text = " ".join(str(value or "").split())
|
||
if not text:
|
||
return False
|
||
markers = (
|
||
"同一对话历史第",
|
||
"长期记忆记录:",
|
||
"context evidence",
|
||
"Context Evidence",
|
||
"claim ledger",
|
||
"boundary ledger",
|
||
)
|
||
marker_count = sum(1 for marker in markers if marker in text)
|
||
colon_density = text.count(":") + text.count(":")
|
||
return marker_count >= 1 and colon_density >= 3
|
||
|
||
|
||
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,
|
||
"action_type": str(item.get("action_type") or "navigate")[:80],
|
||
"action_payload": item.get("action_payload") if isinstance(item.get("action_payload"), dict) else {},
|
||
"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, process_packet=process_packet),
|
||
}
|
||
|
||
|
||
def _work_log_meta(value: object, *, process_packet: dict | None = None, existing_meta: dict | None = None) -> 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
|
||
meta_total_duration_ms = total_duration_ms if has_duration else None
|
||
duration_candidates = [
|
||
meta_total_duration_ms,
|
||
_safe_int((existing_meta or {}).get("total_duration_ms")),
|
||
_safe_int((existing_meta or {}).get("wall_clock_duration_ms")),
|
||
_safe_int((process_packet or {}).get("total_duration_ms")),
|
||
_safe_int((process_packet or {}).get("wall_clock_duration_ms")),
|
||
]
|
||
durable_duration_ms = max([int(item) for item in duration_candidates if item is not None], default=None)
|
||
wall_clock_duration_ms = max(
|
||
[
|
||
int(item)
|
||
for item in (
|
||
_safe_int((existing_meta or {}).get("wall_clock_duration_ms")),
|
||
_safe_int((process_packet or {}).get("wall_clock_duration_ms")),
|
||
)
|
||
if item is not None
|
||
],
|
||
default=None,
|
||
)
|
||
out = {
|
||
"total_step_count": total,
|
||
"visible_step_count": visible,
|
||
"hidden_step_count": max(total - visible, 0),
|
||
"truncated": total > visible,
|
||
"total_duration_ms": durable_duration_ms,
|
||
}
|
||
if wall_clock_duration_ms is not None:
|
||
out["wall_clock_duration_ms"] = wall_clock_duration_ms
|
||
return out
|
||
|
||
|
||
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]:
|
||
# User-side Space AI no longer exposes agent artifacts as conversation cards.
|
||
# They remain in Agent OS/audit payloads for internal diagnosis and future
|
||
# product projections, while the customer UI focuses on natural answer text
|
||
# plus actionable PMFlow transitions.
|
||
_ = payload
|
||
return []
|
||
|
||
|
||
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"]
|