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