PMFlow/backend/app/api/space_api/__init__.py

3845 lines
161 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"]