From 37056f8facb5e6bc5b3ca73f38efbb2da7f70a3c Mon Sep 17 00:00:00 2001 From: Albert Date: Tue, 5 May 2026 23:13:35 +0800 Subject: [PATCH] chore(pmflow): integrate current workspace changes --- .env.example | 4 +- AGENTS.md | 11 +- CLAUDE.md | 61 +- USER.md | 2 + .../.control_plane/pmflow-routing-rules.json | 10 +- .../20260428_add_phone_auth_identity.py | 124 + .../20260428_add_platform_memberships.py | 57 + .../20260428_add_saved_idea_enrichment.py | 60 + .../versions/20260428_add_saved_ideas.py | 87 + .../20260428_add_workspace_memberships.py | 129 + ...428_repair_admin_yangyu_workspace_state.py | 118 + ...60429_add_ai_call_log_provider_payloads.py | 67 + .../20260429_add_ai_call_trace_fields.py | 79 + .../20260429_add_prompt_quality_linkage.py | 120 + .../20260429_add_prompt_release_checks.py | 78 + ...260429_rename_glm_provider_to_dashscope.py | 103 + .../20260430_add_prompt_replay_runs.py | 136 + .../20260430_extend_prompt_eval_replay.py | 104 + .../20260501_add_pmflow_launch_draft_jobs.py | 95 + .../20260503_add_pmflow_ai_feedback_events.py | 120 + ...20260505_add_pmflow_changeset_committer.py | 45 + backend/app/api/ai_call_traces_api.py | 72 + backend/app/api/ai_pmo_api/routes_core.py | 5 +- backend/app/api/ai_pmo_api/routes_resume.py | 4 + backend/app/api/ai_pmo_api/shared.py | 34 +- backend/app/api/ai_readiness_api/__init__.py | 32 + backend/app/api/ai_routing.py | 340 +- backend/app/api/auth.py | 83 +- backend/app/api/llm_engines.py | 174 +- backend/app/api/llm_providers.py | 12 +- .../model_capability_api/routes_context.py | 7 +- .../app/api/model_capability_api/shared.py | 2 +- backend/app/api/model_routing.py | 20 +- backend/app/api/pmflow_launch_api/__init__.py | 79 +- backend/app/api/pmflow_os_api/__init__.py | 167 + backend/app/api/projects_api/helpers.py | 41 + backend/app/api/projects_api/routes_list.py | 11 +- .../app/api/projects_api/routes_mutation.py | 22 +- backend/app/api/projects_api/schemas.py | 1 - backend/app/api/projects_api/shared.py | 2 +- backend/app/api/prompt_assets_api.py | 372 ++ .../api/rag_api/routes_route_governance.py | 9 +- backend/app/api/router.py | 7 +- backend/app/api/semantic_facts_api/routes.py | 12 +- backend/app/api/space_api/__init__.py | 182 + .../api/sstc_api/charters_detail_update.py | 2 + .../app/api/sstc_api/charters_list_create.py | 3 +- backend/app/api/sstc_api/overview.py | 11 +- .../app/api/sstc_api/releases_list_create.py | 2 + .../app/api/sstc_api/reviews_list_create.py | 3 +- backend/app/api/sstc_api/signals_list.py | 2 + .../app/api/sstc_api/tasks_board_status.py | 2 + backend/app/api/users_api/helpers_user.py | 15 + backend/app/api/users_api/routes_mutation.py | 8 + backend/app/api/users_api/routes_query.py | 16 +- backend/app/api/workspaces_api.py | 116 + .../config/ai_function_runtime_profiles.py | 110 + backend/app/config/ai_prompt_eval_rubrics.py | 111 + backend/app/config/ai_prompt_registry.py | 237 + backend/app/config/ai_prompts.py | 5 + .../app/config/ai_prompts_catalog/__init__.py | 4 + .../ai_prompts_catalog/part_scenes_06.py | 115 +- backend/app/core/startup/model_probe.py | 10 +- backend/app/dependencies/__init__.py | 12 + backend/app/dependencies/permissions.py | 42 +- backend/app/models/__init__.py | 20 +- backend/app/models/ai_call_log.py | 13 +- backend/app/models/phone_verification_code.py | 24 + backend/app/models/platform_membership.py | 29 + backend/app/models/pmflow_ai_feedback.py | 54 + backend/app/models/pmflow_change_review.py | 1 + backend/app/models/pmflow_launch.py | 33 +- backend/app/models/reply_iteration.py | 106 + backend/app/models/saved_idea.py | 46 + backend/app/models/tenant.py | 9 +- backend/app/models/tenant_invitation.py | 32 + backend/app/models/tenant_membership.py | 24 + backend/app/models/user.py | 15 +- backend/app/schemas/__init__.py | 6 + backend/app/schemas/auth.py | 37 + backend/app/schemas/pmflow_launch.py | 35 + backend/app/schemas/pmflow_os.py | 52 +- backend/app/schemas/prompt_assets.py | 56 + backend/app/schemas/space.py | 83 + .../mixin_answer_mainline.py | 6 +- .../mixin_answer_retrieval.py | 175 +- .../mixin_fast_answer_semantic_p1.py | 8 +- .../adaptive_rag_service/shared_utils.py | 4 + backend/app/services/ai_call_trace_service.py | 653 +++ .../ai_function_runtime_profile_service.py | 382 ++ backend/app/services/ai_readiness_service.py | 475 ++ .../services/ai_routing_governance_service.py | 3027 ++++++++++- .../services/ai_routing_scene_contracts.py | 349 ++ .../app/services/ai_scene_runtime_policy.py | 167 + .../ai_service_core/mixin_runtime_core.py | 6 +- .../app/services/ai_service_core/shared.py | 6 +- backend/app/services/auth/service.py | 6 +- .../external_query_service/presenter.py | 35 +- .../external_query_service/registry.py | 110 +- .../services/llm_service/mixin_chat_openai.py | 163 +- .../llm_service/mixin_chat_transport.py | 15 +- .../mixin_control_plane_routing.py | 45 +- .../llm_service/mixin_engine_admin.py | 2071 +++++++- .../mixin_engine_resolution_core.py | 118 +- .../llm_service/mixin_generate_db_chat.py | 28 +- .../mixin_generate_db_structured.py | 86 +- .../llm_service/mixin_guardrails_prompt.py | 20 + .../mixin_prompt_profile_runtime.py | 121 +- .../mixin_structured_extract_object.py | 8 + .../mixin_tier_engine_selection.py | 241 +- .../llm_service/mixin_tier_escalation.py | 150 +- .../services/llm_service/shared_part_01.py | 26 +- .../services/llm_service/shared_part_02.py | 166 +- backend/app/services/model/model_config.py | 10 +- .../services/model/model_routing/__init__.py | 4 +- .../services/model/model_routing/service.py | 17 +- backend/app/services/phone_auth_service.py | 282 + .../app/services/platform_access_service.py | 132 + .../services/pmflow_ai_feedback_service.py | 433 ++ .../services/pmflow_change_review_service.py | 405 +- backend/app/services/pmflow_launch_service.py | 614 ++- .../services/pmflow_os_projection_service.py | 820 ++- .../mixin_guardrails.py | 2 +- backend/app/services/prompt_assets_service.py | 1765 +++++++ .../app/services/prompt_release/service.py | 87 +- .../app/services/prompt_runtime_service.py | 85 + .../app/services/space_saved_idea_service.py | 436 ++ .../team_execution_capability_service.py | 10 +- backend/app/services/workspace_service.py | 611 +++ .../test_adaptive_rag_mainline_contract.py | 18 +- backend/tests/test_ai_call_traces_api.py | 302 ++ backend/tests/test_ai_pmo_ingress_mainline.py | 10 +- .../tests/test_ai_prompt_registry_contract.py | 38 +- backend/tests/test_ai_readiness_api.py | 333 ++ .../test_ai_routing_governance_service.py | 1198 ++++- backend/tests/test_ai_service_core_runtime.py | 1081 +++- backend/tests/test_draft_generator_service.py | 6 +- .../test_internal_query_context_packet.py | 4 +- .../tests/test_mcp_external_query_bridge.py | 80 +- backend/tests/test_model_routing_service.py | 69 +- backend/tests/test_phone_auth_api.py | 154 + .../tests/test_pmflow_change_review_api.py | 227 +- backend/tests/test_pmflow_launch_api.py | 352 +- .../tests/test_pmflow_os_projection_api.py | 228 +- backend/tests/test_prompt_assets_api.py | 388 ++ backend/tests/test_prompt_assets_service.py | 436 ++ backend/tests/test_prompt_runtime_service.py | 83 + backend/tests/test_reply_iteration_api.py | 2 +- backend/tests/test_semantic_fact_service.py | 6 +- backend/tests/test_signal_planner_contract.py | 2 +- backend/tests/test_space_saved_ideas_api.py | 310 ++ .../test_sstc_tasks_ai_breakdown_contract.py | 4 +- .../tests/test_training_pipeline_service.py | 2 +- .../tests/test_workspace_membership_api.py | 155 + docs/FEISHU_AI_FLOW_CURRENT.md | 4 +- ...5_SIGNAL_PLANNER_IMPLEMENTATION_PLAN_V4.md | 2 +- docs/PMFLOW_PRODUCT_BLUEPRINT.md | 38 + docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md | 1402 ++++- docs/PMFLOW_UI_CONTRACT.md | 71 + docs/architecture/API_CONTRACTS.md | 537 +- docs/architecture/ARCHITECTURE_INVENTORY.md | 61 +- docs/architecture/CODE_REFERENCE_INDEX.md | 81 +- docs/architecture/DATAFLOW.md | 8 +- .../PMFLOW_OS_PRODUCT_CONTRACT.md | 26 + docs/architecture/RUNTIME_GUARDRAILS.md | 34 +- frontend/index.html | 5 +- frontend/public/brand/pmflow-app-icon.png | Bin 0 -> 86895 bytes frontend/public/brand/pmflow-logo-lockup.png | Bin 0 -> 115993 bytes frontend/public/brand/pmflow-mark.png | Bin 0 -> 58875 bytes frontend/public/favicon.ico | Bin 70 -> 43169 bytes frontend/public/favicon.png | Bin 0 -> 16231 bytes frontend/public/favicon.svg | 6 +- frontend/src/api/aiCallTraces.ts | 192 + frontend/src/api/aiReadiness.ts | 75 + frontend/src/api/aiRouting.ts | 544 +- frontend/src/api/aiSteward.ts | 178 +- frontend/src/api/base.ts | 29 +- frontend/src/api/pmflowLaunch.ts | 63 +- frontend/src/api/pmflowOs.ts | 280 +- frontend/src/api/promptAssets.ts | 367 ++ frontend/src/api/space.ts | 418 +- frontend/src/api/users.ts | 53 + frontend/src/api/workspaces.ts | 77 + frontend/src/auth/RouteBoundary.tsx | 44 +- frontend/src/components/SkillRadarChart.tsx | 2 +- .../ai-config/ConfigSubpageHeader.tsx | 2 +- frontend/src/components/brand/PMFlowLogo.tsx | 47 + .../src/components/brand/PMFlowUserAvatar.tsx | 50 + .../src/components/command/commandItems.ts | 2 +- .../src/components/layout/Layout.test.tsx | 68 + frontend/src/components/layout/Layout.tsx | 47 +- .../src/components/layout/Sidebar.test.tsx | 30 +- frontend/src/components/layout/Sidebar.tsx | 57 +- frontend/src/components/layout/TopBar.tsx | 19 +- .../src/components/login/LoginExperience.tsx | 12 + frontend/src/components/login/LoginForm.tsx | 98 +- .../components/login/LoginWorkflowVisual.tsx | 3 +- frontend/src/components/login/loginTypes.ts | 6 + .../components/marketing/MarketingFrame.tsx | 8 +- .../components/sstc/FeedbackSummaryCard.tsx | 4 +- .../src/components/users/UserFormDialog.tsx | 195 +- .../src/features/ai-readiness/components.tsx | 243 + .../src/features/ai-readiness/readiness.ts | 394 ++ frontend/src/features/ai-readiness/types.ts | 66 + .../personal-space/PersonalSpacePage.test.tsx | 541 +- .../personal-space/PersonalSpacePage.tsx | 511 +- .../personal-space/SpaceCollectionPages.tsx | 628 +++ .../components/SpaceAIConcierge.tsx | 796 +++ .../personal-space/components/SpaceChrome.tsx | 237 +- .../pmflow-os/components/AccountControls.tsx | 18 +- .../components/ExecutionBridgeCards.tsx | 30 +- .../components/FeedbackPrimitives.tsx | 4 +- .../pmflow-os/components/MobileBottomNav.tsx | 13 +- .../pmflow-os/components/PMFlowOsFrame.tsx | 392 +- .../components/ProjectStructuredLoop.tsx | 815 ++- .../features/pmflow-os/components/chrome.tsx | 17 +- frontend/src/features/pmflow-os/mockData.ts | 3 + .../pmflow-os/pages/ChangeReviewPage.tsx | 1096 +++- .../pmflow-os/pages/CommandCenterPage.tsx | 1023 +++- .../pmflow-os/pages/ExecutionBridgePage.tsx | 834 ++- .../pages/MemoryWritebackResultPage.tsx | 254 +- .../pages/ProjectMemoryCollectionPages.tsx | 555 ++ .../pmflow-os/pages/ProjectMemoryPage.tsx | 756 ++- .../pmflow-os/pages/ReleaseGrowthPage.tsx | 67 +- .../pages/StructuredExtractionPage.tsx | 438 +- .../pmflow-os/pages/TeamLayerPage.tsx | 623 ++- .../pmflow-os/pages/WorkGraphPage.tsx | 651 ++- .../features/pmflow-os/projectMemoryUtils.ts | 113 + .../src/features/pmflow-os/store.api.test.tsx | 559 +- frontend/src/features/pmflow-os/store.tsx | 297 +- frontend/src/features/pmflow-os/types.ts | 35 + .../components/LaunchCanvases.tsx | 1558 +++++- .../components/LaunchCanvasesFailure.test.tsx | 168 +- .../project-launch/components/LaunchShell.tsx | 146 +- .../components/MobileLaunchLayout.tsx | 100 +- frontend/src/features/project-launch/types.ts | 15 + .../useProjectLaunchState.test.ts | 152 +- .../project-launch/useProjectLaunchState.ts | 558 +- frontend/src/index.css | 14 + .../pages/AIRoutingCapabilitySlotsPage.tsx | 1 + .../src/pages/AIRoutingCostQuotaCachePage.tsx | 1 + .../pages/AIRoutingFailureDiagnosticsPage.tsx | 1 + frontend/src/pages/AIRoutingModuleMapPage.tsx | 1 + frontend/src/pages/AIRoutingProvidersPage.tsx | 1 + .../src/pages/AIRoutingSceneRoutesPage.tsx | 1 + .../src/pages/ai-config/AIConfigPage.test.tsx | 23 +- frontend/src/pages/ai-config/AIConfigPage.tsx | 56 +- .../ai-config/components/AISetupWizard.tsx | 145 + .../components/GovernanceToolsCard.tsx | 10 +- frontend/src/pages/ai-config/constants.ts | 61 +- .../pages/ai-config/sections/AdvancedTab.tsx | 8 +- .../pages/ai-routing/AIRoutingConsolePage.tsx | 289 +- .../ai-routing/AIRoutingOperationsPages.tsx | 4646 +++++++++++++++++ .../pages/ai-routing/AIRoutingPages.test.tsx | 934 +++- .../src/pages/ai-routing/TeamAIPolicyPage.tsx | 6 +- .../src/pages/ai-routing/sceneContracts.ts | 162 + frontend/src/pages/ai-steward/constants.ts | 4 +- .../pages/ai-steward/sections/EngineTab.tsx | 2 +- frontend/src/pages/login/LoginPage.test.tsx | 37 +- frontend/src/pages/login/LoginPage.tsx | 109 +- .../ModelRoutingConfigPage.test.tsx | 80 + .../ModelRoutingConfigPage.tsx | 281 +- .../src/pages/platform/AICallTracesPage.tsx | 708 +++ .../src/pages/platform/PlatformChrome.tsx | 296 ++ .../pages/platform/PlatformConsolePage.tsx | 132 + .../src/pages/platform/PromptAssetsPage.tsx | 1191 +++++ frontend/src/pages/projects/ProjectsPage.tsx | 164 +- .../QueryRouteGovernancePage.test.tsx | 15 +- .../QueryRouteGovernancePage.tsx | 6 +- .../SemanticFactsBoardPage.tsx | 8 +- .../pages/user-profile/UserProfilePage.tsx | 12 +- frontend/src/pages/users/UsersPage.tsx | 27 +- frontend/src/router/index.tsx | 175 +- .../router/legacyAIStewardRedirect.test.ts | 8 +- frontend/src/stores/auth.ts | 11 + 275 files changed, 51035 insertions(+), 3211 deletions(-) create mode 100644 backend/alembic/versions/20260428_add_phone_auth_identity.py create mode 100644 backend/alembic/versions/20260428_add_platform_memberships.py create mode 100644 backend/alembic/versions/20260428_add_saved_idea_enrichment.py create mode 100644 backend/alembic/versions/20260428_add_saved_ideas.py create mode 100644 backend/alembic/versions/20260428_add_workspace_memberships.py create mode 100644 backend/alembic/versions/20260428_repair_admin_yangyu_workspace_state.py create mode 100644 backend/alembic/versions/20260429_add_ai_call_log_provider_payloads.py create mode 100644 backend/alembic/versions/20260429_add_ai_call_trace_fields.py create mode 100644 backend/alembic/versions/20260429_add_prompt_quality_linkage.py create mode 100644 backend/alembic/versions/20260429_add_prompt_release_checks.py create mode 100644 backend/alembic/versions/20260429_rename_glm_provider_to_dashscope.py create mode 100644 backend/alembic/versions/20260430_add_prompt_replay_runs.py create mode 100644 backend/alembic/versions/20260430_extend_prompt_eval_replay.py create mode 100644 backend/alembic/versions/20260501_add_pmflow_launch_draft_jobs.py create mode 100644 backend/alembic/versions/20260503_add_pmflow_ai_feedback_events.py create mode 100644 backend/alembic/versions/20260505_add_pmflow_changeset_committer.py create mode 100644 backend/app/api/ai_call_traces_api.py create mode 100644 backend/app/api/ai_readiness_api/__init__.py create mode 100644 backend/app/api/prompt_assets_api.py create mode 100644 backend/app/api/space_api/__init__.py create mode 100644 backend/app/api/workspaces_api.py create mode 100644 backend/app/config/ai_function_runtime_profiles.py create mode 100644 backend/app/config/ai_prompt_eval_rubrics.py create mode 100644 backend/app/models/phone_verification_code.py create mode 100644 backend/app/models/platform_membership.py create mode 100644 backend/app/models/pmflow_ai_feedback.py create mode 100644 backend/app/models/saved_idea.py create mode 100644 backend/app/models/tenant_invitation.py create mode 100644 backend/app/models/tenant_membership.py create mode 100644 backend/app/schemas/prompt_assets.py create mode 100644 backend/app/schemas/space.py create mode 100644 backend/app/services/ai_call_trace_service.py create mode 100644 backend/app/services/ai_function_runtime_profile_service.py create mode 100644 backend/app/services/ai_readiness_service.py create mode 100644 backend/app/services/ai_routing_scene_contracts.py create mode 100644 backend/app/services/ai_scene_runtime_policy.py create mode 100644 backend/app/services/phone_auth_service.py create mode 100644 backend/app/services/platform_access_service.py create mode 100644 backend/app/services/pmflow_ai_feedback_service.py create mode 100644 backend/app/services/prompt_assets_service.py create mode 100644 backend/app/services/prompt_runtime_service.py create mode 100644 backend/app/services/space_saved_idea_service.py create mode 100644 backend/app/services/workspace_service.py create mode 100644 backend/tests/test_ai_call_traces_api.py create mode 100644 backend/tests/test_ai_readiness_api.py create mode 100644 backend/tests/test_phone_auth_api.py create mode 100644 backend/tests/test_prompt_assets_api.py create mode 100644 backend/tests/test_prompt_assets_service.py create mode 100644 backend/tests/test_prompt_runtime_service.py create mode 100644 backend/tests/test_space_saved_ideas_api.py create mode 100644 backend/tests/test_workspace_membership_api.py create mode 100644 frontend/public/brand/pmflow-app-icon.png create mode 100644 frontend/public/brand/pmflow-logo-lockup.png create mode 100644 frontend/public/brand/pmflow-mark.png create mode 100644 frontend/public/favicon.png create mode 100644 frontend/src/api/aiCallTraces.ts create mode 100644 frontend/src/api/aiReadiness.ts create mode 100644 frontend/src/api/promptAssets.ts create mode 100644 frontend/src/api/workspaces.ts create mode 100644 frontend/src/components/brand/PMFlowLogo.tsx create mode 100644 frontend/src/components/brand/PMFlowUserAvatar.tsx create mode 100644 frontend/src/components/layout/Layout.test.tsx create mode 100644 frontend/src/features/ai-readiness/components.tsx create mode 100644 frontend/src/features/ai-readiness/readiness.ts create mode 100644 frontend/src/features/ai-readiness/types.ts create mode 100644 frontend/src/features/personal-space/SpaceCollectionPages.tsx create mode 100644 frontend/src/features/personal-space/components/SpaceAIConcierge.tsx create mode 100644 frontend/src/features/pmflow-os/pages/ProjectMemoryCollectionPages.tsx create mode 100644 frontend/src/features/pmflow-os/projectMemoryUtils.ts create mode 100644 frontend/src/pages/AIRoutingCapabilitySlotsPage.tsx create mode 100644 frontend/src/pages/AIRoutingCostQuotaCachePage.tsx create mode 100644 frontend/src/pages/AIRoutingFailureDiagnosticsPage.tsx create mode 100644 frontend/src/pages/AIRoutingModuleMapPage.tsx create mode 100644 frontend/src/pages/AIRoutingProvidersPage.tsx create mode 100644 frontend/src/pages/AIRoutingSceneRoutesPage.tsx create mode 100644 frontend/src/pages/ai-config/components/AISetupWizard.tsx create mode 100644 frontend/src/pages/ai-routing/AIRoutingOperationsPages.tsx create mode 100644 frontend/src/pages/ai-routing/sceneContracts.ts create mode 100644 frontend/src/pages/model-routing-config/ModelRoutingConfigPage.test.tsx create mode 100644 frontend/src/pages/platform/AICallTracesPage.tsx create mode 100644 frontend/src/pages/platform/PlatformChrome.tsx create mode 100644 frontend/src/pages/platform/PlatformConsolePage.tsx create mode 100644 frontend/src/pages/platform/PromptAssetsPage.tsx diff --git a/.env.example b/.env.example index ebb4d250..6a71f9ed 100644 --- a/.env.example +++ b/.env.example @@ -16,8 +16,8 @@ PMFLOW_EXPECTED_DB_DIALECT=postgresql # Backend Runtime CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174 -LLM_PROVIDER=glm -LLM_FALLBACK_PROVIDER=glm +LLM_PROVIDER=dashscope +LLM_FALLBACK_PROVIDER=dashscope CONTROL_PLANE_ROUTING_ENABLED=true CONTROL_PLANE_ROUTING_RULES_PATH=backend/.control_plane/pmflow-routing-rules.json CONTROL_PLANE_AGENTS_DEFAULT_TIMEOUT_SECONDS=120 diff --git a/AGENTS.md b/AGENTS.md index 1420839d..8ac42da4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ 你是一位资深的全栈工程师,精通 Python 3.11+、FastAPI、SQLAlchemy 2.0、LangChain、React 18、TypeScript、shadcn/ui、Tailwind CSS。 -你正在为 **PMFlow** 开发代码:这是一款 AI 原生、人员能力驱动的项目管理操作系统,专为 10-50 人 AI 开发团队打造。 +你正在为 **PMFlow** 开发代码:这是一款 AI 原生、人员能力驱动的项目执行操作系统,首版面向个人开发者、一人公司、半技术产品创作者与 1-10 人 AI 产品小团队,采用 `solo-first, team-ready` 方向。 --- @@ -97,9 +97,14 @@ frontend/ # React 前端 - 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层 - MCP 模板接入中心只允许保存模板参数、命令预览、runtime probe 与“本机已执行”确认状态;后端 `backend_direct` 模板可由 PMFlow backend 直接校验/调用(包括远程 `streamable_http` MCP 与内建 `backend_adapter` runtime),本地 `stdio` 模板只能走 PMFlow Local Bridge 或兼容 MCP host;禁止从服务端直接写宿主机 `~/.codex/config.toml`、禁止替客户执行本机命令,GitHub 类模板只允许保存 env var 名称或占位符,不得落明文 token。 - 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState` -- 前端身份边界必须与真实写接口一致:`/space`、真实 `/project-launch`、`/me`、项目主链 standalone 与管理/治理入口必须登录;管理/治理入口需 OWNER/REVIEWER;`/project-launch?demo=1` 或 `?dataSource=demo` 是唯一匿名启动台演示入口,且只能使用本地 mock/demo,不得调用真实写接口。 +- 前端身份边界必须与真实写接口一致:`/space`、真实 `/project-launch`、`/me`、项目主链 standalone 与空间管理/平台运营入口必须登录;客户空间管理入口看当前 workspace role,平台运营入口看 `platform_context.capabilities`,禁止把客户空间 `OWNER/REVIEWER` 当成 PMFlow 平台管理员;`/project-launch?demo=1` 或 `?dataSource=demo` 是唯一匿名启动台演示入口,且只能使用本地 mock/demo,不得调用真实写接口。 +- 真实 `/project-launch` 的 AI 草案生成优先走任务化链路:`POST /pmflow-launch/draft-jobs` 创建生成任务,前端轮询 `GET /pmflow-launch/draft-jobs/{job_id}`;“补充一句”必须提交用户真实补充并生成新 revision,旧运行中 job 只能标记为 `superseded/cancelled` 或忽略其结果,禁止用前端硬编码补充、UI 暂停动画或旧同步请求冒充可暂停/续写的 AI 调用。 +- Project Launch、需求结构化、任务拆解等保护型结构化 AI 场景的超时必须来自显式场景运行策略并写入 `routing_meta`,包括 `scene_timeout_policy_id / input_bucket / provider_timeout_seconds / job_timeout_seconds / large_input_policy`;底层 transport 只消费该合同,不得靠散落常量或函数名私自决定。`structured_timeout` 必须作为模型超时单独归因和展示,禁止与 JSON 无效、空响应混成笼统“结构化失败”。 +- Project Launch 草案生成与项目主链“发送并提取”变更抽取的 `max_tokens / repair_max_tokens / temperature / response_mode / foreground_wait_seconds / provider_timeout_seconds / job_timeout_seconds / max_retries / large_input_policy` 必须来自 Function Runtime Profile active 发布版本;draft 只能在平台 AI Ops 的业务场景路由里编辑、校验、真实 smoke 和发布,运行时不得读取未发布 draft,也不得把散落常量作为唯一真源。`provider_timeout_seconds` 必须控制上游 HTTP client timeout,`job_timeout_seconds` 必须控制外层任务/调用超时失败,`max_retries` 必须控制 provider transient retry budget,`large_input_policy=preprocess_then_generate` 必须在 large 输入桶进入有界材料压缩后再渲染 prompt,`foreground_wait_seconds` 必须返回给前端作为前台等待窗口。 - 用户可见错误必须使用产品语义分类,禁止直接展示 Axios/FastAPI 原始错误(如 `Request failed with status code 401`、`Could not validate credentials`、traceback);401/无 token 必须表达为登录/会话问题,pending intent 只能恢复输入,禁止登录后自动重放真实写请求。 - 用户可见核心业务内容零兜底:AI 失败、证据不足、结构化失败时必须显式失败或标记待确认,禁止用默认句、模板句、stub 冒充成功。 +- PMFlow ChangeSet 写入边界:`accepted / edited / deferred` 只表示候选项已被人工处理但尚未写入 Project Memory;只要 ChangeSet 未 committed 且 ChangeItem 未 committed/skipped,就必须允许继续 accept/edit/defer。真正锁定边界是用户明确执行 ChangeSet commit / 确认写入项目记忆之后。 +- PMFlow 审查与执行边界:团队层只做责任路由、角色收件箱、团队动态、审查状态、阻塞解释和跳转,不直接执行 ChangeSet commit 或写 Project Memory;开发执行只能针对 Project Memory commit 后形成的正式 Task 发起 Execution Bridge handoff,禁止从 `pending_review` ChangeItem 或候选 Task 直接分派人/agent 执行。 - 业务语义、问答/执行归因与前台动作拍板必须优先走“对象/上下文绑定 -> `SemanticContextPacket` -> `SemanticDecision` -> gatekeeper”主链,禁止在 `backend/app` 运行时代码里恢复 `looks_like_*`、关键词仓或散落正则拍板。 - `query.answer` 路由与外部查询规划不得在运行时代码里维护 `_INTERNAL_ROUTE_OBJECTS / _EXTERNAL_ROUTE_OBJECTS / _SEMANTIC_QUERY_OBJECT_TYPES` 一类对象词表,也不得新增 `*_probe_is_strong / *_route_probe / query_kind(weather/news/company/market/model)` 这类二次语义拍板。`query_profile / source_policy_hint / SemanticDecision` 只能作为上游结构化输入;若证据不足,只能显式返回 `uncertain/clarify`,禁止在执行层靠对象集合、`subject_focus`、`needs_freshness`、`referenced_objects` 再次裁决 internal vs external。 - `llm_service` 运行时必须显式传入 `routing_meta`;禁止再通过函数名片段、hint 常量或模板词在底层推断 tier、结构化输出或 fallback 策略。 @@ -136,7 +141,7 @@ frontend/ # React 前端 ## 11. 已知 BOUNDARY(不要幻构行为) - 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用 -- `agents.defaults.timeoutSeconds` 在 PMFlow 本地路由层 网关行为未验证,当前以 `LLM_REQUEST_TIMEOUT` 环境变量为准 +- `agents.defaults.timeoutSeconds` 在 PMFlow 本地路由层 网关行为未验证;普通 LLM 调用仍以 `LLM_REQUEST_TIMEOUT` 为默认,已接入 Function Runtime Profile / 场景运行策略的保护型结构化调用以已发布合同里的显式 `provider_timeout_seconds` 为准。 - World Model 静态层已接入 Redis 缓存(key: `wm:static:{tenant_id}:{project_id}`,TTL 600s) - 写操作后主动 invalidate,动态层不缓存 diff --git a/CLAUDE.md b/CLAUDE.md index 556c0791..36e51bc4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,14 +75,22 @@ backend/ - `backend/app/config/ai_prompts.py`(`PROMPT_RAG_QUERY_SEMANTICS` 现在明确要求 internal domain 问答把 `resource_role / object_binding` 打出来;模型配置示例绑定 `config:model_routing`,MCP 配置示例绑定 `config:mcp_template_center`,功能/文档问法则分别绑定 `resource_role=feature/doc`) - `backend/app/services/adaptive_rag_service/mixin_followup_anchor.py` + `mixin_answer_mainline.py`(follow-up 澄清恢复主链:`query_route_selection / followup_anchor_selection` 现在先走统一结构化澄清解析,再把 `source_policy_hint / query_semantics_overrides / selected_qa_id` 透回主链;用户澄清“第二条/内部那个”后,不再靠本地词面去猜上一轮回复或 option) - `backend/app/services/integrations/feishu_signal_pipeline/mixin_intent_gate_core.py` + `mixin_context_prefill_a.py` + `mixin_context_prefill_b.py` + `mixin_serialization.py`(Feishu `project.create` 预填链现在统一吃 `_extract_project_prefill()` 的结构化 `suggested_name / suggested_description / suggested_scope`,不再在 runtime 用 `_extract_project_scope / _extract_project_intent_fields / _is_contextual_project_request / heuristic_*` 之类词面 helper 扣项目字段) - - `backend/app/api/rag_api/routes_route_governance.py`(manager-only 的 route/context 治理接口:`/rag/query-route-governance/summary|items|items/{id}`,只读暴露 `query_route_memories` 的 summary/list/detail) + - `backend/app/api/rag_api/routes_route_governance.py`(platform AI Ops 的 route/context 治理接口:`/rag/query-route-governance/summary|items|items/{id}`,只读暴露 `query_route_memories` 的 summary/list/detail) - `backend/app/services/external_query_service/`(external `query.answer` 主链:`External Query Planner -> External Query Gateway -> External Provider Registry -> MCP 优先 -> bounded fallback live search -> verifier -> ExternalContextPacket -> ExternalAnswerPresenter`;其中 `schemas.py` 负责结构化 plan/evidence/context packet,`presenter.py` 负责 external-only 回答生成。当前已额外支持“推荐外部信号源”问法:recommendation ask 的进入条件优先来自统一语义规划层透传到 `ExternalQueryPlan.interaction_level=recommendation` 的结构化层级,不再由 presenter 本地 cue list 拍板;命中后不再退回 `external_verifier_blocked` 拒答,而是先产出 `SignalSourceRecommendationPacket(candidates + caveat)`,再渲染成业务中文的 `top-N candidate + why`;recommendation 结果现在还会同步沉成可 carry-over 的 candidate set 绑定(`candidate_set_id + candidates + materialization payload`),供 reply-anchor follow-up 继续走 non-plan `signal_source.onboard` 直写链,不再默认 requirement draft;即使当前外部公开资料仍在校验,也只允许显式标成“推荐,不是已验证事实”,不能滑回架构腔或边界空话) - `backend/app/services/mcp_config/`(MCP 模板接入中心:catalog、bundle 持久化、后端直连 runtime probe / Microsoft Learn 检索桥接、Local Bridge 接入说明与本机确认状态) - `backend/app/services/semantic_fact_service/`(semantic facts 读模型、八类业务对象统一抽取/失活、ORM 提交钩子入队、治理看板查询) - - `backend/app/api/semantic_facts_api/`(semantic facts 治理后台接口:overview / objects / detail / reextract / backfill) + - `backend/app/api/semantic_facts_api/`(platform AI Ops 的 semantic facts 治理后台接口:overview / objects / detail / reextract / backfill) - `backend/app/api/mcp_api/`(MCP 模板接入中心后台接口:catalog / servers / validate / confirm-client-apply) - - `backend/app/api/ai_routing.py` + `backend/app/services/ai_routing_governance_service.py`(AI 路由与供应商产品化控制台:聚合 low/high 槽位、供应商摘要、团队策略、近期指标、只读诊断和审计;默认真源仍是 `backend/.control_plane/pmflow-routing-rules.json`,团队策略只保存到 `integrations(type='ai_team_policy')`,不得直接改 runtime 路由;作为团队级 AI 运营中心核心接口,在 `API_ROUTE_PROFILE=lean` 下也必须加载) - - `backend/app/api/model_routing.py`(AI 运营中心“高级路由详情”兼容入口,`API_ROUTE_PROFILE=lean` 下也必须加载) + - `backend/app/api/ai_routing.py` + `backend/app/services/ai_routing_governance_service.py`(AI 路由与供应商产品化控制台:团队策略接口属于客户空间 AI 使用设置;overview/admin-console/diagnostics/capability-slots 属于 platform AI Operations Console,要求 `platform.ai_routing.manage`;`backend/.control_plane/pmflow-routing-rules.json` 是 baseline rule pack,能力槽位保存到 `integrations(type='ai_capability_slots')`,但只有发布后的 `active.slots` 会被 runtime tier 解析消费,draft 只用于平台编辑/校验/发布;发布校验按 `SLOT_REQUIREMENTS` 检查供应商声明能力、`capability_probe` live evidence 与后端派生 `effective_capabilities`,缺能力、缺证据或证据过期返回 critical 并阻止 protected slot publish,长上下文声明窗口只算 `declared_only` 不算 verified,low 缺 probe 只 warning;团队策略只保存到 `integrations(type='ai_team_policy')`;在 `API_ROUTE_PROFILE=lean` 下也必须加载) + - `backend/app/config/ai_function_runtime_profiles.py` + `backend/app/services/ai_function_runtime_profile_service.py`(Function Runtime Profile 配置真源;当前将 Project Launch 草案生成与 PMFlow Change Review 变更抽取的生成预算、修复预算、temperature、响应模式、前台等待、provider/job 超时、最大重试、大输入策略、冒烟结果和发布版本保存到 `integrations(type='ai_function_runtime_profiles')`,业务场景路由只展示场景内“生成运行合同”,运行时只消费已发布 active profile,draft 必须先 validate + smoke 再 publish;active profile 会实际控制 HTTP provider timeout、外层任务/调用超时、transient retry budget、large 输入预处理和前端 foreground wait 窗口) + - `backend/app/api/ai_call_traces_api.py` + `backend/app/services/ai_call_trace_service.py` + `backend/app/services/ai_scene_runtime_policy.py` + `backend/app/models/ai_call_log.py`(platform AI Operations Console 的 AI 调用审计与场景超时策略入口;`GET /ai-call-traces*` 要求 `platform.ai_routing.manage`,只读 `ai_call_logs`,展示脱敏 prompt/response、provider/model/slot/routing meta、token/cost/latency、错误诊断和 `runtime_policy`;`structured_timeout` 必须独立展示为“模型超时”,不得与 JSON 无效/空响应混淆;`llm_service` 写入 `trace_id / user_id / provider / model / latency_ms / routing_meta / prompt_key / prompt_release_id / prompt_source`,并在 `provider_request_payload / provider_response_payload / provider_attempts` 保存真实上游 provider 请求、响应与重试尝试链;token 优先读取上游 `usage.prompt_tokens/completion_tokens/total_tokens`,没有 usage 时才使用兼容估算;`AISceneRuntimePolicyService` 为 Project Launch 等业务场景写入 `provider_timeout_seconds / job_timeout_seconds / input_bucket` 等显式策略;审计响应区分 raw provider code 与 `provider_label/runtime_label/engine_name`,并返回 `function_label/scene_label` 中文展示字段;DashScope 供应商编码统一为 `dashscope`,不得恢复旧 provider code;平台详情只展示完整脱敏 provider payload,不返回原始 headers 或密钥) + - `backend/app/api/prompt_assets_api.py` + `backend/app/services/prompt_assets_service.py` + `backend/app/services/prompt_runtime_service.py` + `backend/app/config/ai_prompt_registry.py` + `backend/app/config/ai_prompt_eval_rubrics.py`(platform AI Ops 的 Prompt 运营中心;`GET /prompt-assets*` 要求 `platform.ai_routing.manage`,聚合 registry、默认 seed prompt、active/draft `prompt_releases`、`prompt_release_checks` 门禁状态、分层 `prompt_golden_cases`、`prompt_replay_runs / prompt_replay_case_results`、调用质量摘要、跨客户空间 PMFlow OS `pmflow_ai_feedback_events` 用户反馈、`optimizer_candidates` 优化候选、调用点和迁移状态;`POST /prompt-assets/{prompt_key}/releases*` 允许 `managed_release` 创建/激活/回滚,允许 `guarded_release` 创建候选版本但激活必须先通过 variable contract / schema smoke / golden replay 门禁;release replay 支持 `contract_smoke` 与 `llm_compare`,LLM replay 通过 `llm_service` 和显式 `routing_meta` 走本地路由层,结果记录 baseline/candidate AI call log、judge score/label/reason 与风险标记;non-blocking golden case 失败只进入 warning/人工复核,blocking case 失败才阻塞发布;PMFlow OS 负反馈只生成待复核 `JudgeRun / OptimizerCandidate`,不得直接改 active prompt 或自动发布;`GET /prompt-assets/{prompt_key}/releases/{release_id}/watch-summary` 提供 release 级 24h/7d 质量观察和健康/复核/回滚建议;`inline_discovered` / `needs_migration` 不能冒充已配置生效;低风险运行时调用点通过 `PromptRuntimeService` 读取 active release 并把 prompt_key/release/source 写入 AI 调用日志,格式失败回退默认模板) + - `backend/app/api/llm_engines.py`(platform AI Operations Console 的“供应商与凭据”写入口,提供供应商新增/编辑/启用/删除/连通测试、能力矩阵声明 capabilities、registry-driven `capability_probe` runtime evidence、后端派生 `effective_capabilities` 与引擎设置;能力探测默认 full 覆盖 basic chat、结构化、JSON Schema、工具、流式和长上下文声明门禁,要求 `platform.ai_routing.manage`,`API_ROUTE_PROFILE=lean` 下也必须加载) + - `backend/app/api/model_routing.py`(platform AI Operations Console 的隐藏 Route Lab 诊断接口,要求 `platform.ai_routing.manage`,`API_ROUTE_PROFILE=lean` 下也必须加载;运营导航不暴露函数级编辑入口) + - `backend/app/api/auth.py` + `backend/app/services/phone_auth_service.py` + `backend/app/models/phone_verification_code.py`(手机号验证码登录、自注册与邀请绑定链路;`users.phone_e164` 是全局手机号身份,开发环境可返回 `dev_code` 便于本地验证) + - `backend/app/api/workspaces_api.py` + `backend/app/services/workspace_service.py` + `backend/app/models/tenant_membership.py` + `backend/app/models/tenant_invitation.py`(solo-first/team-ready 空间成员兼容层:数据库沿用 `tenants`,产品语义为个人空间/团队空间;注册自动创建个人空间,手机号邀请加入团队空间不剥夺个人空间,退出团队后回到个人空间;`backend/alembic/versions/20260428_repair_admin_yangyu_workspace_state.py` 负责历史 admin/yangyu 当前空间与旧团队空间数据修复) + - `backend/app/api/space_api/` + `backend/app/services/space_saved_idea_service.py` + `backend/app/schemas/space.py` + `backend/app/models/saved_idea.py`(Personal Space 暂存想法真实闭环;`saved_ideas` 是 pre-project idea seed,只保存未成型想法、原始输入、来源、准备度、缺失问题、结构化 enrichment 元数据和草案关联,不写 Project Memory、ChangeSet 或执行回写;创建/编辑原始输入时尝试 `space_saved_idea_enrichment_v1`,prompt key 为 `PROMPT_SAVED_IDEA_ENRICHMENT` 并经 `PromptRuntimeService` 读取平台 active release、写入 prompt 归因;AI 失败显式落 `ai_failed/system_baseline`;`promote` 只生成 Project Launch 草案并回到人工确认链,确认前不创建项目) + - `backend/app/models/platform_membership.py` + `backend/app/services/platform_access_service.py`(PMFlow 平台方账号平面:`platform_memberships` 和 env bootstrap 只给 PMFlow 内部 `PLATFORM_ADMIN / AI_OPS` 等角色发放 `platform.*` capability;客户空间 `OWNER/REVIEWER` 不等于平台管理员) - `backend/app/services/llm_service/`(显式 `routing_meta` 协议) - `backend/app/services/integrations/feishu_integration_service/`(飞书入站与卡片回调;`mixin_receive_routing.py` 的 `qa_direct` 现已支持 recommendation ask 直出推荐正文,并把原始 `Project database / signal_total / 当前盘面 / 建议动作 / External Freshness Guard` 这类内部/英文术语压成单行中文来源说明) - `backend/app/services/signal/`(信号接入、编排、planner、repair) @@ -95,6 +103,7 @@ backend/ ```text frontend/ ├── e2e/ # Playwright 真实浏览器 E2E 用例 +├── public/ # 静态资源;`brand/` 是 PMFlow Logo / favicon 源资产 ├── src/ │ ├── api/ # 接口封装 │ ├── features/ # 新产品域与原型状态机(如 PMFlow OS / 个人空间 / 新项目启动台) @@ -110,35 +119,43 @@ frontend/ └── vite.config.ts ``` - 当前前端重点入口优先看: + - `frontend/public/brand/` + `frontend/src/components/brand/PMFlowLogo.tsx` + `frontend/src/components/brand/PMFlowUserAvatar.tsx`(PMFlow Logo 源资产、统一读取组件与本地品牌头像;应用壳、营销页、登录视觉和 favicon 不再各自手绘旧标,账号头像不再依赖随机外链头像) - `frontend/src/api/`(统一 API 封装) - - `frontend/src/router/index.tsx`(顶层路由懒加载入口;登录页与 Layout 保持同步首屏;`/command-center` 保留免登录产品预览壳;`/space`、真实 `/project-launch`、`/pmflow-os`、`/change-review`、`/structured-extraction`、`/memory-writeback-result` 与 `/me` 均需登录;`/project-launch?demo=1` 或 `?dataSource=demo` 是唯一匿名启动台演示入口;管理/治理类页面统一走 OWNER/REVIEWER 权限边界) - - `frontend/src/auth/RouteBoundary.tsx`、`frontend/src/lib/apiError.ts`、`frontend/src/lib/pendingIntent.ts`(前端身份边界、产品化 API 错误分类与登录续接 pending intent 真源;用户可见 UI 禁止直接展示 Axios/FastAPI 原始 401/403/5xx 文案) -- `frontend/src/features/personal-space/`(个人空间首页;通过 `frontend/src/api/space.ts` 只读聚合项目摘要、PMFlow OS truth-state / execution-summary / execution-capabilities,并兼容消费旧 AI PMO `waiting_manual / draft_pending_confirm` 工作项,承载执行准备度、继续推进、待确认、AI 正在推进、草案箱、缺项目归属补全和最近项目入口) - - `frontend/src/features/project-launch/`(首次进入页 / 新项目启动台;默认调用真实 `frontend/src/api/pmflowLaunch.ts` draft/confirm API 创建项目,只有显式 `?demo=1` 或 `?dataSource=demo` 才启用本地 mock 演示状态机) + - `frontend/src/router/index.tsx`(顶层路由懒加载入口;登录页与 Layout 保持同步首屏;`/command-center` 保留免登录产品预览壳;`/space`、`/space/ideas`、`/space/drafts`、`/space/projects`、`/space/settings/members`、真实 `/project-launch`、`/pmflow-os`、`/change-review`、`/structured-extraction`、`/memory-writeback-result` 与 `/me` 均需登录;`/project-launch?demo=1` 或 `?dataSource=demo` 是唯一匿名启动台演示入口;旧 `/projects`、`/users*`、`/workloads*` 和 `/dashboard` 只做兼容跳转;空间管理入口走 workspace role,`/platform/*` 和平台治理页走 `platform_context.capabilities`) + - `frontend/src/auth/RouteBoundary.tsx`、`frontend/src/lib/rbac.ts`、`frontend/src/lib/apiError.ts`、`frontend/src/lib/pendingIntent.ts`(前端身份边界、workspace/platform capability 判断、产品化 API 错误分类与登录续接 pending intent 真源;用户可见 UI 禁止直接展示 Axios/FastAPI 原始 401/403/5xx 文案;pending intent 只能恢复输入或承接已生成的 Project Launch 草案,不得登录后自动重放真实写请求) +- `frontend/src/features/personal-space/`(个人空间首页与空间二级收件箱;`PersonalSpacePage.tsx` 通过 `frontend/src/api/space.ts` 聚合项目摘要、PMFlow OS truth-state / execution-summary / execution-capabilities 与真实 `GET /space/saved-ideas`,并兼容消费旧 AI PMO `waiting_manual / draft_pending_confirm` 工作项,首页只预览暂存想法、草案箱和最近项目的少量高优先级条目;`SpaceCollectionPages.tsx` 承接 `/space/ideas` 暂存想法二级页和 `/space/drafts` 草案箱二级页,支持筛选、搜索、补充、归档、转 Project Launch 草案或回主流程确认;暂存想法只能推进到 Project Launch 草案确认链,不直接写项目真相层) + - `frontend/src/features/project-launch/`(首次进入页 / 新项目启动台;默认调用真实 `frontend/src/api/pmflowLaunch.ts` draft job/revise/confirm API 创建项目,只有显式 `?demo=1` 或 `?dataSource=demo` 才启用本地 mock 演示状态机) - `frontend/src/features/account-settings/`(`/me` 个人中心与设置;通过 `frontend/src/api/accountSettings.ts` 管理个人资料、默认语言、AI 回答深度、证据展示偏好和通知偏好,移动端使用分组列表与 bottom sheet,不进入 auth core) - `frontend/src/features/pmflow-os/`(PMFlow OS 前端产品域;共享 truth store 已支持 `?projectId=` 读取真实后端 projection,真实模式下 message、`proposed_items` extraction、ChangeItem `accept/edit/defer`、ChangeSet `commit`、review decision、Team reviewer routing、单条 iteration recommendation -> ChangeSet draft 与同组 iteration recommendation 批量 -> ChangeSet draft 均调后端,显式 `?demo=1` 才启用本地演示状态机) - - `backend/app/api/pmflow_os_api/` + `backend/app/services/pmflow_os_projection_service.py` + `backend/app/services/pmflow_change_review_service.py` + `backend/app/schemas/pmflow_os.py` + `backend/app/models/pmflow_change_review.py`(PMFlow OS A3/C1/C2/P8 真实路径;提供 `truth-state`、`execution-summary`、thread message、`proposed_items` extraction、ChangeSet detail、ChangeItem `accept/edit/defer`、ChangeSet `commit`、review decision、execution handoff/progress writeback、release validation run、release feedback ingest、Team reviewer routing、recommendation change-set draft 与 recommendation group change-set draft;复用 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync / FeedbackIngest / IterationRecommendation` 并投影 `pmflow_threads / pmflow_thread_messages / pmflow_change_sets / pmflow_change_items / pmflow_approvals / pmflow_evidence_links`;`truth-state.reviewerRouting` 是 `{status, warningCode, summary, items}` 合同,生成建议审查人/原因/来源/风险/跳转 URL,并投影 `reviewDecision / approvalState / requestedAt / decidedAt / decisionReason`;`POST /pmflow-os/change-sets/{change_set_id}/review-decisions` 支持 request_review/approve/request_changes/reject,复用 `PMFlowApproval` 审计,不自动 commit;C2 commit 默认 atomic,`ModelDecision / MetricSignal` 暂 fail-closed) - - `backend/app/api/pmflow_launch_api/` + `backend/app/services/pmflow_launch_service.py` + `backend/app/schemas/pmflow_launch.py` + `backend/app/models/pmflow_launch.py`(Project Launch Phase B 真实创建链;`drafts` 只写 `pmflow_launch_drafts`,`confirm` 在同一事务写真实 Project、启动线程、启动消息、初始 truth_nodes、tasks 与 semantic_facts seed);`backend/app/api/legacy_ai_steward_api.py` 仅保留旧文本创建接口的 410 迁移响应,并在 lean profile 下始终挂载 + - `backend/app/api/pmflow_os_api/` + `backend/app/services/pmflow_os_projection_service.py` + `backend/app/services/pmflow_change_review_service.py` + `backend/app/schemas/pmflow_os.py` + `backend/app/models/pmflow_change_review.py`(PMFlow OS A3/C1/C2/P8 真实路径;提供 `truth-state`、`execution-summary`、thread message、`proposed_items` extraction、ChangeSet detail、ChangeItem `accept/edit/defer`、ChangeSet `commit`、review decision、execution handoff/progress writeback、release validation run、release feedback ingest、Team reviewer routing、recommendation change-set draft 与 recommendation group change-set draft;项目主链“发送并提取”使用独立 `change_review` protected scene、`pmflow_change_review_extraction_v1` Function Runtime Profile 与 `PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION`,不再复用 Project Launch draft prompt/function;复用 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync / FeedbackIngest / IterationRecommendation` 并投影 `pmflow_threads / pmflow_thread_messages / pmflow_change_sets / pmflow_change_items / pmflow_approvals / pmflow_evidence_links`;`truth-state.reviewerRouting` 是 `{status, warningCode, summary, items}` 合同,生成建议审查人/原因/来源/风险/跳转 URL,并投影 `reviewDecision / approvalState / requestedAt / decidedAt / decisionReason`;`POST /pmflow-os/change-sets/{change_set_id}/review-decisions` 支持 request_review/approve/request_changes/reject,复用 `PMFlowApproval` 审计,不自动 commit;C2 commit 默认 atomic,`ModelDecision / MetricSignal` 暂 fail-closed) + - `backend/app/api/pmflow_launch_api/` + `backend/app/services/pmflow_launch_service.py` + `backend/app/schemas/pmflow_launch.py` + `backend/app/models/pmflow_launch.py`(Project Launch Phase B 真实创建链;`draft-jobs/revise` 写 `pmflow_launch_draft_jobs` 并任务化生成草案,兼容 `drafts` 只写 `pmflow_launch_drafts`,`confirm` 在同一事务写真实 Project、启动线程、启动消息、初始 truth_nodes、tasks 与 semantic_facts seed);`backend/app/api/legacy_ai_steward_api.py` 仅保留旧文本创建接口的 410 迁移响应,并在 lean profile 下始终挂载 - `docs/architecture/PMFLOW_OS_PRODUCT_CONTRACT.md`(PMFlow OS 产品合同真源;真实后端接入前先读,锁定本地 truth store 对象与 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync` 的映射;Phase C1 review 表和审查 API 已实现,Phase C2 backend/frontend commit 已实现) - `frontend/src/pages/ai-steward/`(历史 AI Steward 页面迁移占位;`/ai-steward` 只做 legacy redirect,不再作为产品入口) - - `frontend/src/components/ai-steward/`(问答浮窗、状态条、回答元卡片;`FloatingQAChat.tsx` 现会透传并回放 `project_operating_context / execution_recipe / fit_brief / external_recommendation / analysis_packet / recommended_next_step`,`QAConversationStatusBar.tsx` 与 `QAResponseMetaCard.tsx` 负责显性展示) - - `frontend/src/pages/ai-config/AIConfigPage.tsx` + `frontend/src/pages/ai-config/constants.ts`(团队级 AI 运营中心;`/ai-config` 技术路由保留,用户可见命名为“AI 运营中心”,首页按策略 / 连接 / 治理 / 高级分组组织入口) - - `frontend/src/pages/ai-routing/` + `frontend/src/api/aiRouting.ts`(AI 运营中心 -> 团队 AI 策略与 AI 路由/供应商;负责人看业务策略,管理员看供应商连接、快速/深度槽位、诊断和高级详情,成员无独立入口) - - `frontend/src/pages/ai-config/mcp-config/`(AI 运营中心 -> MCP 模板接入中心) - - `frontend/src/pages/query-route-governance/`(AI 运营中心 -> route/context 治理页) - - `frontend/src/pages/semantic-facts-board/`(AI 运营中心 -> semantic facts 治理页) + - `frontend/src/components/ai-steward/`(AI 问答共享展示组件与旧问答浮窗兼容代码;当前产品壳不再全局挂载 `FloatingQAChat`,空间页由 `frontend/src/features/personal-space/components/SpaceAIConcierge.tsx` 承接入口,并复用 `QAMarkdownContent` 等展示能力) + - `backend/app/api/ai_readiness_api/` + `backend/app/services/ai_readiness_service.py` + `frontend/src/api/aiReadiness.ts` + `frontend/src/features/ai-readiness/`(空间级 AI 就绪合同;由 `/space`、真实 `/project-launch`、项目级 `AI 副驾`、`/ai-config?tab=setup` 与 `/platform/*` 共同消费;Project Launch readiness 默认由后端从平台运行租户供应商、published active `structured` 槽、scene registry、能力证据和最近 24h `pmflow_launch_draft_v1` 日志推导;单次最近生成失败只作为平台 warning,24h 内已有成功且连续失败少于 3 次时用户端仍保持可生成,连续失败达到阈值才进入 recovering;本机状态只允许 test 或 `VITE_AI_READINESS_LOCAL_OVERRIDE=true` 覆盖;平台 AI Ops 可触发不写草案/项目的结构化 smoke) + - `frontend/src/pages/ai-config/AIConfigPage.tsx` + `frontend/src/pages/ai-config/constants.ts` + `frontend/src/pages/ai-config/components/AISetupWizard.tsx`(空间级 AI 使用设置;`/ai-config` 及其子路由必须使用空间配置壳 `SpaceSidebar + SpaceTopBar`,不得切入项目工作台侧栏;`/ai-config?tab=setup` 是 AI 可用状态与项目生成能力门禁入口,连接、知识、团队策略和 Prompt 策略继续承载客户空间 AI 使用治理;平台供应商、全局路由、route/context、semantic facts 不在客户空间暴露,只有带 `platform_context.capabilities` 的平台 AI Ops 才能看到供应商/路由/诊断 CTA) + - `frontend/src/pages/ai-routing/` + `frontend/src/api/aiRouting.ts` + `frontend/src/api/aiSteward.ts` + `backend/app/services/ai_routing_scene_contracts.py`(`TeamAIPolicyPage.tsx` 是客户空间负责人策略视图;PMFlow 平台 AI Ops 运行总览已合并进 `/platform`,旧 `/platform/ai-routing-console` 只做兼容跳转;AI 运行层保留明确子菜单:`/platform/ai-routing-providers` 供应商与凭据、`/platform/ai-routing-slots` 能力槽位、`/platform/ai-routing-scenes` 业务场景路由、`/platform/ai-routing-costs` 成本/配额/缓存、`/platform/ai-routing-failures` 失败诊断;业务场景路由是 Scene Contract & Impact Console,只读展示后端 `scene_contract`、场景健康度、release gate、active/draft runtime、能力缺口、发布影响、筛选排序和确定性修复动作,不在运营页展开函数名;`sceneContracts.ts` 仅作为后端合同缺失时的前端 fallback;供应商页展示并编辑 capabilities 能力矩阵,能力槽位页展示 active 当前版本、draft 编辑区、发布校验、发布阻塞、能力覆盖/缺口与回滚动作;业务场景路由的修复动作会 deep-link 到槽位、供应商和失败诊断页并自动聚焦 scene/slot/capability;共享实现集中在 `AIRoutingOperationsPages.tsx`,继续只消费脱敏 `admin-console` 聚合数据) + - `frontend/src/pages/platform/AICallTracesPage.tsx` + `frontend/src/api/aiCallTraces.ts`(PMFlow 平台 AI Ops 的 AI 调用审计页面;入口 `/platform/ai-call-traces` 位于“质量与事件层”,支持按状态、供应商、模型、函数、业务场景、能力槽位、prompt_key、prompt_source、trace、时间窗口和关键词筛选,列表展示脱敏预览,详情展示 prompt 版本归因、完整脱敏 provider 请求/响应 payload、上游尝试链、失败归因、场景 timeout 策略和建议动作;prompt-attributed 详情可一键沉淀 Golden case;客户空间、Space、Project Launch 和项目级 AI 副驾不得暴露该入口) + - `frontend/src/pages/platform/PromptAssetsPage.tsx` + `frontend/src/api/promptAssets.ts`(PMFlow 平台 AI Ops 的 Prompt 运营中心;入口 `/platform/prompt-assets` 位于“质量与事件层”,展示 prompt key、业务场景、family、风险、发布策略、默认模板、active release、draft 数、门禁状态、最新 replay、调用点和迁移缺口;详情弹层承载基础信息、模板 Diff、版本历史、发布门禁、回放评审、质量归因和运行信息,`managed_release` 可创建草稿/激活/回滚,`guarded_release` 可创建候选、运行契约回放或 LLM 回放并通过门禁后激活;回放结果展示 judge score/label/reason、baseline/candidate call id、风险标记,右侧发布概览展示 release watch 建议;质量归因页可查看 7 天调用、失败、默认回退、成本估算、PMFlow 用户赞同/不赞同反馈、负反馈原因、优化候选、失败样本 deep-link,并从失败调用生成带 case_type/priority/scenario/tags 的 golden case;待迁移项只读并展示原因;客户空间、Space、Project Launch 和项目级 AI 副驾不得暴露 prompt 正文) + - `frontend/src/pages/model-routing-config/`(隐藏 Route Lab;入口 `/platform/route-lab`,旧 `/platform/model-routing-config`、`/ai-config/model-routing-config` 与裸 `/model-routing-config` 仅兼容跳转;页面只读展示函数级规则、最近执行与历史 override 痕迹,不在平台侧栏中暴露) + - `frontend/src/pages/ai-config/mcp-config/`(AI 使用设置 -> MCP 模板接入中心) + - `frontend/src/pages/platform/PlatformChrome.tsx` + `frontend/src/pages/platform/PlatformConsolePage.tsx`(PMFlow 平台 AI Operations Console 专属应用壳与首页;`/platform/*` 不复用旧项目工作台侧栏/顶部条,侧栏按“运行总览 / AI 运行层 / 工具与外部能力层 / 上下文与语义层 / 质量与事件层”组织,只展示当前 platform capability 可进入的运营工具;旧 `/ai-config/*` 平台诊断路径只做兼容跳转) + - `frontend/src/pages/query-route-governance/`(platform AI Operations Console -> route/context 治理页) + - `frontend/src/pages/semantic-facts-board/`(platform AI Operations Console -> semantic facts 治理页) - `frontend/src/components/layout/LazyBoundary.tsx`(统一 route/page-section/dialog 级 loading skeleton) - - `frontend/src/components/login/`(公开登录页组件目录;`LoginExperience.tsx` 负责双栏登录壳,`LoginWorkflowVisual.tsx` 负责左侧场景,`loginScene.ts` / `loginMath.ts` 拆出展示配置与路径几何,`login.css` 收口登录页专属样式) + - `frontend/src/components/login/`(公开登录页组件目录;`LoginExperience.tsx` 负责双栏登录壳,`LoginForm.tsx` 默认承接手机号验证码登录/自注册,用户名密码仅作为兼容入口,`LoginWorkflowVisual.tsx` 负责左侧场景,`loginScene.ts` / `loginMath.ts` 拆出展示配置与路径几何,`login.css` 收口登录页专属样式) - `frontend/src/api/queryRouteGovernance.ts`(route/context 治理页前端 API) - `frontend/src/pages/scheduler-center/`(Mounted AI Console) - `frontend/src/components/sstc/`(SSTC 工作流组件) - `frontend/src/pages/user-skills/components/UserSkillsRadarPanel.tsx` 与 `frontend/src/pages/workload-detail/components/WorkloadTrendChart.tsx`(图表按页面内二级 chunk 拆分后的入口) - 页面目录化、Mounted AI Console、AI Steward QA 组件与 Recipes/Projects/Users 等模块化拆分的详细库存见库存文档;旧独立收件页已退场,待处理能力收敛到 `/space`、`/change-review`、`/team-layer` 与 `/me`。 -- 顶层路由已改为 `React.lazy + Suspense`,统一 fallback 使用 `frontend/src/components/layout/LazyBoundary.tsx`;`/ai-steward` 只保留迁移重定向,`tab=create` 迁到 `/project-launch`,`tab=engine / tab=feedback-center` 迁到 `/ai-config`,其它 legacy tab 迁到 `/space`;AI 运营中心页面按 active tab 只渲染当前可见区块,Projects/Users/图表页则把重对话框与图表从同步入口拆出。 -- `frontend/src/features/project-launch/` 是新项目启动台独立产品域,也是新建项目唯一用户入口:`types.ts / useProjectLaunchState.ts` 承载状态合同和真实 API 编排,真实模式必须登录并调用 `POST /pmflow-launch/drafts` 生成可审查草案,确认后调用 `POST /pmflow-launch/drafts/{draft_id}/confirm` 创建真实项目并跳转 `/pmflow-os?projectId=`;旧 `POST /ai-steward/project-from-text` 只返回 410 迁移错误,不得绕过草案确认直接写正式对象;`mockData.ts` 只在显式 `?demo=1` 或 `?dataSource=demo` 匿名演示模式下使用。401/无 token 不得展示 `Request failed with status code 401` 或归类为网络异常,必须保存输入到 pending intent 并提示重新登录后继续。`LaunchShell` 桌面端复用 `frontend/src/features/personal-space/components/SpaceChrome.tsx` 的空间左侧栏和顶部条,主画布仍由 `IntentInputPanel / AIProgressCanvas / HumanReviewCanvas / DraftEditCanvas / FailureStatePanel / SuccessTransitionPanel` 承接,移动端仍由 `MobileLaunchLayout` 承接;`frontend/src/pages/ProjectLaunchPage.tsx` 仅做薄入口。 -- `frontend/src/features/personal-space/` 是登录后的空间层入口:`PersonalSpacePage.tsx` 承接桌面个人工作面与独立移动单手布局,`frontend/src/api/space.ts` 只读聚合 `projects` summary、PMFlow OS `truth-state`、`execution-summary`、`execution-capabilities`,并兼容消费旧 AI PMO `waiting_manual / draft_pending_confirm` 工作项,生成 `nextActionQueue` 我的下一步队列、执行准备度、我的今日容量、继续推进、待确认、review attention、发布关注、发布后反馈关注、下一轮迭代草稿关注、AI 正在推进、草案箱、缺项目归属补全、最近项目摘要;未登录访问 `/space` 必须跳登录,不展示个人空间语义。Space P6 的下一步队列按高风险待确认、发布阻塞/失败证据、handoff consumed 后缺少后续 progress、下一轮 draft、发布后反馈/收入/合规信号排序;Space P7 新增 `review_attention`,只读消费 `truth-state.reviewerRouting`,按高风险待我审、`request_changes / rejected` 后待处理、`reviewer_missing` warning、普通 pending review 排序进入“我的下一步”;旧 `missing_project_scope` 只允许在 Space 选择项目后复用 `POST /ai-pmo/work-items/{id}/decision` 继续既有审计链,不新增旁路接口,不直接写项目真相层。动作只跳 `/change-review?projectId=&changeSetId=`、已有 `/team-layer?projectId=`、`/execution-bridge?projectId=`、`/release-growth?projectId=` 或复用旧工作项决策 API;执行准备度只读展示工具连接、待确认、待审查、待验收和阻塞原因,今日容量只读展示有效容量、人工触点、剩余容量、动态建议结构、系统解释和 AI 预处理可释放时间,不写项目业务对象;Space P3/P4/P5 仍只读消费 release validation 与 releaseSignals;不在 Space 写 Project Memory、PMFlowApproval、ProgressEvent、ReleaseValidationRun、FeedbackIngest、RevenueEvent、ComplianceCheckRun、ChangeSet 状态、容量 override 或发布执行,不虚构 reviewer、审查意见、待审 ChangeSet、工具连接、执行能力或容量计划。`components/SpaceChrome.tsx` 承接空间层左侧栏、顶部条与空间用量组件,并被 `/project-launch` 桌面首次新建壳复用;`frontend/src/pages/PersonalSpacePage.tsx` 仅做薄入口。 +- 顶层路由已改为 `React.lazy + Suspense`,统一 fallback 使用 `frontend/src/components/layout/LazyBoundary.tsx`;`/ai-steward` 只保留迁移重定向,`tab=create` 迁到 `/project-launch`,`tab=engine` 迁到 `/ai-config`,`tab=feedback-center` 有 `project_id` 时迁到 `/release-growth?projectId=`、否则迁到 `/space`,其它 legacy tab 迁到 `/space`;AI 使用设置页面按 active tab 只渲染当前可见区块,Projects/Users/图表页则把重对话框与图表从同步入口拆出。 +- `frontend/src/features/ai-readiness/` 是空间级 AI 就绪状态前端合同:`types.ts` 定义状态与 blocker,`readiness.ts` 负责角色权限、后端 readiness 查询、test/dev override 和 `useAIReadiness()`,`components.tsx` 提供顶部状态胶囊与卡片。真实门禁以 `GET /ai-readiness/project-launch` 为准;localStorage 只允许测试或显式本地覆盖,不再作为生产 readiness 真源。 +- `frontend/src/features/project-launch/` 是新项目启动台独立产品域,也是新建项目唯一用户入口:`types.ts / useProjectLaunchState.ts` 承载状态合同和真实 API 编排,真实模式必须登录并优先调用 `POST /pmflow-launch/draft-jobs` 创建 AI 草案生成任务,轮询 `GET /pmflow-launch/draft-jobs/{job_id}` 获取完成草案,确认后调用 `POST /pmflow-launch/drafts/{draft_id}/confirm` 创建真实项目并跳转 `/pmflow-os?projectId=`;“补充一句”通过真实输入弹层创建 `merge_supplement` revision,已有草案时调用 `POST /pmflow-launch/drafts/{draft_id}/revise`,运行中的旧 job 由后端标记 `superseded`,禁止硬编码追加补充句或用 UI 暂停动画冒充 AI 可暂停/续写;旧 `POST /ai-steward/project-from-text` 只返回 410 迁移错误,不得绕过草案确认直接写正式对象;`mockData.ts` 只在显式 `?demo=1` 或 `?dataSource=demo` 匿名演示模式下使用,真实模式的进度、操作流、审查建议、附件引用和计数必须来自当前输入、后端 job/草案、warnings/evidence,不得展示 DevWeekly 周报 demo 内容。401/无 token 不得展示 `Request failed with status code 401` 或归类为网络异常,必须保存输入到 pending intent 并提示重新登录后继续。真实模式下若 `AIReadiness.canGenerateProjectDraft=false`,`LaunchShell` 渲染 `AISetupRequiredPanel`,用户端默认只看到“先记录项目想法,AI 草案生成暂不可用”的客户视图;空间负责人可查看空间 AI 状态,平台 AI Ops 可通过显式平台诊断卡查看具体 cause/CTA;不提供空项目创建入口,避免在缺少结构化上下文时提前创建项目壳;低信息输入(如纯问候、缺少产品/用户/问题线索)由后端 `input_needs_more_info` 422 门禁拦截并回到补充输入态;demo 模式不受该门禁影响。`LaunchShell` 桌面端复用 `frontend/src/features/personal-space/components/SpaceChrome.tsx` 的空间左侧栏和顶部条,主画布仍由 `IntentInputPanel / AIProgressCanvas / HumanReviewCanvas / DraftEditCanvas / FailureStatePanel / SuccessTransitionPanel` 承接,移动端仍由 `MobileLaunchLayout` 承接;`frontend/src/pages/ProjectLaunchPage.tsx` 仅做薄入口。 +- Project Launch 的“保存为暂存想法”必须调用 `POST /space/saved-ideas` 写真实 `saved_ideas`,成功后 `/space#saved-ideas` 可见;它只保存 pre-project idea,不生成草案、不创建项目、不写项目记忆。保存/完善时会优先用 `PROMPT_SAVED_IDEA_ENRICHMENT` 走 Prompt 运营中心 active release 做结构化完整度判断,返回 `enrichment_status / enrichment_payload / missing_questions / readiness_score`;问候、测试词或无实质想法输入必须判为 `needs_more_info` 和 0 准备度;AI 不可用时只能显式显示系统初判,不冒充 AI 结论。`/space` 里点击“生成项目草案”才调用 `POST /space/saved-ideas/{idea_id}/promote`,生成 draft 后通过 pending intent 进入 Project Launch `human_review`,仍需用户确认才创建正式项目。 +- `frontend/src/features/personal-space/` 是登录后的空间层入口:`PersonalSpacePage.tsx` 承接桌面个人工作面与独立移动单手布局,`frontend/src/api/space.ts` 只读聚合 `projects` summary、PMFlow OS `truth-state`、`execution-summary`、`execution-capabilities`,并兼容消费旧 AI PMO `waiting_manual / draft_pending_confirm` 工作项,生成 `nextActionQueue` 我的下一步队列、执行准备度、我的今日容量、继续推进、待确认、review attention、发布关注、发布后反馈关注、下一轮迭代草稿关注、AI 正在推进、草案箱、缺项目归属补全、最近项目摘要;这些空间聚合以后端项目可见性为准,`OWNER/REVIEWER` 看租户级团队视图,`BUILDER/OPERATOR` 只看本人拥有或 `user_projects` 绑定项目,legacy 工作项只看本人拥有或本人项目绑定项。未登录访问 `/space` 必须跳登录,不展示个人空间语义。Space 首屏消费 `frontend/src/features/ai-readiness/` 双层合同:用户端默认显示客户可理解的 AI 可用状态、业务影响和可继续动作,普通成员/空间负责人/平台人员进入用户端都不默认看到供应商、路由、连接测试、模型槽位、trace 或函数名;空间负责人额外看到空间 AI 状态、团队策略和工具连接入口;平台 AI Ops 仅通过显式平台诊断卡或 `/platform/*` 查看具体 cause/CTA。顶部条展示统一 readiness pill,管理员侧边栏显示 `/ai-config?tab=setup` 与 `/space/settings/members` 入口;`/space/projects` 是所有项目视图,旧 `/projects` 只做兼容跳转。`components/SpaceAIConcierge.tsx` 是空间级 AI 管家入口:只围绕个人空间、跨项目下一步、待确认、执行中、草案、暂存想法和 AI 能力状态工作,通过 `/space/ai-concierge/*` 产品接口注入空间上下文包并复用 QA 历史/会话能力;前端不得直接依赖 optional `/rag/*` 路由,历史读取失败只影响历史视图,不阻断新会话提问;真实写操作只能给跳转或待确认建议,不声称已执行。全局 `Layout` 不再挂载旧 `FloatingQAChat` 悬浮球,项目页仍使用项目级 `AI 副驾`。Space 左侧导航只承载稳定空间目的地,当前保留“空间首页”“所有项目”和管理员“AI 使用设置 / 成员与权限”;最近项目、暂存想法、草案箱、待我确认、AI 正在推进属于空间首页内部工作台模块,不作为一级侧栏导航。Space P6 的下一步队列按高风险待确认、发布阻塞/失败证据、handoff consumed 后缺少后续 progress、下一轮 draft、发布后反馈/收入/合规信号排序;Space P7 新增 `review_attention`,只读消费 `truth-state.reviewerRouting`,按高风险待我审、`request_changes / rejected` 后待处理、`reviewer_missing` warning、普通 pending review 排序进入“我的下一步”;旧 `missing_project_scope` 只允许在 Space 选择项目后复用 `POST /ai-pmo/work-items/{id}/decision` 继续既有审计链,不新增旁路接口,不直接写项目真相层。动作只跳 `/change-review?projectId=&changeSetId=`、已有 `/team-layer?projectId=`、`/execution-bridge?projectId=`、`/release-growth?projectId=` 或复用旧工作项决策 API;执行准备度只读展示工具连接、待确认、待审查、待验收和阻塞原因,今日容量只读展示有效容量、人工触点、剩余容量、动态建议结构、系统解释和 AI 预处理可释放时间,不写项目业务对象;Space P3/P4/P5 仍只读消费 release validation 与 releaseSignals;不在 Space 写 Project Memory、PMFlowApproval、ProgressEvent、ReleaseValidationRun、FeedbackIngest、RevenueEvent、ComplianceCheckRun、ChangeSet 状态、容量 override 或发布执行,不虚构 reviewer、审查意见、待审 ChangeSet、工具连接、执行能力或容量计划。`components/SpaceChrome.tsx` 承接空间层左侧栏、顶部条与空间用量组件;`/project-launch` 桌面首次新建壳复用同一空间 Chrome,但不在左侧展示工作台区块 badge;`frontend/src/pages/PersonalSpacePage.tsx` 仅做薄入口。 - `frontend/src/features/account-settings/` 是 Account Settings 独立产品域:`AccountSettingsPanel.tsx` 承接 `/me` 桌面设置页、移动端分组列表与 bottom sheet,`frontend/src/api/accountSettings.ts` 定义个人资料、默认语言、AI 回答深度、证据展示偏好和四类通知偏好合同;当前已有账号同步 API fallback、本地 UI 通知消费模型、Release/Growth 偏好 helper、post-release / next change review 偏好 helper、`getAccountReminderConsumption(settings, event)` 提醒强度消费合同,以及 `getReviewDecisionNotificationPreference(settings, event)` review decision 偏好合同;支持 `reminderIntensity=silent|normal|prominent` 与 `evidenceDisplay=compact|expanded`,并覆盖 `review_requested / review_approved / review_changes_requested / review_rejected / high_risk_approval_required`;`releaseGate / failureAlert / pendingConfirmation / executionWriteback` 只影响页面内提醒表达强度和证据展开方式,不代表真实邮件、桌面推送或系统通知中心已接入,也不得隐藏真实业务证据、reviewerRouting、approval 或审计状态,不改 OAuth/JWT/auth core;`frontend/src/features/pmflow-os/pages/AccountPage.tsx` 仅做薄入口并复用既有退出登录 bottom sheet。 -- `frontend/src/features/pmflow-os/` 当前承载新的对话驱动前端产品域:`store.tsx` 提供共享 truth store;真实模式通过 `?projectId=` 调用 `frontend/src/api/pmflowOs.ts` 读取后端 `ProjectTruthState` projection,并调用真实 message、`proposed_items` extraction、ChangeItem `accept/edit/defer`、ChangeSet `commit`、review decision、Execution Handoff create/consume、execution progress writeback、release validation run create、release feedback ingest、单条 iteration recommendation -> ChangeSet draft、同组 iteration recommendation 批量 -> ChangeSet draft、capacity-plan 与 capacity override simulate/apply API,成功后 refetch truth-state / execution-summary / capacity plan;显式 `?demo=1` 才启用本地演示状态机。`pages/` 下包含 `CommandCenter / StructuredExtraction / ChangeReview / MemoryWritebackResult / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主页面;`ChangeReviewPage` 真实模式可请求审查、通过、要求修改或拒绝当前 ChangeSet,展示审查状态、建议 reviewer、审查意见和 high-risk approval gate,但不自动 commit;`TeamLayerPage` 真实模式读取 `state.reviewerRouting`、`execution-capabilities` 与 `capacity-plan`,展示待审 ChangeSet、建议 reviewer、审查状态、执行能力、个人执行准备度、工具准备度、团队今日容量、动态基线、瓶颈、AI 预处理建议、负责人模拟/应用容量 override 与调整记录;capacity override 只写审计,不自动分派、不自动审查、不写 ChangeSet;`ExecutionBridgePage` 已支持创建 handoff、运行端领取确认和执行进度回写;`ReleaseGrowthPage` 真实模式展示 `execution-summary.releaseGate`、execution evidence、阻塞原因、下一步动作、`latestValidationRun` 和 `releaseSignals`;静态发布/增长原型仅保留在显式 demo 模式;后端只记录 review decision/progress/release gate/release validation/release feedback/recommendation draft/capacity override 元数据,不执行本机命令、真实发布命令、外部支付、真实推送、自动 Project Memory commit、自动任务分配或正式任务创建;`ProjectMemoryPage` 在真实模式消费后端 `truth_nodes + semantic_facts` 的 `projectMemory` 投影。`components/` 下除了 `ProjectStageRail / ThreadComposer / ExtractionRail / ChangeGroupPanel / ObjectGraphSection / ContextPacketPanel / ExecutionTimeline / ReleaseGatePanel / TeamFeedPanel` 之外,新增 `ProjectStructuredLoop / PMFlowOsFrame / AccountControls / FeedbackPrimitives` 等行为组件;`components/MobileBottomNav.tsx` 是正式应用壳唯一移动底部导航,固定为 `空间 / 指挥台 / 待确认 / 动态 / 我的`。 +- `frontend/src/features/pmflow-os/` 当前承载新的对话驱动前端产品域:`store.tsx` 提供共享 truth store;真实模式通过 `?projectId=` 调用 `frontend/src/api/pmflowOs.ts` 读取后端 `ProjectTruthState` projection,并调用真实 message、`proposed_items` extraction、ChangeItem `accept/edit/defer`、ChangeSet `commit`、review decision、Execution Handoff create/consume、execution progress writeback、release validation run create、release feedback ingest、单条 iteration recommendation -> ChangeSet draft、同组 iteration recommendation 批量 -> ChangeSet draft、capacity-plan 与 capacity override simulate/apply API,成功后 refetch truth-state / execution-summary / capacity plan;显式 `?demo=1` 才启用本地演示状态机。`pages/` 下包含 `CommandCenter / StructuredExtraction / ChangeReview / MemoryWritebackResult / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主页面;`ChangeReviewPage` 真实模式可请求审查、通过、要求修改或拒绝当前 ChangeSet,展示审查状态、建议 reviewer、审查意见和 high-risk approval gate,但不自动 commit;其顶部识别统计、待确认列表和右侧 AI 副驾评估只能由后端 `changeSets / changeItems / evidence` 派生,没有真实待审项时显示空态,不得用 demo 的知识库搜索、混合召回、固定风险/建议/计数补位;`TeamLayerPage` 真实模式读取 `state.reviewerRouting`、`execution-capabilities` 与 `capacity-plan`,展示待审 ChangeSet、建议 reviewer、审查状态、执行能力、个人执行准备度、工具准备度、团队今日容量、动态基线、瓶颈、AI 预处理建议、负责人模拟/应用容量 override 与调整记录;capacity override 只写审计,不自动分派、不自动审查、不写 ChangeSet;`ExecutionBridgePage` 已支持创建 handoff、运行端领取确认和执行进度回写;`ReleaseGrowthPage` 真实模式展示 `execution-summary.releaseGate`、execution evidence、阻塞原因、下一步动作、`latestValidationRun` 和 `releaseSignals`;静态发布/增长原型仅保留在显式 demo 模式;后端只记录 review decision/progress/release gate/release validation/release feedback/recommendation draft/capacity override 元数据,不执行本机命令、真实发布命令、外部支付、真实推送、自动 Project Memory commit、自动任务分配或正式任务创建;`ProjectMemoryPage` 在真实模式优先消费后端 `project-memory/overview` 读模型,二级页承接 `/project-memory/items|assets|context-packs|writebacks`,单条点击进入右侧抽屉;读模型失败时才降级展示 `truth-state.projectMemory` 兼容投影。`components/` 下除了 `ProjectStageRail / ThreadComposer / ExtractionRail / ChangeGroupPanel / ObjectGraphSection / ContextPacketPanel / ExecutionTimeline / ReleaseGatePanel / TeamFeedPanel` 之外,新增 `ProjectStructuredLoop / PMFlowOsFrame / AccountControls / FeedbackPrimitives` 等行为组件;`components/MobileBottomNav.tsx` 是正式应用壳唯一移动底部导航,固定为 `空间 / 指挥台 / 待确认 / 动态 / 我的`。 - `ProjectTruthState.feed` 现在是 Team Feed 只读动态流,由 `backend/app/services/pmflow_os_projection_service.py` 聚合 review request、review decision、ChangeSet 状态、execution progress 与 release gate 事件;`TeamLayerPage` 复用 `TeamFeedPanel` 展示 `state.feed`,只做摘要、空态和跳转,不写审批、不写 ChangeSet、不写通知,也不在真实模式用 mock 动态补齐。 - 旧登录实现 `frontend/src/components/ui/sign-in.tsx` 与 `frontend/src/components/ui/demo.tsx` 已移除,`frontend/src/components/login/` 是当前唯一登录真源。 @@ -247,7 +264,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - 当前高价值摘要只保留: - 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用。 - 真实飞书网页 E2E 依赖本机已登录的 Chrome/Feishu 会话,结果校验统一走数据库探针。 - - 路由配置唯一真源固定为 `backend/.control_plane/pmflow-routing-rules.json`;请求超时统一以 `LLM_REQUEST_TIMEOUT` 为准。 + - 路由配置唯一真源固定为 `backend/.control_plane/pmflow-routing-rules.json`;普通 LLM 请求超时以 `LLM_REQUEST_TIMEOUT` 为默认,保护型结构化场景以 Function Runtime Profile active 合同里的 `provider_timeout_seconds` 为准。 - World Model 静态层已接入 Redis 缓存(`wm:static:{tenant_id}:{project_id}`,TTL 600s),写操作后主动 invalidate,动态层不缓存。 - `signal_sources` 仅作为历史创建结果区的兼容展示字段;项目看板与 SSTC 信号阶段统一只读正式 `Signal`。 diff --git a/USER.md b/USER.md index 6a9b8e39..7ce5134d 100644 --- a/USER.md +++ b/USER.md @@ -28,6 +28,7 @@ - 当前产品原型与首版体验默认面向个人开发者与 `1-10` 人小团队,采用 `solo-first, team-ready` 方向;不要按 `10-50` 人重型团队工具去设计。 - PMFlow 产品蓝图真源已沉淀到 `docs/PMFLOW_PRODUCT_BLUEPRINT.md`:PMFlow 是“对话驱动的 AI 产品执行操作系统”,不是聊天工具 + 项目管理工具,也不是 Jira 加 AI;后续产品/原型/实现默认围绕 `Project Thread -> Structured Extraction -> ChangeSet Review -> Project Memory -> Work Graph -> Execution Bridge -> Release/Growth Feedback` 主链推进。 - 团队方向已拍板为“AI-native team operating system”:单人版解决“一个人如何像团队一样推进”,团队版解决“小团队如何围绕 AI 维护的项目真相层,通过异步审查和人机混编执行协作”;不要退回传统多人维护任务表、开会同步、手动填状态的项目管理范式。 +- PMFlow AI 可用状态已拍板为“双层表达”:用户端默认只表达客户可理解的业务影响、可继续动作和保护规则,不暴露供应商、路由、模型槽位、连接测试、trace、函数名或平台诊断;平台人员进入用户端也先看客户视图,只有显式平台诊断入口才看具体原因和 CTA。`/platform/*` 是平台 AI Ops 控制面,默认展示供应商、路由、诊断、模型槽位、失败样本和对应处理动作。 - PMFlow 产品推进执行标准已沉淀到 `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md`:后续每次 PMFlow 产品推进任务完成后,Codex 默认要自己更新该文档的进度台账;新增/修改入口、API、Schema、边界时同步更新 `CLAUDE.md`、`ARCHITECTURE_INVENTORY.md`、`CODE_REFERENCE_INDEX.md`、`API_CONTRACTS.md` 或 `RUNTIME_GUARDRAILS.md`。新开线程继续产品推进时,必须先读 `USER.md`、产品蓝图、执行标准、`CLAUDE.md` 和代码索引,不得从头重想方案。 - 原型图与首版视觉交付默认只做浅色主题,不要求同时输出暗色模式;暗色模式属于后续产品实现能力,不作为当前原型标准。 - 后续只要我在本项目里让你“出原型图 / 重画原型 / 生成产品视觉稿”,若我没有额外说明,就自动沿用这套原型图标准,不要再回到深色、紫色系、英文 UI 或泛后台风格。 @@ -129,3 +130,4 @@ - `backend/tests/test_training_pipeline_service.py` 目前仍有历史 SQLite 测试夹具下的 `JSONB` 建表失败问题,这不是本轮改名引入的,需要单独清理测试基座。 - `backend/app/schemas/recipe.py` 与 `backend/app/schemas/skill.py` 仍有 Pydantic V2 的 class-based config deprecation 警告。 - 当前运行环境里的 `lark_oapi` / `websockets` 仍有上游 deprecation 警告,后续需要统一升级或兼容处理。 +- Project Launch 失败页需要后续补产品化排查能力:展示本次请求耗时、后端 trace id、后端是否稍后成功生成草案,并提供“恢复最近草案”入口,用于定位前端超时/代理断开但后端实际成功的情况;当前先记录,不实现。 diff --git a/backend/.control_plane/pmflow-routing-rules.json b/backend/.control_plane/pmflow-routing-rules.json index 6ebcb0f8..aa95b006 100644 --- a/backend/.control_plane/pmflow-routing-rules.json +++ b/backend/.control_plane/pmflow-routing-rules.json @@ -10,7 +10,7 @@ "model": "deepseek-chat", "api_env": "DEEPSEEK_API_KEY" }, - "glm": { + "dashscope": { "name": "Qwen (DashScope)", "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", "model": "qwen-plus", @@ -31,7 +31,7 @@ }, "tier_defaults": { "low": { - "provider": "glm", + "provider": "dashscope", "model": "qwen-plus", "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", "api_env": "DASHSCOPE_API_KEY" @@ -59,7 +59,7 @@ "enabled": true, "priority": 310, "tier": "low", - "provider": "glm", + "provider": "dashscope", "model": "qwen-plus", "api_env": "DASHSCOPE_API_KEY", "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", @@ -80,7 +80,7 @@ "enabled": true, "priority": 300, "tier": "low", - "provider": "glm", + "provider": "dashscope", "model": "qwen-plus", "api_env": "DASHSCOPE_API_KEY", "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", @@ -150,7 +150,7 @@ "enabled": true, "priority": 50, "tier": "low", - "provider": "glm", + "provider": "dashscope", "model": "qwen-plus", "api_env": "DASHSCOPE_API_KEY", "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", diff --git a/backend/alembic/versions/20260428_add_phone_auth_identity.py b/backend/alembic/versions/20260428_add_phone_auth_identity.py new file mode 100644 index 00000000..8f939d19 --- /dev/null +++ b/backend/alembic/versions/20260428_add_phone_auth_identity.py @@ -0,0 +1,124 @@ +"""add phone auth identity + +Revision ID: 20260428_add_phone_auth_identity +Revises: 20260428_repair_admin_yangyu_workspace_state +Create Date: 2026-04-28 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260428_add_phone_auth_identity" +down_revision = "20260428_repair_admin_yangyu_workspace_state" +branch_labels = None +depends_on = None + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _column_exists(bind, table_name: str, column_name: str) -> bool: + if not _table_exists(bind, table_name): + return False + return any(column["name"] == column_name for column in sa.inspect(bind).get_columns(table_name)) + + +def _index_exists(bind, table_name: str, index_name: str) -> bool: + if not _table_exists(bind, table_name): + return False + return any(index["name"] == index_name for index in sa.inspect(bind).get_indexes(table_name)) + + +def upgrade() -> None: + bind = op.get_bind() + + if _table_exists(bind, "users"): + for column in ( + sa.Column("phone_country_code", sa.String(length=8), nullable=True), + sa.Column("phone_number", sa.String(length=32), nullable=True), + sa.Column("phone_e164", sa.String(length=32), nullable=True), + sa.Column("phone_verified_at", sa.TIMESTAMP(), nullable=True), + sa.Column("last_login_at", sa.TIMESTAMP(), nullable=True), + sa.Column("login_method", sa.String(length=32), nullable=True), + ): + if not _column_exists(bind, "users", column.name): + op.add_column("users", column) + if not _index_exists(bind, "users", "ix_users_phone_e164"): + op.create_index("ix_users_phone_e164", "users", ["phone_e164"], unique=True) + + if _table_exists(bind, "tenant_invitations"): + for column in ( + sa.Column("phone_country_code", sa.String(length=8), nullable=True), + sa.Column("phone_number", sa.String(length=32), nullable=True), + sa.Column("phone_e164", sa.String(length=32), nullable=True), + ): + if not _column_exists(bind, "tenant_invitations", column.name): + op.add_column("tenant_invitations", column) + if not _index_exists(bind, "tenant_invitations", "ix_tenant_invitations_phone_e164"): + op.create_index("ix_tenant_invitations_phone_e164", "tenant_invitations", ["phone_e164"], unique=False) + if bind.dialect.name == "postgresql" and not _index_exists( + bind, + "tenant_invitations", + "uq_tenant_invitations_pending_phone", + ): + op.execute( + """ + CREATE UNIQUE INDEX uq_tenant_invitations_pending_phone + ON tenant_invitations (tenant_id, phone_e164) + WHERE status = 'pending' AND phone_e164 IS NOT NULL + """ + ) + + if not _table_exists(bind, "phone_verification_codes"): + op.create_table( + "phone_verification_codes", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False), + sa.Column("phone_e164", sa.String(length=32), nullable=False, index=True), + sa.Column("purpose", sa.String(length=32), nullable=False, index=True), + sa.Column("code_hash", sa.String(length=128), nullable=False), + sa.Column("expires_at", sa.TIMESTAMP(), nullable=False), + sa.Column("consumed_at", sa.TIMESTAMP(), nullable=True), + sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("request_ip", sa.String(length=64), nullable=True), + ) + op.create_index( + "ix_phone_verification_codes_phone_purpose_created", + "phone_verification_codes", + ["phone_e164", "purpose", "created_at"], + unique=False, + ) + + +def downgrade() -> None: + bind = op.get_bind() + if _table_exists(bind, "phone_verification_codes"): + if _index_exists(bind, "phone_verification_codes", "ix_phone_verification_codes_phone_purpose_created"): + op.drop_index("ix_phone_verification_codes_phone_purpose_created", table_name="phone_verification_codes") + op.drop_table("phone_verification_codes") + if _table_exists(bind, "tenant_invitations"): + if bind.dialect.name == "postgresql" and _index_exists(bind, "tenant_invitations", "uq_tenant_invitations_pending_phone"): + op.drop_index("uq_tenant_invitations_pending_phone", table_name="tenant_invitations") + if _index_exists(bind, "tenant_invitations", "ix_tenant_invitations_phone_e164"): + op.drop_index("ix_tenant_invitations_phone_e164", table_name="tenant_invitations") + for column_name in ("phone_e164", "phone_number", "phone_country_code"): + if _column_exists(bind, "tenant_invitations", column_name): + op.drop_column("tenant_invitations", column_name) + if _table_exists(bind, "users"): + if _index_exists(bind, "users", "ix_users_phone_e164"): + op.drop_index("ix_users_phone_e164", table_name="users") + for column_name in ( + "login_method", + "last_login_at", + "phone_verified_at", + "phone_e164", + "phone_number", + "phone_country_code", + ): + if _column_exists(bind, "users", column_name): + op.drop_column("users", column_name) diff --git a/backend/alembic/versions/20260428_add_platform_memberships.py b/backend/alembic/versions/20260428_add_platform_memberships.py new file mode 100644 index 00000000..c9338115 --- /dev/null +++ b/backend/alembic/versions/20260428_add_platform_memberships.py @@ -0,0 +1,57 @@ +"""add platform memberships + +Revision ID: 20260428_add_platform_memberships +Revises: 20260428_add_phone_auth_identity +Create Date: 2026-04-28 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260428_add_platform_memberships" +down_revision = "20260428_add_phone_auth_identity" +branch_labels = None +depends_on = None + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _index_exists(bind, table_name: str, index_name: str) -> bool: + if not _table_exists(bind, table_name): + return False + return any(index["name"] == index_name for index in sa.inspect(bind).get_indexes(table_name)) + + +def upgrade() -> None: + bind = op.get_bind() + if _table_exists(bind, "platform_memberships"): + return + + op.create_table( + "platform_memberships", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False), + sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("role", sa.String(length=50), nullable=False), + sa.Column("status", sa.String(length=32), nullable=False, server_default="active"), + sa.UniqueConstraint("user_id", "role", name="uq_platform_memberships_user_role"), + ) + op.create_index("ix_platform_memberships_user_id", "platform_memberships", ["user_id"], unique=False) + op.create_index("ix_platform_memberships_status", "platform_memberships", ["status"], unique=False) + + +def downgrade() -> None: + bind = op.get_bind() + if not _table_exists(bind, "platform_memberships"): + return + if _index_exists(bind, "platform_memberships", "ix_platform_memberships_status"): + op.drop_index("ix_platform_memberships_status", table_name="platform_memberships") + if _index_exists(bind, "platform_memberships", "ix_platform_memberships_user_id"): + op.drop_index("ix_platform_memberships_user_id", table_name="platform_memberships") + op.drop_table("platform_memberships") diff --git a/backend/alembic/versions/20260428_add_saved_idea_enrichment.py b/backend/alembic/versions/20260428_add_saved_idea_enrichment.py new file mode 100644 index 00000000..ad2bee88 --- /dev/null +++ b/backend/alembic/versions/20260428_add_saved_idea_enrichment.py @@ -0,0 +1,60 @@ +"""add saved idea enrichment fields + +Revision ID: 20260428_add_saved_idea_enrichment +Revises: 20260428_add_saved_ideas +Create Date: 2026-04-28 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +try: + from sqlalchemy.dialects.postgresql import JSONB +except Exception: # pragma: no cover + JSONB = sa.JSON + + +revision = "20260428_add_saved_idea_enrichment" +down_revision = "20260428_add_saved_ideas" +branch_labels = None +depends_on = None + + +def _json_type(bind): + return JSONB() if bind.dialect.name == "postgresql" else sa.JSON() + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _column_exists(bind, table_name: str, column_name: str) -> bool: + return any(column["name"] == column_name for column in sa.inspect(bind).get_columns(table_name)) + + +def upgrade() -> None: + bind = op.get_bind() + if not _table_exists(bind, "saved_ideas"): + return + if not _column_exists(bind, "saved_ideas", "enrichment_status"): + op.add_column( + "saved_ideas", + sa.Column("enrichment_status", sa.String(length=32), nullable=False, server_default="baseline"), + ) + if not _column_exists(bind, "saved_ideas", "enrichment_payload"): + op.add_column("saved_ideas", sa.Column("enrichment_payload", _json_type(bind), nullable=True)) + if not _column_exists(bind, "saved_ideas", "enrichment_error"): + op.add_column("saved_ideas", sa.Column("enrichment_error", sa.Text(), nullable=True)) + if not _column_exists(bind, "saved_ideas", "enriched_at"): + op.add_column("saved_ideas", sa.Column("enriched_at", sa.TIMESTAMP(), nullable=True)) + + +def downgrade() -> None: + bind = op.get_bind() + if not _table_exists(bind, "saved_ideas"): + return + for column_name in ("enriched_at", "enrichment_error", "enrichment_payload", "enrichment_status"): + if _column_exists(bind, "saved_ideas", column_name): + op.drop_column("saved_ideas", column_name) diff --git a/backend/alembic/versions/20260428_add_saved_ideas.py b/backend/alembic/versions/20260428_add_saved_ideas.py new file mode 100644 index 00000000..494e05b6 --- /dev/null +++ b/backend/alembic/versions/20260428_add_saved_ideas.py @@ -0,0 +1,87 @@ +"""add saved ideas + +Revision ID: 20260428_add_saved_ideas +Revises: 20260428_add_platform_memberships +Create Date: 2026-04-28 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +try: + from sqlalchemy.dialects.postgresql import JSONB +except Exception: # pragma: no cover + JSONB = sa.JSON + + +revision = "20260428_add_saved_ideas" +down_revision = "20260428_add_platform_memberships" +branch_labels = None +depends_on = None + + +def _json_type(bind): + return JSONB() if bind.dialect.name == "postgresql" else sa.JSON() + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def upgrade() -> None: + bind = op.get_bind() + if _table_exists(bind, "saved_ideas"): + return + + json_type = _json_type(bind) + op.create_table( + "saved_ideas", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False), + sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False), + sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("idea_id", sa.String(length=64), nullable=False), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("raw_text", sa.Text(), nullable=False), + sa.Column("summary", sa.Text(), nullable=False), + sa.Column("source_type", sa.String(length=32), nullable=False, server_default="project_launch"), + sa.Column("source_ref", sa.String(length=255), nullable=True), + sa.Column("input_mode", sa.String(length=32), nullable=False, server_default="idea"), + sa.Column("attachments", json_type, nullable=False), + sa.Column("status", sa.String(length=32), nullable=False, server_default="saved"), + sa.Column("ai_summary", sa.Text(), nullable=True), + sa.Column("missing_questions", json_type, nullable=False), + sa.Column("readiness_score", sa.Float(), nullable=False, server_default="0"), + sa.Column("launch_draft_id", sa.String(length=64), nullable=True), + sa.Column("promoted_project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=True), + sa.Column("promoted_at", sa.TIMESTAMP(), nullable=True), + sa.Column("archived_at", sa.TIMESTAMP(), nullable=True), + sa.UniqueConstraint("tenant_id", "idea_id", name="uq_saved_ideas_tenant_idea_id"), + ) + op.create_index("ix_saved_ideas_tenant_id", "saved_ideas", ["tenant_id"], unique=False) + op.create_index("ix_saved_ideas_user_id", "saved_ideas", ["user_id"], unique=False) + op.create_index("ix_saved_ideas_idea_id", "saved_ideas", ["idea_id"], unique=False) + op.create_index("ix_saved_ideas_source_type", "saved_ideas", ["source_type"], unique=False) + op.create_index("ix_saved_ideas_status", "saved_ideas", ["status"], unique=False) + op.create_index("ix_saved_ideas_launch_draft_id", "saved_ideas", ["launch_draft_id"], unique=False) + op.create_index("ix_saved_ideas_promoted_project_id", "saved_ideas", ["promoted_project_id"], unique=False) + + +def downgrade() -> None: + bind = op.get_bind() + if not _table_exists(bind, "saved_ideas"): + return + for index_name in ( + "ix_saved_ideas_promoted_project_id", + "ix_saved_ideas_launch_draft_id", + "ix_saved_ideas_status", + "ix_saved_ideas_source_type", + "ix_saved_ideas_idea_id", + "ix_saved_ideas_user_id", + "ix_saved_ideas_tenant_id", + ): + op.drop_index(index_name, table_name="saved_ideas") + op.drop_table("saved_ideas") diff --git a/backend/alembic/versions/20260428_add_workspace_memberships.py b/backend/alembic/versions/20260428_add_workspace_memberships.py new file mode 100644 index 00000000..1fb3e817 --- /dev/null +++ b/backend/alembic/versions/20260428_add_workspace_memberships.py @@ -0,0 +1,129 @@ +"""add workspace memberships + +Revision ID: 20260428_add_workspace_memberships +Revises: 20260427_add_human_capacity_overrides +Create Date: 2026-04-28 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +try: + from sqlalchemy.dialects.postgresql import JSONB +except Exception: # pragma: no cover + JSONB = sa.JSON + + +revision = "20260428_add_workspace_memberships" +down_revision = "20260427_add_human_capacity_overrides" +branch_labels = None +depends_on = None + + +def _json_type(bind): + return JSONB() if bind.dialect.name == "postgresql" else sa.JSON() + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _column_exists(bind, table_name: str, column_name: str) -> bool: + if not _table_exists(bind, table_name): + return False + return any(column["name"] == column_name for column in sa.inspect(bind).get_columns(table_name)) + + +def _index_exists(bind, table_name: str, index_name: str) -> bool: + if not _table_exists(bind, table_name): + return False + return any(index["name"] == index_name for index in sa.inspect(bind).get_indexes(table_name)) + + +def upgrade() -> None: + bind = op.get_bind() + json_type = _json_type(bind) + + if _table_exists(bind, "tenants"): + if not _column_exists(bind, "tenants", "workspace_type"): + op.add_column("tenants", sa.Column("workspace_type", sa.String(length=32), nullable=False, server_default="team")) + if not _column_exists(bind, "tenants", "slug"): + op.add_column("tenants", sa.Column("slug", sa.String(length=120), nullable=True)) + if not _column_exists(bind, "tenants", "owner_user_id"): + op.add_column("tenants", sa.Column("owner_user_id", sa.BigInteger(), nullable=True)) + if not _column_exists(bind, "tenants", "status"): + op.add_column("tenants", sa.Column("status", sa.String(length=32), nullable=False, server_default="active")) + + if not _table_exists(bind, "tenant_memberships"): + op.create_table( + "tenant_memberships", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, index=True), + sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False), + sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("role", sa.String(length=50), nullable=False), + sa.Column("status", sa.String(length=32), nullable=False, server_default="active"), + sa.Column("is_personal_owner", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.UniqueConstraint("tenant_id", "user_id", name="uq_tenant_memberships_tenant_user"), + ) + op.create_index("ix_tenant_memberships_tenant_status", "tenant_memberships", ["tenant_id", "status"], unique=False) + + if not _table_exists(bind, "tenant_invitations"): + op.create_table( + "tenant_invitations", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, index=True), + sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False), + sa.Column("email", sa.String(length=255), nullable=False, index=True), + sa.Column("role", sa.String(length=50), nullable=False), + sa.Column("project_ids", json_type, nullable=False), + sa.Column("token_hash", sa.String(length=128), nullable=False, unique=True, index=True), + sa.Column("status", sa.String(length=32), nullable=False, server_default="pending"), + sa.Column("expires_at", sa.TIMESTAMP(), nullable=False), + sa.Column("invited_by_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("accepted_by_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True), + ) + op.create_index("ix_tenant_invitations_tenant_status", "tenant_invitations", ["tenant_id", "status"], unique=False) + + # Backfill current single-workspace users into membership rows. + if _table_exists(bind, "users") and _table_exists(bind, "tenant_memberships"): + if bind.dialect.name == "postgresql": + op.execute( + """ + INSERT INTO tenant_memberships (tenant_id, user_id, role, status, is_personal_owner, created_at, updated_at) + SELECT users.tenant_id, users.id, users.role, 'active', false, NOW(), NOW() + FROM users + WHERE users.tenant_id IS NOT NULL + ON CONFLICT ON CONSTRAINT uq_tenant_memberships_tenant_user DO NOTHING + """ + ) + else: + op.execute( + """ + INSERT OR IGNORE INTO tenant_memberships (tenant_id, user_id, role, status, is_personal_owner, created_at, updated_at) + SELECT users.tenant_id, users.id, users.role, 'active', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + FROM users + WHERE users.tenant_id IS NOT NULL + """ + ) + + +def downgrade() -> None: + bind = op.get_bind() + if _table_exists(bind, "tenant_invitations"): + if _index_exists(bind, "tenant_invitations", "ix_tenant_invitations_tenant_status"): + op.drop_index("ix_tenant_invitations_tenant_status", table_name="tenant_invitations") + op.drop_table("tenant_invitations") + if _table_exists(bind, "tenant_memberships"): + if _index_exists(bind, "tenant_memberships", "ix_tenant_memberships_tenant_status"): + op.drop_index("ix_tenant_memberships_tenant_status", table_name="tenant_memberships") + op.drop_table("tenant_memberships") + if _table_exists(bind, "tenants"): + for column_name in ("status", "owner_user_id", "slug", "workspace_type"): + if _column_exists(bind, "tenants", column_name): + op.drop_column("tenants", column_name) + diff --git a/backend/alembic/versions/20260428_repair_admin_yangyu_workspace_state.py b/backend/alembic/versions/20260428_repair_admin_yangyu_workspace_state.py new file mode 100644 index 00000000..222a6715 --- /dev/null +++ b/backend/alembic/versions/20260428_repair_admin_yangyu_workspace_state.py @@ -0,0 +1,118 @@ +"""repair admin and yangyu workspace state + +Revision ID: 20260428_repair_admin_yangyu_workspace_state +Revises: 20260428_add_workspace_memberships +Create Date: 2026-04-28 +""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260428_repair_admin_yangyu_workspace_state" +down_revision = "20260428_add_workspace_memberships" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + if bind.dialect.name != "postgresql": + return + + op.execute( + """ + INSERT INTO tenants (id, name, domain, workspace_type, slug, owner_user_id, status, created_at, updated_at) + SELECT + 1, + 'PMFlow 旧团队空间', + NULL, + 'team', + 'legacy-team', + admin_user.id, + 'active', + NOW(), + NOW() + FROM users AS admin_user + WHERE admin_user.username = 'admin' + ON CONFLICT (id) DO UPDATE + SET + workspace_type = 'team', + status = 'active', + slug = COALESCE(tenants.slug, EXCLUDED.slug), + owner_user_id = COALESCE(tenants.owner_user_id, EXCLUDED.owner_user_id), + updated_at = NOW() + """ + ) + op.execute("SELECT setval('tenants_id_seq', GREATEST((SELECT COALESCE(MAX(id), 1) FROM tenants), 1), true)") + + op.execute( + """ + INSERT INTO tenant_memberships (tenant_id, user_id, role, status, is_personal_owner, created_at, updated_at) + SELECT 1, users.id, 'OWNER', 'active', false, NOW(), NOW() + FROM users + WHERE users.username = 'admin' + ON CONFLICT ON CONSTRAINT uq_tenant_memberships_tenant_user DO UPDATE + SET role = 'OWNER', status = 'active', is_personal_owner = false, updated_at = NOW() + """ + ) + op.execute( + """ + INSERT INTO tenant_memberships (tenant_id, user_id, role, status, is_personal_owner, created_at, updated_at) + SELECT 1, users.id, 'BUILDER', 'active', false, NOW(), NOW() + FROM users + WHERE users.username = 'yangyu' + ON CONFLICT ON CONSTRAINT uq_tenant_memberships_tenant_user DO UPDATE + SET role = 'BUILDER', status = 'active', is_personal_owner = false, updated_at = NOW() + """ + ) + + op.execute( + """ + WITH personal_workspace AS ( + SELECT users.id AS user_id, tenant_memberships.tenant_id + FROM users + JOIN tenant_memberships ON tenant_memberships.user_id = users.id + JOIN tenants ON tenants.id = tenant_memberships.tenant_id + WHERE + users.username = 'admin' + AND tenants.workspace_type = 'personal' + AND tenant_memberships.status = 'active' + AND tenant_memberships.is_personal_owner IS TRUE + ORDER BY tenants.id ASC + LIMIT 1 + ) + UPDATE users + SET tenant_id = personal_workspace.tenant_id, role = 'OWNER', updated_at = NOW() + FROM personal_workspace + WHERE users.id = personal_workspace.user_id + """ + ) + op.execute( + """ + WITH personal_workspace AS ( + SELECT users.id AS user_id, tenant_memberships.tenant_id + FROM users + JOIN tenant_memberships ON tenant_memberships.user_id = users.id + JOIN tenants ON tenants.id = tenant_memberships.tenant_id + WHERE + users.username = 'yangyu' + AND tenants.workspace_type = 'personal' + AND tenant_memberships.status = 'active' + AND tenant_memberships.is_personal_owner IS TRUE + ORDER BY tenants.id ASC + LIMIT 1 + ) + UPDATE users + SET tenant_id = personal_workspace.tenant_id, role = 'OWNER', updated_at = NOW() + FROM personal_workspace + WHERE users.id = personal_workspace.user_id + """ + ) + + +def downgrade() -> None: + # Data repair is intentionally not reverted; rolling it back would reintroduce + # the broken active workspace pointers. + return diff --git a/backend/alembic/versions/20260429_add_ai_call_log_provider_payloads.py b/backend/alembic/versions/20260429_add_ai_call_log_provider_payloads.py new file mode 100644 index 00000000..4c0fa4c3 --- /dev/null +++ b/backend/alembic/versions/20260429_add_ai_call_log_provider_payloads.py @@ -0,0 +1,67 @@ +"""add ai call log provider payloads + +Revision ID: 20260429_add_ai_call_log_provider_payloads +Revises: 20260429_add_prompt_release_checks +Create Date: 2026-04-29 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +try: + from sqlalchemy.dialects.postgresql import JSONB +except Exception: # pragma: no cover + JSONB = sa.JSON + + +revision = "20260429_add_ai_call_log_provider_payloads" +down_revision = "20260429_add_prompt_release_checks" +branch_labels = None +depends_on = None + + +def _json_type(bind): + return JSONB() if bind.dialect.name == "postgresql" else sa.JSON() + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _column_exists(bind, table_name: str, column_name: str) -> bool: + inspector = sa.inspect(bind) + if not inspector.has_table(table_name): + return False + return any(str(item.get("name") or "") == column_name for item in inspector.get_columns(table_name)) + + +def upgrade() -> None: + bind = op.get_bind() + table_name = "ai_call_logs" + if not _table_exists(bind, table_name): + return + + for column_name in ( + "provider_request_payload", + "provider_response_payload", + "provider_attempts", + ): + if not _column_exists(bind, table_name, column_name): + op.add_column(table_name, sa.Column(column_name, _json_type(bind), nullable=True)) + + +def downgrade() -> None: + bind = op.get_bind() + table_name = "ai_call_logs" + if not _table_exists(bind, table_name): + return + + for column_name in ( + "provider_attempts", + "provider_response_payload", + "provider_request_payload", + ): + if _column_exists(bind, table_name, column_name): + op.drop_column(table_name, column_name) diff --git a/backend/alembic/versions/20260429_add_ai_call_trace_fields.py b/backend/alembic/versions/20260429_add_ai_call_trace_fields.py new file mode 100644 index 00000000..3f910413 --- /dev/null +++ b/backend/alembic/versions/20260429_add_ai_call_trace_fields.py @@ -0,0 +1,79 @@ +"""add ai call trace fields + +Revision ID: 20260429_add_ai_call_trace_fields +Revises: 20260428_add_saved_idea_enrichment +Create Date: 2026-04-29 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +try: + from sqlalchemy.dialects.postgresql import JSONB +except Exception: # pragma: no cover + JSONB = sa.JSON + + +revision = "20260429_add_ai_call_trace_fields" +down_revision = "20260428_add_saved_idea_enrichment" +branch_labels = None +depends_on = None + + +def _json_type(bind): + return JSONB() if bind.dialect.name == "postgresql" else sa.JSON() + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _column_exists(bind, table_name: str, column_name: str) -> bool: + inspector = sa.inspect(bind) + if not inspector.has_table(table_name): + return False + return any(str(item.get("name") or "") == column_name for item in inspector.get_columns(table_name)) + + +def upgrade() -> None: + bind = op.get_bind() + table_name = "ai_call_logs" + if not _table_exists(bind, table_name): + return + + columns = [ + ("trace_id", sa.Column("trace_id", sa.String(length=80), nullable=True)), + ("provider", sa.Column("provider", sa.String(length=64), nullable=True)), + ("model", sa.Column("model", sa.String(length=120), nullable=True)), + ("latency_ms", sa.Column("latency_ms", sa.Integer(), nullable=True)), + ("routing_meta", sa.Column("routing_meta", _json_type(bind), nullable=True)), + ] + for column_name, column in columns: + if not _column_exists(bind, table_name, column_name): + op.add_column(table_name, column) + + existing_indexes = {index["name"] for index in sa.inspect(bind).get_indexes(table_name)} + for index_name, column_names in ( + ("ix_ai_call_logs_trace_id", ["trace_id"]), + ("ix_ai_call_logs_provider", ["provider"]), + ("ix_ai_call_logs_model", ["model"]), + ): + if index_name not in existing_indexes: + op.create_index(index_name, table_name, column_names, unique=False) + + +def downgrade() -> None: + bind = op.get_bind() + table_name = "ai_call_logs" + if not _table_exists(bind, table_name): + return + + existing_indexes = {index["name"] for index in sa.inspect(bind).get_indexes(table_name)} + for index_name in ("ix_ai_call_logs_model", "ix_ai_call_logs_provider", "ix_ai_call_logs_trace_id"): + if index_name in existing_indexes: + op.drop_index(index_name, table_name=table_name) + for column_name in ("routing_meta", "latency_ms", "model", "provider", "trace_id"): + if _column_exists(bind, table_name, column_name): + op.drop_column(table_name, column_name) diff --git a/backend/alembic/versions/20260429_add_prompt_quality_linkage.py b/backend/alembic/versions/20260429_add_prompt_quality_linkage.py new file mode 100644 index 00000000..ec8dcfe5 --- /dev/null +++ b/backend/alembic/versions/20260429_add_prompt_quality_linkage.py @@ -0,0 +1,120 @@ +"""add prompt quality linkage + +Revision ID: 20260429_add_prompt_quality_linkage +Revises: 20260429_add_prompt_release_checks +Create Date: 2026-04-29 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +try: + from sqlalchemy.dialects.postgresql import JSONB +except Exception: # pragma: no cover + JSONB = sa.JSON + + +revision = "20260429_add_prompt_quality_linkage" +down_revision = "20260429_add_prompt_release_checks" +branch_labels = None +depends_on = None + + +def _json_type(bind): + return JSONB() if bind.dialect.name == "postgresql" else sa.JSON() + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _column_exists(bind, table_name: str, column_name: str) -> bool: + inspector = sa.inspect(bind) + if not inspector.has_table(table_name): + return False + return any(str(item.get("name") or "") == column_name for item in inspector.get_columns(table_name)) + + +def _index_exists(bind, table_name: str, index_name: str) -> bool: + if not _table_exists(bind, table_name): + return False + return index_name in {str(index.get("name") or "") for index in sa.inspect(bind).get_indexes(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + table_name = "ai_call_logs" + if _table_exists(bind, table_name): + for column_name, column in ( + ("prompt_key", sa.Column("prompt_key", sa.String(length=128), nullable=True)), + ("prompt_release_id", sa.Column("prompt_release_id", sa.Integer(), nullable=True)), + ("prompt_source", sa.Column("prompt_source", sa.String(length=32), nullable=True)), + ): + if not _column_exists(bind, table_name, column_name): + op.add_column(table_name, column) + for index_name, column_names in ( + ("ix_ai_call_logs_prompt_key", ["prompt_key"]), + ("ix_ai_call_logs_prompt_release_id", ["prompt_release_id"]), + ("ix_ai_call_logs_prompt_source", ["prompt_source"]), + ): + if not _index_exists(bind, table_name, index_name): + op.create_index(index_name, table_name, column_names, unique=False) + + if not _table_exists(bind, "prompt_golden_cases"): + op.create_table( + "prompt_golden_cases", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False), + sa.Column("prompt_key", sa.String(length=128), nullable=False), + sa.Column("case_name", sa.String(length=255), nullable=False), + sa.Column("status", sa.String(length=24), nullable=False, server_default="active"), + sa.Column("input_payload", _json_type(bind), nullable=True), + sa.Column("expected_contract", _json_type(bind), nullable=True), + sa.Column("expected_contains", sa.Text(), nullable=True), + sa.Column("source", sa.String(length=48), nullable=False, server_default="manual"), + sa.Column("source_ai_call_log_id", sa.Integer(), nullable=True), + sa.Column("created_by", sa.BigInteger(), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + ) + for index_name, column_names in ( + ("ix_prompt_golden_cases_tenant_id", ["tenant_id"]), + ("ix_prompt_golden_cases_prompt_key", ["prompt_key"]), + ("ix_prompt_golden_cases_status", ["status"]), + ("ix_prompt_golden_cases_source", ["source"]), + ("ix_prompt_golden_cases_source_ai_call_log_id", ["source_ai_call_log_id"]), + ("ix_prompt_golden_cases_created_by", ["created_by"]), + ): + op.create_index(index_name, "prompt_golden_cases", column_names, unique=False) + + +def downgrade() -> None: + bind = op.get_bind() + if _table_exists(bind, "prompt_golden_cases"): + for index_name in ( + "ix_prompt_golden_cases_created_by", + "ix_prompt_golden_cases_source_ai_call_log_id", + "ix_prompt_golden_cases_source", + "ix_prompt_golden_cases_status", + "ix_prompt_golden_cases_prompt_key", + "ix_prompt_golden_cases_tenant_id", + ): + if _index_exists(bind, "prompt_golden_cases", index_name): + op.drop_index(index_name, table_name="prompt_golden_cases") + op.drop_table("prompt_golden_cases") + + table_name = "ai_call_logs" + if _table_exists(bind, table_name): + for index_name in ( + "ix_ai_call_logs_prompt_source", + "ix_ai_call_logs_prompt_release_id", + "ix_ai_call_logs_prompt_key", + ): + if _index_exists(bind, table_name, index_name): + op.drop_index(index_name, table_name=table_name) + for column_name in ("prompt_source", "prompt_release_id", "prompt_key"): + if _column_exists(bind, table_name, column_name): + op.drop_column(table_name, column_name) diff --git a/backend/alembic/versions/20260429_add_prompt_release_checks.py b/backend/alembic/versions/20260429_add_prompt_release_checks.py new file mode 100644 index 00000000..b3ede9ee --- /dev/null +++ b/backend/alembic/versions/20260429_add_prompt_release_checks.py @@ -0,0 +1,78 @@ +"""add prompt release checks + +Revision ID: 20260429_add_prompt_release_checks +Revises: 20260429_rename_glm_provider_to_dashscope +Create Date: 2026-04-29 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +try: + from sqlalchemy.dialects.postgresql import JSONB +except Exception: # pragma: no cover + JSONB = sa.JSON + + +revision = "20260429_add_prompt_release_checks" +down_revision = "20260429_rename_glm_provider_to_dashscope" +branch_labels = None +depends_on = None + + +def _json_type(bind): + return JSONB() if bind.dialect.name == "postgresql" else sa.JSON() + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def upgrade() -> None: + bind = op.get_bind() + if _table_exists(bind, "prompt_release_checks"): + return + + op.create_table( + "prompt_release_checks", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False), + sa.Column("prompt_key", sa.String(length=128), nullable=False), + sa.Column("release_id", sa.Integer(), nullable=False), + sa.Column("check_type", sa.String(length=48), nullable=False), + sa.Column("status", sa.String(length=24), nullable=False), + sa.Column("severity", sa.String(length=16), nullable=False, server_default="info"), + sa.Column("summary", sa.Text(), nullable=False), + sa.Column("details_json", _json_type(bind), nullable=True), + sa.Column("created_by", sa.BigInteger(), nullable=True), + sa.Column("completed_at", sa.String(length=64), nullable=True), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.ForeignKeyConstraint(["release_id"], ["prompt_releases.id"], ondelete="CASCADE"), + ) + op.create_index("ix_prompt_release_checks_tenant_id", "prompt_release_checks", ["tenant_id"], unique=False) + op.create_index("ix_prompt_release_checks_prompt_key", "prompt_release_checks", ["prompt_key"], unique=False) + op.create_index("ix_prompt_release_checks_release_id", "prompt_release_checks", ["release_id"], unique=False) + op.create_index("ix_prompt_release_checks_check_type", "prompt_release_checks", ["check_type"], unique=False) + op.create_index("ix_prompt_release_checks_status", "prompt_release_checks", ["status"], unique=False) + op.create_index("ix_prompt_release_checks_severity", "prompt_release_checks", ["severity"], unique=False) + op.create_index("ix_prompt_release_checks_created_by", "prompt_release_checks", ["created_by"], unique=False) + + +def downgrade() -> None: + bind = op.get_bind() + if not _table_exists(bind, "prompt_release_checks"): + return + for index_name in ( + "ix_prompt_release_checks_created_by", + "ix_prompt_release_checks_severity", + "ix_prompt_release_checks_status", + "ix_prompt_release_checks_check_type", + "ix_prompt_release_checks_release_id", + "ix_prompt_release_checks_prompt_key", + "ix_prompt_release_checks_tenant_id", + ): + op.drop_index(index_name, table_name="prompt_release_checks") + op.drop_table("prompt_release_checks") diff --git a/backend/alembic/versions/20260429_rename_glm_provider_to_dashscope.py b/backend/alembic/versions/20260429_rename_glm_provider_to_dashscope.py new file mode 100644 index 00000000..d45e494a --- /dev/null +++ b/backend/alembic/versions/20260429_rename_glm_provider_to_dashscope.py @@ -0,0 +1,103 @@ +"""rename glm provider code to dashscope + +Revision ID: 20260429_rename_glm_provider_to_dashscope +Revises: 20260429_add_ai_call_trace_fields +Create Date: 2026-04-29 +""" + +from __future__ import annotations + +import json +from typing import Any + +from alembic import op +import sqlalchemy as sa + +try: + from sqlalchemy.dialects.postgresql import JSONB +except Exception: # pragma: no cover + JSONB = sa.JSON + + +revision = "20260429_rename_glm_provider_to_dashscope" +down_revision = "20260429_add_ai_call_trace_fields" +branch_labels = None +depends_on = None + + +def _json_type(bind): + return JSONB() if bind.dialect.name == "postgresql" else sa.JSON() + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _column_exists(bind, table_name: str, column_name: str) -> bool: + inspector = sa.inspect(bind) + if not inspector.has_table(table_name): + return False + return any(str(item.get("name") or "") == column_name for item in inspector.get_columns(table_name)) + + +def _load_json(value: Any) -> Any: + if isinstance(value, str): + try: + return json.loads(value) + except Exception: + return value + return value + + +def _replace_provider_code(value: Any) -> tuple[Any, bool]: + changed = False + if isinstance(value, dict): + result: dict[Any, Any] = {} + for raw_key, raw_value in value.items(): + key = "dashscope" if raw_key == "glm" else raw_key + next_value, child_changed = _replace_provider_code(raw_value) + result[key] = next_value + changed = changed or child_changed or key != raw_key + return result, changed + if isinstance(value, list): + result = [] + for item in value: + next_value, child_changed = _replace_provider_code(item) + result.append(next_value) + changed = changed or child_changed + return result, changed + if value == "glm": + return "dashscope", True + return value, False + + +def _migrate_json_table(bind, table_name: str, column_name: str) -> None: + if not _table_exists(bind, table_name) or not _column_exists(bind, table_name, column_name): + return + table = sa.table( + table_name, + sa.column("id", sa.Integer()), + sa.column(column_name, _json_type(bind)), + ) + rows = bind.execute(sa.select(table.c.id, table.c[column_name])).all() + for row_id, raw_value in rows: + payload = _load_json(raw_value) + next_payload, changed = _replace_provider_code(payload) + if changed: + bind.execute( + table.update().where(table.c.id == row_id).values({column_name: next_payload}) + ) + + +def upgrade() -> None: + bind = op.get_bind() + _migrate_json_table(bind, "integrations", "config") + _migrate_json_table(bind, "ai_call_logs", "routing_meta") + + if _table_exists(bind, "ai_call_logs") and _column_exists(bind, "ai_call_logs", "provider"): + op.execute("UPDATE ai_call_logs SET provider = 'dashscope' WHERE provider = 'glm'") + + +def downgrade() -> None: + # Intentionally irreversible: glm was a misleading historical provider code. + pass diff --git a/backend/alembic/versions/20260430_add_prompt_replay_runs.py b/backend/alembic/versions/20260430_add_prompt_replay_runs.py new file mode 100644 index 00000000..b5a7eeb5 --- /dev/null +++ b/backend/alembic/versions/20260430_add_prompt_replay_runs.py @@ -0,0 +1,136 @@ +"""add prompt replay runs + +Revision ID: 20260430_add_prompt_replay_runs +Revises: 20260429_add_prompt_quality_linkage +Create Date: 2026-04-30 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +try: + from sqlalchemy.dialects.postgresql import JSONB +except Exception: # pragma: no cover + JSONB = sa.JSON + + +revision = "20260430_add_prompt_replay_runs" +down_revision = "20260429_add_prompt_quality_linkage" +branch_labels = None +depends_on = None + + +def _json_type(bind): + return JSONB() if bind.dialect.name == "postgresql" else sa.JSON() + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _index_exists(bind, table_name: str, index_name: str) -> bool: + if not _table_exists(bind, table_name): + return False + return index_name in {str(index.get("name") or "") for index in sa.inspect(bind).get_indexes(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + if not _table_exists(bind, "prompt_replay_runs"): + op.create_table( + "prompt_replay_runs", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False), + sa.Column("prompt_key", sa.String(length=128), nullable=False), + sa.Column("release_id", sa.Integer(), nullable=False), + sa.Column("baseline_release_id", sa.Integer(), nullable=True), + sa.Column("run_status", sa.String(length=24), nullable=False, server_default="pending"), + sa.Column("run_mode", sa.String(length=32), nullable=False, server_default="contract_smoke"), + sa.Column("case_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("passed_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("failed_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("warning_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("score_delta", sa.Float(), nullable=True), + sa.Column("summary", sa.Text(), nullable=True), + sa.Column("risk_flags_json", _json_type(bind), nullable=True), + sa.Column("created_by", sa.BigInteger(), nullable=True), + sa.Column("started_at", sa.String(length=64), nullable=True), + sa.Column("completed_at", sa.String(length=64), nullable=True), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.ForeignKeyConstraint(["release_id"], ["prompt_releases.id"], ondelete="CASCADE"), + ) + for index_name, column_names in ( + ("ix_prompt_replay_runs_tenant_id", ["tenant_id"]), + ("ix_prompt_replay_runs_prompt_key", ["prompt_key"]), + ("ix_prompt_replay_runs_release_id", ["release_id"]), + ("ix_prompt_replay_runs_baseline_release_id", ["baseline_release_id"]), + ("ix_prompt_replay_runs_run_status", ["run_status"]), + ("ix_prompt_replay_runs_run_mode", ["run_mode"]), + ("ix_prompt_replay_runs_created_by", ["created_by"]), + ): + op.create_index(index_name, "prompt_replay_runs", column_names, unique=False) + + if not _table_exists(bind, "prompt_replay_case_results"): + op.create_table( + "prompt_replay_case_results", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False), + sa.Column("run_id", sa.Integer(), nullable=False), + sa.Column("golden_case_id", sa.Integer(), nullable=False), + sa.Column("prompt_key", sa.String(length=128), nullable=False), + sa.Column("release_id", sa.Integer(), nullable=False), + sa.Column("baseline_output", sa.Text(), nullable=True), + sa.Column("candidate_output", sa.Text(), nullable=True), + sa.Column("status", sa.String(length=24), nullable=False), + sa.Column("contract_result_json", _json_type(bind), nullable=True), + sa.Column("diff_summary", sa.Text(), nullable=True), + sa.Column("risk_flags_json", _json_type(bind), nullable=True), + sa.Column("latency_ms", sa.Integer(), nullable=True), + sa.Column("estimated_cost", sa.Float(), nullable=True), + sa.Column("error_summary", sa.Text(), nullable=True), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.ForeignKeyConstraint(["run_id"], ["prompt_replay_runs.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["golden_case_id"], ["prompt_golden_cases.id"], ondelete="CASCADE"), + ) + for index_name, column_names in ( + ("ix_prompt_replay_case_results_tenant_id", ["tenant_id"]), + ("ix_prompt_replay_case_results_run_id", ["run_id"]), + ("ix_prompt_replay_case_results_golden_case_id", ["golden_case_id"]), + ("ix_prompt_replay_case_results_prompt_key", ["prompt_key"]), + ("ix_prompt_replay_case_results_release_id", ["release_id"]), + ("ix_prompt_replay_case_results_status", ["status"]), + ): + op.create_index(index_name, "prompt_replay_case_results", column_names, unique=False) + + +def downgrade() -> None: + bind = op.get_bind() + if _table_exists(bind, "prompt_replay_case_results"): + for index_name in ( + "ix_prompt_replay_case_results_status", + "ix_prompt_replay_case_results_release_id", + "ix_prompt_replay_case_results_prompt_key", + "ix_prompt_replay_case_results_golden_case_id", + "ix_prompt_replay_case_results_run_id", + "ix_prompt_replay_case_results_tenant_id", + ): + if _index_exists(bind, "prompt_replay_case_results", index_name): + op.drop_index(index_name, table_name="prompt_replay_case_results") + op.drop_table("prompt_replay_case_results") + if _table_exists(bind, "prompt_replay_runs"): + for index_name in ( + "ix_prompt_replay_runs_created_by", + "ix_prompt_replay_runs_run_mode", + "ix_prompt_replay_runs_run_status", + "ix_prompt_replay_runs_baseline_release_id", + "ix_prompt_replay_runs_release_id", + "ix_prompt_replay_runs_prompt_key", + "ix_prompt_replay_runs_tenant_id", + ): + if _index_exists(bind, "prompt_replay_runs", index_name): + op.drop_index(index_name, table_name="prompt_replay_runs") + op.drop_table("prompt_replay_runs") diff --git a/backend/alembic/versions/20260430_extend_prompt_eval_replay.py b/backend/alembic/versions/20260430_extend_prompt_eval_replay.py new file mode 100644 index 00000000..e930296c --- /dev/null +++ b/backend/alembic/versions/20260430_extend_prompt_eval_replay.py @@ -0,0 +1,104 @@ +"""extend prompt eval replay + +Revision ID: 20260430_extend_prompt_eval_replay +Revises: 20260430_add_prompt_replay_runs +Create Date: 2026-04-30 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +try: + from sqlalchemy.dialects.postgresql import JSONB +except Exception: # pragma: no cover + JSONB = sa.JSON + + +revision = "20260430_extend_prompt_eval_replay" +down_revision = "20260430_add_prompt_replay_runs" +branch_labels = None +depends_on = None + + +def _json_type(bind): + return JSONB() if bind.dialect.name == "postgresql" else sa.JSON() + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _column_exists(bind, table_name: str, column_name: str) -> bool: + if not _table_exists(bind, table_name): + return False + return column_name in {str(column.get("name") or "") for column in sa.inspect(bind).get_columns(table_name)} + + +def _index_exists(bind, table_name: str, index_name: str) -> bool: + if not _table_exists(bind, table_name): + return False + return index_name in {str(index.get("name") or "") for index in sa.inspect(bind).get_indexes(table_name)} + + +def _add_column_if_missing(bind, table_name: str, column: sa.Column) -> None: + if not _column_exists(bind, table_name, str(column.name)): + op.add_column(table_name, column) + + +def _create_index_if_missing(bind, index_name: str, table_name: str, columns: list[str]) -> None: + if not _index_exists(bind, table_name, index_name): + op.create_index(index_name, table_name, columns, unique=False) + + +def upgrade() -> None: + bind = op.get_bind() + if _table_exists(bind, "prompt_golden_cases"): + _add_column_if_missing(bind, "prompt_golden_cases", sa.Column("case_type", sa.String(length=32), nullable=False, server_default="regression")) + _add_column_if_missing(bind, "prompt_golden_cases", sa.Column("priority", sa.String(length=24), nullable=False, server_default="blocking")) + _add_column_if_missing(bind, "prompt_golden_cases", sa.Column("scenario_key", sa.String(length=128), nullable=True)) + _add_column_if_missing(bind, "prompt_golden_cases", sa.Column("tags", _json_type(bind), nullable=True)) + _add_column_if_missing(bind, "prompt_golden_cases", sa.Column("last_run_status", sa.String(length=24), nullable=True)) + _add_column_if_missing(bind, "prompt_golden_cases", sa.Column("last_failed_at", sa.String(length=64), nullable=True)) + _create_index_if_missing(bind, "ix_prompt_golden_cases_case_type", "prompt_golden_cases", ["case_type"]) + _create_index_if_missing(bind, "ix_prompt_golden_cases_priority", "prompt_golden_cases", ["priority"]) + _create_index_if_missing(bind, "ix_prompt_golden_cases_scenario_key", "prompt_golden_cases", ["scenario_key"]) + _create_index_if_missing(bind, "ix_prompt_golden_cases_last_run_status", "prompt_golden_cases", ["last_run_status"]) + + if _table_exists(bind, "prompt_replay_case_results"): + _add_column_if_missing(bind, "prompt_replay_case_results", sa.Column("baseline_ai_call_log_id", sa.Integer(), nullable=True)) + _add_column_if_missing(bind, "prompt_replay_case_results", sa.Column("candidate_ai_call_log_id", sa.Integer(), nullable=True)) + _add_column_if_missing(bind, "prompt_replay_case_results", sa.Column("judge_score", sa.Float(), nullable=True)) + _add_column_if_missing(bind, "prompt_replay_case_results", sa.Column("judge_label", sa.String(length=32), nullable=True)) + _add_column_if_missing(bind, "prompt_replay_case_results", sa.Column("judge_reason", sa.Text(), nullable=True)) + _create_index_if_missing(bind, "ix_prompt_replay_case_results_judge_label", "prompt_replay_case_results", ["judge_label"]) + _create_index_if_missing(bind, "ix_prompt_replay_case_results_baseline_ai_call_log_id", "prompt_replay_case_results", ["baseline_ai_call_log_id"]) + _create_index_if_missing(bind, "ix_prompt_replay_case_results_candidate_ai_call_log_id", "prompt_replay_case_results", ["candidate_ai_call_log_id"]) + + +def downgrade() -> None: + bind = op.get_bind() + if _table_exists(bind, "prompt_replay_case_results"): + for index_name in ( + "ix_prompt_replay_case_results_candidate_ai_call_log_id", + "ix_prompt_replay_case_results_baseline_ai_call_log_id", + "ix_prompt_replay_case_results_judge_label", + ): + if _index_exists(bind, "prompt_replay_case_results", index_name): + op.drop_index(index_name, table_name="prompt_replay_case_results") + for column_name in ("judge_reason", "judge_label", "judge_score", "candidate_ai_call_log_id", "baseline_ai_call_log_id"): + if _column_exists(bind, "prompt_replay_case_results", column_name): + op.drop_column("prompt_replay_case_results", column_name) + if _table_exists(bind, "prompt_golden_cases"): + for index_name in ( + "ix_prompt_golden_cases_last_run_status", + "ix_prompt_golden_cases_scenario_key", + "ix_prompt_golden_cases_priority", + "ix_prompt_golden_cases_case_type", + ): + if _index_exists(bind, "prompt_golden_cases", index_name): + op.drop_index(index_name, table_name="prompt_golden_cases") + for column_name in ("last_failed_at", "last_run_status", "tags", "scenario_key", "priority", "case_type"): + if _column_exists(bind, "prompt_golden_cases", column_name): + op.drop_column("prompt_golden_cases", column_name) diff --git a/backend/alembic/versions/20260501_add_pmflow_launch_draft_jobs.py b/backend/alembic/versions/20260501_add_pmflow_launch_draft_jobs.py new file mode 100644 index 00000000..720d82c6 --- /dev/null +++ b/backend/alembic/versions/20260501_add_pmflow_launch_draft_jobs.py @@ -0,0 +1,95 @@ +"""add pmflow launch draft jobs + +Revision ID: 20260501_add_pmflow_launch_draft_jobs +Revises: 20260430_extend_prompt_eval_replay +Create Date: 2026-05-01 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +try: + from sqlalchemy.dialects.postgresql import JSONB +except Exception: # pragma: no cover + JSONB = sa.JSON + + +revision = "20260501_add_pmflow_launch_draft_jobs" +down_revision = "20260430_extend_prompt_eval_replay" +branch_labels = None +depends_on = None + + +def _json_type(bind): + return JSONB() if bind.dialect.name == "postgresql" else sa.JSON() + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def upgrade() -> None: + bind = op.get_bind() + if _table_exists(bind, "pmflow_launch_draft_jobs"): + return + + json_type = _json_type(bind) + op.create_table( + "pmflow_launch_draft_jobs", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False), + sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False), + sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("job_id", sa.String(length=64), nullable=False), + sa.Column("revision_id", sa.String(length=64), nullable=False), + sa.Column("parent_draft_id", sa.String(length=64), nullable=True), + sa.Column("superseded_by_job_id", sa.String(length=64), nullable=True), + sa.Column("result_draft_id", sa.String(length=64), nullable=True), + sa.Column("input_text", sa.Text(), nullable=False), + sa.Column("input_type", sa.String(length=32), nullable=False, server_default="text"), + sa.Column("source", sa.String(length=32), nullable=False, server_default="web"), + sa.Column("attachments", json_type, nullable=False), + sa.Column("mode", sa.String(length=32), nullable=False, server_default="create"), + sa.Column("revision_note", sa.Text(), nullable=True), + sa.Column("status", sa.String(length=32), nullable=False, server_default="queued"), + sa.Column("result_payload", json_type, nullable=True), + sa.Column("changed_fields", json_type, nullable=False), + sa.Column("error_code", sa.String(length=64), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("started_at", sa.TIMESTAMP(), nullable=True), + sa.Column("completed_at", sa.TIMESTAMP(), nullable=True), + sa.Column("expires_at", sa.TIMESTAMP(), nullable=False), + sa.UniqueConstraint("tenant_id", "job_id", name="uq_pmflow_launch_draft_jobs_tenant_job_id"), + sa.UniqueConstraint("tenant_id", "revision_id", name="uq_pmflow_launch_draft_jobs_tenant_revision_id"), + ) + op.create_index("ix_pmflow_launch_draft_jobs_tenant_id", "pmflow_launch_draft_jobs", ["tenant_id"], unique=False) + op.create_index("ix_pmflow_launch_draft_jobs_user_id", "pmflow_launch_draft_jobs", ["user_id"], unique=False) + op.create_index("ix_pmflow_launch_draft_jobs_job_id", "pmflow_launch_draft_jobs", ["job_id"], unique=False) + op.create_index("ix_pmflow_launch_draft_jobs_revision_id", "pmflow_launch_draft_jobs", ["revision_id"], unique=False) + op.create_index("ix_pmflow_launch_draft_jobs_parent_draft_id", "pmflow_launch_draft_jobs", ["parent_draft_id"], unique=False) + op.create_index("ix_pmflow_launch_draft_jobs_superseded_by_job_id", "pmflow_launch_draft_jobs", ["superseded_by_job_id"], unique=False) + op.create_index("ix_pmflow_launch_draft_jobs_result_draft_id", "pmflow_launch_draft_jobs", ["result_draft_id"], unique=False) + op.create_index("ix_pmflow_launch_draft_jobs_status", "pmflow_launch_draft_jobs", ["status"], unique=False) + op.create_index("ix_pmflow_launch_draft_jobs_expires_at", "pmflow_launch_draft_jobs", ["expires_at"], unique=False) + + +def downgrade() -> None: + bind = op.get_bind() + if not _table_exists(bind, "pmflow_launch_draft_jobs"): + return + for index_name in ( + "ix_pmflow_launch_draft_jobs_expires_at", + "ix_pmflow_launch_draft_jobs_status", + "ix_pmflow_launch_draft_jobs_result_draft_id", + "ix_pmflow_launch_draft_jobs_superseded_by_job_id", + "ix_pmflow_launch_draft_jobs_parent_draft_id", + "ix_pmflow_launch_draft_jobs_revision_id", + "ix_pmflow_launch_draft_jobs_job_id", + "ix_pmflow_launch_draft_jobs_user_id", + "ix_pmflow_launch_draft_jobs_tenant_id", + ): + op.drop_index(index_name, table_name="pmflow_launch_draft_jobs") + op.drop_table("pmflow_launch_draft_jobs") diff --git a/backend/alembic/versions/20260503_add_pmflow_ai_feedback_events.py b/backend/alembic/versions/20260503_add_pmflow_ai_feedback_events.py new file mode 100644 index 00000000..31a4efe8 --- /dev/null +++ b/backend/alembic/versions/20260503_add_pmflow_ai_feedback_events.py @@ -0,0 +1,120 @@ +"""add pmflow ai feedback events + +Revision ID: 20260503_add_pmflow_ai_feedback_events +Revises: 20260501_add_pmflow_launch_draft_jobs +Create Date: 2026-05-03 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +try: + from sqlalchemy.dialects.postgresql import JSONB +except Exception: # pragma: no cover + JSONB = sa.JSON + + +revision = "20260503_add_pmflow_ai_feedback_events" +down_revision = "20260501_add_pmflow_launch_draft_jobs" +branch_labels = None +depends_on = None + + +def _json_type(bind): + return JSONB() if bind.dialect.name == "postgresql" else sa.JSON() + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def upgrade() -> None: + bind = op.get_bind() + if _table_exists(bind, "pmflow_ai_feedback_events"): + return + + json_type = _json_type(bind) + op.create_table( + "pmflow_ai_feedback_events", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False), + sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False), + sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False), + sa.Column("thread_id", sa.BigInteger(), sa.ForeignKey("pmflow_threads.id"), nullable=True), + sa.Column("message_id", sa.BigInteger(), sa.ForeignKey("pmflow_thread_messages.id"), nullable=True), + sa.Column("change_set_id", sa.BigInteger(), sa.ForeignKey("pmflow_change_sets.id"), nullable=True), + sa.Column("ai_call_log_id", sa.BigInteger(), nullable=True), + sa.Column("actor_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("target_type", sa.String(length=64), nullable=False), + sa.Column("target_id", sa.String(length=128), nullable=False), + sa.Column("answer_kind", sa.String(length=64), nullable=False, server_default="chat_answer"), + sa.Column("rating", sa.String(length=16), nullable=False), + sa.Column("reason_tags", json_type, nullable=False), + sa.Column("comment", sa.Text(), nullable=True), + sa.Column("scene_key", sa.String(length=128), nullable=True), + sa.Column("prompt_key", sa.String(length=128), nullable=True), + sa.Column("prompt_release_id", sa.Integer(), nullable=True), + sa.Column("runtime_profile_id", sa.String(length=128), nullable=True), + sa.Column("model_provider", sa.String(length=64), nullable=True), + sa.Column("model_name", sa.String(length=120), nullable=True), + sa.Column("feedback_metadata", json_type, nullable=False), + sa.UniqueConstraint( + "tenant_id", + "project_id", + "actor_user_id", + "target_type", + "target_id", + name="uq_pmflow_ai_feedback_target_user", + ), + ) + for column_name in ( + "tenant_id", + "project_id", + "thread_id", + "message_id", + "change_set_id", + "ai_call_log_id", + "actor_user_id", + "target_type", + "target_id", + "answer_kind", + "rating", + "scene_key", + "prompt_key", + "prompt_release_id", + "runtime_profile_id", + ): + op.create_index( + f"ix_pmflow_ai_feedback_events_{column_name}", + "pmflow_ai_feedback_events", + [column_name], + unique=False, + ) + + +def downgrade() -> None: + bind = op.get_bind() + if not _table_exists(bind, "pmflow_ai_feedback_events"): + return + for column_name in ( + "runtime_profile_id", + "prompt_release_id", + "prompt_key", + "scene_key", + "rating", + "answer_kind", + "target_id", + "target_type", + "actor_user_id", + "ai_call_log_id", + "change_set_id", + "message_id", + "thread_id", + "project_id", + "tenant_id", + ): + op.drop_index(f"ix_pmflow_ai_feedback_events_{column_name}", table_name="pmflow_ai_feedback_events") + op.drop_table("pmflow_ai_feedback_events") diff --git a/backend/alembic/versions/20260505_add_pmflow_changeset_committer.py b/backend/alembic/versions/20260505_add_pmflow_changeset_committer.py new file mode 100644 index 00000000..ab180bbf --- /dev/null +++ b/backend/alembic/versions/20260505_add_pmflow_changeset_committer.py @@ -0,0 +1,45 @@ +"""add pmflow changeset committer + +Revision ID: 20260505_add_pmflow_changeset_committer +Revises: 20260503_add_pmflow_ai_feedback_events +Create Date: 2026-05-05 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260505_add_pmflow_changeset_committer" +down_revision = "20260503_add_pmflow_ai_feedback_events" +branch_labels = None +depends_on = None + + +def _column_exists(bind, table_name: str, column_name: str) -> bool: + if not sa.inspect(bind).has_table(table_name): + return False + return any(column["name"] == column_name for column in sa.inspect(bind).get_columns(table_name)) + + +def _index_exists(bind, table_name: str, index_name: str) -> bool: + if not sa.inspect(bind).has_table(table_name): + return False + return any(index["name"] == index_name for index in sa.inspect(bind).get_indexes(table_name)) + + +def upgrade() -> None: + bind = op.get_bind() + if not _column_exists(bind, "pmflow_change_sets", "committed_by"): + op.add_column("pmflow_change_sets", sa.Column("committed_by", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True)) + if not _index_exists(bind, "pmflow_change_sets", "ix_pmflow_change_sets_committed_by"): + op.create_index("ix_pmflow_change_sets_committed_by", "pmflow_change_sets", ["committed_by"], unique=False) + + +def downgrade() -> None: + bind = op.get_bind() + if _index_exists(bind, "pmflow_change_sets", "ix_pmflow_change_sets_committed_by"): + op.drop_index("ix_pmflow_change_sets_committed_by", table_name="pmflow_change_sets") + if _column_exists(bind, "pmflow_change_sets", "committed_by"): + op.drop_column("pmflow_change_sets", "committed_by") diff --git a/backend/app/api/ai_call_traces_api.py b/backend/app/api/ai_call_traces_api.py new file mode 100644 index 00000000..de1295bf --- /dev/null +++ b/backend/app/api/ai_call_traces_api.py @@ -0,0 +1,72 @@ +"""AI call trace audit API.""" + +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from fastapi.responses import JSONResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from ..core.database import get_db +from ..core.response import error_response, success_response +from ..dependencies.permissions import require_platform_ai_ops +from ..models import User +from ..services.ai_call_trace_service import AICallTraceService + +router = APIRouter(prefix="/ai-call-traces", tags=["ai_call_traces"]) + + +@router.get("") +async def list_ai_call_traces( + page: int = Query(default=1, ge=1), + page_size: int = Query(default=30, ge=1, le=100), + status: Optional[str] = Query(default=None, max_length=80), + provider: Optional[str] = Query(default=None, max_length=80), + model: Optional[str] = Query(default=None, max_length=160), + function_name: Optional[str] = Query(default=None, max_length=255), + scene_key: Optional[str] = Query(default=None, max_length=120), + slot_key: Optional[str] = Query(default=None, max_length=80), + prompt_key: Optional[str] = Query(default=None, max_length=128), + prompt_source: Optional[str] = Query(default=None, max_length=32), + prompt_release_id: Optional[int] = Query(default=None), + trace_id: Optional[str] = Query(default=None, max_length=120), + tenant_id: Optional[int] = Query(default=None), + keyword: Optional[str] = Query(default=None, max_length=300), + since_hours: Optional[int] = Query(default=168, ge=1, le=8760), + current_user: User = Depends(require_platform_ai_ops), + db: AsyncSession = Depends(get_db), +): + _ = current_user + data = await AICallTraceService(db).list_traces( + page=page, + page_size=page_size, + status=status, + provider=provider, + model=model, + function_name=function_name, + scene_key=scene_key, + slot_key=slot_key, + prompt_key=prompt_key, + prompt_source=prompt_source, + prompt_release_id=prompt_release_id, + trace_id=trace_id, + tenant_id=tenant_id, + keyword=keyword, + since_hours=since_hours, + ) + return success_response(data) + + +@router.get("/{trace_id_or_log_id}") +async def get_ai_call_trace( + trace_id_or_log_id: str, + current_user: User = Depends(require_platform_ai_ops), + db: AsyncSession = Depends(get_db), +): + _ = current_user + try: + data = await AICallTraceService(db).get_trace(trace_id_or_log_id) + except ValueError as exc: + return JSONResponse(status_code=404, content=error_response(404, str(exc))) + return success_response(data) diff --git a/backend/app/api/ai_pmo_api/routes_core.py b/backend/app/api/ai_pmo_api/routes_core.py index 06cd7482..364c3b4e 100644 --- a/backend/app/api/ai_pmo_api/routes_core.py +++ b/backend/app/api/ai_pmo_api/routes_core.py @@ -1,6 +1,7 @@ from __future__ import annotations from .shared import * +from .shared import _visible_work_item_filter from .helpers_inbox_filters import _build_latest_decision_projection, _should_hide_work_item_from_inbox, _safe_int from .helpers_inbox_payload import ( _build_closure_payload_overrides, @@ -11,6 +12,7 @@ from .helpers_inbox_payload import ( from .schemas import PMOProcessTextRequest from ...services.ai_pmo.project_operating_context_builder import ProjectOperatingContextBuilder + @router.post("/process-text") async def process_text( request: PMOProcessTextRequest, @@ -52,12 +54,12 @@ async def list_work_items( db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): - _ = current_user stmt = ( select(WorkItem, IngestEvent, Project) .join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id) .outerjoin(Project, and_(Project.tenant_id == tenant_id, Project.id == WorkItem.project_id)) .where(WorkItem.tenant_id == tenant_id) + .where(_visible_work_item_filter(current_user=current_user, tenant_id=tenant_id)) .order_by(WorkItem.updated_at.desc(), WorkItem.id.desc()) ) if state: @@ -103,6 +105,7 @@ async def get_work_item( and_( WorkItem.tenant_id == tenant_id, WorkItem.id == int(work_item_id), + _visible_work_item_filter(current_user=current_user, tenant_id=tenant_id), ) ) ) diff --git a/backend/app/api/ai_pmo_api/routes_resume.py b/backend/app/api/ai_pmo_api/routes_resume.py index c17178dc..c36d6554 100644 --- a/backend/app/api/ai_pmo_api/routes_resume.py +++ b/backend/app/api/ai_pmo_api/routes_resume.py @@ -1,6 +1,7 @@ from __future__ import annotations from .shared import * +from .shared import _visible_work_item_filter from .helpers_resume import _queue_resume_work_item, _reject_work_item from .schemas import PMODraftDecisionRequest, PMOResumeWorkItemRequest, PMOWorkItemDecisionRequest from ...services.ai_pmo.draft_confirm_handler import DraftConfirmHandler @@ -19,6 +20,7 @@ async def resume_work_item( and_( WorkItem.tenant_id == tenant_id, WorkItem.id == int(work_item_id), + _visible_work_item_filter(current_user=current_user, tenant_id=tenant_id), ) ).limit(1) ) @@ -61,6 +63,7 @@ async def decide_work_item( and_( WorkItem.tenant_id == tenant_id, WorkItem.id == int(work_item_id), + _visible_work_item_filter(current_user=current_user, tenant_id=tenant_id), ) ).limit(1) ) @@ -165,6 +168,7 @@ async def decide_draft_work_item( and_( WorkItem.tenant_id == tenant_id, WorkItem.id == int(work_item_id), + _visible_work_item_filter(current_user=current_user, tenant_id=tenant_id), ) ).limit(1) ) diff --git a/backend/app/api/ai_pmo_api/shared.py b/backend/app/api/ai_pmo_api/shared.py index 3b01ac3d..7548f93c 100644 --- a/backend/app/api/ai_pmo_api/shared.py +++ b/backend/app/api/ai_pmo_api/shared.py @@ -6,7 +6,7 @@ from typing import Any, Literal, Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field -from sqlalchemy import and_, select +from sqlalchemy import and_, or_, select from sqlalchemy.ext.asyncio import AsyncSession from ...core.logging import get_logger @@ -14,10 +14,40 @@ from ...core.response import success_response from ...core.database import AsyncSessionLocal, get_db from ...dependencies.auth import get_current_user from ...dependencies.tenant import get_tenant_id -from ...models import DecisionRecord, IngestEvent, PlanBundle, PortfolioSnapshot, Project, ReplyReceipt, User, WorkItem +from ...models import DecisionRecord, IngestEvent, PlanBundle, PortfolioSnapshot, Project, ReplyReceipt, User, UserProject, WorkItem +from ...services.auth import has_any_role from ...services.ai_pmo.ai_pmo_cadence import AIPMOCadenceService from ...services.ai_pmo.ai_pmo_kernel import AIPMOKernelService router = APIRouter(prefix="/ai-pmo", tags=["AI PMO"]) logger = get_logger(__name__) FRONT_NOISE_INTENTS = {"query.read_only", "query.answer"} + + +def _can_view_all_work_items(user: User) -> bool: + return has_any_role(str(getattr(user, "role", "") or ""), ["OWNER", "REVIEWER"]) + + +def _visible_work_item_filter(*, current_user: User, tenant_id: int): + if _can_view_all_work_items(current_user): + return True + return or_( + WorkItem.owner_user_id == int(current_user.id), + WorkItem.project_id.in_( + select(Project.id) + .outerjoin( + UserProject, + and_( + UserProject.tenant_id == Project.tenant_id, + UserProject.project_id == Project.id, + UserProject.user_id == int(current_user.id), + ), + ) + .where( + and_( + Project.tenant_id == int(tenant_id), + or_(Project.owner_id == int(current_user.id), UserProject.user_id.is_not(None)), + ) + ) + ), + ) diff --git a/backend/app/api/ai_readiness_api/__init__.py b/backend/app/api/ai_readiness_api/__init__.py new file mode 100644 index 00000000..12858cec --- /dev/null +++ b/backend/app/api/ai_readiness_api/__init__.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.database import get_db +from ...core.response import success_response +from ...dependencies import get_current_user, require_platform_ai_ops +from ...models import User +from ...services.ai_readiness_service import AIReadinessService + +router = APIRouter(prefix="/ai-readiness", tags=["ai-readiness"]) + + +@router.get("/project-launch") +async def get_project_launch_ai_readiness( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + payload = await AIReadinessService(db, tenant_id=int(current_user.tenant_id)).get_project_launch_readiness() + return success_response(payload) + + +@router.post("/project-launch/check") +async def run_project_launch_ai_readiness_check( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_platform_ai_ops), +): + payload = await AIReadinessService(db, tenant_id=int(current_user.tenant_id)).run_project_launch_check( + user_id=int(current_user.id), + ) + return success_response(payload) diff --git a/backend/app/api/ai_routing.py b/backend/app/api/ai_routing.py index 336e0201..4029c4ec 100644 --- a/backend/app/api/ai_routing.py +++ b/backend/app/api/ai_routing.py @@ -12,11 +12,15 @@ from sqlalchemy.ext.asyncio import AsyncSession from ..core.database import get_db from ..core.response import error_response, success_response from ..dependencies.auth import get_current_user +from ..dependencies.permissions import require_platform_ai_ops from ..dependencies.tenant import get_tenant_id from ..models import User +from ..services.ai_function_runtime_profile_service import AIFunctionRuntimeProfileService from ..services.ai_routing_governance_service import AIRoutingGovernanceService -from ..services.auth import can_approve_ai_routing_change, can_operate_ai_routing, can_view_ai_policy +from ..services.auth import can_approve_ai_routing_change, can_view_ai_policy from ..services.operation_log_service import log_operation +from ..services.pmflow_change_review_service import PMFlowChangeReviewError, PMFlowChangeReviewService +from ..services.pmflow_launch_service import PMFlowLaunchError, PMFlowLaunchService router = APIRouter(prefix="/ai-routing", tags=["ai_routing"]) @@ -37,6 +41,37 @@ class TeamPolicyUpdateRequest(BaseModel): quality_targets: list[str] = Field(default_factory=list, max_length=8) +class CapabilitySlotUpdateRequest(BaseModel): + primary_engine_id: Optional[str] = Field(default=None, max_length=120) + fallback_engine_ids: list[str] = Field(default_factory=list, max_length=5) + failure_policy: Literal["upgrade_to_high", "try_fallback_then_fail", "explicit_fail"] = "try_fallback_then_fail" + allow_scene_downgrade: bool = False + + +class CapabilitySlotPublishRequest(BaseModel): + summary: Optional[str] = Field(default=None, max_length=200) + + +class CapabilitySlotRollbackRequest(BaseModel): + target_version: Optional[str] = Field(default=None, max_length=80) + + +class FunctionRuntimeProfileUpdateRequest(BaseModel): + max_tokens: Optional[int] = Field(default=None, ge=1, le=20000) + repair_max_tokens: Optional[int] = Field(default=None, ge=1, le=20000) + temperature: Optional[float] = Field(default=None, ge=0, le=1) + foreground_wait_seconds: Optional[int] = Field(default=None, ge=3, le=30) + provider_timeout_seconds: Optional[int] = Field(default=None, ge=10, le=300) + job_timeout_seconds: Optional[int] = Field(default=None, ge=10, le=900) + max_retries: Optional[int] = Field(default=None, ge=0, le=3) + large_input_policy: Optional[Literal["direct_generate", "preprocess_then_generate"]] = None + response_mode: Optional[str] = Field(default=None, max_length=32) + + +class FunctionRuntimeProfilePublishRequest(BaseModel): + summary: Optional[str] = Field(default=None, max_length=200) + + def _forbidden(message: str) -> JSONResponse: return JSONResponse(status_code=403, content=error_response(403, message)) @@ -45,14 +80,16 @@ def _service(db: AsyncSession, tenant_id: int) -> AIRoutingGovernanceService: return AIRoutingGovernanceService(db, tenant_id=int(tenant_id)) +def _profile_service(db: AsyncSession) -> AIFunctionRuntimeProfileService: + return AIFunctionRuntimeProfileService(db) + + @router.get("/overview") async def get_ai_routing_overview( db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_platform_ai_ops), ): - if not can_view_ai_policy(str(current_user.role or "")): - return _forbidden("仅团队负责人可查看 AI 路由概览") data = await _service(db, tenant_id).get_overview() return success_response(data) @@ -104,21 +141,304 @@ async def update_ai_team_policy( async def get_ai_routing_admin_console( db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_platform_ai_ops), ): - if not can_view_ai_policy(str(current_user.role or "")): - return _forbidden("仅团队负责人可查看 AI 路由控制台") data = await _service(db, tenant_id).get_admin_console() return success_response(data) +@router.get("/capability-slots") +async def get_ai_capability_slots( + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_platform_ai_ops), +): + data = await _service(db, tenant_id).get_capability_slots() + return success_response(data) + + +@router.put("/capability-slots/{slot_key}") +async def update_ai_capability_slot( + slot_key: str, + body: CapabilitySlotUpdateRequest, + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_platform_ai_ops), +): + service = _service(db, tenant_id) + before = await service.get_capability_slots() + try: + after = await service.update_capability_slot( + slot_key=slot_key, + payload=body.model_dump(mode="json"), + updated_by=int(current_user.id), + ) + except ValueError as exc: + await db.rollback() + return JSONResponse(status_code=400, content=error_response(400, str(exc))) + await log_operation( + db, + tenant_id=int(tenant_id), + user_id=int(current_user.id), + project_id=None, + entity_type="ai_capability_slot", + entity_id=str(slot_key), + action="draft_update", + before_data=before, + after_data=after, + remark="保存 AI 能力槽位草稿", + ) + await db.commit() + return success_response(after) + + +@router.put("/capability-slots/{slot_key}/draft") +async def update_ai_capability_slot_draft( + slot_key: str, + body: CapabilitySlotUpdateRequest, + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_platform_ai_ops), +): + return await update_ai_capability_slot( + slot_key=slot_key, + body=body, + db=db, + tenant_id=tenant_id, + current_user=current_user, + ) + + +@router.post("/capability-slots/validate") +async def validate_ai_capability_slots( + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_platform_ai_ops), +): + data = await _service(db, tenant_id).validate_capability_slots() + return success_response(data) + + +@router.post("/capability-slots/publish") +async def publish_ai_capability_slots( + body: CapabilitySlotPublishRequest, + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_platform_ai_ops), +): + service = _service(db, tenant_id) + before = await service.get_capability_slots() + try: + after = await service.publish_capability_slots( + published_by=int(current_user.id), + summary=body.summary, + ) + except ValueError as exc: + await db.rollback() + return JSONResponse(status_code=400, content=error_response(400, str(exc))) + await log_operation( + db, + tenant_id=int(tenant_id), + user_id=int(current_user.id), + project_id=None, + entity_type="ai_capability_slot_release", + entity_id=str(after.get("active_version") or ""), + action="publish", + before_data=before, + after_data=after, + remark="发布 AI 能力槽位版本", + ) + await db.commit() + return success_response(after) + + +@router.post("/capability-slots/rollback") +async def rollback_ai_capability_slots( + body: CapabilitySlotRollbackRequest, + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_platform_ai_ops), +): + service = _service(db, tenant_id) + before = await service.get_capability_slots() + try: + after = await service.rollback_capability_slots( + target_version=body.target_version, + rolled_back_by=int(current_user.id), + ) + except ValueError as exc: + await db.rollback() + return JSONResponse(status_code=400, content=error_response(400, str(exc))) + await log_operation( + db, + tenant_id=int(tenant_id), + user_id=int(current_user.id), + project_id=None, + entity_type="ai_capability_slot_release", + entity_id=str(after.get("active_version") or ""), + action="rollback", + before_data=before, + after_data=after, + remark="回滚 AI 能力槽位版本", + ) + await db.commit() + return success_response(after) + + +@router.get("/function-runtime-profiles") +async def get_ai_function_runtime_profiles( + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_platform_ai_ops), +): + _ = tenant_id + data = await _profile_service(db).get_profiles() + return success_response(data) + + +@router.put("/function-runtime-profiles/{function_name}/draft") +async def update_ai_function_runtime_profile_draft( + function_name: str, + body: FunctionRuntimeProfileUpdateRequest, + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_platform_ai_ops), +): + _ = tenant_id + service = _profile_service(db) + before = await service.get_profiles() + try: + after = await service.update_profile( + function_name=function_name, + payload=body.model_dump(exclude_none=True, mode="json"), + updated_by=int(current_user.id), + ) + except ValueError as exc: + await db.rollback() + return JSONResponse(status_code=400, content=error_response(400, str(exc))) + await log_operation( + db, + tenant_id=int(tenant_id), + user_id=int(current_user.id), + project_id=None, + entity_type="ai_function_runtime_profile", + entity_id=str(function_name), + action="draft_update", + before_data=before, + after_data=after, + remark="保存函数运行合同草稿", + ) + await db.commit() + return success_response(after) + + +@router.post("/function-runtime-profiles/validate") +async def validate_ai_function_runtime_profiles( + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_platform_ai_ops), +): + _ = tenant_id + data = await _profile_service(db).get_profiles() + return success_response(data.get("validation") or {}) + + +@router.post("/function-runtime-profiles/{function_name}/smoke") +async def smoke_ai_function_runtime_profile( + function_name: str, + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_platform_ai_ops), +): + profile_service = _profile_service(db) + try: + if function_name == PMFlowLaunchService.DRAFT_FUNCTION_NAME: + draft_payload = await PMFlowLaunchService(db, tenant_id=int(tenant_id)).generate_draft_payload_for_readiness_check( + user_id=int(current_user.id), + profile_stage="draft", + ) + summary = ( + f"success: requirements={len(draft_payload.requirements)}, tasks={len(draft_payload.tasks)}, " + f"risks={len(draft_payload.risks)}, decisions={len(draft_payload.decisions)}" + ) + elif function_name == PMFlowChangeReviewService.AI_EXTRACTION_FUNCTION_NAME: + extraction_payload = await PMFlowChangeReviewService(db, tenant_id=int(tenant_id)).generate_extraction_payload_for_readiness_check( + user_id=int(current_user.id), + profile_stage="draft", + ) + summary = f"success: proposed_items={len(extraction_payload.get('proposed_items') or [])}" + else: + raise ValueError("当前函数暂未接入受控 smoke") + after = await profile_service.record_smoke( + function_name=function_name, + status="success", + summary=summary, + checked_by=int(current_user.id), + ) + except (ValueError, PMFlowLaunchError, PMFlowChangeReviewError, RuntimeError) as exc: + after = await profile_service.record_smoke( + function_name=function_name, + status="failed", + summary=str(exc), + checked_by=int(current_user.id), + ) + await db.commit() + return JSONResponse(status_code=400, content=error_response(400, str(exc), after)) + await log_operation( + db, + tenant_id=int(tenant_id), + user_id=int(current_user.id), + project_id=None, + entity_type="ai_function_runtime_profile", + entity_id=str(function_name), + action="smoke", + before_data=None, + after_data=after, + remark="运行函数运行合同 smoke", + ) + await db.commit() + return success_response(after) + + +@router.post("/function-runtime-profiles/publish") +async def publish_ai_function_runtime_profiles( + body: FunctionRuntimeProfilePublishRequest, + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_platform_ai_ops), +): + _ = tenant_id + service = _profile_service(db) + before = await service.get_profiles() + try: + after = await service.publish_profiles( + published_by=int(current_user.id), + summary=body.summary, + ) + except ValueError as exc: + await db.rollback() + return JSONResponse(status_code=400, content=error_response(400, str(exc))) + await log_operation( + db, + tenant_id=int(tenant_id), + user_id=int(current_user.id), + project_id=None, + entity_type="ai_function_runtime_profile_release", + entity_id=str(after.get("active_version") or ""), + action="publish", + before_data=before, + after_data=after, + remark="发布函数运行合同版本", + ) + await db.commit() + return success_response(after) + + @router.post("/diagnostics/run") async def run_ai_routing_diagnostics( db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_platform_ai_ops), ): - if not can_operate_ai_routing(str(current_user.role or "")): - return _forbidden("仅 OWNER / REVIEWER 可执行 AI 路由诊断") data = await _service(db, tenant_id).run_diagnostics() return success_response(data) diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 6b8541d0..75e0f0cb 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -1,14 +1,23 @@ """认证 API""" -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import or_ from sqlalchemy.future import select from ..core.database import get_db from ..models import User, UserStatus -from ..schemas import UserLoginResponse +from ..schemas import PhoneCodeSendRequest, PhoneCodeVerifyRequest, UserLoginResponse, UserRegisterRequest from ..services.auth import AuthService +from ..services.phone_auth_service import send_phone_verification_code, verify_phone_and_login +from ..services.platform_access_service import get_platform_context +from ..services.workspace_service import ( + ensure_active_workspace_membership, + ensure_personal_workspace, + list_user_workspaces, + register_user_with_personal_workspace, +) router = APIRouter(prefix="/auth", tags=["auth"]) @@ -20,7 +29,7 @@ async def login( ): """用户登录,返回 JWT""" result = await db.execute( - select(User).where(User.username == credentials.username) + select(User).where(or_(User.username == credentials.username, User.phone_e164 == credentials.username)) ) user = result.scalar_one_or_none() @@ -39,10 +48,76 @@ async def login( detail="User is disabled", ) + await ensure_personal_workspace(db, user=user, commit=False) + await ensure_active_workspace_membership(db, user=user, commit=False) + await db.commit() access_token = AuthService.create_access_token(data={"sub": user.username}) + workspaces = await list_user_workspaces(db, user=user) + platform_context = await get_platform_context(db, user=user) return UserLoginResponse( access_token=access_token, token_type="bearer", - user=user.to_dict(), + user={**user.to_dict(), "workspace_context": workspaces, "platform_context": platform_context}, + ) + + +@router.post("/phone/send-code") +async def send_phone_code( + body: PhoneCodeSendRequest, + request: Request, + db: AsyncSession = Depends(get_db), +): + """发送手机号验证码。开发环境会返回 dev_code,生产环境接短信服务商后不返回验证码。""" + return await send_phone_verification_code( + db, + country_code=body.phone_country_code, + phone_number=body.phone_number, + purpose=body.purpose, + request_ip=request.client.host if request.client else None, + ) + + +@router.post("/phone/verify", response_model=UserLoginResponse) +async def verify_phone_code_login( + body: PhoneCodeVerifyRequest, + db: AsyncSession = Depends(get_db), +): + """手机号验证码登录/自注册,并可绑定邀请。""" + result = await verify_phone_and_login( + db, + country_code=body.phone_country_code, + phone_number=body.phone_number, + code=body.code, + purpose=body.purpose, + invite_token=body.invite_token, + name=body.name, + ) + return UserLoginResponse( + access_token=result["access_token"], + token_type=result["token_type"], + user=result["user"], + ) + + +@router.post("/register", response_model=UserLoginResponse) +async def register( + body: UserRegisterRequest, + db: AsyncSession = Depends(get_db), +): + """注册账号并自动创建个人空间。""" + user = await register_user_with_personal_workspace( + db, + username=body.username, + password=body.password, + name=body.name, + email=body.email or f"{body.username}@placeholder.local", + ) + access_token = AuthService.create_access_token(data={"sub": user.username}) + workspaces = await list_user_workspaces(db, user=user) + platform_context = await get_platform_context(db, user=user) + return UserLoginResponse( + access_token=access_token, + token_type="bearer", + user={**user.to_dict(), "workspace_context": workspaces, "platform_context": platform_context}, ) diff --git a/backend/app/api/llm_engines.py b/backend/app/api/llm_engines.py index 61358a95..5159f9f8 100644 --- a/backend/app/api/llm_engines.py +++ b/backend/app/api/llm_engines.py @@ -10,10 +10,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from ..core.response import success_response from ..core.database import get_db -from ..dependencies.auth import get_current_user +from ..dependencies.permissions import require_platform_ai_ops from ..dependencies.tenant import get_tenant_id from ..models import User -from ..services.auth import has_any_role from ..services.llm_service import llm_service router = APIRouter(prefix="/ai-steward/llm-engines", tags=["AI Steward"]) @@ -21,37 +20,78 @@ router = APIRouter(prefix="/ai-steward/llm-engines", tags=["AI Steward"]) class LLMEngineCreateRequest(BaseModel): name: str = Field(..., min_length=1, max_length=80) - provider: Literal["deepseek", "glm", "openai", "ollama"] + provider: Literal["deepseek", "dashscope", "openai", "ollama"] api_key: Optional[str] = Field(default="", max_length=500) model: Optional[str] = Field(default=None, max_length=120) base_url: Optional[str] = Field(default=None, max_length=255) set_active: bool = True + capabilities: dict[str, object] = Field(default_factory=dict) class LLMEngineUpdateRequest(BaseModel): name: Optional[str] = Field(default=None, min_length=1, max_length=80) - provider: Optional[Literal["deepseek", "glm", "openai", "ollama"]] = None + provider: Optional[Literal["deepseek", "dashscope", "openai", "ollama"]] = None api_key: Optional[str] = Field(default=None, max_length=500) model: Optional[str] = Field(default=None, max_length=120) base_url: Optional[str] = Field(default=None, max_length=255) + capabilities: Optional[dict[str, object]] = None class LLMEngineTestRequest(BaseModel): prompt: Optional[str] = Field(default=None, max_length=1000) +class LLMEngineCapabilityProbeRequest(BaseModel): + mode: Literal["quick", "full", "advanced"] = "full" + targets: list[ + Literal[ + "basic", + "chat", + "basic_chat", + "model_self_report", + "self_report", + "model_report", + "structured", + "structured_output", + "json", + "json_schema", + "tool", + "tool_calling", + "function_calling", + "stream", + "streaming", + "long_context", + "long_context_32k", + "long_context_128k", + "vision", + "vision_input", + "audio", + "audio_input", + ] + ] = Field(default_factory=list, max_length=12) + allow_high_cost: bool = False + + +class LLMEngineCapabilityAttestationRequest(BaseModel): + capability_key: Literal["long_context"] = "long_context" + tested_tokens: int = Field(..., gt=0, le=10_000_000) + source_type: Literal["official_doc", "provider_console", "contract", "manual_verification"] = "manual_verification" + source_url: Optional[str] = Field(default=None, max_length=500) + source_note: Optional[str] = Field(default=None, max_length=500) + expires_at: Optional[str] = Field(default=None, max_length=80) + + class LLMEngineSettingsUpdateRequest(BaseModel): enable_local_kb_qa: bool = False -def _require_manager(user: User) -> None: - if not has_any_role(str(user.role or ""), ["OWNER", "REVIEWER"]): - raise HTTPException(status_code=403, detail="仅管理员可操作 LLM 引擎配置") +class LLMEngineEnabledUpdateRequest(BaseModel): + enabled: bool = True @router.get("") async def list_llm_engines( - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_platform_ai_ops), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): @@ -63,11 +103,10 @@ async def list_llm_engines( @router.post("") async def create_llm_engine( body: LLMEngineCreateRequest, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_platform_ai_ops), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): - _require_manager(current_user) try: engine = await llm_service.create_engine( db, @@ -78,6 +117,7 @@ async def create_llm_engine( model=body.model, base_url=body.base_url, set_active=body.set_active, + capabilities=body.capabilities, ) all_items = await llm_service.list_engines(db, tenant_id=tenant_id) return success_response({"engine": engine, **all_items}) @@ -88,11 +128,10 @@ async def create_llm_engine( @router.put("/settings") async def update_llm_engine_settings( body: LLMEngineSettingsUpdateRequest, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_platform_ai_ops), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): - _require_manager(current_user) try: settings = await llm_service.update_engine_settings( db, @@ -109,11 +148,10 @@ async def update_llm_engine_settings( async def update_llm_engine( engine_id: str, body: LLMEngineUpdateRequest, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_platform_ai_ops), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): - _require_manager(current_user) try: engine = await llm_service.update_engine( db, @@ -124,6 +162,7 @@ async def update_llm_engine( api_key=body.api_key, model=body.model, base_url=body.base_url, + capabilities=body.capabilities, ) all_items = await llm_service.list_engines(db, tenant_id=tenant_id) return success_response({"engine": engine, **all_items}) @@ -134,13 +173,12 @@ async def update_llm_engine( @router.post("/{engine_id}/activate") async def activate_llm_engine( engine_id: str, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_platform_ai_ops), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): - _require_manager(current_user) try: - result = await llm_service.activate_engine( + result = await llm_service.set_default_fallback_engine( db, tenant_id=tenant_id, engine_id=engine_id, @@ -151,20 +189,60 @@ async def activate_llm_engine( raise HTTPException(status_code=400, detail=str(exc)) from exc +@router.post("/{engine_id}/default-fallback") +async def set_llm_engine_default_fallback( + engine_id: str, + current_user: User = Depends(require_platform_ai_ops), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + try: + result = await llm_service.set_default_fallback_engine( + db, + tenant_id=tenant_id, + engine_id=engine_id, + ) + all_items = await llm_service.list_engines(db, tenant_id=tenant_id) + return success_response({**result, **all_items}) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.put("/{engine_id}/enabled") +async def update_llm_engine_enabled( + engine_id: str, + body: LLMEngineEnabledUpdateRequest, + current_user: User = Depends(require_platform_ai_ops), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + try: + result = await llm_service.set_engine_enabled( + db, + tenant_id=tenant_id, + engine_id=engine_id, + enabled=body.enabled, + ) + all_items = await llm_service.list_engines(db, tenant_id=tenant_id) + return success_response({**result, **all_items}) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @router.post("/test-active") async def test_active_llm_engine( body: LLMEngineTestRequest, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_platform_ai_ops), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): - _require_manager(current_user) try: data = await llm_service.test_engine_connection( db, tenant_id=tenant_id, engine_id=None, prompt=body.prompt, + user_id=int(current_user.id), ) return success_response(data) except ValueError as exc: @@ -175,31 +253,81 @@ async def test_active_llm_engine( async def test_llm_engine( engine_id: str, body: LLMEngineTestRequest, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_platform_ai_ops), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): - _require_manager(current_user) try: data = await llm_service.test_engine_connection( db, tenant_id=tenant_id, engine_id=engine_id, prompt=body.prompt, + user_id=int(current_user.id), ) return success_response(data) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc +@router.post("/{engine_id}/capability-probe") +async def probe_llm_engine_capabilities( + engine_id: str, + body: LLMEngineCapabilityProbeRequest, + current_user: User = Depends(require_platform_ai_ops), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + try: + data = await llm_service.probe_engine_capabilities( + db, + tenant_id=tenant_id, + engine_id=engine_id, + targets=body.targets, + mode=body.mode, + allow_high_cost=body.allow_high_cost, + user_id=int(current_user.id), + ) + all_items = await llm_service.list_engines(db, tenant_id=tenant_id) + return success_response({"probe": data, **all_items}) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/{engine_id}/capability-attest") +async def attest_llm_engine_capability( + engine_id: str, + body: LLMEngineCapabilityAttestationRequest, + current_user: User = Depends(require_platform_ai_ops), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + try: + engine = await llm_service.attest_engine_capability( + db, + tenant_id=tenant_id, + engine_id=engine_id, + capability_key=body.capability_key, + tested_tokens=body.tested_tokens, + source_type=body.source_type, + source_url=body.source_url, + source_note=body.source_note, + expires_at=body.expires_at, + user_id=int(current_user.id), + ) + all_items = await llm_service.list_engines(db, tenant_id=tenant_id) + return success_response({"engine": engine, **all_items}) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @router.delete("/{engine_id}") async def delete_llm_engine( engine_id: str, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_platform_ai_ops), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): - _require_manager(current_user) try: result = await llm_service.delete_engine( db, diff --git a/backend/app/api/llm_providers.py b/backend/app/api/llm_providers.py index 14f799a3..f8ab94c8 100644 --- a/backend/app/api/llm_providers.py +++ b/backend/app/api/llm_providers.py @@ -20,19 +20,19 @@ def _build_provider_payload() -> dict[str, Any]: low_tier = dict(tier_defaults.get("low") or {}) high_tier = dict(tier_defaults.get("high") or {}) openai_defaults = dict(provider_defaults.get("openai") or {}) - glm_defaults = dict(provider_defaults.get("glm") or {}) + dashscope_defaults = dict(provider_defaults.get("dashscope") or {}) deepseek_defaults = dict(provider_defaults.get("deepseek") or {}) ollama_defaults = dict(provider_defaults.get("ollama") or {}) high_model = str(high_tier.get("model") or openai_defaults.get("model") or "GLM-5").strip() return { - "low": str(low_tier.get("model") or glm_defaults.get("model") or "qwen-plus").strip(), + "low": str(low_tier.get("model") or dashscope_defaults.get("model") or "qwen-plus").strip(), "high": high_model, "base_url": str(high_tier.get("base_url") or openai_defaults.get("base_url") or "https://api.edgefn.net/v1").strip(), "providers": { - "glm": { - "model": str(glm_defaults.get("model") or "qwen-plus").strip(), - "base_url": str(glm_defaults.get("base_url") or "https://dashscope.aliyuncs.com/compatible-mode/v1").strip(), - "api_env": str(glm_defaults.get("api_env") or "DASHSCOPE_API_KEY").strip(), + "dashscope": { + "model": str(dashscope_defaults.get("model") or "qwen-plus").strip(), + "base_url": str(dashscope_defaults.get("base_url") or "https://dashscope.aliyuncs.com/compatible-mode/v1").strip(), + "api_env": str(dashscope_defaults.get("api_env") or "DASHSCOPE_API_KEY").strip(), }, "openai": { "model": high_model, diff --git a/backend/app/api/model_capability_api/routes_context.py b/backend/app/api/model_capability_api/routes_context.py index ccdef2a1..818fd3b5 100644 --- a/backend/app/api/model_capability_api/routes_context.py +++ b/backend/app/api/model_capability_api/routes_context.py @@ -34,12 +34,13 @@ async def analyze_cost_optimization( @router.get("/context/projects/{project_id}/truth-nodes") async def list_project_truth_nodes( project_id: int, + permission_user: User = Depends(require_project_member_or_manager), current_user: User = Depends(get_current_user), tenant_id: int = Depends(get_tenant_id), db: AsyncSession = Depends(get_db), ): """获取项目上下文中的宪章列表,用于前端选择器。""" - _ = current_user + _ = (current_user, permission_user) result = await db.execute( select(TruthNode).where( and_( @@ -66,12 +67,13 @@ async def list_project_truth_nodes( @router.get("/context/projects/{project_id}/experiments") async def list_project_experiments( project_id: int, + permission_user: User = Depends(require_project_member_or_manager), current_user: User = Depends(get_current_user), tenant_id: int = Depends(get_tenant_id), db: AsyncSession = Depends(get_db), ): """获取项目上下文中的实验列表(基于 linked_context_nodes 关联)。""" - _ = current_user + _ = (current_user, permission_user) truth_ids_result = await db.execute( select(TruthNode.id).where( and_( @@ -128,4 +130,3 @@ async def list_project_experiments( } ) return success_response(data={"items": items}) - diff --git a/backend/app/api/model_capability_api/shared.py b/backend/app/api/model_capability_api/shared.py index 4febbf53..27a6359b 100644 --- a/backend/app/api/model_capability_api/shared.py +++ b/backend/app/api/model_capability_api/shared.py @@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from ...core.response import ErrorCode, error_response, success_response from ...core.database import get_db -from ...dependencies import get_current_user, get_tenant_id +from ...dependencies import get_current_user, get_tenant_id, require_project_member_or_manager from ...models import Experiment, TruthNode, User from ...schemas.model_capability import ( CostOptimizationAnalyzeRequest, diff --git a/backend/app/api/model_routing.py b/backend/app/api/model_routing.py index 31dc5a77..6c80b92c 100644 --- a/backend/app/api/model_routing.py +++ b/backend/app/api/model_routing.py @@ -11,10 +11,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from ..core.response import error_response, success_response from ..core.database import get_db -from ..dependencies import get_current_user +from ..dependencies import require_platform_ai_ops from ..models import User -from ..services.auth import has_any_role -from ..services.model.model_routing import ModelRoutingService +from ..services.model.model_routing import ModelRoutingService, PLATFORM_RUNTIME_TENANT_ID from ..services.operation_log_service import log_operation router = APIRouter(prefix="/model-routing", tags=["model_routing"]) @@ -27,9 +26,9 @@ class ModelRoutingUpdateRequest(BaseModel): @router.get("/functions") async def list_model_routing( db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_platform_ai_ops), ): - tenant_id = int(current_user.tenant_id) + tenant_id = PLATFORM_RUNTIME_TENANT_ID items = await ModelRoutingService.list_effective_routing(db, tenant_id=tenant_id) return success_response({"items": items}) @@ -38,9 +37,9 @@ async def list_model_routing( async def get_model_routing( function_name: str, db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_platform_ai_ops), ): - tenant_id = int(current_user.tenant_id) + tenant_id = PLATFORM_RUNTIME_TENANT_ID data = await ModelRoutingService.get_effective_routing( db, tenant_id=tenant_id, @@ -54,12 +53,9 @@ async def update_model_routing( function_name: str, body: ModelRoutingUpdateRequest, db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_platform_ai_ops), ): - if not has_any_role(str(current_user.role or ""), ["OWNER", "REVIEWER"]): - return JSONResponse(status_code=403, content=error_response(403, "仅管理员可修改模型路由配置")) - - tenant_id = int(current_user.tenant_id) + tenant_id = PLATFORM_RUNTIME_TENANT_ID before = await ModelRoutingService.get_effective_routing( db, tenant_id=tenant_id, diff --git a/backend/app/api/pmflow_launch_api/__init__.py b/backend/app/api/pmflow_launch_api/__init__.py index 390349b8..fa6b8f83 100644 --- a/backend/app/api/pmflow_launch_api/__init__.py +++ b/backend/app/api/pmflow_launch_api/__init__.py @@ -1,19 +1,27 @@ from __future__ import annotations +import asyncio + from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse from sqlalchemy.ext.asyncio import AsyncSession -from ...core.database import get_db +from ...core.database import AsyncSessionLocal, get_db from ...core.response import error_response, success_response from ...dependencies import get_current_user from ...models import User -from ...schemas.pmflow_launch import PMFlowLaunchConfirmRequest, PMFlowLaunchDraftRequest +from ...schemas.pmflow_launch import PMFlowLaunchConfirmRequest, PMFlowLaunchDraftJobRequest, PMFlowLaunchDraftRequest from ...services.pmflow_launch_service import PMFlowLaunchError, PMFlowLaunchService router = APIRouter(prefix="/pmflow-launch", tags=["pmflow-launch"]) +async def _run_launch_draft_job(*, tenant_id: int, job_id: str) -> None: + async with AsyncSessionLocal() as bg_db: + service = PMFlowLaunchService(bg_db, tenant_id=int(tenant_id)) + await service.run_draft_job(job_id=job_id) + + @router.post("/drafts") async def create_pmflow_launch_draft( body: PMFlowLaunchDraftRequest, @@ -28,6 +36,73 @@ async def create_pmflow_launch_draft( return success_response(payload.model_dump(mode="json")) +@router.post("/draft-jobs") +async def create_pmflow_launch_draft_job( + body: PMFlowLaunchDraftJobRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + service = PMFlowLaunchService(db, tenant_id=int(current_user.tenant_id)) + try: + payload = await service.create_draft_job(body=body, user_id=int(current_user.id)) + except PMFlowLaunchError as exc: + return JSONResponse(status_code=exc.status_code, content=error_response(exc.status_code, f"{exc.code}: {exc.message}")) + asyncio.create_task( + _run_launch_draft_job(tenant_id=int(current_user.tenant_id), job_id=payload.job_id), + name=f"pmflow-launch-draft-job-{payload.job_id}", + ) + return success_response(payload.model_dump(mode="json")) + + +@router.get("/draft-jobs/{job_id}") +async def get_pmflow_launch_draft_job( + job_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + service = PMFlowLaunchService(db, tenant_id=int(current_user.tenant_id)) + try: + payload = await service.get_draft_job(job_id=job_id, user_id=int(current_user.id)) + except PMFlowLaunchError as exc: + return JSONResponse(status_code=exc.status_code, content=error_response(exc.status_code, f"{exc.code}: {exc.message}")) + return success_response(payload.model_dump(mode="json")) + + +@router.post("/draft-jobs/{job_id}/cancel") +async def cancel_pmflow_launch_draft_job( + job_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + service = PMFlowLaunchService(db, tenant_id=int(current_user.tenant_id)) + try: + payload = await service.cancel_draft_job(job_id=job_id, user_id=int(current_user.id)) + except PMFlowLaunchError as exc: + return JSONResponse(status_code=exc.status_code, content=error_response(exc.status_code, f"{exc.code}: {exc.message}")) + return success_response(payload.model_dump(mode="json")) + + +@router.post("/drafts/{draft_id}/revise") +async def revise_pmflow_launch_draft( + draft_id: str, + body: PMFlowLaunchDraftJobRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + mode = body.mode if body.mode in {"merge_supplement", "regenerate"} else "merge_supplement" + normalized_body = body.model_copy(update={"parent_draft_id": draft_id, "mode": mode}) + service = PMFlowLaunchService(db, tenant_id=int(current_user.tenant_id)) + try: + payload = await service.create_draft_job(body=normalized_body, user_id=int(current_user.id)) + except PMFlowLaunchError as exc: + return JSONResponse(status_code=exc.status_code, content=error_response(exc.status_code, f"{exc.code}: {exc.message}")) + asyncio.create_task( + _run_launch_draft_job(tenant_id=int(current_user.tenant_id), job_id=payload.job_id), + name=f"pmflow-launch-draft-job-{payload.job_id}", + ) + return success_response(payload.model_dump(mode="json")) + + @router.post("/drafts/{draft_id}/confirm") async def confirm_pmflow_launch_draft( draft_id: str, diff --git a/backend/app/api/pmflow_os_api/__init__.py b/backend/app/api/pmflow_os_api/__init__.py index f190a079..344c2c9d 100644 --- a/backend/app/api/pmflow_os_api/__init__.py +++ b/backend/app/api/pmflow_os_api/__init__.py @@ -12,6 +12,7 @@ from ...core.response import error_response, success_response from ...dependencies import get_current_user from ...models import Project, User, UserProject from ...schemas.pmflow_os import ( + PMFlowAIFeedbackCreateRequest, PMFlowCapacityOverridePayload, PMFlowChangeItemDeferRequest, PMFlowChangeItemEditRequest, @@ -29,6 +30,7 @@ from ...schemas.pmflow_os import ( ) from ...services.auth import has_any_role from ...services.adaptive_capacity_service import AdaptiveCapacityService +from ...services.pmflow_ai_feedback_service import PMFlowAIFeedbackService from ...services.pmflow_change_review_service import PMFlowChangeReviewError, PMFlowChangeReviewService from ...services.pmflow_os_projection_service import PMFlowOSProjectionService from ...services.team_execution_capability_service import TeamExecutionCapabilityService @@ -107,6 +109,171 @@ async def get_pmflow_os_truth_state( return success_response(payload.model_dump(mode="json")) +@router.get("/projects/{project_id}/project-memory/overview") +async def get_pmflow_os_project_memory_overview( + project_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + tenant_id = int(current_user.tenant_id) + await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user) + service = PMFlowOSProjectionService(db, tenant_id=tenant_id) + try: + payload = await service.get_project_memory_overview( + project_id=int(project_id), + current_user_id=int(current_user.id), + ) + except ValueError as exc: + if str(exc) == "project_not_found": + raise HTTPException(status_code=404, detail="project_not_found") from exc + raise + return success_response(payload) + + +@router.get("/projects/{project_id}/project-memory/items") +async def get_pmflow_os_project_memory_items( + project_id: int, + category: str | None = Query(default=None), + stage: str | None = Query(default=None), + source_type: str | None = Query(default=None), + status: str | None = Query(default=None), + query: str | None = Query(default=None), + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + tenant_id = int(current_user.tenant_id) + await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user) + service = PMFlowOSProjectionService(db, tenant_id=tenant_id) + try: + payload = await service.get_project_memory_items( + project_id=int(project_id), + category=category, + stage=stage, + source_type=source_type, + status=status, + query=query, + limit=int(limit), + offset=int(offset), + ) + except ValueError as exc: + if str(exc) == "project_not_found": + raise HTTPException(status_code=404, detail="project_not_found") from exc + raise + return success_response(payload) + + +@router.get("/projects/{project_id}/project-memory/items/{item_id}") +async def get_pmflow_os_project_memory_item( + project_id: int, + item_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + tenant_id = int(current_user.tenant_id) + await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user) + service = PMFlowOSProjectionService(db, tenant_id=tenant_id) + try: + payload = await service.get_project_memory_item(project_id=int(project_id), item_id=item_id) + except ValueError as exc: + if str(exc) == "project_not_found": + raise HTTPException(status_code=404, detail="project_not_found") from exc + if str(exc) == "project_memory_item_not_found": + raise HTTPException(status_code=404, detail="project_memory_item_not_found") from exc + raise + return success_response(payload) + + +@router.get("/projects/{project_id}/project-memory/assets") +async def get_pmflow_os_project_memory_assets( + project_id: int, + asset_type: str | None = Query(default=None), + source_type: str | None = Query(default=None), + status: str | None = Query(default=None), + query: str | None = Query(default=None), + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + tenant_id = int(current_user.tenant_id) + await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user) + service = PMFlowOSProjectionService(db, tenant_id=tenant_id) + try: + payload = await service.get_project_memory_assets( + project_id=int(project_id), + asset_type=asset_type, + source_type=source_type, + status=status, + query=query, + limit=int(limit), + offset=int(offset), + ) + except ValueError as exc: + if str(exc) == "project_not_found": + raise HTTPException(status_code=404, detail="project_not_found") from exc + raise + return success_response(payload) + + +@router.get("/projects/{project_id}/project-memory/context-packs") +async def get_pmflow_os_project_memory_context_packs( + project_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + tenant_id = int(current_user.tenant_id) + await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user) + service = PMFlowOSProjectionService(db, tenant_id=tenant_id) + try: + payload = await service.get_project_memory_context_packs(project_id=int(project_id)) + except ValueError as exc: + if str(exc) == "project_not_found": + raise HTTPException(status_code=404, detail="project_not_found") from exc + raise + return success_response(payload) + + +@router.get("/projects/{project_id}/project-memory/writebacks") +async def get_pmflow_os_project_memory_writebacks( + project_id: int, + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + tenant_id = int(current_user.tenant_id) + await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user) + service = PMFlowOSProjectionService(db, tenant_id=tenant_id) + try: + payload = await service.get_project_memory_writebacks(project_id=int(project_id), limit=int(limit), offset=int(offset)) + except ValueError as exc: + if str(exc) == "project_not_found": + raise HTTPException(status_code=404, detail="project_not_found") from exc + raise + return success_response(payload) + + +@router.post("/projects/{project_id}/ai-feedback") +async def create_pmflow_os_ai_feedback( + project_id: int, + body: PMFlowAIFeedbackCreateRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + tenant_id = int(current_user.tenant_id) + await _ensure_project_member_or_manager( + db, + tenant_id=tenant_id, + project_id=int(project_id), + current_user=current_user, + ) + service = PMFlowAIFeedbackService(db, tenant_id=tenant_id) + payload = await service.create_or_update(project_id=int(project_id), body=body, user_id=int(current_user.id)) + return success_response(payload.model_dump(mode="json")) + + @router.post("/projects/{project_id}/threads/{thread_id}/messages") async def create_pmflow_os_thread_message( project_id: int, diff --git a/backend/app/api/projects_api/helpers.py b/backend/app/api/projects_api/helpers.py index 93e32d5d..0ac799eb 100644 --- a/backend/app/api/projects_api/helpers.py +++ b/backend/app/api/projects_api/helpers.py @@ -1,6 +1,7 @@ from __future__ import annotations from .shared import * +from ...services.auth import has_any_role def _as_dict(value: object) -> dict[str, Any]: if isinstance(value, dict): @@ -123,6 +124,46 @@ async def _get_project_or_404( raise HTTPException(status_code=404, detail="Project not found") return project + +def _is_project_manager(user: User) -> bool: + return has_any_role(str(getattr(user, "role", "") or ""), ["OWNER", "REVIEWER"]) + + +def _visible_project_filter(*, current_user: User): + if _is_project_manager(current_user): + return True + return or_( + Project.owner_id == int(current_user.id), + Project.id.in_( + select(UserProject.project_id).where( + and_( + UserProject.tenant_id == int(current_user.tenant_id), + UserProject.user_id == int(current_user.id), + ) + ) + ), + ) + + +async def _get_visible_project_or_404( + db: AsyncSession, + *, + current_user: User, + project_id: int, +) -> Project: + tenant_id = int(current_user.tenant_id) + query = select(Project).where( + and_( + Project.tenant_id == tenant_id, + Project.id == int(project_id), + _visible_project_filter(current_user=current_user), + ) + ) + project = (await db.execute(query)).scalar_one_or_none() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + return project + async def _cleanup_project_dependencies( db: AsyncSession, *, diff --git a/backend/app/api/projects_api/routes_list.py b/backend/app/api/projects_api/routes_list.py index c5fd3022..5e621dde 100644 --- a/backend/app/api/projects_api/routes_list.py +++ b/backend/app/api/projects_api/routes_list.py @@ -1,17 +1,19 @@ from __future__ import annotations from .shared import * -from .helpers import _build_project_summary, _get_project_or_404 +from .helpers import _build_project_summary, _get_visible_project_or_404, _serialize_project_row, _visible_project_filter @router.get("/") async def list_projects( summary: Optional[int] = Query(default=0), status: Optional[str] = Query(default=None), assigned_role: Optional[str] = Query(default=None), - tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): + tenant_id = int(current_user.tenant_id) query = select(Project).where(Project.tenant_id == tenant_id) + query = query.where(_visible_project_filter(current_user=current_user)) if status: query = query.where(Project.status == status) if assigned_role: @@ -35,9 +37,8 @@ async def list_projects( @router.get("/{project_id}") async def get_project( project_id: int, - tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id) + project = await _get_visible_project_or_404(db, current_user=current_user, project_id=project_id) return _serialize_project_row(project) - diff --git a/backend/app/api/projects_api/routes_mutation.py b/backend/app/api/projects_api/routes_mutation.py index b6db3a7b..c30eaa1f 100644 --- a/backend/app/api/projects_api/routes_mutation.py +++ b/backend/app/api/projects_api/routes_mutation.py @@ -4,6 +4,24 @@ from .shared import * from .helpers import _cleanup_project_dependencies, _fetch_project_member_user_ids, _get_project_or_404, _serialize_project_row from .schemas import ProjectCreateRequest, ProjectMembersAssignRequest, ProjectUpdateRequest + +async def _find_existing_project_by_title(db: AsyncSession, *, tenant_id: int, title: str) -> Project | None: + title_expr = func.coalesce(func.nullif(Project.title, ""), func.nullif(Project.name, "")) + result = await db.execute( + select(Project) + .where( + and_( + Project.tenant_id == tenant_id, + Project.status != "inactive", + title_expr.is_not(None), + func.lower(title_expr) == title.lower(), + ) + ) + .limit(1) + ) + return result.scalar_one_or_none() + + @router.post("/") async def create_project( body: ProjectCreateRequest, @@ -14,6 +32,9 @@ async def create_project( title = compact_title(body.title, max_len=24) if not title: raise HTTPException(status_code=400, detail="title required") + existing_project = await _find_existing_project_by_title(db, tenant_id=tenant_id, title=title) + if existing_project is not None: + raise HTTPException(status_code=409, detail="project title already exists") try: member_user_ids = await resolve_project_member_ids( db, @@ -214,4 +235,3 @@ async def delete_project( await context_cache_service.invalidate_system_context(tenant_id=tenant_id) await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=project_id) return {"message": "Project deleted"} - diff --git a/backend/app/api/projects_api/schemas.py b/backend/app/api/projects_api/schemas.py index 75d02318..387f3199 100644 --- a/backend/app/api/projects_api/schemas.py +++ b/backend/app/api/projects_api/schemas.py @@ -23,4 +23,3 @@ class LinkSignalRequest(BaseModel): class ProjectMembersAssignRequest(BaseModel): member_user_ids: list[int] = Field(default_factory=list) - diff --git a/backend/app/api/projects_api/shared.py b/backend/app/api/projects_api/shared.py index af68f03a..c71c3bb4 100644 --- a/backend/app/api/projects_api/shared.py +++ b/backend/app/api/projects_api/shared.py @@ -5,7 +5,7 @@ from typing import Any, Optional from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field -from sqlalchemy import and_, delete, func, select, update +from sqlalchemy import and_, delete, func, or_, select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession diff --git a/backend/app/api/prompt_assets_api.py b/backend/app/api/prompt_assets_api.py new file mode 100644 index 00000000..900facbd --- /dev/null +++ b/backend/app/api/prompt_assets_api.py @@ -0,0 +1,372 @@ +"""Platform Prompt asset inventory API.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query +from fastapi.responses import JSONResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from ..core.database import get_db +from ..core.response import error_response, success_response +from ..dependencies.permissions import require_platform_ai_ops +from ..dependencies.tenant import get_tenant_id +from ..models import User +from ..schemas.prompt_assets import ( + PromptAssetReleaseActionRequest, + PromptAssetReleaseCheckRunRequest, + PromptAssetReleaseCreateRequest, + PromptGoldenCaseCreateRequest, + PromptGoldenCaseFromCallRequest, + PromptReplayRunCreateRequest, +) +from ..services.prompt_assets_service import PromptAssetService + +router = APIRouter(prefix="/prompt-assets", tags=["prompt_assets"]) + + +@router.get("") +async def list_prompt_assets( + family: str | None = Query(default=None), + runtime_source: str | None = Query(default=None), + editable_policy: str | None = Query(default=None), + keyword: str | None = Query(default=None, max_length=200), + current_user: User = Depends(require_platform_ai_ops), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + data = await PromptAssetService(db).list_assets( + tenant_id=effective_tenant_id, + family=family, + runtime_source=runtime_source, + editable_policy=editable_policy, + keyword=keyword, + ) + return success_response(data) + + +@router.get("/{prompt_key}") +async def get_prompt_asset( + prompt_key: str, + current_user: User = Depends(require_platform_ai_ops), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + try: + data = await PromptAssetService(db).get_asset(tenant_id=effective_tenant_id, prompt_key=prompt_key) + except ValueError as exc: + return JSONResponse(status_code=404, content=error_response(404, str(exc))) + return success_response(data) + + +@router.get("/{prompt_key}/quality-summary") +async def get_prompt_asset_quality_summary( + prompt_key: str, + window: str = Query(default="7d", pattern="^(24h|7d|30d)$"), + status: str | None = Query(default=None, max_length=80), + release_id: int | None = Query(default=None), + current_user: User = Depends(require_platform_ai_ops), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + try: + data = await PromptAssetService(db).get_quality_summary( + tenant_id=effective_tenant_id, + prompt_key=prompt_key, + window=window, + status=status, + release_id=release_id, + include_all_pmflow_feedback=True, + ) + except ValueError as exc: + return JSONResponse(status_code=404, content=error_response(404, str(exc))) + return success_response(data) + + +@router.get("/{prompt_key}/golden-cases") +async def list_prompt_asset_golden_cases( + prompt_key: str, + status: str | None = Query(default=None, max_length=40), + current_user: User = Depends(require_platform_ai_ops), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + try: + data = await PromptAssetService(db).list_golden_cases( + tenant_id=effective_tenant_id, + prompt_key=prompt_key, + status=status, + ) + except ValueError as exc: + return JSONResponse(status_code=404, content=error_response(404, str(exc))) + return success_response(data) + + +@router.post("/{prompt_key}/golden-cases") +async def create_prompt_asset_golden_case( + prompt_key: str, + request: PromptGoldenCaseCreateRequest, + current_user: User = Depends(require_platform_ai_ops), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + try: + data = await PromptAssetService(db).create_golden_case( + tenant_id=effective_tenant_id, + prompt_key=prompt_key, + case_name=request.case_name, + input_payload=request.input_payload, + expected_contract=request.expected_contract, + expected_contains=request.expected_contains, + case_type=request.case_type, + priority=request.priority, + scenario_key=request.scenario_key, + tags=request.tags, + created_by=int(current_user.id) if getattr(current_user, "id", None) is not None else None, + notes=request.notes, + ) + except ValueError as exc: + return JSONResponse(status_code=400, content=error_response(400, str(exc))) + return success_response(data) + + +@router.post("/{prompt_key}/golden-cases/from-call/{ai_call_log_id}") +async def create_prompt_asset_golden_case_from_call( + prompt_key: str, + ai_call_log_id: int, + request: PromptGoldenCaseFromCallRequest | None = None, + current_user: User = Depends(require_platform_ai_ops), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + try: + data = await PromptAssetService(db).create_golden_case_from_call( + tenant_id=effective_tenant_id, + prompt_key=prompt_key, + ai_call_log_id=ai_call_log_id, + created_by=int(current_user.id) if getattr(current_user, "id", None) is not None else None, + case_name=request.case_name if request else None, + notes=request.notes if request else None, + ) + except ValueError as exc: + return JSONResponse(status_code=400, content=error_response(400, str(exc))) + return success_response(data) + + +@router.get("/{prompt_key}/releases") +async def list_prompt_asset_releases( + prompt_key: str, + current_user: User = Depends(require_platform_ai_ops), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + try: + data = await PromptAssetService(db).list_releases(tenant_id=effective_tenant_id, prompt_key=prompt_key) + except ValueError as exc: + return JSONResponse(status_code=404, content=error_response(404, str(exc))) + return success_response(data) + + +@router.post("/{prompt_key}/releases") +async def create_prompt_asset_release( + prompt_key: str, + request: PromptAssetReleaseCreateRequest, + current_user: User = Depends(require_platform_ai_ops), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + try: + data = await PromptAssetService(db).create_release( + tenant_id=effective_tenant_id, + prompt_key=prompt_key, + body_snapshot=request.body_snapshot, + created_by=int(current_user.id) if getattr(current_user, "id", None) is not None else None, + notes=request.notes, + ) + except ValueError as exc: + return JSONResponse(status_code=400, content=error_response(400, str(exc))) + return success_response(data) + + +@router.post("/{prompt_key}/releases/{release_id}/activate") +async def activate_prompt_asset_release( + prompt_key: str, + release_id: int, + request: PromptAssetReleaseActionRequest | None = None, + current_user: User = Depends(require_platform_ai_ops), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _ = request + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + try: + data = await PromptAssetService(db).activate_release( + tenant_id=effective_tenant_id, + prompt_key=prompt_key, + release_id=release_id, + actor_id=int(current_user.id) if getattr(current_user, "id", None) is not None else None, + ) + except ValueError as exc: + return JSONResponse(status_code=400, content=error_response(400, str(exc))) + return success_response(data) + + +@router.get("/{prompt_key}/releases/{release_id}/checks") +async def list_prompt_asset_release_checks( + prompt_key: str, + release_id: int, + current_user: User = Depends(require_platform_ai_ops), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + try: + data = await PromptAssetService(db).list_release_checks( + tenant_id=effective_tenant_id, + prompt_key=prompt_key, + release_id=release_id, + ) + except ValueError as exc: + return JSONResponse(status_code=404, content=error_response(404, str(exc))) + return success_response(data) + + +@router.post("/{prompt_key}/releases/{release_id}/checks/run") +async def run_prompt_asset_release_checks( + prompt_key: str, + release_id: int, + request: PromptAssetReleaseCheckRunRequest | None = None, + current_user: User = Depends(require_platform_ai_ops), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + try: + data = await PromptAssetService(db).run_release_checks( + tenant_id=effective_tenant_id, + prompt_key=prompt_key, + release_id=release_id, + actor_id=int(current_user.id) if getattr(current_user, "id", None) is not None else None, + reason=request.reason if request else None, + ) + except ValueError as exc: + return JSONResponse(status_code=400, content=error_response(400, str(exc))) + return success_response(data) + + +@router.post("/{prompt_key}/releases/{release_id}/replay-runs") +async def create_prompt_asset_replay_run( + prompt_key: str, + release_id: int, + request: PromptReplayRunCreateRequest, + current_user: User = Depends(require_platform_ai_ops), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + try: + data = await PromptAssetService(db).create_replay_run( + tenant_id=effective_tenant_id, + prompt_key=prompt_key, + release_id=release_id, + created_by=int(current_user.id) if getattr(current_user, "id", None) is not None else None, + mode=request.mode, + case_status=request.case_status, + max_cases=request.max_cases, + include_llm=request.include_llm, + judge_enabled=request.judge_enabled, + reason=request.reason, + ) + except ValueError as exc: + return JSONResponse(status_code=400, content=error_response(400, str(exc))) + return success_response(data) + + +@router.get("/{prompt_key}/releases/{release_id}/watch-summary") +async def get_prompt_asset_release_watch_summary( + prompt_key: str, + release_id: int, + current_user: User = Depends(require_platform_ai_ops), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + try: + data = await PromptAssetService(db).get_release_watch_summary( + tenant_id=effective_tenant_id, + prompt_key=prompt_key, + release_id=release_id, + ) + except ValueError as exc: + return JSONResponse(status_code=404, content=error_response(404, str(exc))) + return success_response(data) + + +@router.get("/{prompt_key}/releases/{release_id}/replay-runs") +async def list_prompt_asset_replay_runs( + prompt_key: str, + release_id: int, + current_user: User = Depends(require_platform_ai_ops), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + try: + data = await PromptAssetService(db).list_replay_runs( + tenant_id=effective_tenant_id, + prompt_key=prompt_key, + release_id=release_id, + ) + except ValueError as exc: + return JSONResponse(status_code=404, content=error_response(404, str(exc))) + return success_response(data) + + +@router.get("/{prompt_key}/replay-runs/{run_id}") +async def get_prompt_asset_replay_run( + prompt_key: str, + run_id: int, + current_user: User = Depends(require_platform_ai_ops), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + try: + data = await PromptAssetService(db).get_replay_run( + tenant_id=effective_tenant_id, + prompt_key=prompt_key, + run_id=run_id, + ) + except ValueError as exc: + return JSONResponse(status_code=404, content=error_response(404, str(exc))) + return success_response(data) + + +@router.post("/{prompt_key}/releases/{release_id}/rollback") +async def rollback_prompt_asset_release( + prompt_key: str, + release_id: int, + request: PromptAssetReleaseActionRequest | None = None, + current_user: User = Depends(require_platform_ai_ops), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _ = request + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + try: + data = await PromptAssetService(db).rollback_release( + tenant_id=effective_tenant_id, + prompt_key=prompt_key, + release_id=release_id, + actor_id=int(current_user.id) if getattr(current_user, "id", None) is not None else None, + ) + except ValueError as exc: + return JSONResponse(status_code=400, content=error_response(400, str(exc))) + return success_response(data) diff --git a/backend/app/api/rag_api/routes_route_governance.py b/backend/app/api/rag_api/routes_route_governance.py index 88de3610..f48ce017 100644 --- a/backend/app/api/rag_api/routes_route_governance.py +++ b/backend/app/api/rag_api/routes_route_governance.py @@ -5,7 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from .helpers import _resolve_tenant_id from .shared import * -from ...dependencies.permissions import require_manager_user +from ...dependencies.permissions import require_platform_route_governance from ...services.adaptive_rag_service import AdaptiveRAGService @@ -13,7 +13,7 @@ from ...services.adaptive_rag_service import AdaptiveRAGService async def get_query_route_governance_summary( project_id: int | None = Query(default=None), window_days: int | None = Query(default=None), - current_user: User = Depends(require_manager_user), + current_user: User = Depends(require_platform_route_governance), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): @@ -40,7 +40,7 @@ async def list_query_route_governance_records( search: str | None = Query(default=None), limit: int = Query(default=20, ge=1, le=100), offset: int = Query(default=0, ge=0), - current_user: User = Depends(require_manager_user), + current_user: User = Depends(require_platform_route_governance), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): @@ -67,7 +67,7 @@ async def list_query_route_governance_records( async def get_query_route_governance_detail( record_id: int, project_id: int | None = Query(default=None), - current_user: User = Depends(require_manager_user), + current_user: User = Depends(require_platform_route_governance), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): @@ -79,4 +79,3 @@ async def get_query_route_governance_detail( if data is None: raise HTTPException(status_code=404, detail="query route record not found") return success_response(data) - diff --git a/backend/app/api/router.py b/backend/app/api/router.py index d7df9030..ce11fd09 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -18,6 +18,7 @@ CORE_ROUTE_MODULES = ( "tasks", "webhooks", "auth", + "workspaces_api", "users_api", "endpoints.skill", "workloads", @@ -32,9 +33,14 @@ CORE_ROUTE_MODULES = ( "task_briefs", "pmflow_os_api", "pmflow_launch_api", + "space_api", "legacy_ai_steward_api", "account_settings_api", + "ai_readiness_api", + "ai_call_traces_api", + "prompt_assets_api", "ai_routing", + "llm_engines", "model_routing", ) @@ -43,7 +49,6 @@ OPTIONAL_ROUTE_GROUPS = { "knowledge": ("rag_api", "knowledge_graph", "semantic_facts_api", "ai_pmo_api"), "ai_control": ( "ai_steward_api", - "llm_engines", "llm_providers", "ai_logs_api", "prompt_profiles", diff --git a/backend/app/api/semantic_facts_api/routes.py b/backend/app/api/semantic_facts_api/routes.py index 3437061e..ecda5c73 100644 --- a/backend/app/api/semantic_facts_api/routes.py +++ b/backend/app/api/semantic_facts_api/routes.py @@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from ...core.database import get_db from ...core.response import error_response, success_response -from ...dependencies import require_manager_user +from ...dependencies import require_platform_semantic_facts from ...models import User from ...services.semantic_fact_service import SemanticFactService @@ -25,7 +25,7 @@ class SemanticFactsBackfillRequest(BaseModel): @router.get("/admin/overview") async def get_semantic_facts_overview( db: AsyncSession = Depends(get_db), - current_user: User = Depends(require_manager_user), + current_user: User = Depends(require_platform_semantic_facts), ): service = SemanticFactService(db, tenant_id=int(current_user.tenant_id)) return success_response(await service.get_admin_overview()) @@ -37,7 +37,7 @@ async def list_semantic_fact_objects( q: str | None = Query(default=None), status: str | None = Query(default=None), db: AsyncSession = Depends(get_db), - current_user: User = Depends(require_manager_user), + current_user: User = Depends(require_platform_semantic_facts), ): service = SemanticFactService(db, tenant_id=int(current_user.tenant_id)) items = await service.list_admin_objects( @@ -53,7 +53,7 @@ async def get_semantic_fact_object_detail( entity_type: str, entity_id: int, db: AsyncSession = Depends(get_db), - current_user: User = Depends(require_manager_user), + current_user: User = Depends(require_platform_semantic_facts), ): service = SemanticFactService(db, tenant_id=int(current_user.tenant_id)) try: @@ -68,7 +68,7 @@ async def reextract_semantic_fact_object( entity_type: str, entity_id: int, db: AsyncSession = Depends(get_db), - current_user: User = Depends(require_manager_user), + current_user: User = Depends(require_platform_semantic_facts), ): service = SemanticFactService(db, tenant_id=int(current_user.tenant_id)) row = await service.enqueue_entity_event( @@ -92,7 +92,7 @@ async def reextract_semantic_fact_object( async def enqueue_semantic_fact_backfill( body: SemanticFactsBackfillRequest, db: AsyncSession = Depends(get_db), - current_user: User = Depends(require_manager_user), + current_user: User = Depends(require_platform_semantic_facts), ): service = SemanticFactService(db, tenant_id=int(current_user.tenant_id)) result = await service.enqueue_backfill( diff --git a/backend/app/api/space_api/__init__.py b/backend/app/api/space_api/__init__.py new file mode 100644 index 00000000..089fbe48 --- /dev/null +++ b/backend/app/api/space_api/__init__.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import JSONResponse +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 User +from ...schemas.rag import QASessionListQuery, RAGAskRequest +from ...schemas.space import SavedIdeaCreateRequest, SavedIdeaListResponse, SavedIdeaPromoteResponse, SavedIdeaUpdateRequest +from ...services.ai_pmo.ai_pmo_kernel import AIPMOKernelService +from ...services.adaptive_rag_service import AdaptiveRAGService +from ...services.pmflow_launch_service import PMFlowLaunchError +from ...services.space_saved_idea_service import SavedIdeaError, SpaceSavedIdeaService +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), +): + service = AIPMOKernelService(db, tenant_id=int(current_user.tenant_id)) + ingress_result = await service.process_ingress( + **rag_ask_contract.build_kernel_ingress_kwargs( + request=body, + current_user=current_user, + ) + ) + if not bool(ingress_result.get("handled")): + raise HTTPException( + status_code=400, + detail=str(ingress_result.get("reason") or "space_ai_concierge_not_handled"), + ) + payload = rag_ask_contract.build_rag_response_payload(ingress_result=ingress_result) + 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("/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"] diff --git a/backend/app/api/sstc_api/charters_detail_update.py b/backend/app/api/sstc_api/charters_detail_update.py index e342aeef..ff84b7ad 100644 --- a/backend/app/api/sstc_api/charters_detail_update.py +++ b/backend/app/api/sstc_api/charters_detail_update.py @@ -14,9 +14,11 @@ router = APIRouter(tags=["sstc"]) async def get_charter_detail( project_id: str, charter_id: str, + permission_user: User = Depends(require_project_member_or_manager), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): + _ = permission_user pid = _parse_project_id(project_id) cid = _parse_node_id(charter_id, "charter_id") tenant_id = int(current_user.tenant_id) diff --git a/backend/app/api/sstc_api/charters_list_create.py b/backend/app/api/sstc_api/charters_list_create.py index 58798e10..a03678c7 100644 --- a/backend/app/api/sstc_api/charters_list_create.py +++ b/backend/app/api/sstc_api/charters_list_create.py @@ -21,9 +21,11 @@ class CharterCreateRequest(BaseModel): @router.get("/projects/{project_id}/charters") async def list_charters( project_id: str, + permission_user: User = Depends(require_project_member_or_manager), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): + _ = permission_user pid = _parse_project_id(project_id) svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) await _ensure_project(svc, pid) @@ -143,4 +145,3 @@ async def create_charter( content=_as_dict(charter.content), ) return success_response(_build_charter_summary_payload(charter)) - diff --git a/backend/app/api/sstc_api/overview.py b/backend/app/api/sstc_api/overview.py index f56ecc6a..31bd91f8 100644 --- a/backend/app/api/sstc_api/overview.py +++ b/backend/app/api/sstc_api/overview.py @@ -11,9 +11,11 @@ router = APIRouter(tags=["sstc"]) @router.get("/projects/{project_id}/sstc/overview") async def get_board_overview( project_id: str, + permission_user: User = Depends(require_project_member_or_manager), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): + _ = permission_user pid = _parse_project_id(project_id) svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) await _ensure_project(svc, pid) @@ -31,9 +33,11 @@ async def get_board_overview( @router.get("/projects/{project_id}/sstc/stages") async def get_stages( project_id: str, + permission_user: User = Depends(require_project_member_or_manager), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): + _ = permission_user pid = _parse_project_id(project_id) svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) await _ensure_project(svc, pid) @@ -42,9 +46,11 @@ async def get_stages( @router.get("/projects/{project_id}/sstc/risks") async def get_risks( project_id: str, + permission_user: User = Depends(require_project_member_or_manager), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): + _ = permission_user pid = _parse_project_id(project_id) svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) await _ensure_project(svc, pid) @@ -54,9 +60,11 @@ async def get_risks( async def get_governance_snapshot( project_id: str, charter_id: Optional[int] = Query(default=None), + permission_user: User = Depends(require_project_member_or_manager), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): + _ = permission_user pid = _parse_project_id(project_id) tenant_id = int(current_user.tenant_id) svc = SSTCService(db, tenant_id=tenant_id) @@ -72,11 +80,12 @@ async def get_governance_snapshot( @router.get("/projects/{project_id}/sstc-chain") async def get_sstc_chain( project_id: str, + permission_user: User = Depends(require_project_member_or_manager), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): + _ = permission_user pid = _parse_project_id(project_id) svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) await _ensure_project(svc, pid) return success_response(await svc.build_chain(pid)) - diff --git a/backend/app/api/sstc_api/releases_list_create.py b/backend/app/api/sstc_api/releases_list_create.py index e8003d43..66d46abe 100644 --- a/backend/app/api/sstc_api/releases_list_create.py +++ b/backend/app/api/sstc_api/releases_list_create.py @@ -11,9 +11,11 @@ router = APIRouter(tags=["sstc"]) @router.get("/projects/{project_id}/releases") async def list_releases( project_id: str, + permission_user: User = Depends(require_project_member_or_manager), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): + _ = permission_user pid = _parse_project_id(project_id) svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) await _ensure_project(svc, pid) diff --git a/backend/app/api/sstc_api/reviews_list_create.py b/backend/app/api/sstc_api/reviews_list_create.py index 6495865b..7dda38c4 100644 --- a/backend/app/api/sstc_api/reviews_list_create.py +++ b/backend/app/api/sstc_api/reviews_list_create.py @@ -12,9 +12,11 @@ router = APIRouter(tags=["sstc"]) @router.get("/projects/{project_id}/reviews") async def list_reviews( project_id: str, + permission_user: User = Depends(require_project_member_or_manager), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): + _ = permission_user pid = _parse_project_id(project_id) svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) await _ensure_project(svc, pid) @@ -101,4 +103,3 @@ async def create_review( "content": _as_dict(review_node.content), } ) - diff --git a/backend/app/api/sstc_api/signals_list.py b/backend/app/api/sstc_api/signals_list.py index 0142f64b..8b1c6aee 100644 --- a/backend/app/api/sstc_api/signals_list.py +++ b/backend/app/api/sstc_api/signals_list.py @@ -25,9 +25,11 @@ async def list_signals( created_to: Optional[str] = Query(default=None, description="创建时间结束(北京时间 YYYY-MM-DD)"), limit: int = Query(default=10, ge=1, le=100), offset: int = Query(default=0, ge=0), + permission_user: User = Depends(require_project_member_or_manager), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): + _ = permission_user pid = _parse_project_id(project_id) tenant_id = int(current_user.tenant_id) svc = SSTCService(db, tenant_id=tenant_id) diff --git a/backend/app/api/sstc_api/tasks_board_status.py b/backend/app/api/sstc_api/tasks_board_status.py index e94e53d7..e351b386 100644 --- a/backend/app/api/sstc_api/tasks_board_status.py +++ b/backend/app/api/sstc_api/tasks_board_status.py @@ -14,9 +14,11 @@ async def get_task_board( project_id: str, assignee_id: Optional[int] = Query(default=None), priority: Optional[str] = Query(default=None), + permission_user: User = Depends(require_project_member_or_manager), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): + _ = permission_user pid = _parse_project_id(project_id) tenant_id = int(current_user.tenant_id) await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) diff --git a/backend/app/api/users_api/helpers_user.py b/backend/app/api/users_api/helpers_user.py index 4d6f7f5c..5e979188 100644 --- a/backend/app/api/users_api/helpers_user.py +++ b/backend/app/api/users_api/helpers_user.py @@ -5,6 +5,8 @@ from .helpers_roles import _normalize_role_input, _pack_roles def _translate_user_integrity_error(exc: IntegrityError) -> str: message = str(getattr(exc, "orig", exc)).lower() + if "phone_e164" in message or "ix_users_phone_e164" in message: + return "手机号已绑定其他账号" if "users.username" in message or "username" in message: return "用户名已存在" if "users.email" in message or "email" in message: @@ -161,12 +163,25 @@ async def _create_user_with_defaults( if existing_email.scalar_one_or_none(): raise ValueError("邮箱已存在") + phone_payload: dict[str, str | None] = { + "phone_country_code": None, + "phone_number": None, + "phone_e164": None, + } + if user_data.phone_number: + from ...services.phone_auth_service import normalize_phone + + phone_payload = normalize_phone(user_data.phone_country_code, user_data.phone_number) + user = User( tenant_id=tenant_id, username=username, password_hash=AuthService.hash_password(user_data.password), name=(user_data.name or username).strip(), email=(user_data.email or f"{username}@placeholder.local").strip(), + phone_country_code=phone_payload["phone_country_code"], + phone_number=phone_payload["phone_number"], + phone_e164=phone_payload["phone_e164"], role=_pack_roles(user_data.role), assigned_domains=user_data.assigned_domains or [], workload=0, diff --git a/backend/app/api/users_api/routes_mutation.py b/backend/app/api/users_api/routes_mutation.py index 2f560de3..c8b11d5a 100644 --- a/backend/app/api/users_api/routes_mutation.py +++ b/backend/app/api/users_api/routes_mutation.py @@ -69,6 +69,14 @@ async def update_user( user.name = user_data.name.strip() if user_data.email is not None: user.email = user_data.email.strip() + if user_data.phone_number is not None: + from ...services.phone_auth_service import normalize_phone + + phone_payload = normalize_phone(user_data.phone_country_code, user_data.phone_number) + user.phone_country_code = phone_payload["phone_country_code"] + user.phone_number = phone_payload["phone_number"] + user.phone_e164 = phone_payload["phone_e164"] + user.phone_verified_at = user.phone_verified_at or now_cn_naive() if user_data.assigned_domains is not None: user.assigned_domains = user_data.assigned_domains if user_data.workload is not None: diff --git a/backend/app/api/users_api/routes_query.py b/backend/app/api/users_api/routes_query.py index 61a4b6bf..733cd403 100644 --- a/backend/app/api/users_api/routes_query.py +++ b/backend/app/api/users_api/routes_query.py @@ -3,11 +3,20 @@ from __future__ import annotations from .shared import * from .helpers_roles import _build_role_filter, _has_any_role, _normalize_role_input, _parse_int_list from .helpers_user import _collect_user_enrichment, _serialize_user +from ...services.platform_access_service import get_platform_context @router.get("/me", response_model=UserResponse) -async def get_current_user_info(current_user: User = Depends(get_current_active_user)): +async def get_current_user_info( + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +): """当前用户信息。""" - return UserResponse(**_serialize_user(current_user)) + return UserResponse( + **{ + **_serialize_user(current_user), + "platform_context": await get_platform_context(db, user=current_user), + } + ) @router.get("/") async def list_users( @@ -47,6 +56,8 @@ async def list_users( User.username.ilike(q), User.name.ilike(q), User.email.ilike(q), + User.phone_e164.ilike(q), + User.phone_number.ilike(q), ) ) @@ -163,4 +174,3 @@ async def get_user( workloads, skills, projects = await _collect_user_enrichment(db, tenant_id, [user]) return _serialize_user(user, workloads, skills, projects) - diff --git a/backend/app/api/workspaces_api.py b/backend/app/api/workspaces_api.py new file mode 100644 index 00000000..90d74cf6 --- /dev/null +++ b/backend/app/api/workspaces_api.py @@ -0,0 +1,116 @@ +"""Workspace API. + +Product language: workspace/space. Database compatibility: tenants. +""" + +from __future__ import annotations + +from pydantic import BaseModel, Field +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from ..core.database import get_db +from ..core.response import success_response +from ..dependencies import get_current_user, get_optional_current_user +from ..models import User +from ..services.workspace_service import ( + accept_invitation, + create_invitation, + create_team_workspace, + leave_workspace, + list_user_workspaces, + switch_workspace, +) + +router = APIRouter(prefix="/workspaces", tags=["workspaces"]) + + +class TeamWorkspaceCreateRequest(BaseModel): + name: str = Field(min_length=1, max_length=120) + + +class WorkspaceSwitchRequest(BaseModel): + tenant_id: int + + +class WorkspaceInvitationCreateRequest(BaseModel): + phone_country_code: str = "+86" + phone_number: str + email: str | None = None + role: str = "BUILDER" + project_ids: list[int] = Field(default_factory=list) + + +class WorkspaceInvitationNewUserRequest(BaseModel): + username: str + password: str + name: str + email: str + + +class WorkspaceInvitationAcceptRequest(BaseModel): + token: str + user: WorkspaceInvitationNewUserRequest | None = None + + +@router.get("/me") +async def get_my_workspaces( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + return success_response(await list_user_workspaces(db, user=current_user)) + + +@router.post("/teams") +async def create_team( + body: TeamWorkspaceCreateRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + return success_response(await create_team_workspace(db, current_user=current_user, name=body.name)) + + +@router.post("/switch") +async def switch_current_workspace( + body: WorkspaceSwitchRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + return success_response(await switch_workspace(db, current_user=current_user, tenant_id=body.tenant_id)) + + +@router.post("/invitations") +async def invite_member( + body: WorkspaceInvitationCreateRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + return success_response( + await create_invitation( + db, + current_user=current_user, + email=body.email, + phone_country_code=body.phone_country_code, + phone_number=body.phone_number, + role=body.role, + project_ids=body.project_ids, + ) + ) + + +@router.post("/invitations/accept") +async def accept_member_invitation( + body: WorkspaceInvitationAcceptRequest, + current_user: User | None = Depends(get_optional_current_user), + db: AsyncSession = Depends(get_db), +): + return success_response(await accept_invitation(db, token=body.token, current_user=current_user, new_user=body.user)) + + +@router.post("/{tenant_id}/leave") +async def leave_current_workspace( + tenant_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + return success_response(await leave_workspace(db, current_user=current_user, tenant_id=tenant_id)) diff --git a/backend/app/config/ai_function_runtime_profiles.py b/backend/app/config/ai_function_runtime_profiles.py new file mode 100644 index 00000000..6cc7880f --- /dev/null +++ b/backend/app/config/ai_function_runtime_profiles.py @@ -0,0 +1,110 @@ +"""Default runtime profiles for protected AI functions.""" + +from __future__ import annotations + +from typing import Any + + +AI_FUNCTION_RUNTIME_PROFILE_INTEGRATION_TYPE = "ai_function_runtime_profiles" + +DEFAULT_AI_FUNCTION_RUNTIME_PROFILES: dict[str, dict[str, Any]] = { + "pmflow_launch_draft_v1": { + "function_name": "pmflow_launch_draft_v1", + "label": "项目启动草案生成", + "scene_key": "project_launch", + "prompt_key": "PROMPT_PMFLOW_LAUNCH_DRAFT", + "required_slot": "structured", + "response_contract": "structured", + "max_tokens": 7200, + "max_tokens_min": 3000, + "max_tokens_max": 10000, + "recommended_min_tokens": 6500, + "repair_max_tokens": 3600, + "temperature": 0.0, + "foreground_wait_seconds": 10, + "provider_timeout_seconds": 90, + "job_timeout_seconds": 180, + "max_retries": 1, + "large_input_policy": "preprocess_then_generate", + "response_mode": "fast", + "response_format": "json_object", + "retry_policy": { + "on_length": "model_repair_then_fail", + "on_json_invalid": "model_repair_then_fail", + "max_attempts": 2, + }, + "health_policy": { + "window_hours": 24, + "consecutive_failure_threshold": 3, + "smoke_required_for_publish": True, + }, + "cost_policy": { + "budget_class": "medium", + "estimated_cost_ceiling_cny": 0.02, + "show_cost_warning_above_tokens": 8000, + }, + }, + "pmflow_change_review_extraction_v1": { + "function_name": "pmflow_change_review_extraction_v1", + "label": "项目主链变更抽取", + "scene_key": "change_review", + "prompt_key": "PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION", + "required_slot": "structured", + "response_contract": "structured", + "max_tokens": 5600, + "max_tokens_min": 1800, + "max_tokens_max": 8000, + "recommended_min_tokens": 4200, + "repair_max_tokens": 2400, + "temperature": 0.0, + "foreground_wait_seconds": 8, + "provider_timeout_seconds": 90, + "job_timeout_seconds": 180, + "max_retries": 1, + "large_input_policy": "preprocess_then_generate", + "response_mode": "fast", + "response_format": "json_object", + "retry_policy": { + "on_length": "model_repair_then_fail", + "on_json_invalid": "model_repair_then_fail", + "max_attempts": 2, + }, + "health_policy": { + "window_hours": 24, + "consecutive_failure_threshold": 3, + "smoke_required_for_publish": True, + }, + "cost_policy": { + "budget_class": "medium", + "estimated_cost_ceiling_cny": 0.015, + "show_cost_warning_above_tokens": 6500, + }, + }, +} + + +def default_runtime_profile_bundle() -> dict[str, Any]: + return { + "version": "profile-default-v1", + "active_version": "profile-default-v1", + "draft_version": "draft", + "active": { + "profiles": DEFAULT_AI_FUNCTION_RUNTIME_PROFILES, + "published_at": None, + "published_by": None, + }, + "draft": { + "profiles": DEFAULT_AI_FUNCTION_RUNTIME_PROFILES, + "updated_at": None, + "updated_by": None, + "last_smoke": {}, + }, + "history": [], + } + + +__all__ = [ + "AI_FUNCTION_RUNTIME_PROFILE_INTEGRATION_TYPE", + "DEFAULT_AI_FUNCTION_RUNTIME_PROFILES", + "default_runtime_profile_bundle", +] diff --git a/backend/app/config/ai_prompt_eval_rubrics.py b/backend/app/config/ai_prompt_eval_rubrics.py new file mode 100644 index 00000000..697bfb93 --- /dev/null +++ b/backend/app/config/ai_prompt_eval_rubrics.py @@ -0,0 +1,111 @@ +"""Prompt evaluation rubrics for platform Prompt Operations.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + + +DEFAULT_PROMPT_EVAL_RUBRIC: dict[str, Any] = { + "rubric_key": "default_prompt_eval_v1", + "label": "通用 Prompt 发布评估", + "dimensions": { + "contract_adherence": "是否遵守输入/输出契约、变量约束和格式要求。", + "business_correctness": "是否围绕业务场景给出准确、可执行且不过度扩展的结果。", + "evidence_grounding": "是否明确证据、上下文与不确定性,不把无依据内容包装成事实。", + "safety_boundary": "是否遵守 PMFlow 主链边界、权限边界和 fail-closed 规则。", + "operator_readability": "是否便于运营、审查和后续沉淀样本。", + }, + "release_policy": { + "recommend_release": "平均表现优于或不低于 baseline,且无关键风险。", + "needs_revision": "存在可修订问题,但不构成强阻塞。", + "block_release": "违反核心契约、边界或高风险样本失败。", + "manual_review": "证据不足或模型评估不稳定,需要人工复核。", + }, +} + + +FAMILY_PROMPT_EVAL_RUBRICS: dict[str, dict[str, Any]] = { + "classification": { + "rubric_key": "classification_prompt_eval_v1", + "label": "分类/路由 Prompt 评估", + "focus": [ + "分类字段是否稳定、可解释。", + "不确定时是否澄清或返回 uncertain,而不是强行裁决。", + "是否避免在执行层恢复关键词/对象词表二次拍板。", + ], + }, + "extraction": { + "rubric_key": "extraction_prompt_eval_v1", + "label": "结构化抽取 Prompt 评估", + "focus": [ + "字段完整度、JSON 可解析性和 pending 标注是否可靠。", + "证据不足时是否显式失败或待确认。", + "是否避免模板句、默认句冒充抽取成功。", + ], + }, + "planning": { + "rubric_key": "planning_prompt_eval_v1", + "label": "规划/拆解 Prompt 评估", + "focus": [ + "任务拆解是否可执行、顺序清晰且边界明确。", + "是否保留人工确认和高风险审批边界。", + "是否避免自动写入项目真相层或越过主链。", + ], + }, + "response": { + "rubric_key": "response_prompt_eval_v1", + "label": "回答呈现 Prompt 评估", + "focus": [ + "来源合同是否清楚,内部/外部/不确定是否分明。", + "是否使用客户可理解中文,不泄露 raw source/internal label。", + "是否避免过度自信、泛化结论和内部术语外露。", + ], + }, + "governance": { + "rubric_key": "governance_prompt_eval_v1", + "label": "治理/安全 Prompt 评估", + "focus": [ + "是否严格遵守权限、审计和 fail-closed 边界。", + "风险、阻塞和人工确认原因是否清楚。", + "是否避免为了完整回答而绕过运行时护栏。", + ], + }, +} + + +PROMPT_KEY_EVAL_RUBRICS: dict[str, dict[str, Any]] = { + "PROMPT_RAG_QUERY_SEMANTICS": { + "rubric_key": "rag_query_semantics_eval_v1", + "label": "Query Route Gateway 语义路由评估", + "focus": [ + "route_mode / decision_origin / reply_contract 是否由结构化语义证据支撑。", + "internal/external/uncertain 分流是否遵守 Query Route Gateway,而不是靠对象词表补判。", + "证据不足时是否澄清或 external-first,禁止默认混合答案。", + ], + }, + "PROMPT_EXTERNAL_DIRECT_FACT_QA": { + "rubric_key": "external_direct_fact_eval_v1", + "label": "外部实时问答评估", + "focus": [ + "是否仅使用外部 verified evidence,不混入 Feishu/PMFlow 内部来源。", + "外部证据不足时是否显式说明限制。", + "来源摘要是否客户可读且不暴露 raw provider/source label。", + ], + }, +} + + +def get_prompt_eval_rubric(*, prompt_key: str, prompt_family: str) -> dict[str, Any]: + """Return a merged rubric payload for a prompt key/family.""" + + rubric = deepcopy(DEFAULT_PROMPT_EVAL_RUBRIC) + family = FAMILY_PROMPT_EVAL_RUBRICS.get(str(prompt_family or "").strip()) + if family: + rubric.update(deepcopy(family)) + rubric["base_rubric_key"] = DEFAULT_PROMPT_EVAL_RUBRIC["rubric_key"] + prompt_specific = PROMPT_KEY_EVAL_RUBRICS.get(str(prompt_key or "").strip()) + if prompt_specific: + rubric.update(deepcopy(prompt_specific)) + rubric["family_rubric_key"] = family.get("rubric_key") if family else None + return rubric diff --git a/backend/app/config/ai_prompt_registry.py b/backend/app/config/ai_prompt_registry.py index c825b703..00dc60f8 100644 --- a/backend/app/config/ai_prompt_registry.py +++ b/backend/app/config/ai_prompt_registry.py @@ -120,11 +120,17 @@ def _entry( responsibility_scope: str | None = None, priority: str | None = None, audience: str | None = None, + runtime_source: str = "catalog", + editable_policy: str = "managed_release", + risk_level: str | None = None, ) -> dict[str, Any]: resolved_family = _normalize_family(prompt_family or _derive_prompt_family(prompt_key, business_scenario)) resolved_scope = str(responsibility_scope or "").strip() or _derive_responsibility_scope(resolved_family) resolved_priority = str(priority or "").strip().lower() or _derive_priority(resolved_family) resolved_audience = str(audience or "").strip() or _derive_audience(resolved_family) + resolved_risk = str(risk_level or "").strip().lower() + if resolved_risk not in {"critical", "high", "medium", "low"}: + resolved_risk = "high" if resolved_family in {PROMPT_FAMILY_CLASSIFICATION, PROMPT_FAMILY_EXTRACTION, PROMPT_FAMILY_PLANNING} else "medium" return { "prompt_key": prompt_key, @@ -137,6 +143,9 @@ def _entry( "responsibility_scope": resolved_scope, "priority": resolved_priority, "audience": resolved_audience, + "runtime_source": str(runtime_source or "catalog").strip() or "catalog", + "editable_policy": str(editable_policy or "managed_release").strip() or "managed_release", + "risk_level": resolved_risk, } @@ -227,6 +236,22 @@ def _part_01_entries() -> tuple[dict[str, Any], ...]: output_contract=QA_OUTPUT, call_sites=FEISHU_REPLY_CALL_SITES, ), + _entry( + "PROMPT_FEISHU_QA_NATURAL_REPLY", + module="part_01", + business_scenario="飞书问答自然回复", + input_contract="输入 question / answer / context_summary。", + output_contract=QA_OUTPUT, + call_sites=FEISHU_REPLY_CALL_SITES, + ), + _entry( + "PROMPT_FEISHU_QA_NATURAL_REPLY_REWRITE", + module="part_01", + business_scenario="飞书问答自然回复改写", + input_contract="输入原始回答、上下文摘要和语气约束。", + output_contract=QA_OUTPUT, + call_sites=FEISHU_REPLY_CALL_SITES, + ), _entry( "PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION", module="part_01", @@ -583,6 +608,215 @@ def _scene_entries() -> tuple[dict[str, Any], ...]: output_contract="输出仅基于外部证据的对比回答。", call_sites=("backend/app/services/external_query_service/presenter.py",), ), + _entry( + "PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA", + module="part_scenes_06", + business_scenario="外部信号源推荐问答", + input_contract="输入 question / plan_summary / fit_brief_summary / recommendation_goal / project_object_anchors / signal_source_objectives / retrieval_status。", + output_contract=QA_OUTPUT, + call_sites=("backend/app/services/external_query_service/presenter.py",), + ), + _entry( + "PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET", + module="part_scenes_06", + business_scenario="外部信号源推荐候选包", + input_contract="输入 question / plan_summary / fit_brief_summary / recommendation_goal / project_object_anchors / signal_source_objectives / retrieval_status。", + output_contract="输出 JSON 对象,包含 candidates 和 caveat。", + call_sites=("backend/app/services/external_query_service/presenter.py",), + ), + _entry( + "PROMPT_PMFLOW_LAUNCH_DRAFT", + module="part_scenes_06", + business_scenario="PMFlow 新项目启动草案", + input_contract="输入用户创意文本与附件摘要。", + output_contract="输出 JSON 对象,包含待确认项目草案、需求、任务、风险、决策和证据。", + call_sites=("backend/app/services/pmflow_launch_service.py", "backend/app/services/space_saved_idea_service.py"), + prompt_family=PROMPT_FAMILY_PLANNING, + ), + _entry( + "PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION", + module="part_scenes_06", + business_scenario="项目主链变更抽取", + input_contract="输入项目线程消息与附件摘要。", + output_contract="输出 JSON 对象,包含 change_summary、proposed_items、evidence、missing_info、warnings 和 confidence。", + call_sites=("backend/app/services/pmflow_change_review_service.py",), + prompt_family=PROMPT_FAMILY_EXTRACTION, + ), + _entry( + "PROMPT_SAVED_IDEA_ENRICHMENT", + module="part_scenes_06", + business_scenario="暂存想法结构化整理", + input_contract="输入用户保存的想法文本;需要识别问候、测试词、无意义短语等非想法输入。", + output_contract="输出 JSON 对象,包含完整度、缺口、readiness_score 和状态。", + call_sites=("backend/app/services/space_saved_idea_service.py",), + prompt_family=PROMPT_FAMILY_EXTRACTION, + ), + ) + + +def _runtime_guardrail_entries() -> tuple[dict[str, Any], ...]: + return ( + _entry( + "PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT", + module="ai_prompts", + business_scenario="统一语义规划与主链决策", + input_contract="输入 semantic_context_packet。", + output_contract=JSON_OUTPUT, + call_sites=("backend/app/services/ai_pmo/semantic_context/semantic_decision_service.py",), + prompt_family=PROMPT_FAMILY_GOVERNANCE, + responsibility_scope="治理层:判断 topic、workflow、draft follow-up 与 interaction boundary,不执行写操作。", + risk_level="critical", + editable_policy="guarded_release", + ), + _entry( + "PROMPT_RAG_QUERY_SEMANTICS", + module="ai_prompts", + business_scenario="Adaptive RAG query semantics 规划", + input_contract="输入用户消息、workflow bundle、上下文与来源策略。", + output_contract=JSON_OUTPUT, + call_sites=("backend/app/services/adaptive_rag_service/mixin_core_intent.py",), + prompt_family=PROMPT_FAMILY_GOVERNANCE, + risk_level="critical", + editable_policy="guarded_release", + ), + _entry( + "PROMPT_RAG_FOLLOWUP_ANCHOR_SEMANTICS", + module="ai_prompts", + business_scenario="RAG follow-up 锚点归属判断", + input_contract=JSON_INPUT, + output_contract=JSON_OUTPUT, + call_sites=("backend/app/services/adaptive_rag_service/mixin_followup_anchor.py",), + prompt_family=PROMPT_FAMILY_GOVERNANCE, + risk_level="high", + editable_policy="guarded_release", + ), + _entry( + "PROMPT_RAG_PENDING_CLARIFICATION_SELECTION_SEMANTICS", + module="ai_prompts", + business_scenario="RAG 待澄清项选择", + input_contract=JSON_INPUT, + output_contract=JSON_OUTPUT, + call_sites=("backend/app/services/adaptive_rag_service/mixin_followup_anchor.py",), + prompt_family=PROMPT_FAMILY_GOVERNANCE, + risk_level="high", + editable_policy="guarded_release", + ), + _entry( + "PROMPT_INTERNAL_TARGET_RESOLUTION", + module="ai_prompts", + business_scenario="内部问答目标对象解析", + input_contract=JSON_INPUT, + output_contract=JSON_OUTPUT, + call_sites=("backend/app/services/adaptive_rag_service/mixin_target_resolution.py",), + prompt_family=PROMPT_FAMILY_GOVERNANCE, + risk_level="critical", + editable_policy="guarded_release", + ), + _entry( + "PROMPT_EXTERNAL_QUERY_PLAN", + module="ai_prompts", + business_scenario="外部实时查询计划", + input_contract="输入 question / route decision / source policy。", + output_contract=JSON_OUTPUT, + call_sites=("backend/app/services/external_query_service/planner.py",), + prompt_family=PROMPT_FAMILY_PLANNING, + risk_level="high", + editable_policy="guarded_release", + ), + _entry( + "PROMPT_RAG_ANSWER_SCENE_SEMANTICS", + module="ai_prompts", + business_scenario="RAG answer 场景选择", + input_contract=JSON_INPUT, + output_contract=JSON_OUTPUT, + call_sites=("backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py",), + prompt_family=PROMPT_FAMILY_GOVERNANCE, + risk_level="high", + editable_policy="guarded_release", + ), + _entry( + "PMFLOW_REQUIREMENT_PLAN_ROUND_PROMPT", + module="ai_prompts", + business_scenario="需求收敛追问轮次", + input_contract="输入 requirement_plan_packet。", + output_contract=JSON_OUTPUT, + call_sites=("backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py",), + prompt_family=PROMPT_FAMILY_PLANNING, + risk_level="critical", + editable_policy="guarded_release", + ), + _entry( + "PMFLOW_REQUIREMENT_PLAN_DRAFT_PROMPT", + module="ai_prompts", + business_scenario="需求草稿生成", + input_contract="输入 requirement_plan_draft_packet。", + output_contract=JSON_OUTPUT, + call_sites=("backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py",), + prompt_family=PROMPT_FAMILY_PLANNING, + risk_level="critical", + editable_policy="guarded_release", + ), + ) + + +def _semantic_fact_entries() -> tuple[dict[str, Any], ...]: + common = { + "module": "ai_prompts", + "input_contract": "输入对象快照、证据文本与抽取范围。", + "output_contract": "输出 semantic facts JSON,不要 Markdown。", + "call_sites": ("backend/app/services/semantic_fact_service/shared.py", "backend/app/services/semantic_fact_service/mixin_extract.py"), + "prompt_family": PROMPT_FAMILY_EXTRACTION, + "risk_level": "high", + "editable_policy": "guarded_release", + } + return tuple( + _entry(prompt_key, business_scenario=f"语义事实抽取:{label}", **common) + for prompt_key, label in ( + ("PROMPT_SEMANTIC_FACT_EXTRACT_PROJECT", "项目"), + ("PROMPT_SEMANTIC_FACT_EXTRACT_TASK", "任务"), + ("PROMPT_SEMANTIC_FACT_EXTRACT_SIGNAL", "信号"), + ("PROMPT_SEMANTIC_FACT_EXTRACT_DOCUMENT", "文档"), + ("PROMPT_SEMANTIC_FACT_EXTRACT_USER", "用户"), + ("PROMPT_SEMANTIC_FACT_EXTRACT_REQUIREMENT", "需求"), + ("PROMPT_SEMANTIC_FACT_EXTRACT_RELEASE", "发布"), + ("PROMPT_SEMANTIC_FACT_EXTRACT_REVIEW", "复盘"), + ) + ) + + +def _inline_discovered_entries() -> tuple[dict[str, Any], ...]: + common = { + "runtime_source": "inline_discovered", + "editable_policy": "needs_migration", + "risk_level": "medium", + } + return ( + _entry("PROMPT_SIGNAL_WEBPAGE_VALUE_ANALYSIS", module="inline_discovered", business_scenario="网页信号价值分析", input_contract=JSON_INPUT, output_contract=JSON_OUTPUT, call_sites=("backend/app/services/signal/signal_capture_service/mixin_source_analyzers.py",), prompt_family=PROMPT_FAMILY_EXTRACTION, **common), + _entry("PROMPT_SIGNAL_GITHUB_EVENT_VALUE_ANALYSIS", module="inline_discovered", business_scenario="GitHub 事件价值分析", input_contract=JSON_INPUT, output_contract=JSON_OUTPUT, call_sites=("backend/app/services/signal/signal_capture_service/mixin_source_analyzers.py",), prompt_family=PROMPT_FAMILY_EXTRACTION, **common), + _entry("PROMPT_SIGNAL_HACKERNEWS_TREND_ANALYSIS", module="inline_discovered", business_scenario="HackerNews 趋势信号分析", input_contract=JSON_INPUT, output_contract=JSON_OUTPUT, call_sites=("backend/app/services/signal/signal_capture_service/mixin_source_analyzers.py",), prompt_family=PROMPT_FAMILY_EXTRACTION, **common), + _entry("PROMPT_AUDIT_FUNCTIONALITY_REVIEW", module="inline_discovered", business_scenario="代码功能正确性审计", input_contract="输入任务、文件、diff 与可选完整文件内容。", output_contract=JSON_OUTPUT, call_sites=("backend/app/services/auditor_core/mixin_ai_score.py",), prompt_family=PROMPT_FAMILY_GOVERNANCE, **common), + _entry("PROMPT_AUDIT_ARCHITECTURE_REVIEW", module="inline_discovered", business_scenario="代码架构审计", input_contract="输入任务、提交信息、diff 和变更文件。", output_contract=JSON_OUTPUT, call_sites=("backend/app/services/auditor_core/mixin_ai_score.py",), prompt_family=PROMPT_FAMILY_GOVERNANCE, **common), + _entry("PROMPT_BLOCKER_CODE_ANALYSIS", module="inline_discovered", business_scenario="代码阻塞分析", input_contract=JSON_INPUT, output_contract=JSON_OUTPUT, call_sites=("backend/app/services/blocker_detection_core/mixin_ai.py",), prompt_family=PROMPT_FAMILY_EXTRACTION, **common), + _entry("PROMPT_BLOCKER_TASK_ANALYSIS", module="inline_discovered", business_scenario="任务阻塞分析", input_contract=JSON_INPUT, output_contract=JSON_OUTPUT, call_sites=("backend/app/services/blocker_detection_core/mixin_ai.py",), prompt_family=PROMPT_FAMILY_EXTRACTION, **common), + _entry("PROMPT_COGNITIVE_TASK_CHARTER_MISMATCH", module="inline_discovered", business_scenario="任务与宪章一致性检查", input_contract=JSON_INPUT, output_contract=JSON_OUTPUT, call_sites=("backend/app/services/cognitive_conflict_core/mixin_detection.py",), prompt_family=PROMPT_FAMILY_GOVERNANCE, **common), + _entry("PROMPT_COGNITIVE_DEFINITION_CONFLICTS", module="inline_discovered", business_scenario="项目定义隐性冲突分析", input_contract=JSON_INPUT, output_contract=JSON_LIST_OUTPUT, call_sites=("backend/app/services/cognitive_conflict_core/mixin_detection.py",), prompt_family=PROMPT_FAMILY_GOVERNANCE, **common), + _entry("PROMPT_COGNITIVE_CONFLICT_RESOLUTION", module="inline_discovered", business_scenario="认知冲突解决方案", input_contract=JSON_INPUT, output_contract=JSON_OUTPUT, call_sites=("backend/app/services/cognitive_conflict_core/mixin_resolution.py",), prompt_family=PROMPT_FAMILY_PLANNING, **common), + _entry("PROMPT_COGNITIVE_CHARTER_CONFLICTS", module="inline_discovered", business_scenario="宪章潜在冲突分析", input_contract=JSON_INPUT, output_contract=JSON_LIST_OUTPUT, call_sites=("backend/app/services/cognitive_conflict_core/mixin_storage.py",), prompt_family=PROMPT_FAMILY_GOVERNANCE, **common), + _entry("PROMPT_CODEGEN_FULL_CODE_DRAFT", module="inline_discovered", business_scenario="代码生成草案", input_contract="输入需求、战略分析和代码上下文。", output_contract=JSON_OUTPUT, call_sites=("backend/app/services/codegen_core/mixin_prompt_llm.py",), prompt_family=PROMPT_FAMILY_PLANNING, risk_level="high", **{k: v for k, v in common.items() if k != "risk_level"}), + _entry("PROMPT_DAILY_BRIEFING_PERSONAL_SUMMARY", module="inline_discovered", business_scenario="每日个人工作摘要", input_contract=JSON_INPUT, output_contract=QA_OUTPUT, call_sites=("backend/app/services/daily_briefing_core/mixin_helpers_summary.py",), prompt_family=PROMPT_FAMILY_RESPONSE, **common), + _entry("PROMPT_EVENT_SIGNAL_IMPACT_ASSESSMENT", module="inline_discovered", business_scenario="信号对项目影响评估", input_contract=JSON_INPUT, output_contract=JSON_OUTPUT, call_sites=("backend/app/services/event_workflow_core/mixin_helpers.py",), prompt_family=PROMPT_FAMILY_GOVERNANCE, **common), + _entry("PROMPT_EVENT_BLOCKER_WORKAROUND", module="inline_discovered", business_scenario="阻塞绕过方案生成", input_contract=JSON_INPUT, output_contract=JSON_OUTPUT, call_sites=("backend/app/services/event_workflow_core/mixin_handlers_primary.py",), prompt_family=PROMPT_FAMILY_PLANNING, **common), + _entry("PROMPT_EVENT_COMPETITOR_IMPACT", module="inline_discovered", business_scenario="竞品动态战略影响分析", input_contract=JSON_INPUT, output_contract=JSON_OUTPUT, call_sites=("backend/app/services/event_workflow_core/mixin_handlers_secondary.py",), prompt_family=PROMPT_FAMILY_GOVERNANCE, **common), + _entry("PROMPT_AI_STEWARD_SIGNAL_IMPORTANCE", module="inline_discovered", business_scenario="手动信号重要性分析", input_contract=JSON_INPUT, output_contract=JSON_OUTPUT, call_sites=("backend/app/api/ai_steward_api/routes_capture.py",), prompt_family=PROMPT_FAMILY_EXTRACTION, **common), + _entry("PROMPT_AI_STEWARD_CODE_BLOCKER_SOLUTION", module="inline_discovered", business_scenario="代码问题解决方案", input_contract=JSON_INPUT, output_contract=JSON_OUTPUT, call_sites=("backend/app/api/ai_steward_api/routes_brief_blockers.py",), prompt_family=PROMPT_FAMILY_PLANNING, **common), + _entry("PROMPT_AI_STEWARD_COMPETITIVE_SIGNAL_DRAFT", module="inline_discovered", business_scenario="竞品分析信号草稿", input_contract=JSON_INPUT, output_contract=JSON_OUTPUT, call_sites=("backend/app/services/ai_steward_service/mixin_draft_generation.py",), prompt_family=PROMPT_FAMILY_PLANNING, **common), + _entry("PROMPT_AI_STEWARD_CHARTER_DRAFT", module="inline_discovered", business_scenario="项目宪章草稿", input_contract=JSON_INPUT, output_contract=JSON_OUTPUT, call_sites=("backend/app/services/ai_steward_service/mixin_draft_generation.py",), prompt_family=PROMPT_FAMILY_PLANNING, **common), + _entry("PROMPT_AI_PMO_SIGNAL_TITLE_REFINE", module="inline_discovered", business_scenario="信号标题提炼", input_contract=JSON_INPUT, output_contract="仅输出标题文本。", call_sites=("backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_helpers.py",), prompt_family=PROMPT_FAMILY_RESPONSE, **common), + _entry("PROMPT_LLM_TASK_BREAKDOWN_HELPER", module="inline_discovered", business_scenario="LLM helper 任务拆解", input_contract=QA_INPUT, output_contract=JSON_LIST_OUTPUT, call_sites=("backend/app/services/llm_service/mixin_task_helpers.py",), prompt_family=PROMPT_FAMILY_PLANNING, **common), + _entry("PROMPT_LLM_CONTEXT_INSIGHTS_HELPER", module="inline_discovered", business_scenario="LLM helper 上下文洞察", input_contract=QA_INPUT, output_contract=JSON_OUTPUT, call_sites=("backend/app/services/llm_service/mixin_task_helpers.py",), prompt_family=PROMPT_FAMILY_EXTRACTION, **common), + _entry("PROMPT_LLM_STRUCTURED_JSON_REPAIR", module="inline_discovered", business_scenario="结构化 JSON 修复", input_contract="输入原始任务 prompt、schema hint 和模型输出。", output_contract=JSON_OUTPUT, call_sites=("backend/app/services/llm_service/mixin_generate_db_structured.py",), prompt_family=PROMPT_FAMILY_GOVERNANCE, risk_level="high", **{k: v for k, v in common.items() if k != "risk_level"}), + _entry("PROMPT_LLM_ENGINE_CONNECTION_TEST", module="inline_discovered", business_scenario="供应商连接测试", input_contract="输入测试 prompt。", output_contract="默认只返回 OK。", call_sites=("backend/app/services/llm_service/mixin_engine_admin.py", "backend/app/core/startup/model_probe.py"), prompt_family=PROMPT_FAMILY_GOVERNANCE, risk_level="low", **{k: v for k, v in common.items() if k != "risk_level"}), + _entry("PROMPT_SSTC_CHARTER_SEED_LOCALIZE_ZH", module="inline_discovered", business_scenario="SSTC charter seed 中文本地化", input_contract=JSON_INPUT, output_contract=JSON_OUTPUT, call_sites=("backend/app/services/sstc/charter_seed_utils.py",), prompt_family=PROMPT_FAMILY_EXTRACTION, **common), ) @@ -592,6 +826,9 @@ PROMPT_REGISTRY: tuple[dict[str, Any], ...] = ( *_part_03_entries(), *_part_04_entries(), *_scene_entries(), + *_runtime_guardrail_entries(), + *_semantic_fact_entries(), + *_inline_discovered_entries(), ) PROMPT_REGISTRY_MAP: dict[str, dict[str, Any]] = { diff --git a/backend/app/config/ai_prompts.py b/backend/app/config/ai_prompts.py index a9d2aedf..d53a88b7 100644 --- a/backend/app/config/ai_prompts.py +++ b/backend/app/config/ai_prompts.py @@ -61,7 +61,9 @@ from .ai_prompts_catalog import ( PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA, PROMPT_EXTERNAL_TREND_QA, PROMPT_INDUSTRY_TREND_QA, + PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION, PROMPT_PMFLOW_LAUNCH_DRAFT, + PROMPT_SAVED_IDEA_ENRICHMENT, ) from .ai_prompt_registry import ( PROMPT_REGISTRY, @@ -1013,6 +1015,9 @@ __all__ = [ "PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA", "PROMPT_EXTERNAL_TREND_QA", "PROMPT_INDUSTRY_TREND_QA", + "PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION", + "PROMPT_PMFLOW_LAUNCH_DRAFT", + "PROMPT_SAVED_IDEA_ENRICHMENT", "PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT", "PROMPT_RAG_QUERY_SEMANTICS", "PROMPT_RAG_FOLLOWUP_ANCHOR_SEMANTICS", diff --git a/backend/app/config/ai_prompts_catalog/__init__.py b/backend/app/config/ai_prompts_catalog/__init__.py index 8764ae9b..9547bcc5 100644 --- a/backend/app/config/ai_prompts_catalog/__init__.py +++ b/backend/app/config/ai_prompts_catalog/__init__.py @@ -17,7 +17,9 @@ from .part_scenes_06 import ( PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA, PROMPT_EXTERNAL_TREND_QA, PROMPT_INDUSTRY_TREND_QA, + PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION, PROMPT_PMFLOW_LAUNCH_DRAFT, + PROMPT_SAVED_IDEA_ENRICHMENT, ) __all__ = [ @@ -74,5 +76,7 @@ __all__ = [ "PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA", "PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET", "PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA", + "PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION", "PROMPT_PMFLOW_LAUNCH_DRAFT", + "PROMPT_SAVED_IDEA_ENRICHMENT", ] diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_06.py b/backend/app/config/ai_prompts_catalog/part_scenes_06.py index 5bb58108..35d90683 100644 --- a/backend/app/config/ai_prompts_catalog/part_scenes_06.py +++ b/backend/app/config/ai_prompts_catalog/part_scenes_06.py @@ -1,6 +1,6 @@ from __future__ import annotations -from .part_01 import TEXT_OUTPUT_GUARDRAIL, _compose_prompt +from .part_01 import JSON_OUTPUT_GUARDRAIL, TEXT_OUTPUT_GUARDRAIL, _compose_prompt PROMPT_EXTERNAL_TREND_QA = _compose_prompt( "你是 PMFlow 的市场情报助手。", @@ -25,7 +25,7 @@ PROMPT_EXTERNAL_TREND_QA = _compose_prompt( 项目上下文:{project_context} 外部搜索结果:{external_results} """.strip(), - TEXT_OUTPUT_GUARDRAIL, + JSON_OUTPUT_GUARDRAIL, ) PROMPT_INDUSTRY_TREND_QA = _compose_prompt( @@ -203,7 +203,7 @@ JSON schema: PROMPT_PMFLOW_LAUNCH_DRAFT = _compose_prompt( "你是 PMFlow 的新项目启动草案生成器。", """ -用户输入了一个 AI 产品创意。你只能生成“待用户确认”的项目草案,不能声称项目已经创建。 +用户输入了一个 AI 产品创意。你只能生成“待用户确认”的项目草案,不能声称项目已经创建、已确认、已写入项目记忆或已开始执行。 输出必须是 JSON 对象,不要 Markdown,不要解释。字段: {{ @@ -222,17 +222,114 @@ PROMPT_PMFLOW_LAUNCH_DRAFT = _compose_prompt( }} 硬约束: -1. requirements 目标 3-5 条,tasks 目标 3-7 条,risks 目标 2-4 条,decisions 目标 1-3 条。 -2. 所有对象必须来自用户输入的合理结构化推断;信息不足时写入 warnings,不要编造具体事实。 -3. confidence 用 0-1 数值。 -4. AI 生成内容只能是待确认草案,不要使用“已确认”“已写入”等表述。 -5. evidence.excerpt 必须引用或概括用户输入中的证据。 +1. requirements 目标 7-10 条,tasks 目标 10-16 条,risks 目标 4-7 条,decisions 目标 3-6 条;输入信息不足时允许低于目标,但必须在 warnings 写明缺口。 +2. 草案要像“可变现项目的待确认启动计划”,不是只列 3 个功能点。requirements 必须覆盖:目标用户/痛点、核心使用闭环、输入或数据来源、AI/模型处理链路、输出物与交互体验、信任/人工确认、安全合规或权限、变现/定价/留存验证、埋点或反馈指标。 +3. requirements 每项必须有 title、summary、acceptance_criteria;acceptance_criteria 至少 2 条,表达可验收结果,不能只写“完成”“支持”这类空泛描述。 +4. tasks 必须拆成可执行的首批工作包,而不是一句话待办;必须覆盖 product、engineering、qa 三类,至少包含:用户/场景验证、信息架构或交互、关键接口/数据流、AI 输出质量验证、权限/隐私边界、变现或增长验证、埋点/反馈闭环。priority 只能是 low、medium、high、urgent;requirement_index 必须指向 requirements 的数组下标,不能越界。 +5. risks 必须包含 mitigation,且至少覆盖:需求真实性、数据/权限、AI 质量、合规/隐私、商业化或获客、技术可行性中的 4 类;decisions 必须包含 reason 和 owner,owner 信息不足时写“用户确认”。 +6. evidence 每项 source 只能使用 user_input 或 attachment_summary;excerpt 必须引用或概括用户输入/附件中的证据。 +7. 所有对象必须来自用户输入的合理结构化推断;可以提出“待验证假设”,但不要编造具体事实、客户、收入、时间表、外部数据或已经发生的验证结果。 +8. 草案必须覆盖 4 个确认面:项目概览、MVP 范围、风险与待确认、首批任务。MVP 范围应表达“最小可验证商业闭环”,包括获客/激活/核心价值交付/付费或转化验证/留存反馈,而不是只做单点功能 demo。 +9. confidence 用 0-1 数值。输入越短、不确定项越多,confidence 越低;当商业闭环、目标用户、数据来源或变现方式缺失时,必须降低 confidence 并写入 warnings。 +10. AI 生成内容只能是待确认草案,不要使用“已确认”“已写入”“已创建”等表述。 +11. 所有字符串使用中文,除非用户输入中有英文产品名、技术名或专有名词。 用户输入: {input_text} 附件摘要: {attachments_json} +""".strip(), + JSON_OUTPUT_GUARDRAIL, +) + +PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION = _compose_prompt( + "你是 PMFlow 的项目主链变更抽取器。", + """ +用户在项目主链对话里提交了一段补充、变更、风险、约束或执行目标。你的任务是把这段输入抽取成“待人工审查”的 ChangeSet 候选项。 + +你不能声称任何变更已经确认、已经写入项目记忆、已经创建任务或已经开始执行。只输出可审查候选。 + +输出必须是 JSON 对象,不要 Markdown,不要解释。字段: +{{ + "change_summary": "string", + "proposed_items": [ + {{ + "group": "new_object|update_object|risk|decision", + "object_type": "Requirement|Task|Risk|Decision", + "object_id": "string|null", + "title": "string", + "summary": "string", + "diff": "string", + "payload": {{}}, + "confidence": 0.0, + "risk_level": "low|medium|high" + }} + ], + "evidence": [{{"source": "thread_message|attachment_summary", "excerpt": "string"}}], + "missing_info": ["string"], + "warnings": ["string"], + "confidence": 0.0 +}} + +抽取规则: +1. proposed_items 只放用户这次输入明确支持的候选项,禁止为了凑数生成完整项目计划。 +2. 如果用户表达的是新增功能、范围、约束、验收口径,优先抽成 Requirement;如果表达的是可执行工作,抽成 Task;如果表达不确定性、合规、质量、数据或商业风险,抽成 Risk;如果表达需要拍板的方向、取舍或责任归属,抽成 Decision。 +3. title 必须短、具体、可审查;summary 说明业务含义;diff 说明相对当前项目需要新增或调整什么。 +4. payload 使用前端和后端可消费的中文字段:summary、acceptanceCriteria、description、priority、mitigation、owner、stage 等;字段缺失不要编造,写入 missing_info。 +5. object_id 只有用户明确指向已有对象时才填写,否则为 null。 +6. evidence 必须引用或概括本次 thread_message 或附件摘要中的证据,不能引用外部事实或历史事实。 +7. 信息不足时可以返回空 proposed_items,但必须写 missing_info 和 warnings,confidence 不超过 0.4。 +8. 所有字符串使用中文,除非用户输入中包含英文产品名、技术名或专有名词。 + +项目线程消息: +{message_text} + +附件摘要: +{attachments_json} +""".strip(), + JSON_OUTPUT_GUARDRAIL, +) + +PROMPT_SAVED_IDEA_ENRICHMENT = _compose_prompt( + "你是 PMFlow 的暂存想法结构化整理器。", + """ +用户保存了一个尚未进入正式项目的想法。你只能整理它的完整度和缺口,不能声称已创建项目、已写入项目记忆、已确认需求或已生成执行任务。 + +输出必须是 JSON 对象,不要 Markdown,不要解释。字段: +{{ + "summary": "string", + "target_user": "string|null", + "problem": "string|null", + "value_hypothesis": "string|null", + "validation_signal": "string|null", + "constraints": ["string"], + "missing_questions": ["string"], + "readiness_score": 0.0, + "status": "needs_more_info|enriched|draft_ready", + "confidence": 0.0 +}} + +判断规则: +1. 只基于用户输入整理,不要补编具体客户、收入、指标、时间表或技术方案。 +2. readiness_score 用 0-1 数值,表示是否足够进入 Project Launch 草案生成确认链。 +3. 缺少目标用户、核心问题、价值假设、验证信号或关键约束时,missing_questions 最多给 3 个最关键问题。 +4. status 只能取: + - needs_more_info:关键信息不足,暂不适合生成草案; + - enriched:已有基本方向,但仍建议补充; + - draft_ready:信息足够生成待确认草案。 +5. summary 用一句话概括想法,不超过 80 个中文字符。 +6. confidence 用 0-1 数值,表达整理置信度。 +7. 如果用户输入只是问候、测试词、无意义短语、单个产品名或没有任何可执行想法内容: + - summary 写“用户输入为问候语或测试内容,无实质想法内容”(若不是问候而是空泛短语,可改成“用户输入信息过少,无实质想法内容”) + - target_user/problem/value_hypothesis/validation_signal 输出 null + - constraints 输出 [] + - missing_questions 固定优先输出: + ["这个想法具体想解决什么问题?", "目标用户是谁?", "预期创造什么价值?"] + - readiness_score 输出 0.0,status 输出 needs_more_info,confidence 不超过 0.3 + +用户输入: +{raw_text} """.strip(), TEXT_OUTPUT_GUARDRAIL, ) @@ -245,5 +342,7 @@ __all__ = [ "PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA", "PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET", "PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA", + "PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION", "PROMPT_PMFLOW_LAUNCH_DRAFT", + "PROMPT_SAVED_IDEA_ENRICHMENT", ] diff --git a/backend/app/core/startup/model_probe.py b/backend/app/core/startup/model_probe.py index bc5893f7..17cbdd35 100644 --- a/backend/app/core/startup/model_probe.py +++ b/backend/app/core/startup/model_probe.py @@ -22,7 +22,7 @@ async def probe_model_connectivity_on_startup() -> None: low_runtime = build_tier_runtime("low") high_runtime = build_tier_runtime("high") - low_provider = str(low_runtime.get("provider") or "glm").strip() or "glm" + low_provider = str(low_runtime.get("provider") or "dashscope").strip() or "dashscope" low_model = str(low_runtime.get("model") or "qwen-plus").strip() or "qwen-plus" high_provider = str(high_runtime.get("provider") or "openai").strip() or "openai" high_model = str(high_runtime.get("model") or "GLM-5").strip() or "GLM-5" @@ -65,13 +65,7 @@ async def probe_model_connectivity_on_startup() -> None: error=str(resolve_exc), ) if not isinstance(runtime, dict) or not runtime: - runtime = llm_service._env_provider_runtime(provider) - if level == "LOW": - runtime["model"] = low_model - runtime["base_url"] = str(low_runtime.get("base_url") or runtime.get("base_url") or "") - elif level == "HIGH": - runtime["model"] = high_model - runtime["base_url"] = str(high_runtime.get("base_url") or runtime.get("base_url") or "") + raise ValueError("未解析到已保存的供应商配置") except Exception as exc: logger.error( "startup_model_probe_runtime_invalid", diff --git a/backend/app/dependencies/__init__.py b/backend/app/dependencies/__init__.py index 47a9f682..f68ebf12 100644 --- a/backend/app/dependencies/__init__.py +++ b/backend/app/dependencies/__init__.py @@ -9,10 +9,16 @@ FastAPI 依赖注入 from .auth import ( oauth2_scheme, get_current_user, + get_optional_current_user, get_current_active_user, require_roles, ) from .permissions import ( + require_platform_admin_access, + require_platform_ai_ops, + require_platform_capability, + require_platform_route_governance, + require_platform_semantic_facts, require_manager_user, require_project_member_or_manager, require_self_or_manager, @@ -22,8 +28,14 @@ from .tenant import get_tenant_id __all__ = [ "oauth2_scheme", "get_current_user", + "get_optional_current_user", "get_current_active_user", "require_roles", + "require_platform_admin_access", + "require_platform_ai_ops", + "require_platform_capability", + "require_platform_route_governance", + "require_platform_semantic_facts", "require_manager_user", "require_project_member_or_manager", "require_self_or_manager", diff --git a/backend/app/dependencies/permissions.py b/backend/app/dependencies/permissions.py index 40dc8b07..8a9ab0f4 100644 --- a/backend/app/dependencies/permissions.py +++ b/backend/app/dependencies/permissions.py @@ -9,8 +9,9 @@ from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession from ..core.database import get_db -from ..models import User, UserProject +from ..models import Project, User, UserProject from ..services.auth import MANAGER_ROLES, parse_role_values +from ..services.platform_access_service import has_platform_capability from .auth import get_current_user @@ -58,15 +59,44 @@ async def require_project_member_or_manager( except ValueError as exc: raise HTTPException(status_code=400, detail="Invalid project_id") from exc - membership = await db.execute( - select(UserProject.user_id).where( + access = await db.execute( + select(Project.id) + .outerjoin( + UserProject, and_( - UserProject.tenant_id == int(current_user.tenant_id), - UserProject.project_id == pid, + UserProject.tenant_id == Project.tenant_id, + UserProject.project_id == Project.id, UserProject.user_id == int(current_user.id), + ), + ) + .where( + and_( + Project.tenant_id == int(current_user.tenant_id), + Project.id == pid, + (Project.owner_id == int(current_user.id)) | (UserProject.user_id.is_not(None)), ) ) ) - if membership.scalar_one_or_none() is None: + if access.scalar_one_or_none() is None: raise HTTPException(status_code=403, detail="Project membership required") return current_user + + +def require_platform_capability(capability: str): + """Require a PMFlow platform capability, not a customer workspace role.""" + + async def checker( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + ) -> User: + if await has_platform_capability(db, user=current_user, capability=capability): + return current_user + raise HTTPException(status_code=403, detail=f"Platform capability required: {capability}") + + return checker + + +require_platform_admin_access = require_platform_capability("platform.admin.access") +require_platform_ai_ops = require_platform_capability("platform.ai_routing.manage") +require_platform_route_governance = require_platform_capability("platform.route_governance.manage") +require_platform_semantic_facts = require_platform_capability("platform.semantic_facts.manage") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 0b5f05c9..f458425d 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,6 +2,10 @@ from .base import Base, BaseNoTenant, BaseNoCreatedAt from .enums import UserRole, UserStatus from .tenant import Tenant +from .tenant_membership import TenantMembership +from .tenant_invitation import TenantInvitation +from .platform_membership import PlatformMembership +from .phone_verification_code import PhoneVerificationCode from .user import User from .user_project import UserProject from .skill import SkillTag @@ -50,7 +54,7 @@ from .query_route_memory import QueryRouteMemory from .semantic_fact import SemanticFact from .semantic_fact_extraction_run import SemanticFactExtractionRun from .feedback_center import FeedbackCenterRecord, FeedbackCenterRecordRevision -from .reply_iteration import QualityAsset, QualityAssetPair, JudgeRun, OptimizerCandidate, PromptRelease +from .reply_iteration import QualityAsset, QualityAssetPair, JudgeRun, OptimizerCandidate, PromptRelease, PromptReleaseCheck, PromptGoldenCase, PromptReplayRun, PromptReplayCaseResult from .training_pipeline import ( TrainingDataset, TrainingDatasetItem, @@ -108,8 +112,10 @@ from .ai_project_control import ( ComplianceCheckRun, EvidencePack, ) -from .pmflow_launch import PMFlowLaunchDraft, PMFlowThread, PMFlowThreadMessage +from .pmflow_launch import PMFlowLaunchDraft, PMFlowLaunchDraftJob, PMFlowThread, PMFlowThreadMessage from .pmflow_change_review import PMFlowChangeSet, PMFlowChangeItem, PMFlowApproval, PMFlowEvidenceLink +from .pmflow_ai_feedback import PMFlowAIFeedbackEvent +from .saved_idea import SavedIdea __all__ = [ "Base", @@ -118,6 +124,9 @@ __all__ = [ "UserRole", "UserStatus", "Tenant", + "TenantMembership", + "TenantInvitation", + "PlatformMembership", "User", "UserProject", "SkillTag", @@ -170,6 +179,10 @@ __all__ = [ "JudgeRun", "OptimizerCandidate", "PromptRelease", + "PromptReleaseCheck", + "PromptGoldenCase", + "PromptReplayRun", + "PromptReplayCaseResult", "TrainingDataset", "TrainingDatasetItem", "TrainingJob", @@ -222,10 +235,13 @@ __all__ = [ "ComplianceCheckRun", "EvidencePack", "PMFlowLaunchDraft", + "PMFlowLaunchDraftJob", "PMFlowThread", "PMFlowThreadMessage", "PMFlowChangeSet", "PMFlowChangeItem", "PMFlowApproval", "PMFlowEvidenceLink", + "PMFlowAIFeedbackEvent", + "SavedIdea", ] diff --git a/backend/app/models/ai_call_log.py b/backend/app/models/ai_call_log.py index 0ae2f028..b63fbf97 100644 --- a/backend/app/models/ai_call_log.py +++ b/backend/app/models/ai_call_log.py @@ -2,7 +2,7 @@ from datetime import datetime from typing import Optional -from sqlalchemy import BigInteger, DECIMAL, ForeignKey, Integer, Text, TIMESTAMP, VARCHAR, func +from sqlalchemy import BigInteger, DECIMAL, ForeignKey, Integer, JSON, Text, TIMESTAMP, VARCHAR, func from sqlalchemy.orm import Mapped, mapped_column from .base import BaseNoTenant @@ -32,6 +32,17 @@ class AICallLog(BaseLog): tokens_used: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) cost: Mapped[Optional[float]] = mapped_column(DECIMAL(10, 6), nullable=True) status: Mapped[str] = mapped_column(VARCHAR(50), nullable=False) + trace_id: Mapped[Optional[str]] = mapped_column(VARCHAR(80), nullable=True, index=True) + provider: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True) + model: Mapped[Optional[str]] = mapped_column(VARCHAR(120), nullable=True, index=True) + latency_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + routing_meta: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) + prompt_key: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True) + prompt_release_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True) + prompt_source: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True, index=True) + provider_request_payload: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) + provider_response_payload: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) + provider_attempts: Mapped[Optional[list]] = mapped_column(JSON, nullable=True) planned_tier: Mapped[Optional[str]] = mapped_column(VARCHAR(16), nullable=True, index=True) executed_tier: Mapped[Optional[str]] = mapped_column(VARCHAR(16), nullable=True, index=True) routing_decision: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True) diff --git a/backend/app/models/phone_verification_code.py b/backend/app/models/phone_verification_code.py new file mode 100644 index 00000000..a930dd0f --- /dev/null +++ b/backend/app/models/phone_verification_code.py @@ -0,0 +1,24 @@ +"""Phone verification code model.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from sqlalchemy import Integer, TIMESTAMP, VARCHAR +from sqlalchemy.orm import Mapped, mapped_column + +from .base import BaseNoTenant + + +class PhoneVerificationCode(BaseNoTenant): + __tablename__ = "phone_verification_codes" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + phone_e164: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True) + purpose: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True) + code_hash: Mapped[str] = mapped_column(VARCHAR(128), nullable=False) + expires_at: Mapped[datetime] = mapped_column(TIMESTAMP, nullable=False) + consumed_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) + attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + request_ip: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True) diff --git a/backend/app/models/platform_membership.py b/backend/app/models/platform_membership.py new file mode 100644 index 00000000..c647c492 --- /dev/null +++ b/backend/app/models/platform_membership.py @@ -0,0 +1,29 @@ +"""Platform-side membership for PMFlow internal operators.""" + +from __future__ import annotations + +from sqlalchemy import BigInteger, ForeignKey, Integer, UniqueConstraint, VARCHAR +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .base import BaseNoTenant + + +class PlatformMembership(BaseNoTenant): + """Global platform role, separate from customer workspace membership.""" + + __tablename__ = "platform_memberships" + __table_args__ = ( + UniqueConstraint("user_id", "role", name="uq_platform_memberships_user_role"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + BigInteger, + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + role: Mapped[str] = mapped_column(VARCHAR(50), nullable=False) + status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="active") + + user = relationship("User") diff --git a/backend/app/models/pmflow_ai_feedback.py b/backend/app/models/pmflow_ai_feedback.py new file mode 100644 index 00000000..6aaa7131 --- /dev/null +++ b/backend/app/models/pmflow_ai_feedback.py @@ -0,0 +1,54 @@ +"""PMFlow OS AI answer feedback models.""" + +from __future__ import annotations + +from typing import Any, Optional + +from sqlalchemy import BigInteger, ForeignKey, Integer, Text, UniqueConstraint, VARCHAR +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base +from .types import JSONB + + +class PMFlowAIFeedbackEvent(Base): + __tablename__ = "pmflow_ai_feedback_events" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + project_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=False, index=True) + thread_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("pmflow_threads.id"), nullable=True, index=True) + message_id: Mapped[Optional[int]] = mapped_column( + BigInteger, ForeignKey("pmflow_thread_messages.id"), nullable=True, index=True + ) + change_set_id: Mapped[Optional[int]] = mapped_column( + BigInteger, ForeignKey("pmflow_change_sets.id"), nullable=True, index=True + ) + ai_call_log_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True) + actor_user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id"), nullable=False, index=True) + target_type: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=True) + target_id: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, index=True) + answer_kind: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, default="chat_answer", index=True) + rating: Mapped[str] = mapped_column(VARCHAR(16), nullable=False, index=True) + reason_tags: Mapped[Any] = mapped_column(JSONB, nullable=False, default=list) + comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + scene_key: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True) + prompt_key: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True) + prompt_release_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True) + runtime_profile_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True) + model_provider: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True) + model_name: Mapped[Optional[str]] = mapped_column(VARCHAR(120), nullable=True) + feedback_metadata: Mapped[Any] = mapped_column(JSONB, nullable=False, default=dict) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "project_id", + "actor_user_id", + "target_type", + "target_id", + name="uq_pmflow_ai_feedback_target_user", + ), + ) + + +__all__ = ["PMFlowAIFeedbackEvent"] diff --git a/backend/app/models/pmflow_change_review.py b/backend/app/models/pmflow_change_review.py index 23d2c978..2a4069dd 100644 --- a/backend/app/models/pmflow_change_review.py +++ b/backend/app/models/pmflow_change_review.py @@ -30,6 +30,7 @@ class PMFlowChangeSet(Base): created_by: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id"), nullable=False, index=True) reviewed_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) committed_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) + committed_by: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("users.id"), nullable=True, index=True) class PMFlowChangeItem(Base): diff --git a/backend/app/models/pmflow_launch.py b/backend/app/models/pmflow_launch.py index 99560991..9a852b14 100644 --- a/backend/app/models/pmflow_launch.py +++ b/backend/app/models/pmflow_launch.py @@ -37,6 +37,37 @@ class PMFlowLaunchDraft(Base): ) +class PMFlowLaunchDraftJob(Base): + __tablename__ = "pmflow_launch_draft_jobs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id"), nullable=False, index=True) + job_id: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=True) + revision_id: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=True) + parent_draft_id: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True) + superseded_by_job_id: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True) + result_draft_id: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True) + input_text: Mapped[str] = mapped_column(Text, nullable=False) + input_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="text") + source: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="web") + attachments: Mapped[Any] = mapped_column(JSONB, nullable=False, default=list) + mode: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="create") + revision_note: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="queued", index=True) + result_payload: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + changed_fields: Mapped[Any] = mapped_column(JSONB, nullable=False, default=list) + error_code: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True) + error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + started_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) + completed_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) + expires_at: Mapped[datetime] = mapped_column(TIMESTAMP, nullable=False, index=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "job_id", name="uq_pmflow_launch_draft_jobs_tenant_job_id"), + UniqueConstraint("tenant_id", "revision_id", name="uq_pmflow_launch_draft_jobs_tenant_revision_id"), + ) + + class PMFlowThread(Base): __tablename__ = "pmflow_threads" @@ -72,4 +103,4 @@ class PMFlowThreadMessage(Base): status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="sent", index=True) -__all__ = ["PMFlowLaunchDraft", "PMFlowThread", "PMFlowThreadMessage"] +__all__ = ["PMFlowLaunchDraft", "PMFlowLaunchDraftJob", "PMFlowThread", "PMFlowThreadMessage"] diff --git a/backend/app/models/reply_iteration.py b/backend/app/models/reply_iteration.py index 72714f36..3245620e 100644 --- a/backend/app/models/reply_iteration.py +++ b/backend/app/models/reply_iteration.py @@ -143,10 +143,116 @@ class PromptRelease(Base): ) +class PromptReleaseCheck(Base): + __tablename__ = "prompt_release_checks" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + prompt_key: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, index=True) + release_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("prompt_releases.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + check_type: Mapped[str] = mapped_column(VARCHAR(48), nullable=False, index=True) + status: Mapped[str] = mapped_column(VARCHAR(24), nullable=False, index=True) + severity: Mapped[str] = mapped_column(VARCHAR(16), nullable=False, default="info", index=True) + summary: Mapped[str] = mapped_column(Text, nullable=False) + details_json: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + created_by: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True) + completed_at: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True) + + +class PromptGoldenCase(Base): + __tablename__ = "prompt_golden_cases" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + prompt_key: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, index=True) + case_name: Mapped[str] = mapped_column(VARCHAR(255), nullable=False) + status: Mapped[str] = mapped_column(VARCHAR(24), nullable=False, default="active", index=True) + input_payload: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + expected_contract: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + expected_contains: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + source: Mapped[str] = mapped_column(VARCHAR(48), nullable=False, default="manual", index=True) + source_ai_call_log_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True) + case_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="regression", index=True) + priority: Mapped[str] = mapped_column(VARCHAR(24), nullable=False, default="blocking", index=True) + scenario_key: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True) + tags: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + last_run_status: Mapped[Optional[str]] = mapped_column(VARCHAR(24), nullable=True, index=True) + last_failed_at: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True) + created_by: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True) + notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + +class PromptReplayRun(Base): + __tablename__ = "prompt_replay_runs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + prompt_key: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, index=True) + release_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("prompt_releases.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + baseline_release_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True) + run_status: Mapped[str] = mapped_column(VARCHAR(24), nullable=False, default="pending", index=True) + run_mode: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="contract_smoke", index=True) + case_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + passed_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + failed_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + warning_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + score_delta: Mapped[Optional[float]] = mapped_column(nullable=True) + summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + risk_flags_json: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + created_by: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True) + started_at: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True) + completed_at: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True) + + +class PromptReplayCaseResult(Base): + __tablename__ = "prompt_replay_case_results" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + run_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("prompt_replay_runs.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + golden_case_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("prompt_golden_cases.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + prompt_key: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, index=True) + release_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) + baseline_output: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + candidate_output: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(VARCHAR(24), nullable=False, index=True) + contract_result_json: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + diff_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + risk_flags_json: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + baseline_ai_call_log_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True) + candidate_ai_call_log_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True) + judge_score: Mapped[Optional[float]] = mapped_column(nullable=True) + judge_label: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True, index=True) + judge_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + latency_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + estimated_cost: Mapped[Optional[float]] = mapped_column(nullable=True) + error_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + __all__ = [ "QualityAsset", "QualityAssetPair", "JudgeRun", "OptimizerCandidate", "PromptRelease", + "PromptReleaseCheck", + "PromptGoldenCase", + "PromptReplayRun", + "PromptReplayCaseResult", ] diff --git a/backend/app/models/saved_idea.py b/backend/app/models/saved_idea.py new file mode 100644 index 00000000..2dd7a4a4 --- /dev/null +++ b/backend/app/models/saved_idea.py @@ -0,0 +1,46 @@ +"""Personal space saved idea models.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Optional + +from sqlalchemy import BigInteger, Float, ForeignKey, Integer, Text, TIMESTAMP, UniqueConstraint, VARCHAR +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base +from .types import JSONB + + +class SavedIdea(Base): + __tablename__ = "saved_ideas" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id"), nullable=False, index=True) + idea_id: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=True) + title: Mapped[str] = mapped_column(VARCHAR(255), nullable=False) + raw_text: Mapped[str] = mapped_column(Text, nullable=False) + summary: Mapped[str] = mapped_column(Text, nullable=False) + source_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="project_launch", index=True) + source_ref: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True) + input_mode: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="idea") + attachments: Mapped[Any] = mapped_column(JSONB, nullable=False, default=list) + status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="saved", index=True) + ai_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + missing_questions: Mapped[Any] = mapped_column(JSONB, nullable=False, default=list) + readiness_score: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) + enrichment_status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="baseline") + enrichment_payload: Mapped[Any] = mapped_column(JSONB, nullable=True, default=dict) + enrichment_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + enriched_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) + launch_draft_id: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True) + promoted_project_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=True, index=True) + promoted_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) + archived_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "idea_id", name="uq_saved_ideas_tenant_idea_id"), + ) + + +__all__ = ["SavedIdea"] diff --git a/backend/app/models/tenant.py b/backend/app/models/tenant.py index a4412f02..ff9b1d15 100644 --- a/backend/app/models/tenant.py +++ b/backend/app/models/tenant.py @@ -1,4 +1,7 @@ -"""租户模型""" +"""空间模型。 + +数据库沿用 tenants 表名;产品语义为 Workspace。 +""" from .base import BaseNoTenant from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy import BigInteger, Integer, VARCHAR @@ -11,3 +14,7 @@ class Tenant(BaseNoTenant): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(VARCHAR(255), nullable=False) domain: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True) + workspace_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="team") + slug: Mapped[Optional[str]] = mapped_column(VARCHAR(120), nullable=True) + owner_user_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True) + status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="active") diff --git a/backend/app/models/tenant_invitation.py b/backend/app/models/tenant_invitation.py new file mode 100644 index 00000000..eed456b9 --- /dev/null +++ b/backend/app/models/tenant_invitation.py @@ -0,0 +1,32 @@ +"""Workspace invitation model.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Optional + +from sqlalchemy import BigInteger, ForeignKey, Integer, TIMESTAMP, VARCHAR +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .base import Base +from .types import JSONB + + +class TenantInvitation(Base): + __tablename__ = "tenant_invitations" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + email: Mapped[str] = mapped_column(VARCHAR(255), nullable=False, index=True) + phone_country_code: Mapped[Optional[str]] = mapped_column(VARCHAR(8), nullable=True) + phone_number: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True) + phone_e164: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True, index=True) + role: Mapped[str] = mapped_column(VARCHAR(50), nullable=False) + project_ids: Mapped[Any] = mapped_column(JSONB, nullable=False, default=list) + token_hash: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, unique=True, index=True) + status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="pending") + expires_at: Mapped[datetime] = mapped_column(TIMESTAMP, nullable=False) + invited_by_user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id"), nullable=False) + accepted_by_user_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("users.id"), nullable=True) + + invited_by = relationship("User", foreign_keys=[invited_by_user_id]) + accepted_by = relationship("User", foreign_keys=[accepted_by_user_id]) diff --git a/backend/app/models/tenant_membership.py b/backend/app/models/tenant_membership.py new file mode 100644 index 00000000..d27979a0 --- /dev/null +++ b/backend/app/models/tenant_membership.py @@ -0,0 +1,24 @@ +"""Workspace membership model.""" + +from __future__ import annotations + +from sqlalchemy import BigInteger, Boolean, ForeignKey, Integer, UniqueConstraint, VARCHAR +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .base import Base + + +class TenantMembership(Base): + __tablename__ = "tenant_memberships" + __table_args__ = ( + UniqueConstraint("tenant_id", "user_id", name="uq_tenant_memberships_tenant_user"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + role: Mapped[str] = mapped_column(VARCHAR(50), nullable=False) + status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="active") + is_personal_owner: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + + user = relationship("User") + diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 0b849ae3..1ec6cc86 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -5,7 +5,7 @@ from .base import Base from .enums import UserRole, UserStatus from .types import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy import BigInteger, VARCHAR, TEXT, Integer, Boolean +from sqlalchemy import BigInteger, VARCHAR, TEXT, Integer, Boolean, TIMESTAMP from typing import List, Optional, TYPE_CHECKING, Any if TYPE_CHECKING: @@ -20,6 +20,12 @@ class User(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) username: Mapped[str] = mapped_column(VARCHAR(100), nullable=False, unique=True) email: Mapped[str] = mapped_column(VARCHAR(255), nullable=False, unique=True) + phone_country_code: Mapped[Optional[str]] = mapped_column(VARCHAR(8), nullable=True) + phone_number: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True) + phone_e164: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True, unique=True, index=True) + phone_verified_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True) + last_login_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True) + login_method: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True) password_hash: Mapped[str] = mapped_column(VARCHAR(255), nullable=False) name: Mapped[str] = mapped_column(VARCHAR(100), nullable=False) role: Mapped[str] = mapped_column(VARCHAR(50), nullable=False) @@ -43,9 +49,16 @@ class User(Base): def to_dict(self) -> dict: return { "id": str(self.id), + "tenant_id": str(self.tenant_id), "username": self.username, "name": self.name, "email": self.email, + "phone_country_code": self.phone_country_code, + "phone_number": self.phone_number, + "phone_e164": self.phone_e164, + "phone_verified_at": self.phone_verified_at.isoformat() if self.phone_verified_at else None, + "last_login_at": self.last_login_at.isoformat() if self.last_login_at else None, + "login_method": self.login_method, "role": self.role, "assigned_domains": self.assigned_domains or [], "workload": self.workload, diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index a8399274..09e8001c 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -9,6 +9,9 @@ from .auth import ( UserUpdate, UserResponse, UserLoginResponse, + PhoneCodeSendRequest, + PhoneCodeVerifyRequest, + UserRegisterRequest, UserResetPasswordRequest, UserStatusPatchRequest, ) @@ -80,6 +83,9 @@ __all__ = [ "UserUpdate", "UserResponse", "UserLoginResponse", + "PhoneCodeSendRequest", + "PhoneCodeVerifyRequest", + "UserRegisterRequest", "UserResetPasswordRequest", "UserStatusPatchRequest", "SignalCreate", diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 163e8d05..4d7a9237 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -15,6 +15,34 @@ class UserLoginResponse(BaseModel): user: dict +class UserRegisterRequest(BaseModel): + """自注册请求:创建账号和个人空间。""" + + username: str + password: str + name: str + email: str + + +class PhoneCodeSendRequest(BaseModel): + """手机号验证码发送请求。""" + + phone_country_code: str = "+86" + phone_number: str + purpose: str = "login" + + +class PhoneCodeVerifyRequest(BaseModel): + """手机号验证码登录/注册/邀请绑定请求。""" + + phone_country_code: str = "+86" + phone_number: str + code: str + purpose: str = "login" + invite_token: Optional[str] = None + name: Optional[str] = None + + class UserCreate(BaseModel): """创建用户请求""" @@ -22,6 +50,8 @@ class UserCreate(BaseModel): password: str name: str email: Optional[str] = None + phone_country_code: Optional[str] = None + phone_number: Optional[str] = None role: Union[UserRole, List[UserRole], str] assigned_domains: Optional[List[str]] = [] project_ids: Optional[List[int]] = [] @@ -32,6 +62,8 @@ class UserUpdate(BaseModel): name: Optional[str] = None email: Optional[str] = None + phone_country_code: Optional[str] = None + phone_number: Optional[str] = None role: Optional[Union[UserRole, List[UserRole], str]] = None assigned_domains: Optional[List[str]] = None workload: Optional[int] = None @@ -59,6 +91,10 @@ class UserResponse(BaseModel): username: str name: str email: Optional[str] = None + phone_country_code: Optional[str] = None + phone_number: Optional[str] = None + phone_e164: Optional[str] = None + phone_verified_at: Optional[str] = None role: str assigned_domains: List[str] workload: int @@ -67,5 +103,6 @@ class UserResponse(BaseModel): workload_ratio: Optional[float] = None top_skills: List[str] = [] project_ids: List[int] = [] + platform_context: Optional[dict] = None created_at: str updated_at: str diff --git a/backend/app/schemas/pmflow_launch.py b/backend/app/schemas/pmflow_launch.py index 4f51979d..dc8a5618 100644 --- a/backend/app/schemas/pmflow_launch.py +++ b/backend/app/schemas/pmflow_launch.py @@ -21,6 +21,12 @@ class PMFlowLaunchDraftRequest(BaseModel): source: str = "web" +class PMFlowLaunchDraftJobRequest(PMFlowLaunchDraftRequest): + parent_draft_id: Optional[str] = None + revision_note: Optional[str] = Field(default=None, max_length=1200) + mode: Literal["create", "merge_supplement", "regenerate"] = "create" + + class PMFlowLaunchRequirement(BaseModel): title: str summary: str = "" @@ -75,6 +81,32 @@ class PMFlowLaunchDraftResponse(PMFlowLaunchDraftPayload): expires_at: str +class PMFlowLaunchDraftJobRuntimePolicy(BaseModel): + foreground_wait_seconds: int = 10 + provider_timeout_seconds: int = 90 + job_timeout_seconds: int = 180 + max_retries: int = 1 + large_input_policy: str = "preprocess_then_generate" + + +class PMFlowLaunchDraftJobResponse(BaseModel): + job_id: str + revision_id: str + status: Literal["queued", "running", "user_paused", "superseded", "completed", "failed", "cancelled"] + mode: str + parent_draft_id: Optional[str] = None + superseded_by_job_id: Optional[str] = None + draft_id: Optional[str] = None + draft: Optional[PMFlowLaunchDraftResponse] = None + changed_fields: list[str] = Field(default_factory=list) + error_code: Optional[str] = None + error_message: Optional[str] = None + started_at: Optional[str] = None + completed_at: Optional[str] = None + expires_at: str + runtime_policy: Optional[PMFlowLaunchDraftJobRuntimePolicy] = None + + class PMFlowLaunchConfirmRequest(BaseModel): edited_draft: dict[str, Any] = Field(default_factory=dict) confirm: bool = True @@ -95,6 +127,9 @@ __all__ = [ "PMFlowLaunchConfirmRequest", "PMFlowLaunchConfirmResponse", "PMFlowLaunchDecision", + "PMFlowLaunchDraftJobRequest", + "PMFlowLaunchDraftJobResponse", + "PMFlowLaunchDraftJobRuntimePolicy", "PMFlowLaunchDraftPayload", "PMFlowLaunchDraftRequest", "PMFlowLaunchDraftResponse", diff --git a/backend/app/schemas/pmflow_os.py b/backend/app/schemas/pmflow_os.py index 5df68df5..20c8c2c1 100644 --- a/backend/app/schemas/pmflow_os.py +++ b/backend/app/schemas/pmflow_os.py @@ -40,6 +40,9 @@ PMFlowReviewerConfidence = Literal["low", "medium", "high"] PMFlowFallbackReviewerRole = Literal["OWNER", "REVIEWER", "MEMBER"] PMFlowReviewDecisionAction = Literal["request_review", "approve", "request_changes", "reject"] PMFlowApprovalState = Literal["not_requested", "requested", "approved", "changes_requested", "rejected"] +PMFlowAIFeedbackRating = Literal["positive", "negative"] +PMFlowAIFeedbackAnswerKind = Literal["chat_answer", "structured_extraction", "copilot_answer"] +PMFlowAIFeedbackTargetType = Literal["thread_message", "change_set", "copilot_answer"] PMFlowExecutionCapabilityMode = Literal[ "ai_direct", "ai_with_user_acceptance", @@ -93,6 +96,50 @@ class PMFlowThreadMessageResponse(BaseModel): metadata: dict[str, Any] = Field(default_factory=dict) +class PMFlowAIFeedbackCreateRequest(BaseModel): + target_type: PMFlowAIFeedbackTargetType + target_id: str = Field(min_length=1, max_length=128) + answer_kind: PMFlowAIFeedbackAnswerKind = "chat_answer" + rating: PMFlowAIFeedbackRating + reason_tags: list[str] = Field(default_factory=list) + comment: Optional[str] = Field(default=None, max_length=4000) + thread_id: Optional[str] = None + message_id: Optional[str] = None + change_set_id: Optional[str] = None + ai_call_log_id: Optional[str] = None + scene_key: Optional[str] = Field(default=None, max_length=128) + prompt_key: Optional[str] = Field(default=None, max_length=128) + prompt_release_id: Optional[int] = None + runtime_profile_id: Optional[str] = Field(default=None, max_length=128) + model_provider: Optional[str] = Field(default=None, max_length=64) + model_name: Optional[str] = Field(default=None, max_length=120) + feedback_metadata: dict[str, Any] = Field(default_factory=dict) + + +class PMFlowAIFeedbackResponse(BaseModel): + id: str + projectId: str + threadId: Optional[str] = None + messageId: Optional[str] = None + changeSetId: Optional[str] = None + aiCallLogId: Optional[str] = None + targetType: str + targetId: str + answerKind: str + rating: PMFlowAIFeedbackRating + reasonTags: list[str] = Field(default_factory=list) + comment: Optional[str] = None + sceneKey: Optional[str] = None + promptKey: Optional[str] = None + promptReleaseId: Optional[int] = None + runtimeProfileId: Optional[str] = None + modelProvider: Optional[str] = None + modelName: Optional[str] = None + metadata: dict[str, Any] = Field(default_factory=dict) + createdAt: str + updatedAt: str + + class PMFlowProposedChangeItem(BaseModel): group: PMFlowChangeGroup object_type: PMFlowObjectType @@ -113,7 +160,7 @@ class PMFlowExtractionCreateRequest(BaseModel): headline: Optional[str] = None routing_meta: dict[str, Any] = Field(default_factory=dict) llm_call_id: Optional[str] = None - proposed_items: list[PMFlowProposedChangeItem] = Field(min_length=1) + proposed_items: list[PMFlowProposedChangeItem] = Field(default_factory=list) class PMFlowChangeItemEditRequest(BaseModel): @@ -377,6 +424,8 @@ class PMFlowProjectMemory(BaseModel): class ProjectTruthStateProjection(BaseModel): loopStage: ProjectLoopStage = "command_idle" lastWritebackAt: Optional[str] = None + lastWritebackBy: Optional[str] = None + lastWritebackById: Optional[str] = None project: PMFlowProjectSummary thread: PMFlowThread messages: list[dict[str, Any]] = Field(default_factory=list) @@ -392,6 +441,7 @@ class ProjectTruthStateProjection(BaseModel): releases: list[PMFlowRelease] = Field(default_factory=list) metricSignals: list[PMFlowMetricSignal] = Field(default_factory=list) approvals: list[dict[str, Any]] = Field(default_factory=list) + aiFeedback: list[PMFlowAIFeedbackResponse] = Field(default_factory=list) feed: list[PMFlowFeedEvent] = Field(default_factory=list) teamInbox: PMFlowTeamInbox = Field(default_factory=PMFlowTeamInbox) reviewerRouting: PMFlowReviewerRoutingProjection = Field(default_factory=PMFlowReviewerRoutingProjection) diff --git a/backend/app/schemas/prompt_assets.py b/backend/app/schemas/prompt_assets.py new file mode 100644 index 00000000..97b34152 --- /dev/null +++ b/backend/app/schemas/prompt_assets.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class PromptAssetReleaseCreateRequest(BaseModel): + model_config = ConfigDict(extra="ignore") + + body_snapshot: str = Field(min_length=1) + notes: Optional[str] = None + + +class PromptAssetReleaseActionRequest(BaseModel): + model_config = ConfigDict(extra="ignore") + + reason: Optional[str] = None + + +class PromptAssetReleaseCheckRunRequest(BaseModel): + model_config = ConfigDict(extra="ignore") + + reason: Optional[str] = None + + +class PromptGoldenCaseCreateRequest(BaseModel): + model_config = ConfigDict(extra="ignore") + + case_name: str = Field(min_length=1, max_length=255) + input_payload: dict = Field(default_factory=dict) + expected_contract: Optional[dict] = None + expected_contains: Optional[str] = None + case_type: str = Field(default="regression", max_length=32) + priority: str = Field(default="blocking", max_length=24) + scenario_key: Optional[str] = Field(default=None, max_length=128) + tags: list[str] = Field(default_factory=list) + notes: Optional[str] = None + + +class PromptGoldenCaseFromCallRequest(BaseModel): + model_config = ConfigDict(extra="ignore") + + case_name: Optional[str] = Field(default=None, max_length=255) + notes: Optional[str] = None + + +class PromptReplayRunCreateRequest(BaseModel): + model_config = ConfigDict(extra="ignore") + + mode: str = Field(default="contract_smoke", max_length=32) + case_status: str = Field(default="active", max_length=32) + max_cases: int = Field(default=20, ge=1, le=100) + include_llm: bool = False + judge_enabled: bool = True + reason: Optional[str] = None diff --git a/backend/app/schemas/space.py b/backend/app/schemas/space.py new file mode 100644 index 00000000..4a600b0a --- /dev/null +++ b/backend/app/schemas/space.py @@ -0,0 +1,83 @@ +"""Personal Space API schemas.""" + +from __future__ import annotations + +from typing import Any, Literal, Optional + +from pydantic import BaseModel, Field + + +SavedIdeaStatus = Literal["saved", "enriched", "needs_more_info", "draft_ready", "promoted", "archived"] +SavedIdeaSourceType = Literal["project_launch", "ai_concierge", "logout_guard", "feishu", "upload", "manual"] +SavedIdeaInputMode = Literal["idea", "import", "template"] +SavedIdeaEnrichmentStatus = Literal["baseline", "ai_enriched", "ai_failed"] + + +class SavedIdeaAttachment(BaseModel): + type: str = "text" + name: Optional[str] = None + summary: Optional[str] = None + payload: Optional[dict[str, Any]] = None + + +class SavedIdeaCreateRequest(BaseModel): + raw_text: str = Field(min_length=1, max_length=4000) + title: Optional[str] = Field(default=None, max_length=255) + summary: Optional[str] = Field(default=None, max_length=1000) + source_type: SavedIdeaSourceType = "project_launch" + source_ref: Optional[str] = Field(default=None, max_length=255) + input_mode: SavedIdeaInputMode = "idea" + attachments: list[SavedIdeaAttachment] = Field(default_factory=list) + + +class SavedIdeaUpdateRequest(BaseModel): + title: Optional[str] = Field(default=None, min_length=1, max_length=255) + raw_text: Optional[str] = Field(default=None, min_length=1, max_length=4000) + summary: Optional[str] = Field(default=None, min_length=1, max_length=1000) + status: Optional[SavedIdeaStatus] = None + + +class SavedIdeaRead(BaseModel): + id: str + title: str + raw_text: str + summary: str + source_type: str + source_ref: Optional[str] = None + input_mode: str + attachments: list[dict[str, Any]] = Field(default_factory=list) + status: str + status_label: str + ai_summary: Optional[str] = None + missing_questions: list[str] = Field(default_factory=list) + readiness_score: float + enrichment_status: str = "baseline" + enrichment_payload: dict[str, Any] = Field(default_factory=dict) + enrichment_error: Optional[str] = None + enriched_at: Optional[str] = None + launch_draft_id: Optional[str] = None + promoted_project_id: Optional[int] = None + created_at: str + updated_at: str + href: str + + +class SavedIdeaListResponse(BaseModel): + items: list[SavedIdeaRead] = Field(default_factory=list) + + +class SavedIdeaPromoteResponse(BaseModel): + idea: SavedIdeaRead + draft: dict[str, Any] + redirect_url: str + + +__all__ = [ + "SavedIdeaAttachment", + "SavedIdeaCreateRequest", + "SavedIdeaEnrichmentStatus", + "SavedIdeaListResponse", + "SavedIdeaPromoteResponse", + "SavedIdeaRead", + "SavedIdeaUpdateRequest", +] diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py b/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py index b8c0c303..c75e3805 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py @@ -1099,19 +1099,19 @@ class AdaptiveRAGAnswerMainlineMixin: is_snapshot_direct_question = str((scene_semantics or {}).get("snapshot_focus") or "none").strip().lower() != "none" if planner.allow_fast_path and not prefers_external_retrieval and not is_snapshot_direct_question: - glm_semantic_result = await self._try_glm_project_semantic_answer( + fast_semantic_result = await self._try_fast_project_semantic_answer( clean_q=effective_q, project_id=project_id, query_semantics=query_semantics, internal_context_packet=internal_context_packet, ) - if glm_semantic_result is not None: + if fast_semantic_result is not None: return await self._persist_fast_answer( question=clean_q, asked_by=asked_by, asked_via=asked_via, project_id=project_id, - fast_result=glm_semantic_result, + fast_result=fast_semantic_result, session_id=session_id, external_session_id=context_session_id, turn_index=turn_index, diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py b/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py index 57faf793..157a3ae1 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py @@ -1,6 +1,8 @@ """Adaptive RAG mixin module.""" from __future__ import annotations +import os + from .shared import * from .shared_utils import _env_bool from .internal_query_context import internal_context_target_resolution, update_internal_query_context_packet @@ -8,6 +10,89 @@ from .internal_query_context import internal_context_target_resolution, update_i class AdaptiveRAGAnswerRetrievalMixin: _ENV_ENABLE_FIT_BRIEF_EXTERNAL_RECOMMENDATION = "PMFLOW_ENABLE_FIT_BRIEF_EXTERNAL_RECOMMENDATION" + _EXTERNAL_QUERY_SLOT_INTEGRATION_TYPE = "ai_capability_slots" + _EXTERNAL_QUERY_PLATFORM_TENANT_ID = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1") + + @staticmethod + def _external_query_slot_applies(query_semantics: Optional[dict[str, Any]]) -> bool: + if not isinstance(query_semantics, dict): + return False + route_mode = str(query_semantics.get("route_mode") or "").strip().lower() + source_policy = str(query_semantics.get("effective_source_policy") or query_semantics.get("source_policy_hint") or "").strip().lower() + route_decision = query_semantics.get("route_decision") if isinstance(query_semantics.get("route_decision"), dict) else {} + decision_route_mode = str(route_decision.get("route_mode") or "").strip().lower() + decision_source_policy = str(route_decision.get("effective_source_policy") or "").strip().lower() + return route_mode == "external" or decision_route_mode == "external" or source_policy == "external" or decision_source_policy == "external" + + async def _load_active_external_query_slot_binding(self, *, query_semantics: Optional[dict[str, Any]]) -> dict[str, Any] | None: + if not self._external_query_slot_applies(query_semantics): + return None + db = getattr(self, "db", None) + if db is None: + return None + try: + from sqlalchemy import and_, select + + from ...models.integration import Integration + except Exception: + return None + try: + row = ( + await db.execute( + select(Integration).where( + and_( + Integration.tenant_id == self._EXTERNAL_QUERY_PLATFORM_TENANT_ID, + Integration.type == self._EXTERNAL_QUERY_SLOT_INTEGRATION_TYPE, + ) + ) + ) + ).scalar_one_or_none() + except Exception: + return None + config = row.config if row is not None and isinstance(row.config, dict) else {} + active = config.get("active") if isinstance(config.get("active"), dict) else {} + slots = active.get("slots") if isinstance(active.get("slots"), dict) else {} + saved = slots.get("external_query") if isinstance(slots.get("external_query"), dict) else {} + primary_provider_id = str(saved.get("primary_provider_id") or saved.get("primary_engine_id") or "").strip() + if not primary_provider_id: + return None + fallback_provider_ids = [ + str(item or "").strip() + for item in list(saved.get("fallback_provider_ids") or saved.get("fallback_engine_ids") or []) + if str(item or "").strip() + ] + return { + "slot_key": "external_query", + "slot_version": str(config.get("active_version") or config.get("version") or "unpublished").strip(), + "primary_provider_id": primary_provider_id, + "fallback_provider_ids": fallback_provider_ids, + "verifier_required": bool(saved.get("verifier_required", True)), + "source_filtering_required": bool(saved.get("source_filtering_required", True)), + "failure_policy": str(saved.get("failure_policy") or "explicit_fail").strip(), + } + + @staticmethod + def _order_external_query_adapters(*, adapters: dict[str, Any], binding: dict[str, Any] | None) -> list[Any]: + if not isinstance(binding, dict): + return [adapters["mcp"], adapters["live_search"]] + ordered_ids = [ + str(binding.get("primary_provider_id") or "").strip(), + *[ + str(item or "").strip() + for item in list(binding.get("fallback_provider_ids") or []) + if str(item or "").strip() + ], + ] + ordered: list[Any] = [] + for provider_id in ordered_ids: + adapter = adapters.get(provider_id) + if adapter is not None and adapter not in ordered: + ordered.append(adapter) + for provider_id in ("mcp", "live_search"): + adapter = adapters.get(provider_id) + if adapter is not None and adapter not in ordered: + ordered.append(adapter) + return ordered async def _execute_external_mcp_search(self, plan) -> dict[str, Any]: from ..external_query_service.schemas import ExternalAdapterResult, ExternalQueryPlan @@ -80,6 +165,7 @@ class AdaptiveRAGAnswerRetrievalMixin: result_limit: int = 3, max_pages: int = 3, ) -> tuple[list[RetrievedItem], dict[str, Any]]: + active_external_slot = await self._load_active_external_query_slot_binding(query_semantics=query_semantics) live_search_ready = all( callable(item) for item in ( @@ -92,6 +178,31 @@ class AdaptiveRAGAnswerRetrievalMixin: ) mcp_ready = callable(getattr(self, "_execute_external_mcp_search", None)) and getattr(self, "db", None) is not None if not live_search_ready and not mcp_ready: + if active_external_slot: + return [], { + "mode": "external_query_slot_unavailable", + "slot_key": "external_query", + "slot_version": active_external_slot.get("slot_version"), + "slot_runtime_source": "active_slot", + "failure_policy": "explicit_fail", + "reason": "external_query_active_slot_has_no_available_adapter", + "domains_used": ["url"], + "db_query_ms": 0, + "vector_search_ms": 0, + "graph_query_ms": 0, + "db_timeout": False, + "vector_timeout": False, + "graph_timeout": False, + "tool_trace": [ + { + "step": 1, + "tool": "external_query_gateway", + "slot_key": "external_query", + "verifier_ok": False, + "reason": "active_external_query_slot_unavailable", + } + ], + } legacy_retrieval = getattr(self, "_retrieve_live_external_context", None) if callable(legacy_retrieval): rows, meta = await legacy_retrieval( @@ -120,25 +231,30 @@ class AdaptiveRAGAnswerRetrievalMixin: tenant_id=getattr(self, "tenant_id", 1), query_builder=getattr(self, "_build_live_external_search_queries", None), ) + adapters = { + "mcp": MCPExternalAdapter( + search_callable=getattr(self, "_execute_external_mcp_search", None), + fetch_text_callable=getattr(self, "_fetch_url_text", None), + chunk_text_callable=getattr(self, "_chunk_text", None), + score_chunk_callable=getattr(self, "_score_live_external_chunk", None), + max_pages=max_pages, + ), + "live_search": LiveSearchFallbackAdapter( + build_queries_callable=getattr(self, "_build_live_external_search_queries", None), + search_results_callable=getattr(self, "_search_live_external_results", None), + fetch_text_callable=getattr(self, "_fetch_url_text", None), + chunk_text_callable=getattr(self, "_chunk_text", None), + score_search_result_callable=getattr(self, "_score_live_external_search_result", None), + score_chunk_callable=getattr(self, "_score_live_external_chunk", None), + max_pages=max_pages, + ), + } registry = ExternalProviderRegistry( - adapters=[ - MCPExternalAdapter( - search_callable=getattr(self, "_execute_external_mcp_search", None), - fetch_text_callable=getattr(self, "_fetch_url_text", None), - chunk_text_callable=getattr(self, "_chunk_text", None), - score_chunk_callable=getattr(self, "_score_live_external_chunk", None), - max_pages=max_pages, - ), - LiveSearchFallbackAdapter( - build_queries_callable=getattr(self, "_build_live_external_search_queries", None), - search_results_callable=getattr(self, "_search_live_external_results", None), - fetch_text_callable=getattr(self, "_fetch_url_text", None), - chunk_text_callable=getattr(self, "_chunk_text", None), - score_search_result_callable=getattr(self, "_score_live_external_search_result", None), - score_chunk_callable=getattr(self, "_score_live_external_chunk", None), - max_pages=max_pages, - ), - ] + adapters=self._order_external_query_adapters(adapters=adapters, binding=active_external_slot), + provider_statuses=ExternalProviderRegistry.default_provider_statuses( + mcp_available=mcp_ready, + live_search_available=live_search_ready, + ), ) execution = await ExternalQueryGateway( planner=planner, @@ -151,7 +267,7 @@ class AdaptiveRAGAnswerRetrievalMixin: context_packet = execution.to_context_packet(question=question).model_dump(mode="json") verifier = dict(execution.verifier or {}) if not bool(verifier.get("ok")): - return [], { + blocked_meta = { "mode": "external_query_gateway_verifier_blocked", "adapter": execution.adapter_name, "verifier": verifier, @@ -179,6 +295,17 @@ class AdaptiveRAGAnswerRetrievalMixin: "graph_timeout": False, "external_context_packet": context_packet, } + if active_external_slot: + blocked_meta.update( + { + "slot_key": "external_query", + "slot_version": active_external_slot.get("slot_version"), + "slot_runtime_source": "active_slot", + "slot_provider_id": execution.adapter_name, + "failure_policy": "explicit_fail", + } + ) + return [], blocked_meta rows: list[RetrievedItem] = [] for item in list(execution.evidence or [])[: max(1, int(result_limit))]: @@ -247,6 +374,16 @@ class AdaptiveRAGAnswerRetrievalMixin: ], } ) + if active_external_slot: + meta.update( + { + "slot_key": "external_query", + "slot_version": active_external_slot.get("slot_version"), + "slot_runtime_source": "active_slot", + "slot_provider_id": execution.adapter_name, + "failure_policy": "explicit_fail", + } + ) return rows, meta async def _maybe_fetch_live_external_freshness_context( diff --git a/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py b/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py index ce1f6ee4..64e95a7b 100644 --- a/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py +++ b/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py @@ -84,7 +84,7 @@ class AdaptiveRAGFastAnswerSemanticMixinP1: lines = [f"{idx}. {point}" for idx, point in enumerate(attention_points, start=1)] return f"{base_answer}\n\n建议关注:\n" + "\n".join(lines) - async def _try_glm_project_semantic_answer( + async def _try_fast_project_semantic_answer( self, *, clean_q: str, @@ -160,7 +160,7 @@ class AdaptiveRAGFastAnswerSemanticMixinP1: "source": "db", "label": "Project database", "score": 0.99, - "metadata": {"table": "projects", "match_count": 0, "retrieval_mode": "glm_semantic"}, + "metadata": {"table": "projects", "match_count": 0, "retrieval_mode": "fast_project_semantic"}, } ], "db_query_ms": db_query_ms, @@ -342,13 +342,13 @@ class AdaptiveRAGFastAnswerSemanticMixinP1: "table": "projects", "matched_project_ids": matched_ids, "project_index_count": len(project_index), - "retrieval_mode": "glm_semantic", + "retrieval_mode": "fast_project_semantic", }, } ], "db_query_ms": db_query_ms, "response_time_ms": response_ms, - "prompt_variant_id": "glm_semantic", + "prompt_variant_id": "fast_project_semantic", } except Exception: return None diff --git a/backend/app/services/adaptive_rag_service/shared_utils.py b/backend/app/services/adaptive_rag_service/shared_utils.py index ca1b7e4d..7a8394f3 100644 --- a/backend/app/services/adaptive_rag_service/shared_utils.py +++ b/backend/app/services/adaptive_rag_service/shared_utils.py @@ -171,6 +171,10 @@ def _structured_runtime_entity_type( if planned_entity_type in {"project", "task", "signal", "feature", "config", "user", "doc", "document", "requirement", "release", "review"}: return planned_entity_type + subject_focus = str(semantics.get("subject_focus") or "").strip().lower() + if subject_focus in {"project", "task", "signal", "feature", "config", "user", "doc", "document", "requirement", "release", "review"}: + return subject_focus + business_frame = _coalesce_business_frame_from_runtime( query_semantics=semantics, operation_contract=operation_contract, diff --git a/backend/app/services/ai_call_trace_service.py b/backend/app/services/ai_call_trace_service.py new file mode 100644 index 00000000..fed7dc26 --- /dev/null +++ b/backend/app/services/ai_call_trace_service.py @@ -0,0 +1,653 @@ +"""AI call trace read model for platform AI Ops.""" + +from __future__ import annotations + +import json +import re +from datetime import datetime, timedelta +from decimal import Decimal +from typing import Any + +from sqlalchemy import and_, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import AICallLog, Integration +from ..services.ai_scene_runtime_policy import AISceneRuntimePolicyService +from ..services.ai_routing_scene_contracts import AI_SCENE_REGISTRY + + +LLM_ENGINE_INTEGRATION_TYPE = "llm_engine" +PROVIDER_LABELS = { + "dashscope": "Qwen / DashScope", + "openai": "OpenAI 兼容网关", + "deepseek": "DeepSeek", + "ollama": "本地 Ollama", +} +EXTRA_SCENE_LABELS = { + "platform_ai_ops": "平台 AI 运营", +} +FUNCTION_LABELS = { + "probe_engine_capabilities": "供应商能力探测汇总(历史兼容)", + "test_engine_connection": "供应商连接测试", + "pmflow_launch_draft_v1": "新项目草案生成", + "pmflow_change_review_extraction_v1": "项目主链变更抽取", + "space_saved_idea_enrichment_v1": "空间想法结构化", +} +PROBE_TARGET_LABELS = { + "basic_chat": "基础聊天", + "model_self_report": "模型自报", + "structured_output": "结构化输出", + "json_schema": "JSON Schema", + "tool_calling": "工具调用", + "streaming": "流式响应", + "long_context": "长上下文", + "long_context_32k": "长上下文 32K", + "long_context_128k": "长上下文 128K", + "vision_input": "视觉输入", + "audio_input": "音频输入", +} +SENSITIVE_PATTERNS = ( + re.compile(r"(?i)(sk-[a-z0-9][a-z0-9_\-]{12,})"), + re.compile(r"(?i)(api[_-]?key|authorization|access[_-]?token|refresh[_-]?token|secret)(\s*[=:]\s*)([^\s,;,;]+)"), + re.compile(r"(?i)(bearer\s+)([a-z0-9._\-]{16,})"), + re.compile(r"(? None: + self.db = db + + async def list_traces( + self, + *, + page: int = 1, + page_size: int = 30, + status: str | None = None, + provider: str | None = None, + model: str | None = None, + function_name: str | None = None, + scene_key: str | None = None, + slot_key: str | None = None, + prompt_key: str | None = None, + prompt_source: str | None = None, + prompt_release_id: int | None = None, + trace_id: str | None = None, + tenant_id: int | None = None, + keyword: str | None = None, + since_hours: int | None = 168, + ) -> dict[str, Any]: + page = max(1, int(page or 1)) + page_size = min(max(1, int(page_size or 30)), 100) + filters = self._build_filters( + status=status, + provider=provider, + model=model, + function_name=function_name, + scene_key=scene_key, + slot_key=slot_key, + prompt_key=prompt_key, + prompt_source=prompt_source, + prompt_release_id=prompt_release_id, + trace_id=trace_id, + tenant_id=tenant_id, + keyword=keyword, + since_hours=since_hours, + ) + where_clause = and_(*filters) if filters else None + count_stmt = select(func.count(AICallLog.id)) + if where_clause is not None: + count_stmt = count_stmt.where(where_clause) + total = int((await self.db.execute(count_stmt)).scalar() or 0) + stmt = select(AICallLog).order_by(AICallLog.created_at.desc(), AICallLog.id.desc()).offset((page - 1) * page_size).limit(page_size) + if where_clause is not None: + stmt = stmt.where(where_clause) + rows = list((await self.db.execute(stmt)).scalars().all()) + runtime_index = await self._runtime_display_index(rows) + return { + "summary": await self._summary(filters=filters), + "items": [self._row_summary(row, runtime_index=runtime_index) for row in rows], + "page": page, + "page_size": page_size, + "total": total, + "has_next": page * page_size < total, + } + + async def get_trace(self, trace_id_or_log_id: str) -> dict[str, Any]: + text = str(trace_id_or_log_id or "").strip() + stmt = select(AICallLog) + if text.isdigit(): + stmt = stmt.where(AICallLog.id == int(text)) + else: + stmt = stmt.where(AICallLog.trace_id == text) + row = (await self.db.execute(stmt.order_by(AICallLog.created_at.desc(), AICallLog.id.desc()).limit(1))).scalar_one_or_none() + if not row: + raise ValueError("AI 调用记录不存在") + runtime_index = await self._runtime_display_index([row]) + return self._row_detail(row, runtime_index=runtime_index) + + def _build_filters( + self, + *, + status: str | None, + provider: str | None, + model: str | None, + function_name: str | None, + scene_key: str | None, + slot_key: str | None, + prompt_key: str | None, + prompt_source: str | None, + prompt_release_id: int | None, + trace_id: str | None, + tenant_id: int | None, + keyword: str | None, + since_hours: int | None, + ) -> list[Any]: + filters: list[Any] = [] + if since_hours and int(since_hours) > 0: + filters.append(AICallLog.created_at >= datetime.now() - timedelta(hours=min(int(since_hours), 24 * 365))) + if not function_name: + filters.append(AICallLog.function_name != "probe_engine_capabilities") + if status and status != "all": + filters.append(AICallLog.status == status) + if provider: + filters.append(AICallLog.provider == provider) + if model: + filters.append(AICallLog.model.ilike(f"%{model.strip()}%")) + if function_name: + filters.append(AICallLog.function_name.ilike(f"%{function_name.strip()}%")) + if scene_key: + scene_functions = [ + str(function_name) + for function_name in (AI_SCENE_REGISTRY.get(scene_key, {}).get("function_names") or []) + if str(function_name).strip() + ] + if scene_functions: + filters.append(AICallLog.function_name.in_(scene_functions)) + else: + filters.append(AICallLog.function_name == "__no_scene_match__") + if slot_key: + filters.append(or_(AICallLog.executed_tier == slot_key, AICallLog.planned_tier == slot_key)) + if prompt_key: + filters.append(AICallLog.prompt_key == str(prompt_key).strip()) + if prompt_source: + filters.append(AICallLog.prompt_source == str(prompt_source).strip()) + if prompt_release_id is not None: + filters.append(AICallLog.prompt_release_id == int(prompt_release_id)) + if trace_id: + filters.append(AICallLog.trace_id.ilike(f"%{trace_id.strip()}%")) + if tenant_id: + filters.append(AICallLog.tenant_id == int(tenant_id)) + if keyword: + value = f"%{keyword.strip()}%" + filters.append( + or_( + AICallLog.function_name.ilike(value), + AICallLog.prompt_key.ilike(value), + AICallLog.prompt.ilike(value), + AICallLog.response.ilike(value), + ) + ) + return filters + + async def _summary(self, *, filters: list[Any]) -> dict[str, Any]: + where_clause = and_(*filters) if filters else None + stmt = select( + func.count(AICallLog.id), + func.sum(AICallLog.tokens_used), + func.sum(AICallLog.cost), + func.avg(AICallLog.latency_ms), + ) + if where_clause is not None: + stmt = stmt.where(where_clause) + total, tokens, cost, latency = (await self.db.execute(stmt)).one() + status_rows_stmt = select(AICallLog.status, func.count(AICallLog.id)).group_by(AICallLog.status) + provider_rows_stmt = select(AICallLog.provider, func.count(AICallLog.id)).group_by(AICallLog.provider).order_by(func.count(AICallLog.id).desc()).limit(5) + if where_clause is not None: + status_rows_stmt = status_rows_stmt.where(where_clause) + provider_rows_stmt = provider_rows_stmt.where(where_clause) + statuses = {str(status or "unknown"): int(count or 0) for status, count in (await self.db.execute(status_rows_stmt)).all()} + top_providers = [ + { + "provider": str(provider or "unknown"), + "provider_code": str(provider or "unknown"), + "provider_label": self._provider_label(provider), + "count": int(count or 0), + } + for provider, count in (await self.db.execute(provider_rows_stmt)).all() + ] + total_count = int(total or 0) + failed_count = sum(count for status, count in statuses.items() if self._is_failed_status(status)) + return { + "total_calls": total_count, + "success_calls": max(total_count - failed_count, 0), + "failed_calls": failed_count, + "success_rate": round((total_count - failed_count) / total_count, 4) if total_count else 0, + "tokens_used": int(tokens or 0), + "estimated_cost": self._number(cost), + "average_latency_ms": round(float(latency), 2) if latency is not None else None, + "status_counts": statuses, + "top_providers": top_providers, + } + + def _row_summary(self, row: AICallLog, *, runtime_index: dict[str, dict[str, str]] | None = None) -> dict[str, Any]: + meta = self._row_meta(row) + scene_key = self._scene_key(row, meta) + slot_key = self._slot_key(row, meta) + runtime = self._runtime_display(row, meta, runtime_index or {}) + provider_usage = self._provider_usage(row, meta) + return { + "id": row.id, + "trace_id": row.trace_id, + "tenant_id": row.tenant_id, + "user_id": row.user_id, + "created_at": row.created_at.isoformat() if row.created_at else None, + "function_name": row.function_name, + "function_label": self._function_label(row.function_name), + "scene_key": scene_key, + "scene_label": self._scene_label(scene_key), + "status": row.status, + "status_label": self._status_label(row.status), + "provider": runtime["provider_code"] or None, + "provider_code": runtime["provider_code"] or None, + "provider_label": runtime["provider_label"] or None, + "runtime_label": runtime["runtime_label"] or None, + "engine_id": runtime["engine_id"] or None, + "engine_name": runtime["engine_name"] or None, + "model": row.model or str(meta.get("model") or "") or None, + "slot_key": slot_key, + "slot_label": self._slot_label(slot_key), + "prompt_key": row.prompt_key or str(meta.get("prompt_key") or "") or None, + "prompt_release_id": row.prompt_release_id if row.prompt_release_id is not None else meta.get("prompt_release_id"), + "prompt_source": row.prompt_source or str(meta.get("prompt_source") or "") or None, + "routing_decision": row.routing_decision, + "route_source": row.route_source, + "route_rule_name": row.route_rule_name, + "tokens_used": int(row.tokens_used or 0), + "prompt_tokens": provider_usage.get("prompt_tokens"), + "completion_tokens": provider_usage.get("completion_tokens"), + "total_tokens": provider_usage.get("total_tokens"), + "request_max_tokens": self._request_max_tokens(row), + "finish_reason": self._finish_reason(row), + "function_runtime_max_tokens": meta.get("function_runtime_max_tokens"), + "function_runtime_profile_version": meta.get("function_runtime_profile_version") or None, + "estimated_cost": self._number(row.cost), + "latency_ms": row.latency_ms, + "prompt_preview": self._preview(row.prompt), + "response_preview": self._preview(row.response or ""), + "error_summary": self._error_summary(row), + "error_diagnosis": self._error_diagnosis(row, meta), + } + + def _row_detail(self, row: AICallLog, *, runtime_index: dict[str, dict[str, str]] | None = None) -> dict[str, Any]: + meta = self._row_meta(row) + summary = self._row_summary(row, runtime_index=runtime_index) + return { + **summary, + "request": { + "prompt_redacted": self._redact(row.prompt), + "prompt_chars": len(row.prompt or ""), + "provider_request_payload_redacted": self._redact_payload(row.provider_request_payload), + "provider_request_payload_chars": self._payload_chars(row.provider_request_payload), + "provider_payload_missing_reason": str(meta.get("upstream_missing_reason") or "").strip() or None, + "upstream_request_sent": meta.get("upstream_request_sent") if isinstance(meta.get("upstream_request_sent"), bool) else None, + }, + "response": { + "response_redacted": self._redact(row.response or ""), + "response_chars": len(row.response or ""), + "looks_truncated": "[ai_call_log_truncated]" in str(row.response or ""), + "provider_response_payload_redacted": self._redact_payload(row.provider_response_payload), + "provider_response_payload_chars": self._payload_chars(row.provider_response_payload), + "provider_attempts_redacted": self._redact_payload(row.provider_attempts), + "provider_attempts_count": len(row.provider_attempts or []) if isinstance(row.provider_attempts, list) else 0, + }, + "routing": { + "planned_tier": row.planned_tier, + "executed_tier": row.executed_tier, + "routing_decision": row.routing_decision, + "route_source": row.route_source, + "route_rule_name": row.route_rule_name, + "routing_config_version": row.routing_config_version, + "routing_config_updated_at": row.routing_config_updated_at, + "routing_meta": meta, + }, + "probe": self._probe_detail(meta), + "audit": { + "raw_payload_available": bool(row.provider_request_payload or row.provider_response_payload or row.provider_attempts), + "raw_payload_policy": "已落库真实上游 provider request/response payload;平台审计详情默认展示完整脱敏视图,不在客户侧暴露。", + }, + "runtime_policy": self._runtime_policy_detail(row, meta), + } + + @staticmethod + def _provider_usage(row: AICallLog, meta: dict[str, Any]) -> dict[str, int]: + payload = row.provider_response_payload if isinstance(row.provider_response_payload, dict) else {} + usage = payload.get("usage") if isinstance(payload.get("usage"), dict) else {} + if not usage and isinstance(meta.get("provider_usage"), dict): + usage = meta.get("provider_usage") or {} + result: dict[str, int] = {} + for source_key, target_key in ( + ("prompt_tokens", "prompt_tokens"), + ("completion_tokens", "completion_tokens"), + ("total_tokens", "total_tokens"), + ("input_tokens", "prompt_tokens"), + ("output_tokens", "completion_tokens"), + ): + if source_key not in usage: + continue + try: + value = int(usage.get(source_key) or 0) + except (TypeError, ValueError): + continue + if value > 0: + result[target_key] = value + if "total_tokens" not in result and (result.get("prompt_tokens") or result.get("completion_tokens")): + result["total_tokens"] = int(result.get("prompt_tokens") or 0) + int(result.get("completion_tokens") or 0) + return result + + @staticmethod + def _request_max_tokens(row: AICallLog) -> int | None: + payload = row.provider_request_payload if isinstance(row.provider_request_payload, dict) else {} + try: + value = int(payload.get("max_tokens") or 0) + except (TypeError, ValueError): + return None + return value if value > 0 else None + + @staticmethod + def _finish_reason(row: AICallLog) -> str | None: + payload = row.provider_response_payload if isinstance(row.provider_response_payload, dict) else {} + choices = payload.get("choices") if isinstance(payload.get("choices"), list) else [] + choice = choices[0] if choices and isinstance(choices[0], dict) else {} + text = str(choice.get("finish_reason") or "").strip() + return text or None + + @staticmethod + def _probe_detail(meta: dict[str, Any]) -> dict[str, Any] | None: + if not isinstance(meta, dict): + return None + probe_run_id = str(meta.get("probe_run_id") or "").strip() + probe_target = str(meta.get("probe_target") or "").strip() + probe_targets = meta.get("probe_targets") if isinstance(meta.get("probe_targets"), list) else [] + if not probe_run_id and not probe_target and not probe_targets: + return None + return { + "probe_run_id": probe_run_id or None, + "mode": str(meta.get("probe_mode") or "").strip() or None, + "target": probe_target or None, + "target_label": PROBE_TARGET_LABELS.get(probe_target, probe_target) if probe_target else None, + "targets": probe_targets, + "method": str(meta.get("probe_method") or "").strip() or None, + "status": str(meta.get("probe_status") or "").strip() or None, + "evidence_strength": str(meta.get("evidence_strength") or "").strip() or None, + "failure_code": str(meta.get("failure_code") or "").strip() or None, + "summary": meta.get("probe_summary") if isinstance(meta.get("probe_summary"), dict) else {}, + "direct_operation": bool(meta.get("direct_operation")), + } + + @staticmethod + def _row_meta(row: AICallLog) -> dict[str, Any]: + if isinstance(row.routing_meta, dict): + return row.routing_meta + response = str(row.response or "") + if response.strip().startswith("{"): + try: + parsed = json.loads(response) + if isinstance(parsed, dict) and isinstance(parsed.get("routing_meta"), dict): + return parsed["routing_meta"] + except Exception: + return {} + return {} + + def _redact_payload(self, payload: Any) -> str: + if payload is None: + return "" + try: + text = json.dumps(payload, ensure_ascii=False, indent=2, default=str) + except Exception: + text = str(payload) + return self._redact(text) + + @staticmethod + def _payload_chars(payload: Any) -> int: + if payload is None: + return 0 + try: + return len(json.dumps(payload, ensure_ascii=False, default=str)) + except Exception: + return len(str(payload)) + + async def _runtime_display_index(self, rows: list[AICallLog]) -> dict[str, dict[str, str]]: + tenant_ids = {int(row.tenant_id) for row in rows if row.tenant_id is not None} + if not tenant_ids: + return {} + stmt = select(Integration).where( + Integration.type == LLM_ENGINE_INTEGRATION_TYPE, + Integration.tenant_id.in_(tenant_ids), + ) + result = await self.db.execute(stmt) + entries: dict[str, dict[str, str]] = {} + provider_model_candidates: dict[str, list[dict[str, str]]] = {} + for integration in result.scalars().all(): + config = integration.config if isinstance(integration.config, dict) else {} + for engine in config.get("engines") or []: + if not isinstance(engine, dict): + continue + provider_code = str(engine.get("provider") or "").strip().lower() + model = str(engine.get("model") or "").strip() + engine_id = str(engine.get("id") or "").strip() + item = { + "provider_code": provider_code, + "provider_label": self._provider_label(provider_code), + "model": model, + "engine_id": engine_id, + "engine_name": str(engine.get("name") or "").strip(), + } + if engine_id: + entries[self._runtime_key(integration.tenant_id, "engine", engine_id)] = item + if provider_code and model: + key = self._runtime_key(integration.tenant_id, "provider_model", f"{provider_code}:{model}") + provider_model_candidates.setdefault(key, []).append(item) + for key, candidates in provider_model_candidates.items(): + names = {item.get("engine_name") or "" for item in candidates} + if len(candidates) == 1 or len(names) == 1: + entries[key] = candidates[0] + return entries + + def _runtime_display(self, row: AICallLog, meta: dict[str, Any], runtime_index: dict[str, dict[str, str]]) -> dict[str, str]: + provider_code = str(row.provider or meta.get("provider_code") or meta.get("provider") or "").strip().lower() + model = str(row.model or meta.get("model") or "").strip() + engine_id = str(meta.get("engine_id") or "").strip() + matched = {} + if engine_id: + matched = runtime_index.get(self._runtime_key(row.tenant_id, "engine", engine_id), {}) + if not matched and provider_code and model: + matched = runtime_index.get(self._runtime_key(row.tenant_id, "provider_model", f"{provider_code}:{model}"), {}) + provider_label = str(meta.get("provider_label") or matched.get("provider_label") or self._provider_label(provider_code)).strip() + engine_name = str(meta.get("engine_name") or matched.get("engine_name") or "").strip() + return { + "provider_code": provider_code, + "provider_label": provider_label, + "runtime_label": engine_name or provider_label or provider_code, + "engine_id": engine_id or str(matched.get("engine_id") or "").strip(), + "engine_name": engine_name, + } + + @staticmethod + def _runtime_key(tenant_id: Any, kind: str, value: str) -> str: + return f"{int(tenant_id or 0)}:{kind}:{value}" + + @staticmethod + def _provider_label(provider: Any) -> str: + value = str(provider or "").strip().lower() + if not value or value == "unknown": + return "unknown" if value == "unknown" else "" + return PROVIDER_LABELS.get(value, value) + + @staticmethod + def _number(value: Any) -> float: + if value is None: + return 0.0 + if isinstance(value, Decimal): + return float(value) + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + @staticmethod + def _scene_key(row: AICallLog, meta: dict[str, Any]) -> str | None: + scene_key = str(meta.get("scene_key") or meta.get("business_scene") or "").strip() + if scene_key: + return scene_key + return SCENE_BY_FUNCTION.get(str(row.function_name or "")) + + @staticmethod + def _scene_label(scene_key: str | None) -> str: + key = str(scene_key or "").strip() + if not key: + return "未归类" + return EXTRA_SCENE_LABELS.get(key) or SCENE_LABELS.get(key, key) + + @staticmethod + def _function_label(function_name: str | None) -> str: + value = str(function_name or "").strip() + if value.startswith("probe_capability_"): + target = value.removeprefix("probe_capability_") + return f"能力探测:{PROBE_TARGET_LABELS.get(target, target)}" + return FUNCTION_LABELS.get(value, value or "未记录") + + @staticmethod + def _slot_key(row: AICallLog, meta: dict[str, Any]) -> str | None: + return ( + str(meta.get("slot_key") or "").strip() + or str(row.executed_tier or "").strip() + or str(row.planned_tier or "").strip() + or None + ) + + @staticmethod + def _slot_label(slot_key: str | None) -> str: + return { + "low": "快速槽", + "high": "深度槽", + "structured": "结构化槽", + "long_context": "长上下文槽", + "tool": "工具槽", + "external_query": "外部查询槽", + }.get(str(slot_key or ""), str(slot_key or "未标注")) + + @staticmethod + def _status_label(status: str | None) -> str: + text = str(status or "").strip() + if text in {"success", "ok"}: + return "成功" + if text == "structured_timeout": + return "模型超时" + if text in {"structured_json_invalid", "structured_invalid"}: + return "结构化 JSON 无效" + if text == "structured_empty": + return "模型空响应" + if text == "structured_http_error": + return "供应商 HTTP 异常" + if "structured" in text: + return "结构化失败" + if "capability_probe" in text or text.startswith("capability_"): + return "能力探测" + if text in {"failed", "error"} or "fail" in text: + return "失败" + return text or "未知" + + @staticmethod + def _is_failed_status(status: str) -> bool: + text = str(status or "").strip().lower() + return text not in {"success", "ok"} and ( + "fail" in text or "error" in text or "invalid" in text or "empty" in text or "timeout" in text + ) + + @classmethod + def _error_summary(cls, row: AICallLog) -> str | None: + if not cls._is_failed_status(str(row.status or "")): + return None + response = str(row.response or "").strip() + if response.startswith("[error]"): + return cls._redact(response[:300]) + return cls._redact(response[:180]) if response else cls._status_label(row.status) + + @classmethod + def _error_diagnosis(cls, row: AICallLog, meta: dict[str, Any]) -> dict[str, Any] | None: + status = str(row.status or "").strip().lower() + attempts = row.provider_attempts if isinstance(row.provider_attempts, list) else [] + attempt_errors = [ + { + "status": str(item.get("status") or ""), + "error_type": str(item.get("error_type") or ""), + "error": cls._redact(str(item.get("error") or "")[:300]), + } + for item in attempts + if isinstance(item, dict) and (item.get("error_type") or item.get("error") or item.get("status") == "request_error") + ] + if status == "structured_timeout": + recommendation = AISceneRuntimePolicyService.timeout_recommendation(status=status, routing_meta=meta) or {} + return { + "code": "model_read_timeout", + "label": "模型读超时", + "summary": "上游模型在规定时间内没有返回结构化草案或结构化结果。", + "attempt_count": len(attempts), + "attempt_errors": attempt_errors, + "recommendation": recommendation, + } + if status in {"structured_json_invalid", "structured_invalid", "structured_empty", "structured_http_error"}: + return { + "code": status, + "label": cls._status_label(status), + "summary": cls._error_summary(row) or cls._status_label(status), + "attempt_count": len(attempts), + "attempt_errors": attempt_errors, + } + return None + + @staticmethod + def _runtime_policy_detail(row: AICallLog, meta: dict[str, Any]) -> dict[str, Any]: + return { + "policy_id": str(meta.get("scene_timeout_policy_id") or "").strip() or None, + "policy_version": str(meta.get("scene_timeout_policy_version") or "").strip() or None, + "input_bucket": str(meta.get("input_bucket") or "").strip() or None, + "input_tokens_estimate": meta.get("input_tokens_estimate"), + "foreground_wait_seconds": meta.get("foreground_wait_seconds"), + "provider_timeout_seconds": meta.get("provider_timeout_seconds"), + "job_timeout_seconds": meta.get("job_timeout_seconds"), + "max_retries": meta.get("max_retries"), + "scene_fallback_policy": str(meta.get("scene_fallback_policy") or "").strip() or None, + "large_input_policy": str(meta.get("large_input_policy") or "").strip() or None, + "timeout_policy_source": str(meta.get("timeout_policy_source") or "").strip() or None, + "timeout_policy_reason": str(meta.get("timeout_policy_reason") or "").strip() or None, + "latency_ms": row.latency_ms, + } + + @classmethod + def _preview(cls, text: str) -> str: + redacted = cls._redact(text) + return redacted[:180] + ("..." if len(redacted) > 180 else "") + + @staticmethod + def _redact(text: str) -> str: + value = str(text or "") + for pattern in SENSITIVE_PATTERNS: + if pattern.pattern.startswith("([A-Za-z0-9"): + value = pattern.sub(lambda match: f"{match.group(1)[:2]}***@{match.group(2)}", value) + elif "api" in pattern.pattern.lower() or "authorization" in pattern.pattern.lower(): + value = pattern.sub(lambda match: f"{match.group(1)}{match.group(2)}[REDACTED]", value) + elif "bearer" in pattern.pattern.lower(): + value = pattern.sub(lambda match: f"{match.group(1)}[REDACTED]", value) + else: + value = pattern.sub("[REDACTED]", value) + return value diff --git a/backend/app/services/ai_function_runtime_profile_service.py b/backend/app/services/ai_function_runtime_profile_service.py new file mode 100644 index 00000000..d4501599 --- /dev/null +++ b/backend/app/services/ai_function_runtime_profile_service.py @@ -0,0 +1,382 @@ +"""Function-level runtime profiles for protected AI calls.""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from typing import Any + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..config.ai_function_runtime_profiles import ( + AI_FUNCTION_RUNTIME_PROFILE_INTEGRATION_TYPE, + DEFAULT_AI_FUNCTION_RUNTIME_PROFILES, + default_runtime_profile_bundle, +) +from ..models import Integration +from ..services.ai_routing_scene_contracts import AI_SCENE_REGISTRY +from ..utils.timezone import now_cn_naive + +PLATFORM_RUNTIME_TENANT_ID = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1") + + +def _json_clone(value: Any) -> Any: + return json.loads(json.dumps(value, ensure_ascii=False)) + + +def _safe_dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _release_version() -> str: + return f"profile-v{now_cn_naive().strftime('%Y%m%d%H%M%S')}" + + +@dataclass(frozen=True) +class AIFunctionRuntimeProfile: + function_name: str + scene_key: str + prompt_key: str + required_slot: str + response_contract: str + max_tokens: int + repair_max_tokens: int + temperature: float + foreground_wait_seconds: int + provider_timeout_seconds: int + job_timeout_seconds: int + max_retries: int + large_input_policy: str + response_mode: str + response_format: str + profile_version: str + profile_source: str + + def routing_meta(self) -> dict[str, Any]: + return { + "function_runtime_profile_version": self.profile_version, + "function_runtime_profile_source": self.profile_source, + "function_runtime_max_tokens": self.max_tokens, + "function_runtime_repair_max_tokens": self.repair_max_tokens, + "foreground_wait_seconds": self.foreground_wait_seconds, + "provider_timeout_seconds": self.provider_timeout_seconds, + "job_timeout_seconds": self.job_timeout_seconds, + "max_retries": self.max_retries, + "large_input_policy": self.large_input_policy, + "timeout_policy_source": "function_runtime_profile", + "timeout_policy_reason": f"{self.function_name} function runtime profile controls provider/job timeout", + } + + +class AIFunctionRuntimeProfileService: + """Load, validate, draft and publish function runtime profiles.""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def resolve_profile( + self, + *, + function_name: str, + scene_key: str | None = None, + prompt_key: str | None = None, + stage: str = "active", + ) -> AIFunctionRuntimeProfile: + release = await self.get_profiles() + normalized_stage = "draft" if str(stage or "").strip().lower() == "draft" else "active" + profiles = release.get(f"{normalized_stage}_profiles") if isinstance(release.get(f"{normalized_stage}_profiles"), dict) else {} + profile = _safe_dict(profiles.get(function_name)) or _safe_dict(DEFAULT_AI_FUNCTION_RUNTIME_PROFILES.get(function_name)) + if not profile: + raise ValueError(f"function runtime profile not found: {function_name}") + normalized = self._normalize_profile(function_name=function_name, raw=profile) + if scene_key and normalized.scene_key != str(scene_key).strip(): + raise ValueError(f"function runtime profile scene mismatch: {function_name}") + if prompt_key and normalized.prompt_key != str(prompt_key).strip(): + raise ValueError(f"function runtime profile prompt mismatch: {function_name}") + return AIFunctionRuntimeProfile( + function_name=normalized.function_name, + scene_key=normalized.scene_key, + prompt_key=normalized.prompt_key, + required_slot=normalized.required_slot, + response_contract=normalized.response_contract, + max_tokens=normalized.max_tokens, + repair_max_tokens=normalized.repair_max_tokens, + temperature=normalized.temperature, + foreground_wait_seconds=normalized.foreground_wait_seconds, + provider_timeout_seconds=normalized.provider_timeout_seconds, + job_timeout_seconds=normalized.job_timeout_seconds, + max_retries=normalized.max_retries, + large_input_policy=normalized.large_input_policy, + response_mode=normalized.response_mode, + response_format=normalized.response_format, + profile_version=str(release.get(f"{normalized_stage}_version") or release.get("active_version") or "profile-default-v1"), + profile_source=normalized_stage, + ) + + async def get_profiles(self) -> dict[str, Any]: + bundle = self._normalize_bundle(await self._load_config()) + active_profiles = _safe_dict(_safe_dict(bundle.get("active")).get("profiles")) + draft_profiles = _safe_dict(_safe_dict(bundle.get("draft")).get("profiles")) + validation = self.validate_bundle(bundle=bundle, stage="draft") + return { + "version": str(bundle.get("active_version") or "profile-default-v1"), + "active_version": str(bundle.get("active_version") or "profile-default-v1"), + "draft_version": str(bundle.get("draft_version") or "draft"), + "published_at": _safe_dict(bundle.get("active")).get("published_at"), + "published_by": _safe_dict(bundle.get("active")).get("published_by"), + "updated_at": _safe_dict(bundle.get("draft")).get("updated_at"), + "updated_by": _safe_dict(bundle.get("draft")).get("updated_by"), + "dirty": self._bundle_dirty(bundle), + "validation": validation, + "history": bundle.get("history") if isinstance(bundle.get("history"), list) else [], + "draft_last_smoke": _safe_dict(_safe_dict(bundle.get("draft")).get("last_smoke")), + "active_profiles": active_profiles, + "draft_profiles": draft_profiles, + "profiles": draft_profiles, + } + + async def update_profile(self, *, function_name: str, payload: dict[str, Any], updated_by: int) -> dict[str, Any]: + integration, bundle = await self._get_or_init_bundle() + draft = _safe_dict(bundle.get("draft")) + profiles = _safe_dict(draft.get("profiles")) + current = _safe_dict(profiles.get(function_name)) or _safe_dict(DEFAULT_AI_FUNCTION_RUNTIME_PROFILES.get(function_name)) + if not current: + raise ValueError("函数运行合同不存在") + next_profile = _json_clone(current) + for key in ("max_tokens", "repair_max_tokens"): + if key in payload: + next_profile[key] = int(payload.get(key) or 0) + for key in ("foreground_wait_seconds", "provider_timeout_seconds", "job_timeout_seconds", "max_retries"): + if key in payload: + next_profile[key] = int(payload.get(key) or 0) + for key in ("temperature",): + if key in payload: + next_profile[key] = float(payload.get(key) or 0.0) + for key in ("response_mode", "large_input_policy"): + if key in payload: + next_profile[key] = str(payload.get(key) or "").strip() or current.get(key) + self._normalize_profile(function_name=function_name, raw=next_profile) + profiles[function_name] = next_profile + draft["profiles"] = profiles + draft["updated_at"] = now_cn_naive().isoformat() + draft["updated_by"] = int(updated_by) + last_smoke = _safe_dict(draft.get("last_smoke")) + last_smoke.pop(function_name, None) + draft["last_smoke"] = last_smoke + bundle["draft"] = draft + bundle["draft_version"] = "draft" + integration.config = _json_clone(bundle) + integration.is_active = True + await self.db.flush() + return await self.get_profiles() + + async def record_smoke(self, *, function_name: str, status: str, summary: str, checked_by: int | None = None) -> dict[str, Any]: + integration, bundle = await self._get_or_init_bundle() + draft = _json_clone(_safe_dict(bundle.get("draft"))) + last_smoke = _json_clone(_safe_dict(draft.get("last_smoke"))) + last_smoke[function_name] = { + "status": str(status or "unknown"), + "summary": str(summary or "")[:500], + "checked_at": now_cn_naive().isoformat(), + "checked_by": int(checked_by) if checked_by is not None else None, + } + draft["last_smoke"] = last_smoke + bundle["draft"] = draft + integration.config = _json_clone(bundle) + integration.is_active = True + await self.db.flush() + return await self.get_profiles() + + async def publish_profiles(self, *, published_by: int, summary: str | None = None) -> dict[str, Any]: + integration, bundle = await self._get_or_init_bundle() + validation = self.validate_bundle(bundle=bundle, stage="draft") + blocking = [item for item in validation.get("items", []) if item.get("severity") == "critical"] + if blocking: + raise ValueError("函数运行合同草稿存在阻塞项,不能发布") + version = _release_version() + now = now_cn_naive().isoformat() + draft_profiles = _safe_dict(_safe_dict(bundle.get("draft")).get("profiles")) + bundle["active"] = { + "profiles": _json_clone(draft_profiles), + "published_at": now, + "published_by": int(published_by), + } + bundle["active_version"] = version + bundle["version"] = version + history = bundle.get("history") if isinstance(bundle.get("history"), list) else [] + history.insert( + 0, + { + "version": version, + "published_at": now, + "published_by": int(published_by), + "summary": str(summary or "发布函数运行合同草稿").strip()[:200], + "profiles": _json_clone(draft_profiles), + }, + ) + bundle["history"] = history[:10] + integration.config = _json_clone(bundle) + integration.is_active = True + await self.db.flush() + return await self.get_profiles() + + def validate_bundle(self, *, bundle: dict[str, Any], stage: str = "draft") -> dict[str, Any]: + profiles = _safe_dict(_safe_dict(bundle.get(stage)).get("profiles")) + last_smoke = _safe_dict(_safe_dict(bundle.get("draft")).get("last_smoke")) + items: list[dict[str, Any]] = [] + for function_name, raw_profile in profiles.items(): + try: + profile = self._normalize_profile(function_name=str(function_name), raw=_safe_dict(raw_profile)) + except ValueError as exc: + items.append(self._validation_item(str(function_name), "critical", "profile_invalid", str(exc))) + continue + scene = _safe_dict(AI_SCENE_REGISTRY.get(profile.scene_key)) + if profile.function_name not in set(scene.get("function_names") or []): + items.append(self._validation_item(function_name, "critical", "scene_function_mismatch", "函数未登记在对应业务场景合同中。")) + if profile.required_slot not in set(scene.get("required_slots") or []): + items.append(self._validation_item(function_name, "critical", "slot_mismatch", "函数 required_slot 与业务场景必需槽不匹配。")) + default_profile = _safe_dict(DEFAULT_AI_FUNCTION_RUNTIME_PROFILES.get(function_name)) + recommended_min = int(default_profile.get("recommended_min_tokens") or default_profile.get("max_tokens_min") or 0) + if recommended_min and profile.max_tokens < recommended_min: + items.append(self._validation_item(function_name, "warning", "token_budget_below_recommendation", f"当前 max_tokens={profile.max_tokens},低于建议值 {recommended_min},长结构化草案可能被截断。")) + smoke_required = bool(_safe_dict(profile.__dict__.get("health_policy")).get("smoke_required_for_publish")) if isinstance(profile.__dict__.get("health_policy"), dict) else bool(_safe_dict(default_profile.get("health_policy")).get("smoke_required_for_publish")) + smoke = _safe_dict(last_smoke.get(function_name)) + if stage == "draft" and smoke_required and str(smoke.get("status") or "").lower() != "success": + items.append(self._validation_item(function_name, "critical", "smoke_required", "发布前必须运行函数运行合同 smoke,并且结果为 success。")) + severity = "critical" if any(item["severity"] == "critical" for item in items) else ("warning" if items else "ok") + return { + "status": "blocked" if severity == "critical" else ("warning" if severity == "warning" else "passed"), + "severity": severity, + "items": items, + } + + async def _load_config(self) -> dict[str, Any]: + row = ( + await self.db.execute( + select(Integration).where( + and_( + Integration.tenant_id == PLATFORM_RUNTIME_TENANT_ID, + Integration.type == AI_FUNCTION_RUNTIME_PROFILE_INTEGRATION_TYPE, + ) + ) + ) + ).scalar_one_or_none() + return _safe_dict(row.config if row is not None else {}) + + async def _get_or_init_bundle(self) -> tuple[Integration, dict[str, Any]]: + row = ( + await self.db.execute( + select(Integration).where( + and_( + Integration.tenant_id == PLATFORM_RUNTIME_TENANT_ID, + Integration.type == AI_FUNCTION_RUNTIME_PROFILE_INTEGRATION_TYPE, + ) + ) + ) + ).scalar_one_or_none() + if row is None: + row = Integration( + tenant_id=PLATFORM_RUNTIME_TENANT_ID, + type=AI_FUNCTION_RUNTIME_PROFILE_INTEGRATION_TYPE, + config=default_runtime_profile_bundle(), + is_active=True, + ) + self.db.add(row) + await self.db.flush() + bundle = self._normalize_bundle(_safe_dict(row.config)) + return row, bundle + + @staticmethod + def _normalize_bundle(raw: dict[str, Any]) -> dict[str, Any]: + default = default_runtime_profile_bundle() + bundle = _safe_dict(raw) + active = _safe_dict(bundle.get("active")) + draft = _safe_dict(bundle.get("draft")) + active_profiles = _safe_dict(active.get("profiles")) or _safe_dict(bundle.get("profiles")) or DEFAULT_AI_FUNCTION_RUNTIME_PROFILES + draft_profiles = _safe_dict(draft.get("profiles")) or active_profiles + for function_name, profile in DEFAULT_AI_FUNCTION_RUNTIME_PROFILES.items(): + default_profile = _json_clone(profile) + active_profiles[function_name] = { + **default_profile, + **_safe_dict(active_profiles.get(function_name)), + } + draft_profiles[function_name] = { + **default_profile, + **_safe_dict(active_profiles.get(function_name, profile)), + **_safe_dict(draft_profiles.get(function_name)), + } + return { + "version": str(bundle.get("active_version") or bundle.get("version") or default["version"]), + "active_version": str(bundle.get("active_version") or bundle.get("version") or default["active_version"]), + "draft_version": str(bundle.get("draft_version") or "draft"), + "active": {**active, "profiles": _json_clone(active_profiles)}, + "draft": {**draft, "profiles": _json_clone(draft_profiles), "last_smoke": _safe_dict(draft.get("last_smoke"))}, + "history": bundle.get("history") if isinstance(bundle.get("history"), list) else [], + } + + @staticmethod + def _normalize_profile(*, function_name: str, raw: dict[str, Any]) -> AIFunctionRuntimeProfile: + profile = {**_safe_dict(DEFAULT_AI_FUNCTION_RUNTIME_PROFILES.get(function_name)), **_safe_dict(raw)} + min_tokens = int(profile.get("max_tokens_min") or 256) + max_tokens_limit = int(profile.get("max_tokens_max") or 12000) + max_tokens = int(profile.get("max_tokens") or 0) + repair_max_tokens = int(profile.get("repair_max_tokens") or 0) + if max_tokens < min_tokens or max_tokens > max_tokens_limit: + raise ValueError(f"max_tokens 必须在 {min_tokens}-{max_tokens_limit} 之间") + if repair_max_tokens < 180 or repair_max_tokens > max_tokens: + raise ValueError("repair_max_tokens 必须不小于 180 且不大于 max_tokens") + temperature = float(profile.get("temperature") or 0.0) + if temperature < 0 or temperature > 1: + raise ValueError("temperature 必须在 0-1 之间") + foreground_wait_seconds = int(profile.get("foreground_wait_seconds") or 0) + provider_timeout_seconds = int(profile.get("provider_timeout_seconds") or 0) + job_timeout_seconds = int(profile.get("job_timeout_seconds") or 0) + max_retries = int(profile.get("max_retries") or 0) + large_input_policy = str(profile.get("large_input_policy") or "preprocess_then_generate").strip() + if foreground_wait_seconds < 3 or foreground_wait_seconds > 30: + raise ValueError("foreground_wait_seconds 必须在 3-30 秒之间") + if provider_timeout_seconds < 10 or provider_timeout_seconds > 300: + raise ValueError("provider_timeout_seconds 必须在 10-300 秒之间") + if job_timeout_seconds < provider_timeout_seconds or job_timeout_seconds > 900: + raise ValueError("job_timeout_seconds 必须不小于 provider_timeout_seconds 且不大于 900 秒") + if max_retries < 0 or max_retries > 3: + raise ValueError("max_retries 必须在 0-3 之间") + if large_input_policy not in {"direct_generate", "preprocess_then_generate"}: + raise ValueError("large_input_policy 仅支持 direct_generate / preprocess_then_generate") + return AIFunctionRuntimeProfile( + function_name=function_name, + scene_key=str(profile.get("scene_key") or "").strip(), + prompt_key=str(profile.get("prompt_key") or "").strip(), + required_slot=str(profile.get("required_slot") or "").strip(), + response_contract=str(profile.get("response_contract") or "structured").strip(), + max_tokens=max_tokens, + repair_max_tokens=repair_max_tokens, + temperature=temperature, + foreground_wait_seconds=foreground_wait_seconds, + provider_timeout_seconds=provider_timeout_seconds, + job_timeout_seconds=job_timeout_seconds, + max_retries=max_retries, + large_input_policy=large_input_policy, + response_mode=str(profile.get("response_mode") or "fast").strip(), + response_format=str(profile.get("response_format") or "json_object").strip(), + profile_version="", + profile_source="", + ) + + @staticmethod + def _bundle_dirty(bundle: dict[str, Any]) -> bool: + return _safe_dict(_safe_dict(bundle.get("active")).get("profiles")) != _safe_dict(_safe_dict(bundle.get("draft")).get("profiles")) + + @staticmethod + def _validation_item(function_name: str, severity: str, code: str, message: str) -> dict[str, Any]: + return { + "function_name": function_name, + "severity": severity, + "code": code, + "message": message, + } + + +__all__ = ["AIFunctionRuntimeProfile", "AIFunctionRuntimeProfileService"] diff --git a/backend/app/services/ai_readiness_service.py b/backend/app/services/ai_readiness_service.py new file mode 100644 index 00000000..d9dda484 --- /dev/null +++ b/backend/app/services/ai_readiness_service.py @@ -0,0 +1,475 @@ +"""AI readiness evaluation for protected PMFlow product scenes.""" + +from __future__ import annotations + +from datetime import timedelta +from typing import Any + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import AICallLog, Integration +from ..services.ai_routing_scene_contracts import AI_SCENE_REGISTRY, scene_contract +from ..services.ai_routing_governance_service import ( + CAPABILITY_SLOT_INTEGRATION_TYPE, + LLM_ENGINE_INTEGRATION_TYPE, + PLATFORM_RUNTIME_TENANT_ID, +) +from ..services.pmflow_launch_service import PMFlowLaunchError, PMFlowLaunchService +from ..utils.timezone import now_cn_naive + + +def _safe_dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _safe_list(value: Any) -> list[Any]: + return value if isinstance(value, list) else [] + + +def _is_success_status(status: str) -> bool: + normalized = str(status or "").strip().lower() + return normalized.startswith("success") or normalized in {"ok", "success_cache_hit", "success_failover"} + + +class AIReadinessService: + """Derive customer-facing AI readiness from runtime facts.""" + + SCENE_KEY = "project_launch" + FUNCTION_NAME = "pmflow_launch_draft_v1" + + def __init__(self, db: AsyncSession, *, tenant_id: int): + self.db = db + self.tenant_id = int(tenant_id) + + async def get_project_launch_readiness(self) -> dict[str, Any]: + engine_bundle = await self._load_platform_integration(LLM_ENGINE_INTEGRATION_TYPE) + slot_bundle = await self._load_platform_integration(CAPABILITY_SLOT_INTEGRATION_TYPE) + recent_calls = await self._recent_launch_calls() + latest_call = recent_calls[0] if recent_calls else None + return self._build_project_launch_readiness( + engine_bundle=engine_bundle, + slot_bundle=slot_bundle, + latest_call=latest_call, + recent_calls=recent_calls, + smoke_error=None, + ) + + async def run_project_launch_check(self, *, user_id: int) -> dict[str, Any]: + smoke_error: str | None = None + try: + await PMFlowLaunchService(self.db, tenant_id=self.tenant_id).generate_draft_payload_for_readiness_check( + user_id=int(user_id), + ) + except PMFlowLaunchError as exc: + smoke_error = f"{exc.code}: {exc.message}" + except Exception as exc: # pragma: no cover - defensive boundary + smoke_error = str(exc) + engine_bundle = await self._load_platform_integration(LLM_ENGINE_INTEGRATION_TYPE) + slot_bundle = await self._load_platform_integration(CAPABILITY_SLOT_INTEGRATION_TYPE) + recent_calls = await self._recent_launch_calls() + latest_call = recent_calls[0] if recent_calls else None + payload = self._build_project_launch_readiness( + engine_bundle=engine_bundle, + slot_bundle=slot_bundle, + latest_call=latest_call, + recent_calls=recent_calls, + smoke_error=smoke_error, + ) + payload["platform"]["last_smoke_error"] = smoke_error + payload["platform"]["last_checked_at"] = now_cn_naive().isoformat() + return payload + + async def _load_platform_integration(self, integration_type: str) -> dict[str, Any]: + row = ( + await self.db.execute( + select(Integration).where( + and_( + Integration.tenant_id == PLATFORM_RUNTIME_TENANT_ID, + Integration.type == integration_type, + ) + ) + ) + ).scalar_one_or_none() + return _safe_dict(row.config if row is not None else {}) + + async def _recent_launch_calls(self, *, limit: int = 20, hours: int = 24) -> list[AICallLog]: + tenant_ids = sorted({int(self.tenant_id), int(PLATFORM_RUNTIME_TENANT_ID)}) + since = now_cn_naive() - timedelta(hours=hours) + return ( + await self.db.execute( + select(AICallLog) + .where( + and_( + AICallLog.tenant_id.in_(tenant_ids), + AICallLog.function_name == self.FUNCTION_NAME, + AICallLog.created_at >= since, + ) + ) + .order_by(AICallLog.created_at.desc(), AICallLog.id.desc()) + .limit(limit) + ) + ).scalars().all() + + def _build_project_launch_readiness( + self, + *, + engine_bundle: dict[str, Any], + slot_bundle: dict[str, Any], + latest_call: AICallLog | None, + recent_calls: list[AICallLog], + smoke_error: str | None, + ) -> dict[str, Any]: + blockers: list[dict[str, Any]] = [] + warnings: list[dict[str, Any]] = [] + checks: dict[str, str] = { + "provider_runtime": "unknown", + "capability_probe": "unknown", + "active_slot": "unknown", + "scene_route": "unknown", + "recent_smoke": "missing", + "recent_failures": "unknown", + } + + engines = [item for item in _safe_list(engine_bundle.get("engines")) if isinstance(item, dict)] + engines_by_id = {str(item.get("id") or ""): item for item in engines if str(item.get("id") or "")} + active_slots = self._active_slots(slot_bundle) + contract = scene_contract(self.SCENE_KEY) + scene_registry_entry = _safe_dict(AI_SCENE_REGISTRY.get(self.SCENE_KEY)) + + provider_ok = any(self._engine_connectable(item) for item in engines) + checks["provider_runtime"] = "passed" if provider_ok else "failed" + if not provider_ok: + blockers.append( + self._blocker( + "provider_unavailable", + "供应商运行时不可用", + "平台运行租户没有可用的模型运行时,项目草案生成必须保持关闭。", + "failed", + "查看供应商与凭据", + "/platform/ai-routing-providers", + ) + ) + + scene_ok = ( + self.FUNCTION_NAME in set(scene_registry_entry.get("function_names") or []) + and "structured" in set(scene_registry_entry.get("required_slots") or []) + and str(contract.get("expected_slot_key") or "").strip() == "structured" + ) + checks["scene_route"] = "passed" if scene_ok else "failed" + if not scene_ok: + blockers.append( + self._blocker( + "scene_route_mismatch", + "项目启动场景合同不匹配", + "project_launch 必须绑定结构化草案函数和 structured 槽。", + "failed", + "查看场景路由", + "/platform/ai-routing-scenes?scene=project_launch", + ) + ) + + structured_slot = _safe_dict(active_slots.get("structured")) + structured_engine_id = str(structured_slot.get("primary_engine_id") or "").strip() + structured_engine = engines_by_id.get(structured_engine_id) + if not structured_engine_id: + checks["active_slot"] = "failed" + blockers.append( + self._blocker( + "structured_slot_unpublished", + "结构化槽未发布", + "project_launch 需要 active structured 槽;当前结构化槽未发布到运行版本。", + "failed", + "发布结构化槽", + "/platform/ai-routing-slots?slot=structured", + ) + ) + elif not isinstance(structured_engine, dict) or not self._engine_connectable(structured_engine): + checks["active_slot"] = "failed" + blockers.append( + self._blocker( + "provider_unavailable", + "结构化槽运行时不可用", + "active structured 槽绑定的运行时不存在、被停用或连接不可用。", + "failed", + "查看结构化槽", + "/platform/ai-routing-slots?slot=structured", + ) + ) + else: + checks["active_slot"] = "passed" + coverage = self._structured_capability_coverage(structured_engine) + checks["capability_probe"] = "passed" if coverage["satisfied"] else "failed" + if not coverage["satisfied"]: + blockers.append( + self._blocker( + "capability_probe_missing", + "结构化能力证据不足", + "结构化槽运行时必须具备已验证的结构化输出与 JSON Schema 能力。", + "failed", + "执行能力探测", + "/platform/ai-routing-providers?capability=structured", + ) + ) + + if latest_call is None: + checks["recent_smoke"] = "missing" + checks["recent_failures"] = "unknown" + warnings.append( + self._blocker( + "smoke_not_run", + "尚未运行草案 smoke test", + "供应商和槽位恢复后,需要一次项目草案结构化 smoke test 覆盖历史失败。", + "warning", + "运行草案 smoke", + "/platform/ai-routing-overview", + ) + ) + else: + latest_success = _is_success_status(str(latest_call.status or "")) + recent_success = next((call for call in recent_calls if _is_success_status(str(call.status or ""))), None) + consecutive_failures = 0 + for call in recent_calls: + if _is_success_status(str(call.status or "")): + break + consecutive_failures += 1 + + if latest_success: + checks["recent_smoke"] = "passed" + checks["recent_failures"] = "passed" + elif recent_success is not None and consecutive_failures < 3: + checks["recent_smoke"] = "passed" + checks["recent_failures"] = "warning" + warnings.append( + self._blocker( + "recent_generation_failed", + "最近一次草案生成失败", + self._customer_safe_failure_summary(str(latest_call.response or latest_call.status or "")), + "warning", + "查看失败诊断", + "/platform/ai-routing-failures", + ) + ) + else: + checks["recent_smoke"] = "failed" + checks["recent_failures"] = "failed" + warnings.append( + self._blocker( + "recent_generation_failed", + "最近一次草案生成失败", + self._customer_safe_failure_summary(str(latest_call.response or latest_call.status or "")), + "warning", + "运行草案 smoke", + "/platform/ai-routing-overview", + ) + ) + + if smoke_error: + checks["recent_smoke"] = "failed" + warnings.append( + self._blocker( + "smoke_failed", + "草案 smoke test 失败", + self._customer_safe_failure_summary(smoke_error), + "warning", + "查看失败诊断", + "/platform/ai-routing-failures", + ) + ) + + if blockers: + status = "blocked" + elif checks["recent_smoke"] == "passed": + status = "ready" + elif warnings: + status = "recovering" + else: + status = "unknown" + + can_generate = status == "ready" + customer = self._customer_copy(status) + workspace_owner = self._workspace_owner_copy(status) + visible_blockers = self._customer_blockers(status=status, warnings=warnings, hard_blockers=blockers) + platform_blockers = [*blockers, *warnings] + + return { + "scene": self.SCENE_KEY, + "status": status, + "customer_state": "ai_collaboration_ready" if can_generate else "ai_draft_temporarily_unavailable", + "short_label": "AI 协作可用" if can_generate else ("AI 协作恢复中" if status == "recovering" else "AI 协作暂不可用"), + "title": customer["title"], + "summary": customer["summary"], + "updated_at_label": "可用" if can_generate else ("恢复中" if status == "recovering" else "暂不可用"), + "can_generate_project_draft": can_generate, + "blockers": visible_blockers, + "customer_actions": [{"label": "新建项目", "to": "/project-launch"}] if can_generate else [ + {"label": "记录项目想法", "to": "/project-launch"}, + {"label": "查看所有项目", "to": "/space/projects"}, + ], + "workspace_owner_actions": [ + {"label": "查看空间 AI 状态", "to": "/ai-config?tab=setup"}, + {"label": "查看团队策略", "to": "/ai-config/team-ai-policy"}, + ], + "platform": { + "severity": "ok" if can_generate else ("warning" if status == "recovering" else "critical"), + "blockers": platform_blockers, + "checks": checks, + "latest_call": self._latest_call_payload(latest_call), + "last_checked_at": now_cn_naive().isoformat(), + }, + "platform_diagnostic": { + "cause_code": "ready" if can_generate else (platform_blockers[0]["code"] if platform_blockers else "readiness_unknown"), + "cause_owner": "pmflow_platform", + "raw_status": status, + "severity": "ok" if can_generate else ("warning" if status == "recovering" else "critical"), + "affected_scenes": ["项目草案生成", "暂存想法晋升草案"], + "customer_title": customer["title"], + "customer_summary": customer["summary"], + "operator_summary": workspace_owner["summary"] if status != "ready" else "项目草案生成链路可用。", + "platform_actions": [ + {"label": "查看供应商与凭据", "to": "/platform/ai-routing-providers"}, + {"label": "查看能力槽", "to": "/platform/ai-routing-slots"}, + {"label": "查看失败诊断", "to": "/platform/ai-routing-failures"}, + ], + "checks": checks, + "blockers": platform_blockers, + }, + } + + @staticmethod + def _active_slots(slot_bundle: dict[str, Any]) -> dict[str, Any]: + active = _safe_dict(slot_bundle.get("active")) + slots = active.get("slots") if isinstance(active.get("slots"), dict) else slot_bundle.get("slots") + return _safe_dict(slots) + + @staticmethod + def _engine_connectable(engine: dict[str, Any]) -> bool: + if not bool(engine.get("enabled", True)): + return False + if not str(engine.get("api_key") or "").strip() and str(engine.get("provider") or "").strip().lower() != "ollama": + return False + status = str(engine.get("connection_status") or "").strip().lower() + last_test = str(engine.get("last_test_status") or "").strip().lower() + return status == "available" or last_test == "success" + + @staticmethod + def _structured_capability_coverage(engine: dict[str, Any]) -> dict[str, Any]: + capabilities = _safe_dict(engine.get("capabilities")) + evidence = _safe_dict(capabilities.get("capability_evidence")) + + def _verified(key: str) -> bool: + row = _safe_dict(evidence.get(key)) + return str(row.get("status") or "").strip().lower() == "verified" + + structured = bool(capabilities.get("structured_output")) + json_schema = bool(capabilities.get("json_schema")) + probe_status = str(capabilities.get("capability_probe_status") or "").strip().lower() + has_evidence = _verified("structured_output") and _verified("json_schema") + return { + "satisfied": structured and json_schema and (probe_status == "passed" or has_evidence), + "structured_output": structured, + "json_schema": json_schema, + "probe_status": probe_status or "untested", + "has_evidence": has_evidence, + } + + @staticmethod + def _blocker( + code: str, + label: str, + detail: str, + status: str, + action_label: str | None = None, + action_to: str | None = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "code": code, + "key": code, + "label": label, + "description": detail, + "detail": detail, + "status": status, + } + if action_label and action_to: + payload["action_label"] = action_label + payload["action_to"] = action_to + return payload + + @staticmethod + def _customer_copy(status: str) -> dict[str, str]: + if status == "ready": + return { + "title": "AI 协作可用", + "summary": "PMFlow 可以生成项目草案、提取需求与风险,并把结果交回主流程确认。", + } + if status == "recovering": + return { + "title": "AI 草案生成正在恢复", + "summary": "平台能力正在恢复验证中。你可以先记录想法,系统确认稳定后会开放草案生成。", + } + return { + "title": "AI 项目草案暂不可用", + "summary": "当前空间暂时不能生成 AI 项目草案。你可以先记录项目想法、查看项目和处理待确认事项;系统不会用模板草案冒充 AI 结果。", + } + + @staticmethod + def _workspace_owner_copy(status: str) -> dict[str, str]: + if status == "ready": + return {"summary": "项目草案生成链路可用,确认前仍不会写入正式项目。"} + if status == "recovering": + return {"summary": "平台 AI 草案能力正在恢复验证。空间配置无需反复调整,可以继续维护团队策略和知识组织。"} + return {"summary": "平台 AI 草案能力尚未恢复。空间内仍可维护团队策略、工具连接和知识组织。"} + + def _customer_blockers( + self, + *, + status: str, + warnings: list[dict[str, Any]], + hard_blockers: list[dict[str, Any]], + ) -> list[dict[str, Any]]: + if status == "ready": + return [ + self._blocker("draft_generation", "项目草案生成", "可以把项目想法生成可确认的结构化草案。", "passed"), + self._blocker("safe_confirmation", "确认保护", "生成结果仍进入项目主流程确认,不会直接写入正式项目状态。", "passed"), + ] + impact_status = "warning" if status == "recovering" else "failed" + impact_text = "AI 草案生成正在恢复验证。" if status == "recovering" else "暂时不能生成 AI 项目草案。" + if hard_blockers: + impact_text = "平台 AI 草案能力尚未恢复。" + elif warnings: + impact_text = "平台正在验证 AI 草案生成链路。" + return [ + self._blocker("draft_generation", "当前影响", impact_text, impact_status), + self._blocker("existing_work", "仍可继续", "记录想法、处理待确认、查看项目和推进已有项目不受影响。", "passed"), + self._blocker("safe_fallback", "保护规则", "平台能力未恢复前,不会开放 AI 草案生成,也不会用模板草案冒充 AI 结果。", "warning"), + ] + + @staticmethod + def _latest_call_payload(row: AICallLog | None) -> dict[str, Any] | None: + if row is None: + return None + return { + "id": int(row.id), + "created_at": row.created_at.isoformat() if row.created_at else None, + "function_name": str(row.function_name or ""), + "status": str(row.status or ""), + "planned_tier": str(row.planned_tier or ""), + "executed_tier": str(row.executed_tier or ""), + "routing_decision": str(row.routing_decision or ""), + "route_source": str(row.route_source or ""), + "route_rule_name": str(row.route_rule_name or ""), + } + + @staticmethod + def _customer_safe_failure_summary(raw: str) -> str: + text = str(raw or "").strip() + if not text: + return "系统没有拿到可用的结构化草案结果。" + if "401" in text or "Unauthorized" in text: + return "最近一次草案生成失败,平台凭据或供应商授权需要重新验证。" + if "structured" in text: + return "最近一次草案生成没有通过结构化结果校验。" + return text[:180] + + +__all__ = ["AIReadinessService"] diff --git a/backend/app/services/ai_routing_governance_service.py b/backend/app/services/ai_routing_governance_service.py index 09f6a1d2..c74e52a6 100644 --- a/backend/app/services/ai_routing_governance_service.py +++ b/backend/app/services/ai_routing_governance_service.py @@ -12,10 +12,12 @@ from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession from ..models import AICallLog, Integration +from ..services.ai_function_runtime_profile_service import AIFunctionRuntimeProfileService +from ..services.external_query_service.registry import ExternalProviderRegistry +from ..services.ai_routing_scene_contracts import AI_SCENE_REGISTRY, scene_contract from ..services.model.model_config import ( build_routing_config_snapshot, find_matching_rule, - get_provider_defaults, get_tier_defaults, load_model_config, ) @@ -24,9 +26,81 @@ from ..utils.timezone import now_cn_naive TEAM_POLICY_INTEGRATION_TYPE = "ai_team_policy" LLM_ENGINE_INTEGRATION_TYPE = "llm_engine" MODEL_ROUTING_INTEGRATION_TYPE = "model_routing" +CAPABILITY_SLOT_INTEGRATION_TYPE = "ai_capability_slots" +PLATFORM_RUNTIME_TENANT_ID = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1") +CAPABILITY_PROBE_EVIDENCE_TTL_DAYS = int(os.getenv("AI_CAPABILITY_PROBE_EVIDENCE_TTL_DAYS", "90") or "90") +CAPABILITY_PROBE_KEYS = ( + "basic_chat", + "model_self_report", + "structured_output", + "json_schema", + "tool_calling", + "streaming", + "long_context_32k", + "long_context_128k", + "vision_input", + "audio_input", + "private_deployment", + "external_search_ready", + "long_context", +) +CAPABILITY_PROBE_STATUSES = {"untested", "running", "passed", "failed", "partial"} +CAPABILITY_EVIDENCE_STATUSES = {"verified", "failed", "skipped", "not_probeable", "declared_only"} +LIVE_EVIDENCE_STRENGTHS = {"live_probe", "smoke_probe", "provider_contract", "operator_verified"} +DECLARED_EVIDENCE_STRENGTHS = {"declared_only", "manual", "inferred", "model_self_report"} +DECLARED_PROBE_REQUIREMENT_FLAGS = { + "structured_output": "structured_output", + "json_schema": "json_schema", + "tool_calling": "tool_calling", + "long_context": "long_context", + "vision_input": "vision", + "audio_input": "audio", + "private_deployment": "private_deployment", + "external_search_ready": "external_search_ready", +} +CAPABILITY_DECLARED_FIELDS_META = "capability_declared_fields" +PROVIDER_MODEL_CONTEXT_CONTRACTS = { + ("dashscope", "qwen-plus"): { + "context_tokens": 1_000_000, + "max_input_tokens": 997_952, + "source": "aliyun_model_studio_official_spec", + "label": "阿里云百炼 Qwen-Plus 官方模型规格", + }, +} + + +def _provider_model_context_contract(provider: Any, model: Any) -> dict[str, Any] | None: + normalized_provider = str(provider or "").strip().lower() + normalized_model = str(model or "").strip().lower() + if not normalized_provider or not normalized_model: + return None + exact = PROVIDER_MODEL_CONTEXT_CONTRACTS.get((normalized_provider, normalized_model)) + if exact: + return dict(exact) + if normalized_provider == "dashscope" and normalized_model.startswith("qwen-plus-"): + return dict(PROVIDER_MODEL_CONTEXT_CONTRACTS[("dashscope", "qwen-plus")]) + return None + + +def _explicit_declared_capability_fields(source: dict[str, Any]) -> set[str]: + raw_fields = source.get(CAPABILITY_DECLARED_FIELDS_META) + allowed = set(DECLARED_PROBE_REQUIREMENT_FLAGS.values()) + if isinstance(raw_fields, list): + return {str(item) for item in raw_fields if str(item) in allowed} + return {field for field in allowed if field in source} + + +def _probe_status_declared_scope(declared: dict[str, Any], explicit_fields: set[str]) -> dict[str, Any]: + if not explicit_fields: + return {} + scoped = dict(declared) + for declared_flag in DECLARED_PROBE_REQUIREMENT_FLAGS.values(): + if declared_flag not in explicit_fields: + scoped[declared_flag] = False + return scoped PROVIDER_LABELS = { - "glm": "Qwen / DashScope", + "dashscope": "Qwen / DashScope", "openai": "OpenAI 兼容网关", "deepseek": "DeepSeek", "ollama": "本地 Ollama", @@ -35,6 +109,154 @@ PROVIDER_LABELS = { SLOT_LABELS = { "low": "快速槽", "high": "深度槽", + "structured": "结构化槽", + "long_context": "长上下文槽", + "tool": "工具槽", + "external_query": "外部查询槽", +} +OBSERVABLE_RUNTIME_SLOTS = ("low", "high", "structured", "long_context", "tool", "external_query") +SLOT_RECOMMENDATION_FAILURE_RATE_THRESHOLD = float(os.getenv("AI_SLOT_RECOMMENDATION_FAILURE_RATE_THRESHOLD", "0.3") or "0.3") +SLOT_RECOMMENDATION_FALLBACK_RATE_THRESHOLD = float(os.getenv("AI_SLOT_RECOMMENDATION_FALLBACK_RATE_THRESHOLD", "0.3") or "0.3") +SLOT_RECOMMENDATION_DEGRADED_RATE_THRESHOLD = float(os.getenv("AI_SLOT_RECOMMENDATION_DEGRADED_RATE_THRESHOLD", "0.2") or "0.2") +SLOT_RECOMMENDATION_MIN_CALLS = int(os.getenv("AI_SLOT_RECOMMENDATION_MIN_CALLS", "3") or "3") +LOW_SLOT_LATENCY_WARNING_MS = int(os.getenv("AI_LOW_SLOT_LATENCY_WARNING_MS", "3000") or "3000") +LOW_SLOT_COST_WARNING = float(os.getenv("AI_LOW_SLOT_COST_WARNING", "1.0") or "1.0") + +SLOT_SPECS = { + "low": { + "label": "快速槽", + "summary": "轻量理解、润色、低风险草案与快速分流;失败后可升级到深度槽。", + "configurable": True, + "protected": False, + "default_failure_policy": "upgrade_to_high", + }, + "high": { + "label": "深度槽", + "summary": "结构化、复杂推理、人员匹配和保护场景;默认不允许客户空间策略降级。", + "configurable": True, + "protected": True, + "default_failure_policy": "explicit_fail", + }, + "structured": { + "label": "结构化槽", + "summary": "面向 JSON/DSL/抽取类输出,发布后由结构化生成 runtime 消费。", + "configurable": True, + "protected": True, + "default_failure_policy": "explicit_fail", + }, + "long_context": { + "label": "长上下文槽", + "summary": "面向长文档、长线程和跨项目上下文,发布后仅由显式长上下文调用消费。", + "configurable": True, + "protected": True, + "default_failure_policy": "try_fallback_then_fail", + }, + "tool": { + "label": "工具槽", + "summary": "面向工具调用和 MCP 执行编排,发布后仅由显式工具规划调用消费。", + "configurable": True, + "protected": True, + "default_failure_policy": "explicit_fail", + }, + "external_query": { + "label": "外部查询槽", + "summary": "正式外部查询 runtime 槽;绑定 external provider registry / MCP adapter / bounded live search fallback 的受控执行链,只在 route_mode=external 后消费。", + "configurable": True, + "protected": True, + "default_failure_policy": "explicit_fail", + "runtime_binding": "external_provider_registry", + }, + "private": { + "label": "私有槽", + "summary": "候选槽,不接 runtime;面向私有部署或客户隔离运行时,等待租户隔离和合规门禁接入。", + "configurable": False, + "protected": True, + "default_failure_policy": "explicit_fail", + }, + "multimodal": { + "label": "多模态槽", + "summary": "候选槽,不接 runtime;面向图片、语音、文件理解,等待多模态能力矩阵接入。", + "configurable": False, + "protected": True, + "default_failure_policy": "try_fallback_then_fail", + }, +} + +CAPABILITY_LABELS = { + "structured_output": "结构化输出", + "json_schema": "JSON Schema", + "tool_calling": "工具调用", + "long_context": "长上下文", + "vision": "视觉理解", + "audio": "音频理解", + "local_only": "本地运行", + "private_deployment": "私有部署", + "external_search_ready": "外部查询链路", +} + +SLOT_REQUIREMENTS = { + "low": { + "summary": "低成本、低延迟、可用连接;不要求强结构化能力。", + "required_flags": [], + "forbidden_flags": [], + "min_context_tokens": None, + "any_flags": [], + }, + "high": { + "summary": "复杂推理和保护场景;禁止仅本地运行时。", + "required_flags": [], + "forbidden_flags": ["local_only"], + "min_context_tokens": None, + "any_flags": [], + }, + "structured": { + "summary": "必须同时具备结构化输出和 JSON Schema 能力。", + "required_flags": ["structured_output", "json_schema"], + "forbidden_flags": [], + "min_context_tokens": None, + "any_flags": [], + }, + "long_context": { + "summary": "必须支持至少 32K 上下文窗口。", + "required_flags": ["long_context"], + "forbidden_flags": [], + "min_context_tokens": 32768, + "any_flags": [], + }, + "tool": { + "summary": "必须支持工具调用或 function calling。", + "required_flags": ["tool_calling"], + "forbidden_flags": [], + "min_context_tokens": None, + "any_flags": [], + }, + "external_query": { + "summary": "必须接入外部查询 provider registry 或等价实时检索适配器。", + "required_flags": ["external_search_ready"], + "forbidden_flags": [], + "min_context_tokens": None, + "any_flags": [], + }, + "private": { + "summary": "必须满足私有部署或本地隔离运行要求。", + "required_flags": ["private_deployment"], + "forbidden_flags": [], + "min_context_tokens": None, + "any_flags": [], + }, + "multimodal": { + "summary": "必须至少具备视觉或音频一种多模态理解能力。", + "required_flags": [], + "forbidden_flags": [], + "min_context_tokens": None, + "any_flags": ["vision", "audio"], + }, +} + +FAILURE_POLICY_LABELS = { + "upgrade_to_high": "失败后升级深度槽", + "try_fallback_then_fail": "先走备用链路,仍失败则显式失败", + "explicit_fail": "显式失败,不静默降级", } PROTECTED_FUNCTIONS = { @@ -47,36 +269,40 @@ PROTECTED_FUNCTIONS = { } SCENE_FUNCTIONS = { - "project_launch": ["pmflow_launch_draft_v1", "project_from_text_analysis", "project_from_text_charter"], - "requirement_structuring": ["project_requirement_extraction", "pmflow_requirement_plan_draft", "pmflow_requirement_plan_round"], - "task_breakdown": ["generate_task_breakdown", "decompose_tasks_from_charter", "sstc_ai_breakdown"], - "assignee_matching": ["recommend_assignees_for_task"], - "internal_qa": ["adaptive_rag_answer_normal", "adaptive_rag_answer_deep", "rag_db_project_semantic_retrieval"], - "external_query": ["external_query_plan", "adaptive_rag_answer"], - "feishu_understanding": ["feishu_level_classification", "feishu_signal_extraction", "pmflow_semantic_context_decision"], - "feishu_reply": ["feishu_humanized_reply", "feishu_qa_direct_natural_reply"], + scene_key: list(scene.get("function_names", [])) + for scene_key, scene in AI_SCENE_REGISTRY.items() } SCENE_LABELS = { - "project_launch": "项目启动草案", - "requirement_structuring": "需求结构化", - "task_breakdown": "任务拆解", - "assignee_matching": "人员匹配", - "internal_qa": "内部问答", - "external_query": "外部实时查询", - "feishu_understanding": "飞书理解", - "feishu_reply": "飞书回复", + scene_key: str(scene.get("label") or scene_key) + for scene_key, scene in AI_SCENE_REGISTRY.items() +} + +SCENE_SLOT_REQUIREMENTS = { + scene_key: list(scene.get("required_slots", [])) + for scene_key, scene in AI_SCENE_REGISTRY.items() +} + +SCENE_DEFAULT_SLOT = { + scene_key: str(scene.get("default_slot") or "") + for scene_key, scene in AI_SCENE_REGISTRY.items() +} + +PROTECTED_SCENE_KEYS = { + scene_key + for scene_key, scene in AI_SCENE_REGISTRY.items() + if bool(scene.get("protected")) and scene.get("lifecycle") == "active" +} + +SCENE_FUNCTION_SLOT_ALLOWLIST = { + scene_key: set(scene.get("allowed_slots", [])) + for scene_key, scene in AI_SCENE_REGISTRY.items() + if scene.get("allowed_slots") } DEFAULT_SCENE_POLICIES = { - "project_launch": "快速起草,失败后深度修复,结果必须进入确认", - "requirement_structuring": "深度优先,保护项目真相层入口", - "task_breakdown": "快速结构化,失败后深度修复,仍失败则标记待补充", - "assignee_matching": "深度分析,必须结合人员能力图谱", - "internal_qa": "先走受控内部查询,再按复杂度组织答案", - "external_query": "外部查询必须经过计划、来源校验和答案验证", - "feishu_understanding": "快速理解与分流,低置信度进入澄清", - "feishu_reply": "快速润色,不改变业务结论", + scene_key: str(scene.get("summary") or "") + for scene_key, scene in AI_SCENE_REGISTRY.items() } @@ -104,11 +330,6 @@ def _mask_secret(secret: str) -> str: return f"{value[:4]}...{value[-4:]}" -def _looks_placeholder(value: str) -> bool: - text = str(value or "").strip().lower() - return bool(text) and any(marker in text for marker in ("placeholder", "dummy", "test-key", "local-placeholder")) - - def _normalize_tier(value: Any) -> str: text = str(value or "").strip().lower() return text if text in {"low", "high"} else "" @@ -121,6 +342,527 @@ def _iso(value: Any) -> str | None: return text or None +def _json_clone(value: Any) -> Any: + try: + return json.loads(json.dumps(value, ensure_ascii=False)) + except Exception: + return value + + +def _release_version() -> str: + return f"slot-v{now_cn_naive().strftime('%Y%m%d%H%M%S')}" + + +def _empty_capability_probe(source: str = "inferred") -> dict[str, Any]: + normalized_source = str(source or "inferred").strip().lower() + if normalized_source not in {"inferred", "manual", "probed"}: + normalized_source = "inferred" + return { + "status": "untested", + "last_verified_at": None, + "verified_capabilities": {key: False for key in CAPABILITY_PROBE_KEYS}, + "evidence": {}, + "source": normalized_source, + } + + +def _evidence_strength_from_row(row: dict[str, Any], *, key: str) -> str: + strength = str(row.get("evidence_strength") or "").strip().lower() + if strength: + return strength + source = str(row.get("source") or "").strip().lower() + status = str(row.get("status") or "").strip().lower() + message = str(row.get("message") or row.get("summary") or "").strip() + if source in {"manual", "default", "inferred"}: + return source + if key in {"long_context", "long_context_32k", "long_context_128k"} and ("声明" in message or "declared" in message.lower()): + return "declared_only" + if status == "verified" and source in {"probe", "probed"}: + return "live_probe" + return "unknown" + + +def _probe_row_is_verified(row: dict[str, Any], *, key: str) -> bool: + if not isinstance(row, dict): + return False + status = str(row.get("status") or "").strip().lower() + ok = row.get("ok") + if ok is not True and status != "verified": + return False + return _evidence_strength_from_row(row, key=key) in LIVE_EVIDENCE_STRENGTHS + + +def _derive_effective_capabilities(*, declared: dict[str, Any], probe: dict[str, Any]) -> dict[str, bool]: + evidence = probe.get("evidence") if isinstance(probe.get("evidence"), dict) else {} + + def _row_for(key: str) -> dict[str, Any] | None: + row = evidence.get(key) + if isinstance(row, dict): + return row + flat = declared.get("capability_evidence") if isinstance(declared.get("capability_evidence"), dict) else {} + flat_row = flat.get(key) + return flat_row if isinstance(flat_row, dict) else None + + def _verified(key: str) -> bool: + row = _row_for(key) + if isinstance(row, dict) and _probe_row_is_verified(row, key=key): + return True + return False + + def _verified_min_tokens(key: str, min_tokens: int) -> bool: + row = _row_for(key) + if not isinstance(row, dict) or not _probe_row_is_verified(row, key=key): + return False + try: + tested_tokens = int(row.get("tested_tokens") or row.get("max_context_tokens") or 0) + except (TypeError, ValueError): + tested_tokens = 0 + return tested_tokens >= min_tokens + + long_context_verified = _verified("long_context") or _verified("long_context_32k") or _verified("long_context_128k") + long_32k_verified = _verified_min_tokens("long_context", 32768) or _verified_min_tokens("long_context_32k", 32768) + long_128k_verified = _verified_min_tokens("long_context", 128000) or _verified_min_tokens("long_context_128k", 128000) + return { + "basic_chat": _verified("basic_chat"), + "structured_output": _verified("structured_output"), + "json_schema": _verified("json_schema"), + "tool_calling": _verified("tool_calling"), + "streaming": _verified("streaming"), + "long_context": long_context_verified, + "long_context_32k": long_32k_verified, + "long_context_128k": long_128k_verified, + "vision_input": _verified("vision_input"), + "audio_input": _verified("audio_input"), + "private_deployment": _verified("private_deployment"), + "external_search_ready": _verified("external_search_ready"), + "vision": _verified("vision_input"), + "audio": _verified("audio_input"), + } + + +def _capability_evidence_is_in_scope(key: str, row: dict[str, Any], *, declared: dict[str, Any]) -> bool: + normalized_key = str(key or "").strip() + if normalized_key == "model_self_report": + return False + if normalized_key == "basic_chat": + return True + if normalized_key == "structured_output": + return bool(declared.get("structured_output")) + if normalized_key == "json_schema": + return bool(declared.get("json_schema")) + if normalized_key == "tool_calling": + return bool(declared.get("tool_calling")) + if normalized_key == "streaming": + return _probe_row_is_verified(row, key=normalized_key) + if normalized_key == "long_context": + return bool(declared.get("long_context")) or _probe_row_is_verified(row, key=normalized_key) + if normalized_key in {"long_context_32k", "long_context_128k"}: + try: + declared_tokens = int(declared.get("max_context_tokens") or 0) + except (TypeError, ValueError): + declared_tokens = 0 + required_tokens = 128000 if normalized_key == "long_context_128k" else 32768 + return (bool(declared.get("long_context")) and declared_tokens >= required_tokens) or _probe_row_is_verified(row, key=normalized_key) + if normalized_key == "vision_input": + return bool(declared.get("vision")) or _probe_row_is_verified(row, key=normalized_key) + if normalized_key == "audio_input": + return bool(declared.get("audio")) or _probe_row_is_verified(row, key=normalized_key) + return True + + +def _summarize_capability_probe_status( + *, + declared: dict[str, Any], + probe: dict[str, Any], + missing_scope_declared: dict[str, Any] | None = None, +) -> str: + evidence = probe.get("evidence") if isinstance(probe.get("evidence"), dict) else {} + long_context_row = evidence.get("long_context") if isinstance(evidence.get("long_context"), dict) else None + covered_long_context_thresholds: set[str] = set() + if isinstance(long_context_row, dict) and _probe_row_is_verified(long_context_row, key="long_context"): + try: + verified_tokens = int(long_context_row.get("tested_tokens") or long_context_row.get("max_context_tokens") or 0) + except (TypeError, ValueError): + verified_tokens = 0 + if verified_tokens >= 32768: + covered_long_context_thresholds.add("long_context_32k") + if verified_tokens >= 128000: + covered_long_context_thresholds.add("long_context_128k") + scoped = { + str(key): value + for key, value in evidence.items() + if isinstance(value, dict) + and str(key) not in covered_long_context_thresholds + and _capability_evidence_is_in_scope(str(key), value, declared=declared) + } + placeholder_scope = missing_scope_declared if isinstance(missing_scope_declared, dict) else declared + for key, declared_flag in DECLARED_PROBE_REQUIREMENT_FLAGS.items(): + if bool(placeholder_scope.get(declared_flag)) and key not in scoped: + scoped[key] = { + "status": "declared_only", + "ok": None, + "evidence_strength": "declared_only", + "summary": "已声明但尚未获得真实探测或人工确认依据", + } + if not scoped: + return "untested" + verified = sum(1 for key, value in scoped.items() if _probe_row_is_verified(value, key=key)) + failed = sum( + 1 + for value in scoped.values() + if str(value.get("status") or "").strip().lower() == "failed" or (value.get("ok") is False and str(value.get("status") or "").strip().lower() not in {"skipped", "not_probeable", "declared_only"}) + ) + neutral = len(scoped) - verified - failed + if verified and not failed and not neutral: + return "passed" + if verified and (failed or neutral): + return "partial" + if failed: + return "failed" + return "untested" + + +def _normalize_legacy_long_context_probe(*, declared: dict[str, Any], probe: dict[str, Any], flat_evidence: dict[str, Any]) -> None: + if not bool(declared.get("long_context")): + return + try: + declared_tokens = int(declared.get("max_context_tokens") or 0) + except (TypeError, ValueError): + declared_tokens = 0 + if declared_tokens <= 0: + return + evidence = probe.get("evidence") if isinstance(probe.get("evidence"), dict) else {} + row = evidence.get("long_context") + if not isinstance(row, dict): + return + status = str(row.get("status") or "").strip().lower() + failure_code = str(row.get("failure_code") or "").strip() + text = " ".join(str(row.get(key) or "") for key in ("summary", "error_summary", "request_summary", "response_summary")) + if status != "failed" or failure_code != "declared_window_too_small" or "32768" not in text: + return + message = f"长上下文真实 smoke 需要高成本调用,本次只记录声明窗口 {declared_tokens} tokens,不作为槽位发布 verified 证据" + normalized_row = dict(row) + normalized_row.update( + { + "ok": False, + "status": "declared_only", + "summary": message, + "error_summary": "", + "evidence_strength": "declared_only", + "failure_code": None, + "tested_tokens": declared_tokens, + } + ) + evidence["long_context"] = normalized_row + flat_evidence["long_context"] = { + **dict(flat_evidence.get("long_context") or {}), + "status": "declared_only", + "source": "probe", + "message": message, + "evidence_strength": "declared_only", + "failure_code": None, + "max_context_tokens": declared_tokens, + } + + +def _probe_source_from_flat(source: Any) -> str: + normalized = str(source or "").strip().lower() + if normalized in {"probe", "mixed", "probed"}: + return "probed" + if normalized == "manual": + return "manual" + return "inferred" + + +def _flat_source_from_probe(source: Any, *, fallback: str = "default") -> str: + normalized = str(source or "").strip().lower() + if normalized == "probed": + return "probe" + if normalized == "manual": + return "manual" + return fallback if fallback in {"default", "manual", "probe", "mixed"} else "default" + + +def _normalize_capability_probe( + raw: Any, + *, + flat_evidence: dict[str, Any], + flat_status: Any, + flat_last_verified_at: Any, + flat_source: Any, +) -> dict[str, Any]: + source = raw if isinstance(raw, dict) else {} + probe = _empty_capability_probe(_probe_source_from_flat(flat_source)) + raw_source = str(source.get("source") or "").strip().lower() + if raw_source in {"inferred", "manual", "probed"}: + probe["source"] = raw_source + raw_status = str(source.get("status") or flat_status or "").strip().lower() + if raw_status in CAPABILITY_PROBE_STATUSES: + probe["status"] = raw_status + raw_last_verified_at = str(source.get("last_verified_at") or flat_last_verified_at or "").strip() + probe["last_verified_at"] = raw_last_verified_at or None + + verified_capabilities = source.get("verified_capabilities") if isinstance(source.get("verified_capabilities"), dict) else {} + for key in CAPABILITY_PROBE_KEYS: + probe["verified_capabilities"][key] = bool(verified_capabilities.get(key, False)) + + nested_evidence = source.get("evidence") if isinstance(source.get("evidence"), dict) else {} + evidence_keys = list(dict.fromkeys([*CAPABILITY_PROBE_KEYS, *nested_evidence.keys(), *flat_evidence.keys()])) + normalized_evidence: dict[str, dict[str, Any]] = {} + for key in evidence_keys: + nested_row = nested_evidence.get(key) if isinstance(nested_evidence.get(key), dict) else {} + flat_row = flat_evidence.get(key) if isinstance(flat_evidence.get(key), dict) else {} + if not nested_row and not flat_row: + continue + raw_status = str(nested_row.get("status") or flat_row.get("status") or "").strip().lower() + ok_value = nested_row.get("ok") + if not isinstance(ok_value, bool): + ok_value = raw_status == "verified" + checked_at = str(nested_row.get("checked_at") or flat_row.get("verified_at") or probe["last_verified_at"] or "").strip() or None + summary = str(nested_row.get("summary") or flat_row.get("message") or "").strip() + error_summary = str(nested_row.get("error_summary") or ("" if ok_value else flat_row.get("message") or "")).strip() + evidence_strength = _evidence_strength_from_row({**flat_row, **nested_row, "status": raw_status}, key=str(key)) + if evidence_strength == "unknown" and ok_value is True and probe.get("source") == "probed": + message = str(summary or error_summary or "") + evidence_strength = ( + "declared_only" + if str(key) in {"long_context", "long_context_32k", "long_context_128k"} and ("声明" in message or "declared" in message.lower()) + else "live_probe" + ) + row = { + "ok": bool(ok_value), + "status": raw_status if raw_status in CAPABILITY_EVIDENCE_STATUSES else ("verified" if ok_value else "failed"), + "method": str(nested_row.get("method") or flat_row.get("method") or "").strip() or None, + "evidence_strength": evidence_strength, + "checked_at": checked_at, + "summary": summary[:300], + "error_summary": error_summary[:300], + "failure_code": str(nested_row.get("failure_code") or flat_row.get("failure_code") or "").strip() or None, + "request_summary": str(nested_row.get("request_summary") or flat_row.get("request_summary") or "").strip()[:500], + "response_summary": str(nested_row.get("response_summary") or flat_row.get("response_summary") or "").strip()[:500], + "latency_ms": nested_row.get("latency_ms", flat_row.get("latency_ms")), + "source": str(nested_row.get("source") or flat_row.get("source") or "").strip() or None, + } + for passthrough_key in ("source_type", "source_url", "source_note", "verified_by", "verified_at", "expires_at"): + passthrough_value = nested_row.get(passthrough_key, flat_row.get(passthrough_key)) + if passthrough_value not in (None, ""): + row[passthrough_key] = passthrough_value + if key in {"long_context", "long_context_32k", "long_context_128k"}: + tested_tokens = nested_row.get("tested_tokens", flat_row.get("max_context_tokens")) + try: + row["tested_tokens"] = int(tested_tokens) if tested_tokens is not None and tested_tokens != "" else None + except (TypeError, ValueError): + row["tested_tokens"] = None + model_self_report = nested_row.get("model_self_report", flat_row.get("model_self_report")) + if str(key) == "model_self_report" and isinstance(model_self_report, dict): + row["model_self_report"] = model_self_report + normalized_evidence[key] = row + probe["verified_capabilities"][key] = _probe_row_is_verified(row, key=str(key)) + probe["evidence"] = normalized_evidence + + if probe["status"] == "untested" and normalized_evidence: + ok_count = sum(1 for item in normalized_evidence.values() if item.get("ok") is True) + failed_count = sum(1 for item in normalized_evidence.values() if item.get("ok") is not True) + probe["status"] = "passed" if ok_count and not failed_count else ("partial" if ok_count else "failed") + return probe + + +def _flat_evidence_from_probe(probe: dict[str, Any]) -> dict[str, dict[str, Any]]: + evidence = probe.get("evidence") if isinstance(probe.get("evidence"), dict) else {} + flattened: dict[str, dict[str, Any]] = {} + for key, value in evidence.items(): + if not isinstance(value, dict): + continue + flattened[str(key)] = { + "status": str(value.get("status") or ("verified" if value.get("ok") is True else "failed")), + "source": value.get("source") or "probe", + "verified_at": value.get("checked_at"), + "message": str(value.get("summary") or value.get("error_summary") or "")[:300], + "latency_ms": value.get("latency_ms"), + "method": value.get("method"), + "evidence_strength": value.get("evidence_strength"), + "failure_code": value.get("failure_code"), + "request_summary": value.get("request_summary"), + "response_summary": value.get("response_summary"), + } + if value.get("tested_tokens") is not None: + flattened[str(key)]["max_context_tokens"] = value.get("tested_tokens") + for passthrough_key in ("source_type", "source_url", "source_note", "verified_by", "expires_at"): + if value.get(passthrough_key) not in (None, ""): + flattened[str(key)][passthrough_key] = value.get(passthrough_key) + if str(key) == "model_self_report" and isinstance(value.get("model_self_report"), dict): + flattened[str(key)]["model_self_report"] = value.get("model_self_report") + return flattened + + +def _parse_iso_datetime(value: Any) -> datetime | None: + text = str(value or "").strip() + if not text: + return None + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is not None: + parsed = parsed.replace(tzinfo=None) + return parsed + + +def _default_engine_capabilities(provider: str, model: str = "") -> dict[str, Any]: + normalized_provider = str(provider or "").strip().lower() + normalized_model = str(model or "").strip().lower() + is_local = normalized_provider == "ollama" + is_high_reasoning = normalized_provider in {"openai", "deepseek"} or "glm-5" in normalized_model + is_dashscope = normalized_provider == "dashscope" + context_contract = _provider_model_context_contract(normalized_provider, normalized_model) + default_context_tokens = ( + int(context_contract["context_tokens"]) + if context_contract + else (8192 if is_local else (128000 if is_high_reasoning else 32768)) + ) + return { + "structured_output": not is_local, + "json_schema": normalized_provider in {"openai", "deepseek"} or is_dashscope, + "tool_calling": normalized_provider in {"openai", "deepseek", "dashscope"}, + "long_context": not is_local, + "max_context_tokens": default_context_tokens, + "vision": False, + "audio": False, + "local_only": is_local, + "private_deployment": is_local, + "external_search_ready": False, + "avg_latency_ms": None, + "cost_tier": "low" if normalized_provider in {"dashscope", "ollama"} else "medium", + "reliability_tier": "local" if is_local else ("high" if is_high_reasoning else "standard"), + "capability_source": "default", + "capability_probe_status": "untested", + "capability_last_verified_at": None, + "capability_blocking_reasons": [], + "capability_evidence": {}, + "capability_probe": _empty_capability_probe("inferred"), + "declared_capabilities": {}, + "effective_capabilities": {}, + } + + +def _normalize_engine_capabilities(raw: Any, *, provider: str, model: str = "") -> dict[str, Any]: + defaults = _default_engine_capabilities(provider, model) + source = raw if isinstance(raw, dict) else {} + + def _bool(key: str) -> bool: + return bool(source[key]) if key in source else bool(defaults[key]) + + def _int_or_none(key: str) -> int | None: + value = source.get(key, defaults.get(key)) + if value is None or value == "": + return None + try: + return max(0, int(value)) + except (TypeError, ValueError): + return defaults.get(key) + + def _tier(key: str, allowed: set[str]) -> str: + value = str(source.get(key) or defaults.get(key) or "").strip().lower() + return value if value in allowed else str(defaults.get(key) or "") + + evidence = source.get("capability_evidence") if isinstance(source.get("capability_evidence"), dict) else {} + blocking_reasons = source.get("capability_blocking_reasons") if isinstance(source.get("capability_blocking_reasons"), list) else [] + metadata_keys = { + "capability_source", + "capability_probe_status", + "capability_last_verified_at", + "capability_blocking_reasons", + "capability_evidence", + "capability_probe", + "declared_capabilities", + "effective_capabilities", + CAPABILITY_DECLARED_FIELDS_META, + } + has_saved_capability_fields = any(key in source for key in defaults if key not in metadata_keys) + capability_source = str(source.get("capability_source") or "").strip().lower() + if capability_source not in {"default", "manual", "probe", "mixed"}: + capability_source = "manual" if has_saved_capability_fields else "default" + probe_status = str(source.get("capability_probe_status") or "").strip().lower() + if probe_status not in {"untested", "running", "passed", "partial", "failed"}: + probe_status = "untested" + capability_probe = _normalize_capability_probe( + source.get("capability_probe"), + flat_evidence=evidence, + flat_status=probe_status, + flat_last_verified_at=source.get("capability_last_verified_at"), + flat_source=capability_source, + ) + declared_capabilities = { + "structured_output": _bool("structured_output"), + "json_schema": _bool("json_schema"), + "tool_calling": _bool("tool_calling"), + "long_context": _bool("long_context"), + "max_context_tokens": _int_or_none("max_context_tokens"), + "vision": _bool("vision"), + "audio": _bool("audio"), + "local_only": _bool("local_only"), + "private_deployment": _bool("private_deployment"), + "external_search_ready": _bool("external_search_ready"), + "avg_latency_ms": _int_or_none("avg_latency_ms"), + "cost_tier": _tier("cost_tier", {"low", "medium", "high", "unknown"}), + "reliability_tier": _tier("reliability_tier", {"standard", "high", "local", "unknown"}), + } + explicit_declared_fields = _explicit_declared_capability_fields(source) + _normalize_legacy_long_context_probe(declared=declared_capabilities, probe=capability_probe, flat_evidence=evidence) + if not evidence and capability_probe.get("evidence"): + evidence = _flat_evidence_from_probe(capability_probe) + if not blocking_reasons: + blocking_reasons = [ + f"{key}: {str(value.get('error_summary') or '')[:180]}" + for key, value in (capability_probe.get("evidence") or {}).items() + if ( + isinstance(value, dict) + and _capability_evidence_is_in_scope(str(key), value, declared=declared_capabilities) + and value.get("ok") is not True + and str(value.get("error_summary") or "").strip() + ) + ] + missing_scope_declared = _probe_status_declared_scope(declared_capabilities, explicit_declared_fields) + scoped_probe_status = _summarize_capability_probe_status( + declared=declared_capabilities, + probe=capability_probe, + missing_scope_declared=missing_scope_declared, + ) + if scoped_probe_status != "untested" or probe_status == "untested": + probe_status = scoped_probe_status + capability_probe["status"] = probe_status + last_verified_at = str(source.get("capability_last_verified_at") or capability_probe.get("last_verified_at") or "").strip() or None + if capability_source == "default" and capability_probe.get("source") in {"manual", "probed"}: + capability_source = _flat_source_from_probe(capability_probe.get("source"), fallback=capability_source) + normalized = { + **declared_capabilities, + "capability_source": capability_source, + "capability_probe_status": probe_status, + "capability_last_verified_at": last_verified_at, + "capability_blocking_reasons": [str(item)[:220] for item in blocking_reasons if str(item or "").strip()][:8], + "capability_evidence": { + str(key): value + for key, value in evidence.items() + if str(key or "").strip() and isinstance(value, dict) + }, + "capability_probe": capability_probe, + "declared_capabilities": { + "structured_output": declared_capabilities["structured_output"], + "json_schema": declared_capabilities["json_schema"], + "tool_calling": declared_capabilities["tool_calling"], + "long_context": declared_capabilities["long_context"], + "max_context_tokens": declared_capabilities["max_context_tokens"], + "vision": declared_capabilities["vision"], + "audio": declared_capabilities["audio"], + "private_deployment": declared_capabilities["private_deployment"], + "external_search_ready": declared_capabilities["external_search_ready"], + }, + CAPABILITY_DECLARED_FIELDS_META: sorted(explicit_declared_fields), + } + normalized["effective_capabilities"] = _derive_effective_capabilities(declared=normalized, probe=capability_probe) + return normalized + + class AIRoutingGovernanceService: def __init__(self, db: AsyncSession, *, tenant_id: int): self.db = db @@ -130,7 +872,16 @@ class AIRoutingGovernanceService: bundle = load_model_config() engine_bundle = await self._load_integration_config(LLM_ENGINE_INTEGRATION_TYPE) routing_bundle = await self._load_integration_config(MODEL_ROUTING_INTEGRATION_TYPE) - slots = self._build_slots(engine_bundle=engine_bundle, config_bundle=bundle) + slot_bundle = self._normalize_capability_slot_bundle( + await self._load_integration_config(CAPABILITY_SLOT_INTEGRATION_TYPE) + ) + capability_slots = self._build_capability_slots( + engine_bundle=engine_bundle, + slot_bundle=slot_bundle, + config_bundle=bundle, + stage="active", + ) + slots = [slot for slot in capability_slots if slot.get("slot_key") in {"low", "high"}] metrics = await self._build_metrics() diagnostics = await self._build_diagnostics( engine_bundle=engine_bundle, @@ -144,6 +895,7 @@ class AIRoutingGovernanceService: "config_updated_at": snapshot.get("routing_config_updated_at") or "", "source_of_truth": "PMFlow 本地路由层 baseline", "slots": slots, + "capability_slots": capability_slots, "metrics": metrics, "diagnostics": diagnostics, "latency_status": "暂未采集平均耗时", @@ -154,16 +906,282 @@ class AIRoutingGovernanceService: engine_bundle = await self._load_integration_config(LLM_ENGINE_INTEGRATION_TYPE) routing_bundle = await self._load_integration_config(MODEL_ROUTING_INTEGRATION_TYPE) config_bundle = load_model_config() + capability_slot_release = await self.get_capability_slots() + function_runtime_profiles = await AIFunctionRuntimeProfileService(self.db).get_profiles() + function_observability = await self._function_runtime_observability(hours=24 * 7) + scene_routes = self._build_scene_routes( + routing_bundle=routing_bundle, + config_bundle=config_bundle, + function_runtime_profiles=function_runtime_profiles, + function_observability=function_observability, + ) + scene_failure_rates = await self._scene_failure_rates(hours=24 * 7) + scene_slot_matrix = self._build_scene_slot_matrix( + scene_routes=scene_routes, + capability_slot_release=capability_slot_release, + scene_failure_rates=scene_failure_rates, + ) + slot_observability = await self._build_slot_observability( + capability_slot_release=capability_slot_release, + ) + slot_recommendations = self._build_slot_recommendations( + slots=overview.get("capability_slots", []), + slot_observability=slot_observability, + ) return { "overview": overview, "providers": self._build_providers(engine_bundle=engine_bundle), - "scene_routes": self._build_scene_routes(routing_bundle=routing_bundle, config_bundle=config_bundle), + "capability_slots": overview.get("capability_slots", []), + "capability_slot_release": capability_slot_release, + "function_runtime_profiles": function_runtime_profiles, + "scene_routes": scene_routes, + "scene_slot_matrix": scene_slot_matrix, + "slot_observability": slot_observability, + "slot_recommendations": slot_recommendations, "protected_functions": sorted(PROTECTED_FUNCTIONS), "recent_failures": await self._recent_failures(limit=8), "diagnostics": overview["diagnostics"], - "audit_hint": "策略变更会写入操作日志,底层模型默认值仍由 PMFlow 本地路由层 baseline 管理。", + "audit_hint": "供应商、槽位、路由和团队策略变更都会写入操作日志。", } + async def get_capability_slots(self) -> dict[str, Any]: + bundle = load_model_config() + engine_bundle = await self._load_integration_config(LLM_ENGINE_INTEGRATION_TYPE) + slot_bundle = self._normalize_capability_slot_bundle( + await self._load_integration_config(CAPABILITY_SLOT_INTEGRATION_TYPE) + ) + active_slots = self._build_capability_slots( + engine_bundle=engine_bundle, + slot_bundle=slot_bundle, + config_bundle=bundle, + stage="active", + ) + draft_slots = self._build_capability_slots( + engine_bundle=engine_bundle, + slot_bundle=slot_bundle, + config_bundle=bundle, + stage="draft", + ) + validation = self.validate_capability_slot_bundle( + engine_bundle=engine_bundle, + slot_bundle=slot_bundle, + stage="draft", + ) + return { + "version": str(slot_bundle.get("active_version") or slot_bundle.get("version") or "unpublished").strip(), + "active_version": str(slot_bundle.get("active_version") or "unpublished").strip(), + "draft_version": str(slot_bundle.get("draft_version") or "draft").strip(), + "published_at": _iso((slot_bundle.get("active") or {}).get("published_at") if isinstance(slot_bundle.get("active"), dict) else None), + "published_by": (slot_bundle.get("active") or {}).get("published_by") if isinstance(slot_bundle.get("active"), dict) else None, + "updated_at": _iso((slot_bundle.get("draft") or {}).get("updated_at") if isinstance(slot_bundle.get("draft"), dict) else None), + "updated_by": (slot_bundle.get("draft") or {}).get("updated_by") if isinstance(slot_bundle.get("draft"), dict) else None, + "dirty": self._capability_slot_dirty(slot_bundle), + "validation": validation, + "history": self._capability_slot_history(slot_bundle), + "slots": draft_slots, + "active_slots": active_slots, + "draft_slots": draft_slots, + } + + async def update_capability_slot( + self, + *, + slot_key: str, + payload: dict[str, Any], + updated_by: int, + ) -> dict[str, Any]: + key = str(slot_key or "").strip().lower() + if key not in SLOT_SPECS: + raise ValueError("能力槽位不存在") + if not bool((SLOT_SPECS.get(key) or {}).get("configurable", False)): + raise ValueError("该能力槽当前为候选槽,不接 runtime,不能配置运行时") + if key == "external_query": + return await self._update_external_query_slot(payload=payload, updated_by=updated_by) + engine_bundle = await self._load_integration_config(LLM_ENGINE_INTEGRATION_TYPE) + engines = [item for item in engine_bundle.get("engines", []) if isinstance(item, dict)] + engine_by_id = {str(item.get("id") or ""): item for item in engines if str(item.get("id") or "")} + + primary_engine_id = str(payload.get("primary_engine_id") or "").strip() + if primary_engine_id and primary_engine_id not in engine_by_id: + raise ValueError("主运行时不存在,请先在供应商与凭据中新增连接") + if primary_engine_id and not bool(engine_by_id[primary_engine_id].get("enabled", True)): + raise ValueError("主运行时已停用,不能绑定到能力槽") + + fallback_engine_ids: list[str] = [] + for item in payload.get("fallback_engine_ids") or []: + engine_id = str(item or "").strip() + if not engine_id or engine_id == primary_engine_id or engine_id in fallback_engine_ids: + continue + if engine_id not in engine_by_id: + raise ValueError("备用运行时不存在,请刷新后重试") + if not bool(engine_by_id[engine_id].get("enabled", True)): + raise ValueError("备用运行时已停用,不能绑定到能力槽") + fallback_engine_ids.append(engine_id) + + failure_policy = str(payload.get("failure_policy") or SLOT_SPECS[key]["default_failure_policy"]).strip() + if failure_policy not in FAILURE_POLICY_LABELS: + raise ValueError("失败策略不支持") + allow_scene_downgrade = bool(payload.get("allow_scene_downgrade", False)) + if key == "high" and allow_scene_downgrade: + raise ValueError("深度槽属于保护槽,不允许客户空间策略降级") + + integration, slot_bundle = await self._get_or_init_capability_slot_bundle() + slots = self._slot_config_map(slot_bundle, stage="draft") + slots[key] = { + "primary_engine_id": primary_engine_id or None, + "fallback_engine_ids": fallback_engine_ids, + "failure_policy": failure_policy, + "allow_scene_downgrade": allow_scene_downgrade, + "updated_at": now_cn_naive().isoformat(), + "updated_by": int(updated_by), + } + draft = slot_bundle.get("draft") if isinstance(slot_bundle.get("draft"), dict) else {} + draft["slots"] = slots + draft["updated_at"] = now_cn_naive().isoformat() + draft["updated_by"] = int(updated_by) + slot_bundle["draft"] = draft + slot_bundle["draft_version"] = "draft" + integration.config = json.loads(json.dumps(slot_bundle, ensure_ascii=False)) + integration.is_active = True + await self.db.flush() + return await self.get_capability_slots() + + async def _update_external_query_slot(self, *, payload: dict[str, Any], updated_by: int) -> dict[str, Any]: + registry = self._external_query_registry_status(binding=payload) + provider_ids = {str(item.get("id") or "") for item in registry.get("providers", []) if str(item.get("id") or "")} + primary_provider_id = str(payload.get("primary_provider_id") or payload.get("primary_engine_id") or "").strip() + if primary_provider_id and primary_provider_id not in provider_ids: + raise ValueError("外部查询主 provider 不存在,请刷新 provider registry 后重试") + fallback_provider_ids: list[str] = [] + raw_fallback_provider_ids = payload.get("fallback_provider_ids") + if not isinstance(raw_fallback_provider_ids, list): + raw_fallback_provider_ids = payload.get("fallback_engine_ids") + for item in raw_fallback_provider_ids or []: + provider_id = str(item or "").strip() + if not provider_id or provider_id == primary_provider_id or provider_id in fallback_provider_ids: + continue + if provider_id not in provider_ids: + raise ValueError("外部查询备用 provider 不存在,请刷新 provider registry 后重试") + fallback_provider_ids.append(provider_id) + failure_policy = str(payload.get("failure_policy") or "explicit_fail").strip() + if failure_policy != "explicit_fail": + raise ValueError("外部查询槽失败策略必须为显式失败,禁止回退到普通 LLM 编造答案") + integration, slot_bundle = await self._get_or_init_capability_slot_bundle() + slots = self._slot_config_map(slot_bundle, stage="draft") + slots["external_query"] = { + "primary_provider_id": primary_provider_id or None, + "fallback_provider_ids": fallback_provider_ids, + "verifier_required": True, + "source_filtering_required": True, + "failure_policy": "explicit_fail", + "allow_scene_downgrade": False, + "updated_at": now_cn_naive().isoformat(), + "updated_by": int(updated_by), + } + draft = slot_bundle.get("draft") if isinstance(slot_bundle.get("draft"), dict) else {} + draft["slots"] = slots + draft["updated_at"] = now_cn_naive().isoformat() + draft["updated_by"] = int(updated_by) + slot_bundle["draft"] = draft + slot_bundle["draft_version"] = "draft" + integration.config = json.loads(json.dumps(slot_bundle, ensure_ascii=False)) + integration.is_active = True + await self.db.flush() + return await self.get_capability_slots() + + async def validate_capability_slots(self) -> dict[str, Any]: + engine_bundle = await self._load_integration_config(LLM_ENGINE_INTEGRATION_TYPE) + slot_bundle = self._normalize_capability_slot_bundle( + await self._load_integration_config(CAPABILITY_SLOT_INTEGRATION_TYPE) + ) + return self.validate_capability_slot_bundle( + engine_bundle=engine_bundle, + slot_bundle=slot_bundle, + stage="draft", + ) + + async def publish_capability_slots(self, *, published_by: int, summary: str | None = None) -> dict[str, Any]: + engine_bundle = await self._load_integration_config(LLM_ENGINE_INTEGRATION_TYPE) + integration, slot_bundle = await self._get_or_init_capability_slot_bundle() + validation = self.validate_capability_slot_bundle( + engine_bundle=engine_bundle, + slot_bundle=slot_bundle, + stage="draft", + ) + blocking = [item for item in validation.get("items", []) if item.get("severity") == "critical"] + if blocking: + raise ValueError("草稿存在阻塞项,不能发布") + version = _release_version() + now = now_cn_naive().isoformat() + draft_slots = self._slot_config_map(slot_bundle, stage="draft") + slot_bundle["active"] = { + "slots": _json_clone(draft_slots), + "published_at": now, + "published_by": int(published_by), + } + slot_bundle["active_version"] = version + slot_bundle["version"] = version + history = self._capability_slot_history(slot_bundle) + history.insert( + 0, + { + "version": version, + "published_at": now, + "published_by": int(published_by), + "summary": str(summary or "发布能力槽位草稿").strip()[:200], + "slots": _json_clone(draft_slots), + }, + ) + slot_bundle["history"] = history[:10] + integration.config = json.loads(json.dumps(slot_bundle, ensure_ascii=False)) + integration.is_active = True + await self.db.flush() + return await self.get_capability_slots() + + async def rollback_capability_slots(self, *, target_version: str | None, rolled_back_by: int) -> dict[str, Any]: + integration, slot_bundle = await self._get_or_init_capability_slot_bundle() + history = self._capability_slot_history(slot_bundle) + current_version = str(slot_bundle.get("active_version") or "").strip() + candidates = [item for item in history if str(item.get("version") or "").strip() != current_version] + if target_version: + target = next((item for item in history if str(item.get("version") or "").strip() == str(target_version).strip()), None) + else: + target = candidates[0] if candidates else None + if not isinstance(target, dict) or not isinstance(target.get("slots"), dict): + raise ValueError("没有可回滚的已发布版本") + version = _release_version() + now = now_cn_naive().isoformat() + restored_slots = _json_clone(target["slots"]) + slot_bundle["active"] = { + "slots": restored_slots, + "published_at": now, + "published_by": int(rolled_back_by), + "rolled_back_from": str(target.get("version") or ""), + } + slot_bundle["draft"] = { + "slots": _json_clone(restored_slots), + "updated_at": now, + "updated_by": int(rolled_back_by), + } + slot_bundle["active_version"] = version + slot_bundle["draft_version"] = "draft" + slot_bundle["version"] = version + history.insert( + 0, + { + "version": version, + "published_at": now, + "published_by": int(rolled_back_by), + "summary": f"回滚到 {target.get('version') or '上一版本'}", + "slots": _json_clone(restored_slots), + }, + ) + slot_bundle["history"] = history[:10] + integration.config = json.loads(json.dumps(slot_bundle, ensure_ascii=False)) + integration.is_active = True + await self.db.flush() + return await self.get_capability_slots() + async def run_diagnostics(self) -> dict[str, Any]: overview = await self.get_overview() return { @@ -187,11 +1205,16 @@ class AIRoutingGovernanceService: return normalized async def _load_integration_config(self, integration_type: str) -> dict[str, Any]: + tenant_id = ( + PLATFORM_RUNTIME_TENANT_ID + if integration_type in {LLM_ENGINE_INTEGRATION_TYPE, MODEL_ROUTING_INTEGRATION_TYPE, CAPABILITY_SLOT_INTEGRATION_TYPE} + else self.tenant_id + ) row = ( await self.db.execute( select(Integration).where( and_( - Integration.tenant_id == self.tenant_id, + Integration.tenant_id == tenant_id, Integration.type == integration_type, ) ) @@ -221,6 +1244,289 @@ class AIRoutingGovernanceService: await self.db.flush() return row + async def _get_or_init_capability_slot_bundle(self) -> tuple[Integration, dict[str, Any]]: + row = ( + await self.db.execute( + select(Integration).where( + and_( + Integration.tenant_id == PLATFORM_RUNTIME_TENANT_ID, + Integration.type == CAPABILITY_SLOT_INTEGRATION_TYPE, + ) + ) + ) + ).scalar_one_or_none() + if row is None: + row = Integration( + tenant_id=PLATFORM_RUNTIME_TENANT_ID, + type=CAPABILITY_SLOT_INTEGRATION_TYPE, + config=self._normalize_capability_slot_bundle({}), + is_active=True, + ) + self.db.add(row) + await self.db.flush() + bundle = self._normalize_capability_slot_bundle(_safe_dict(row.config)) + return row, bundle + + @staticmethod + def _normalize_capability_slot_bundle(raw: dict[str, Any]) -> dict[str, Any]: + bundle = _safe_dict(raw) + legacy_slots = bundle.get("slots") if isinstance(bundle.get("slots"), dict) else {} + active = bundle.get("active") if isinstance(bundle.get("active"), dict) else {} + draft = bundle.get("draft") if isinstance(bundle.get("draft"), dict) else {} + active_slots = active.get("slots") if isinstance(active.get("slots"), dict) else legacy_slots + draft_slots = draft.get("slots") if isinstance(draft.get("slots"), dict) else active_slots + active_version = str(bundle.get("active_version") or bundle.get("version") or "").strip() + if not active_version: + active_version = "unpublished" + normalized = { + "version": active_version, + "active_version": active_version, + "draft_version": str(bundle.get("draft_version") or "draft").strip() or "draft", + "active": { + **{key: value for key, value in active.items() if key != "slots"}, + "slots": _json_clone(active_slots), + }, + "draft": { + **{key: value for key, value in draft.items() if key != "slots"}, + "slots": _json_clone(draft_slots), + }, + "history": bundle.get("history") if isinstance(bundle.get("history"), list) else [], + } + if "published_at" not in normalized["active"] and bundle.get("updated_at"): + normalized["active"]["published_at"] = bundle.get("updated_at") + if "updated_at" not in normalized["draft"] and bundle.get("updated_at"): + normalized["draft"]["updated_at"] = bundle.get("updated_at") + return normalized + + @staticmethod + def _slot_config_map(slot_bundle: dict[str, Any], *, stage: str) -> dict[str, Any]: + normalized_stage = "draft" if stage == "draft" else "active" + stage_bundle = slot_bundle.get(normalized_stage) if isinstance(slot_bundle.get(normalized_stage), dict) else {} + slots = stage_bundle.get("slots") if isinstance(stage_bundle.get("slots"), dict) else {} + return _json_clone(slots) if isinstance(slots, dict) else {} + + @staticmethod + def _capability_slot_dirty(slot_bundle: dict[str, Any]) -> bool: + return _json_clone(AIRoutingGovernanceService._slot_config_map(slot_bundle, stage="active")) != _json_clone( + AIRoutingGovernanceService._slot_config_map(slot_bundle, stage="draft") + ) + + @staticmethod + def _capability_slot_history(slot_bundle: dict[str, Any]) -> list[dict[str, Any]]: + history = slot_bundle.get("history") if isinstance(slot_bundle.get("history"), list) else [] + return [item for item in history if isinstance(item, dict)] + + def validate_capability_slot_bundle( + self, + *, + engine_bundle: dict[str, Any], + slot_bundle: dict[str, Any], + stage: str, + ) -> dict[str, Any]: + engines = [item for item in engine_bundle.get("engines", []) if isinstance(item, dict)] + engine_by_id = {str(item.get("id") or ""): item for item in engines if str(item.get("id") or "")} + slots = self._slot_config_map(slot_bundle, stage=stage) + items: list[dict[str, Any]] = [] + enabled_reserved_slots = [ + key + for key in SLOT_SPECS + if key not in {"low", "high"} + and isinstance(slots.get(key), dict) + and ( + str((slots.get(key) or {}).get("primary_engine_id") or "").strip() + or str((slots.get(key) or {}).get("primary_provider_id") or "").strip() + or bool((slots.get(key) or {}).get("fallback_engine_ids")) + or bool((slots.get(key) or {}).get("fallback_provider_ids")) + ) + ] + for slot_key in ["low", "high", *enabled_reserved_slots]: + saved = slots.get(slot_key) if isinstance(slots.get(slot_key), dict) else {} + primary_engine_id = str(saved.get("primary_engine_id") or "").strip() + slot_label = str((SLOT_SPECS.get(slot_key) or {}).get("label") or SLOT_LABELS.get(slot_key, slot_key)) + if slot_key == "external_query": + items.extend(self._external_query_slot_risks(saved=saved, slot_label=slot_label)) + continue + if not primary_engine_id: + items.append(self._risk(f"{slot_key}_primary_missing", "critical", f"{slot_label}未选择主运行时", "必须先从供应商与凭据中选择已启用运行时。")) + continue + primary_engine = engine_by_id.get(primary_engine_id) + if not primary_engine: + items.append(self._risk(f"{slot_key}_primary_missing_provider", "critical", f"{slot_label}主运行时不存在", "请刷新供应商列表或重新选择运行时。")) + continue + if not bool(primary_engine.get("enabled", True)): + items.append(self._risk(f"{slot_key}_primary_disabled", "critical", f"{slot_label}主运行时已停用", "发布前必须启用运行时或重新选择。")) + status = str(primary_engine.get("connection_status") or "untested").strip() + if status == "test_failed": + items.append(self._risk(f"{slot_key}_primary_test_failed", "critical", f"{slot_label}主运行时测试失败", "发布前必须先修复供应商连接。")) + elif status != "available": + items.append(self._risk(f"{slot_key}_primary_untested", "warning", f"{slot_label}主运行时未完成连通测试", "建议先在供应商与凭据中测试通过再发布。")) + provider = str(primary_engine.get("provider") or "").strip().lower() + if slot_key == "high" and provider == "ollama": + items.append(self._risk("high_slot_ollama_blocked", "critical", "深度槽不能绑定本地 Ollama", "深度槽承载结构化、复杂推理和保护场景,不能使用仅知识库问答运行时。")) + items.extend( + self._engine_requirement_risks( + slot_key=slot_key, + slot_label=slot_label, + engine=primary_engine, + role_label="主运行时", + ) + ) + if slot_key == "low": + primary_coverage = self._engine_capability_coverage(slot_key=slot_key, engine=primary_engine) + primary_probe = (primary_coverage.get("capabilities") or {}).get("capability_probe") + primary_probe_status = str((primary_probe or {}).get("status") or "untested").strip().lower() if isinstance(primary_probe, dict) else "untested" + primary_probe_source = str((primary_probe or {}).get("source") or "inferred").strip().lower() if isinstance(primary_probe, dict) else "inferred" + if primary_probe_status == "untested" or primary_probe_source != "probed": + items.append( + self._risk( + "low_primary_capability_evidence_untested", + "warning", + "快速槽主运行时缺少 capability probe", + "快速槽可先发布,但当前能力仅来自默认推断或人工标注,建议补一次供应商能力探测。", + ) + ) + fallback_ids = saved.get("fallback_engine_ids") if isinstance(saved.get("fallback_engine_ids"), list) else [] + for fallback_id_raw in fallback_ids: + fallback_id = str(fallback_id_raw or "").strip() + if not fallback_id: + continue + if fallback_id == primary_engine_id: + items.append(self._risk(f"{slot_key}_fallback_duplicate_primary", "warning", f"{slot_label}备用链路重复主运行时", "重复运行时不会提供真实容灾能力。")) + continue + fallback_engine = engine_by_id.get(fallback_id) + if not fallback_engine: + items.append(self._risk(f"{slot_key}_fallback_missing", "critical", f"{slot_label}备用运行时不存在", "请移除失效备用项。")) + continue + if not bool(fallback_engine.get("enabled", True)): + items.append(self._risk(f"{slot_key}_fallback_disabled", "critical", f"{slot_label}备用运行时已停用", "请启用备用运行时或移除该备用项。")) + items.extend( + self._engine_requirement_risks( + slot_key=slot_key, + slot_label=slot_label, + engine=fallback_engine, + role_label="备用运行时", + ) + ) + if self._same_engine_identity(primary_engine, fallback_engine): + items.append(self._risk(f"{slot_key}_fallback_same_runtime", "warning", f"{slot_label}备用链路与主运行时相同", "相同 provider/model/base_url 只能规避少量连接状态问题,不能提升模型能力。")) + if slot_key == "high" and bool(saved.get("allow_scene_downgrade", False)): + items.append(self._risk("high_downgrade_blocked", "critical", "深度槽不允许被场景策略降级", "保护场景必须显式失败或走高阶修复,不能静默降级。")) + if not items: + items.append(self._risk("ok", "ok", "草稿可发布", "当前草稿未触发阻塞项。")) + return { + "stage": stage, + "can_publish": not any(item.get("severity") == "critical" for item in items), + "summary": self._diagnostic_summary(items), + "items": items, + } + + def _external_query_slot_risks(self, *, saved: dict[str, Any], slot_label: str) -> list[dict[str, Any]]: + registry = self._external_query_registry_status(binding=saved) + providers = [item for item in registry.get("providers", []) if isinstance(item, dict)] + provider_by_id = {str(item.get("id") or ""): item for item in providers if str(item.get("id") or "")} + primary_provider_id = str(saved.get("primary_provider_id") or saved.get("primary_engine_id") or "").strip() + fallback_provider_ids = [ + str(item or "").strip() + for item in list(saved.get("fallback_provider_ids") or saved.get("fallback_engine_ids") or []) + if str(item or "").strip() + ] + risks: list[dict[str, Any]] = [] + if not primary_provider_id: + risks.append(self._risk("external_query_primary_provider_missing", "critical", f"{slot_label}未选择主外部 provider", "必须先绑定 MCP adapter 或 bounded live search fallback。")) + return risks + primary = provider_by_id.get(primary_provider_id) + if not primary: + risks.append(self._risk("external_query_primary_provider_unknown", "critical", f"{slot_label}主 provider 不存在", "请刷新 provider registry 后重新选择外部查询 provider。")) + elif not bool(primary.get("available")): + risks.append(self._risk("external_query_primary_provider_unavailable", "critical", f"{slot_label}主 provider 不可用", "发布前必须恢复 MCP adapter 或 bounded live search fallback。")) + for provider_id in fallback_provider_ids: + if provider_id == primary_provider_id: + risks.append(self._risk("external_query_fallback_duplicate_primary", "warning", f"{slot_label}备用 provider 重复主 provider", "重复 provider 不会提供真实外部查询容灾。")) + continue + provider = provider_by_id.get(provider_id) + if not provider: + risks.append(self._risk("external_query_fallback_provider_unknown", "critical", f"{slot_label}备用 provider 不存在", "请移除失效备用 provider。")) + elif not bool(provider.get("available")): + risks.append(self._risk("external_query_fallback_provider_unavailable", "critical", f"{slot_label}备用 provider 不可用", "请恢复该 provider 或从 fallback 链移除。")) + selected_ids = [primary_provider_id, *fallback_provider_ids] + selected_available = [ + provider_by_id[item] + for item in selected_ids + if item in provider_by_id and bool(provider_by_id[item].get("available")) + ] + if not selected_available: + risks.append(self._risk("external_query_provider_unavailable", "critical", f"{slot_label}没有可用外部 provider", "至少需要一个可用的 MCP adapter 或 bounded live search fallback。")) + if not any(bool(item.get("available")) for item in providers): + risks.append(self._risk("external_query_registry_empty", "critical", "外部 provider registry 不可用", "发布前必须至少启用一个受控外部查询 provider。")) + if not (bool(registry.get("mcp_adapter_available")) or bool(registry.get("live_search_fallback_available"))): + risks.append(self._risk("external_query_adapter_missing", "critical", "外部查询执行链不可用", "必须启用 MCP adapter 或 bounded live search fallback 中至少一个执行链。")) + verifier = registry.get("verifier") if isinstance(registry.get("verifier"), dict) else {} + if not bool(saved.get("verifier_required", True)): + risks.append(self._risk("external_query_verifier_not_required", "critical", "外部查询未强制 verifier", "外部查询槽必须开启 verifier_required,禁止未验证来源直接出答案。")) + if not bool(verifier.get("available")): + risks.append(self._risk("external_query_verifier_unavailable", "critical", "外部查询 verifier 不可用", "发布前必须恢复答案验证器。")) + source_filtering = registry.get("source_filtering") if isinstance(registry.get("source_filtering"), dict) else {} + if not bool(saved.get("source_filtering_required", True)): + risks.append(self._risk("external_query_source_filtering_not_required", "critical", "外部查询未强制来源过滤", "外部查询槽必须开启 source_filtering_required,禁止内部来源混入外部答案。")) + if not bool(source_filtering.get("fail_closed")): + risks.append(self._risk("external_query_source_filtering_open", "critical", "外部来源过滤未 fail-closed", "来源过滤必须 fail-closed,无法确认公开来源时只能显式失败或 freshness guard。")) + if str(saved.get("failure_policy") or "explicit_fail").strip() != "explicit_fail": + risks.append(self._risk("external_query_failure_policy_invalid", "critical", "外部查询失败策略不合规", "外部查询失败必须显式失败或 freshness guard,禁止回退普通 LLM。")) + return risks + + def _engine_requirement_risks( + self, + *, + slot_key: str, + slot_label: str, + engine: dict[str, Any], + role_label: str, + ) -> list[dict[str, Any]]: + coverage = self._engine_capability_coverage(slot_key=slot_key, engine=engine) + if bool(coverage.get("satisfied", True)): + return [] + risks: list[dict[str, Any]] = [] + missing = "、".join(str(item) for item in coverage.get("missing_labels", []) if str(item or "").strip()) + missing_evidence = "、".join(str(item) for item in coverage.get("missing_evidence_labels", []) if str(item or "").strip()) + stale_evidence = "、".join(str(item) for item in coverage.get("stale_evidence_labels", []) if str(item or "").strip()) + if missing: + risks.append( + self._risk( + f"{slot_key}_{role_label}_capability_missing", + "critical", + f"{slot_label}{role_label}缺少能力", + f"缺少:{missing};请在供应商与凭据中选择符合要求的运行时,或先修正供应商能力配置。", + ) + ) + if missing_evidence: + risks.append( + self._risk( + f"{slot_key}_{role_label}_capability_evidence_missing", + "critical", + f"{slot_label}{role_label}缺少能力探测证据", + f"缺少探测证据:{missing_evidence};请先对供应商执行 capability probe,再发布能力槽位。", + ) + ) + if stale_evidence: + risks.append( + self._risk( + f"{slot_key}_{role_label}_capability_evidence_stale", + "critical", + f"{slot_label}{role_label}能力探测证据已过期", + f"证据过期:{stale_evidence};请重新执行 capability probe 后再发布能力槽位。", + ) + ) + if risks: + return risks + return [ + self._risk( + f"{slot_key}_{role_label}_capability_missing", + "critical", + f"{slot_label}{role_label}不满足能力要求", + "能力矩阵未满足;请在供应商与凭据中补齐能力标签,或选择符合要求的运行时。", + ) + ] + def _merge_team_policy(self, saved: dict[str, Any]) -> dict[str, Any]: strategy = str(saved.get("strategy") or "balanced").strip() legacy_strategy_map = {"quality": "quality_first", "speed": "speed_first"} @@ -290,42 +1596,470 @@ class AIRoutingGovernanceService: return "快速起草,失败升级" def _build_slots(self, *, engine_bundle: dict[str, Any], config_bundle: dict[str, Any]) -> list[dict[str, Any]]: + return [ + slot + for slot in self._build_capability_slots( + engine_bundle=engine_bundle, + slot_bundle={}, + config_bundle=config_bundle, + stage="active", + ) + if slot.get("slot_key") in {"low", "high"} + ] + + def _build_capability_slots( + self, + *, + engine_bundle: dict[str, Any], + slot_bundle: dict[str, Any], + config_bundle: dict[str, Any], + stage: str = "active", + ) -> list[dict[str, Any]]: engines = [item for item in engine_bundle.get("engines", []) if isinstance(item, dict)] + engine_by_id = {str(item.get("id") or ""): item for item in engines if str(item.get("id") or "")} + normalized_bundle = self._normalize_capability_slot_bundle(slot_bundle) + saved_slots = self._slot_config_map(normalized_bundle, stage=stage) tier_defaults = config_bundle.get("tier_defaults") if isinstance(config_bundle.get("tier_defaults"), dict) else get_tier_defaults() slots: list[dict[str, Any]] = [] - for tier in ("low", "high"): - defaults = tier_defaults.get(tier) if isinstance(tier_defaults.get(tier), dict) else {} - provider = str(defaults.get("provider") or "").strip().lower() - model = str(defaults.get("model") or "").strip() - base_url = str(defaults.get("base_url") or "").strip() - matching_engine = next( - (item for item in engines if str(item.get("provider") or "").strip().lower() == provider), - None, - ) - env_configured = bool(os.getenv(str(defaults.get("api_env") or "").strip(), "").strip()) if defaults.get("api_env") else False - source = "租户配置" if matching_engine else "环境变量" if env_configured else "未配置" - api_key_masked = _mask_secret(str((matching_engine or {}).get("api_key") or "")) if matching_engine else "" + for slot_key, spec in SLOT_SPECS.items(): + saved = saved_slots.get(slot_key) if isinstance(saved_slots.get(slot_key), dict) else {} + if slot_key == "external_query": + slots.append( + self._build_external_query_slot_summary( + saved=saved, + spec=spec, + stage=stage, + ) + ) + continue + if slot_key in {"low", "high"}: + defaults = tier_defaults.get(slot_key) if isinstance(tier_defaults.get(slot_key), dict) else {} + inferred_engine = self._infer_engine_for_tier(engines=engines, defaults=defaults) + else: + defaults = {} + inferred_engine = None + primary_engine_id = str(saved.get("primary_engine_id") or "").strip() + primary_engine = engine_by_id.get(primary_engine_id) if primary_engine_id else inferred_engine + fallback_engine_ids = [ + str(item or "").strip() + for item in saved.get("fallback_engine_ids", []) + if str(item or "").strip() in engine_by_id + ] if isinstance(saved.get("fallback_engine_ids"), list) else [] + fallback_engines = [self._slot_engine_summary(engine_by_id[item]) for item in fallback_engine_ids] + provider = str((primary_engine or {}).get("provider") or defaults.get("provider") or "").strip().lower() + model = str((primary_engine or {}).get("model") or defaults.get("model") or "").strip() + base_url = str((primary_engine or {}).get("base_url") or defaults.get("base_url") or "").strip() + configured = bool(primary_engine and (bool(primary_engine.get("enabled", True)) or provider == "ollama")) + source = "槽位绑定" if primary_engine_id else ("供应商配置" if primary_engine else "未配置") + api_key_masked = _mask_secret(str((primary_engine or {}).get("api_key") or "")) if primary_engine else "" + failure_policy = str(saved.get("failure_policy") or spec["default_failure_policy"]).strip() + if failure_policy not in FAILURE_POLICY_LABELS: + failure_policy = str(spec["default_failure_policy"]) + candidate_engines: list[dict[str, Any]] = [] + blocked_candidate_engines: list[dict[str, Any]] = [] + for item in engines: + if not bool(item.get("enabled", True)): + continue + summary = self._slot_engine_summary(item) + coverage = self._engine_capability_coverage(slot_key=slot_key, engine=item) + if not summary: + continue + if bool(coverage.get("satisfied", True)): + candidate_engines.append(summary) + continue + blocked_candidate_engines.append( + { + **summary, + "gap_reasons": self._coverage_gap_reasons(coverage), + } + ) slots.append( { - "tier": tier, - "label": SLOT_LABELS[tier], + "slot_key": slot_key, + "tier": slot_key, + "label": str(spec["label"]), + "summary": str(spec["summary"]), + "configurable": bool(spec["configurable"]), + "protected": bool(spec["protected"]), "provider": provider, "provider_label": PROVIDER_LABELS.get(provider, provider or "未配置"), "model": model, "base_url": base_url, - "configured": bool(matching_engine or env_configured or provider == "ollama"), + "configured": configured, "config_source": source, "api_key_masked": api_key_masked, + "primary_engine_id": str((primary_engine or {}).get("id") or "") or None, + "primary_engine": self._slot_engine_summary(primary_engine), + "fallback_engine_ids": fallback_engine_ids, + "fallback_engines": fallback_engines, + "requirements": self._slot_requirement_summary(slot_key), + "capability_coverage": self._engine_capability_coverage(slot_key=slot_key, engine=primary_engine), + "candidate_engine_count": len([item for item in candidate_engines if item]), + "candidate_engines": [item for item in candidate_engines if item][:5], + "blocked_candidate_engine_count": len([item for item in blocked_candidate_engines if item]), + "blocked_candidate_engines": [item for item in blocked_candidate_engines if item][:5], + "failure_policy": failure_policy, + "failure_policy_label": FAILURE_POLICY_LABELS[failure_policy], + "allow_scene_downgrade": bool(saved.get("allow_scene_downgrade", False)), + "updated_at": _iso(saved.get("updated_at")), + "updated_by": saved.get("updated_by"), } ) return slots + def _build_external_query_slot_summary( + self, + *, + saved: dict[str, Any], + spec: dict[str, Any], + stage: str, + ) -> dict[str, Any]: + registry = self._external_query_registry_status(binding=saved) + provider_by_id = {str(item.get("id") or ""): item for item in registry.get("providers", []) if isinstance(item, dict) and str(item.get("id") or "")} + primary_provider_id = str(saved.get("primary_provider_id") or saved.get("primary_engine_id") or "").strip() + fallback_provider_ids = [ + str(item or "").strip() + for item in list(saved.get("fallback_provider_ids") or saved.get("fallback_engine_ids") or []) + if str(item or "").strip() in provider_by_id and str(item or "").strip() != primary_provider_id + ] + primary_provider = provider_by_id.get(primary_provider_id) if primary_provider_id else None + fallback_providers = [provider_by_id[item] for item in fallback_provider_ids if item in provider_by_id] + configured = bool(primary_provider_id and primary_provider and bool(primary_provider.get("available"))) + coverage = self._external_query_capability_coverage(saved=saved, registry=registry) + failure_policy = str(saved.get("failure_policy") or spec["default_failure_policy"]).strip() + if failure_policy != "explicit_fail": + failure_policy = "explicit_fail" + return { + "slot_key": "external_query", + "tier": "external_query", + "label": str(spec["label"]), + "summary": str(spec["summary"]), + "configurable": bool(spec["configurable"]), + "protected": bool(spec["protected"]), + "provider": "external_registry", + "provider_label": "External Provider Registry", + "model": str((primary_provider or {}).get("label") or ""), + "base_url": "external_query_service", + "configured": configured, + "config_source": "槽位绑定" if primary_provider_id else "未配置", + "api_key_masked": "", + "primary_engine_id": None, + "primary_engine": None, + "fallback_engine_ids": [], + "fallback_engines": [], + "primary_provider_id": primary_provider_id or None, + "primary_provider": primary_provider, + "fallback_provider_ids": fallback_provider_ids, + "fallback_providers": fallback_providers, + "verifier_required": True, + "source_filtering_required": True, + "external_provider_registry": registry, + "requirements": self._slot_requirement_summary("external_query"), + "capability_coverage": coverage, + "candidate_engine_count": int(registry.get("available_provider_count") or 0), + "candidate_engines": [], + "blocked_candidate_engine_count": len([item for item in registry.get("providers", []) if isinstance(item, dict) and not bool(item.get("available"))]), + "blocked_candidate_engines": [], + "candidate_external_providers": [item for item in registry.get("providers", []) if isinstance(item, dict) and bool(item.get("available"))], + "blocked_external_providers": [item for item in registry.get("providers", []) if isinstance(item, dict) and not bool(item.get("available"))], + "failure_policy": failure_policy, + "failure_policy_label": FAILURE_POLICY_LABELS[failure_policy], + "allow_scene_downgrade": False, + "updated_at": _iso(saved.get("updated_at")), + "updated_by": saved.get("updated_by"), + "stage": stage, + } + + @staticmethod + def _external_query_registry_status(*, binding: dict[str, Any] | None = None) -> dict[str, Any]: + return ExternalProviderRegistry.status_snapshot(binding=binding) + + def _external_query_capability_coverage(self, *, saved: dict[str, Any], registry: dict[str, Any]) -> dict[str, Any]: + missing: list[str] = [] + missing_labels: list[str] = [] + primary_provider_id = str(saved.get("primary_provider_id") or saved.get("primary_engine_id") or "").strip() + providers = [item for item in registry.get("providers", []) if isinstance(item, dict)] + provider_by_id = {str(item.get("id") or ""): item for item in providers if str(item.get("id") or "")} + if not primary_provider_id: + missing.append("primary_provider") + missing_labels.append("未选择主外部 provider") + elif primary_provider_id not in provider_by_id: + missing.append("primary_provider_unknown") + missing_labels.append("主外部 provider 不存在") + elif not bool(provider_by_id[primary_provider_id].get("available")): + missing.append("primary_provider_unavailable") + missing_labels.append("主外部 provider 不可用") + if not (bool(registry.get("mcp_adapter_available")) or bool(registry.get("live_search_fallback_available"))): + missing.append("external_adapter") + missing_labels.append("MCP adapter / bounded live search fallback 不可用") + verifier = registry.get("verifier") if isinstance(registry.get("verifier"), dict) else {} + if not bool(verifier.get("available")): + missing.append("verifier") + missing_labels.append("外部答案 verifier 不可用") + source_filtering = registry.get("source_filtering") if isinstance(registry.get("source_filtering"), dict) else {} + if not bool(source_filtering.get("fail_closed")): + missing.append("source_filtering") + missing_labels.append("来源过滤未 fail-closed") + if str(saved.get("failure_policy") or "explicit_fail").strip() != "explicit_fail": + missing.append("failure_policy") + missing_labels.append("失败策略必须显式失败") + return { + "satisfied": len(missing) == 0, + "missing": missing, + "missing_labels": missing_labels, + "missing_evidence": [], + "missing_evidence_labels": [], + "stale_evidence": [], + "stale_evidence_labels": [], + "capabilities": { + "external_search_ready": len(missing) == 0, + "capability_source": "registry", + "capability_probe_status": "not_required", + "capability_last_verified_at": None, + "capability_blocking_reasons": missing_labels, + }, + } + + @staticmethod + def _infer_engine_for_tier(*, engines: list[dict[str, Any]], defaults: dict[str, Any]) -> dict[str, Any] | None: + provider = str(defaults.get("provider") or "").strip().lower() + model = str(defaults.get("model") or "").strip().lower() + for item in engines: + if not bool(item.get("enabled", True)): + continue + if str(item.get("provider") or "").strip().lower() != provider: + continue + item_model = str(item.get("model") or "").strip().lower() + if model and item_model and item_model != model: + continue + return item + for item in engines: + if bool(item.get("enabled", True)) and str(item.get("provider") or "").strip().lower() == provider: + return item + return None + + @staticmethod + def _slot_engine_summary(engine: dict[str, Any] | None) -> dict[str, Any] | None: + if not isinstance(engine, dict): + return None + provider = str(engine.get("provider") or "").strip().lower() + connection_status = str(engine.get("connection_status") or "untested").strip() or "untested" + capabilities = _normalize_engine_capabilities( + engine.get("capabilities"), + provider=provider, + model=str(engine.get("model") or ""), + ) + return { + "id": str(engine.get("id") or ""), + "name": str(engine.get("name") or PROVIDER_LABELS.get(provider, provider)).strip(), + "provider": provider, + "provider_label": PROVIDER_LABELS.get(provider, provider), + "model": str(engine.get("model") or ""), + "base_url": str(engine.get("base_url") or ""), + "enabled": bool(engine.get("enabled", True)), + "connection_status": connection_status, + "api_key_masked": _mask_secret(str(engine.get("api_key") or "")), + "capabilities": capabilities, + } + + @staticmethod + def _slot_requirement_summary(slot_key: str) -> dict[str, Any]: + requirement = SLOT_REQUIREMENTS.get(slot_key, {}) + required_flags = [str(item) for item in requirement.get("required_flags", []) if str(item or "").strip()] + forbidden_flags = [str(item) for item in requirement.get("forbidden_flags", []) if str(item or "").strip()] + any_flags = [str(item) for item in requirement.get("any_flags", []) if str(item or "").strip()] + min_context = requirement.get("min_context_tokens") + labels = [CAPABILITY_LABELS.get(item, item) for item in required_flags] + if any_flags: + labels.append(" / ".join(CAPABILITY_LABELS.get(item, item) for item in any_flags)) + if min_context: + labels.append(f"上下文 >= {int(min_context):,} tokens") + if forbidden_flags: + labels.extend(f"禁止 {CAPABILITY_LABELS.get(item, item)}" for item in forbidden_flags) + return { + "summary": str(requirement.get("summary") or ""), + "required_flags": required_flags, + "forbidden_flags": forbidden_flags, + "any_flags": any_flags, + "min_context_tokens": min_context, + "labels": labels, + } + + @classmethod + def _engine_capability_coverage(cls, *, slot_key: str, engine: dict[str, Any] | None) -> dict[str, Any]: + requirement = SLOT_REQUIREMENTS.get(slot_key, {}) + if not isinstance(engine, dict): + return { + "satisfied": False, + "missing": ["runtime"], + "missing_labels": ["未选择运行时"], + "missing_evidence": [], + "missing_evidence_labels": [], + "stale_evidence": [], + "stale_evidence_labels": [], + "capabilities": _normalize_engine_capabilities({}, provider="", model=""), + } + provider = str(engine.get("provider") or "").strip().lower() + capabilities = _normalize_engine_capabilities( + engine.get("capabilities"), + provider=provider, + model=str(engine.get("model") or ""), + ) + missing: list[str] = [] + missing_labels: list[str] = [] + missing_evidence: list[str] = [] + missing_evidence_labels: list[str] = [] + stale_evidence: list[str] = [] + stale_evidence_labels: list[str] = [] + + def _has_verified_evidence(key: str) -> bool: + effective_key = "long_context_32k" if key == "long_context" else key + effective = capabilities.get("effective_capabilities") if isinstance(capabilities.get("effective_capabilities"), dict) else {} + if bool(effective.get(effective_key) or effective.get(key)): + return True + probe = capabilities.get("capability_probe") + if isinstance(probe, dict): + probe_source = str(probe.get("source") or "").strip().lower() + probe_evidence = probe.get("evidence") if isinstance(probe.get("evidence"), dict) else {} + probe_row = probe_evidence.get(effective_key) or probe_evidence.get(key) + if isinstance(probe_row, dict) and probe_source == "probed" and _probe_row_is_verified(probe_row, key=effective_key): + return True + evidence = capabilities.get("capability_evidence") + if not isinstance(evidence, dict): + return False + row = evidence.get(effective_key) or evidence.get(key) + if not isinstance(row, dict): + return False + return _probe_row_is_verified(row, key=effective_key) + + def _evidence_row(key: str) -> dict[str, Any] | None: + probe = capabilities.get("capability_probe") + evidence_key = "long_context_32k" if key == "long_context" else key + if isinstance(probe, dict): + probe_evidence = probe.get("evidence") if isinstance(probe.get("evidence"), dict) else {} + probe_row = probe_evidence.get(evidence_key) or probe_evidence.get(key) + if isinstance(probe_row, dict): + return probe_row + evidence = capabilities.get("capability_evidence") + if isinstance(evidence, dict): + row = evidence.get(evidence_key) or evidence.get(key) + if isinstance(row, dict): + return row + return None + + def _evidence_checked_at(key: str) -> str | None: + row = _evidence_row(key) + if not isinstance(row, dict): + return None + checked_at = str(row.get("checked_at") or row.get("verified_at") or "").strip() + if checked_at: + return checked_at + return None + + def _has_stale_evidence(key: str) -> bool: + row = _evidence_row(key) + if isinstance(row, dict): + expires_at = str(row.get("expires_at") or "").strip() + parsed_expires_at = _parse_iso_datetime(expires_at) if expires_at else None + if parsed_expires_at is not None and parsed_expires_at < now_cn_naive(): + return True + checked_at = _evidence_checked_at(key) + if not checked_at: + return False + parsed = _parse_iso_datetime(checked_at) + if parsed is None: + return False + return parsed < (now_cn_naive() - timedelta(days=CAPABILITY_PROBE_EVIDENCE_TTL_DAYS)) + + def _requires_probe_evidence(key: str) -> bool: + if slot_key not in {"structured", "tool", "long_context"}: + return False + return key in {"structured_output", "json_schema", "tool_calling", "long_context", "max_context_tokens"} + + def _record_capability_gap(key: str, label: str) -> None: + if not bool(capabilities.get(key)): + missing.append(key) + missing_labels.append(label) + return + evidence_key = "long_context" if key == "max_context_tokens" else key + if _requires_probe_evidence(key) and not _has_verified_evidence(evidence_key): + missing_evidence.append(evidence_key) + missing_evidence_labels.append(label) + elif _requires_probe_evidence(key) and _has_stale_evidence(evidence_key): + stale_evidence.append(evidence_key) + stale_evidence_labels.append(label) + + for flag in requirement.get("required_flags", []) or []: + key = str(flag) + _record_capability_gap(key, CAPABILITY_LABELS.get(key, key)) + any_flags = [str(item) for item in requirement.get("any_flags", []) or [] if str(item or "").strip()] + if any_flags and not any(bool(capabilities.get(item)) for item in any_flags): + missing.append("any:" + ",".join(any_flags)) + missing_labels.append(" 或 ".join(CAPABILITY_LABELS.get(item, item) for item in any_flags)) + elif any_flags and slot_key in {"structured", "tool", "long_context"}: + verified_any = any(bool(capabilities.get(item)) and _has_verified_evidence(item) for item in any_flags) + if not verified_any: + missing_evidence.append("any:" + ",".join(any_flags)) + missing_evidence_labels.append(" 或 ".join(CAPABILITY_LABELS.get(item, item) for item in any_flags)) + elif any(_has_stale_evidence(item) for item in any_flags if bool(capabilities.get(item))): + stale_evidence.append("any:" + ",".join(any_flags)) + stale_evidence_labels.append(" 或 ".join(CAPABILITY_LABELS.get(item, item) for item in any_flags)) + for flag in requirement.get("forbidden_flags", []) or []: + key = str(flag) + if bool(capabilities.get(key)): + missing.append(f"forbidden:{key}") + missing_labels.append(f"禁止 {CAPABILITY_LABELS.get(key, key)}") + min_context = requirement.get("min_context_tokens") + if min_context: + actual = int(capabilities.get("max_context_tokens") or 0) + if actual < int(min_context): + missing.append("max_context_tokens") + missing_labels.append(f"上下文窗口不足 {int(min_context):,} tokens") + elif _requires_probe_evidence("max_context_tokens") and not _has_verified_evidence("long_context"): + missing_evidence.append("long_context") + missing_evidence_labels.append(f"上下文 >= {int(min_context):,} tokens") + elif _requires_probe_evidence("max_context_tokens") and _has_stale_evidence("long_context"): + stale_evidence.append("long_context") + stale_evidence_labels.append(f"上下文 >= {int(min_context):,} tokens") + return { + "satisfied": len(missing) == 0 and len(missing_evidence) == 0 and len(stale_evidence) == 0, + "missing": missing, + "missing_labels": missing_labels, + "missing_evidence": missing_evidence, + "missing_evidence_labels": missing_evidence_labels, + "stale_evidence": stale_evidence, + "stale_evidence_labels": stale_evidence_labels, + "capabilities": capabilities, + } + + @staticmethod + def _coverage_gap_reasons(coverage: dict[str, Any]) -> list[str]: + reasons: list[str] = [] + missing_labels = coverage.get("missing_labels") if isinstance(coverage.get("missing_labels"), list) else [] + missing_evidence_labels = coverage.get("missing_evidence_labels") if isinstance(coverage.get("missing_evidence_labels"), list) else [] + stale_evidence_labels = coverage.get("stale_evidence_labels") if isinstance(coverage.get("stale_evidence_labels"), list) else [] + if missing_labels: + reasons.append("缺能力:" + "、".join(str(item) for item in missing_labels if str(item or "").strip())) + if missing_evidence_labels: + reasons.append("缺证据:" + "、".join(str(item) for item in missing_evidence_labels if str(item or "").strip())) + if stale_evidence_labels: + reasons.append("证据过期:" + "、".join(str(item) for item in stale_evidence_labels if str(item or "").strip())) + return [item for item in reasons if item][:6] + def _build_providers(self, *, engine_bundle: dict[str, Any]) -> list[dict[str, Any]]: - active_id = str(engine_bundle.get("active_engine_id") or "") + default_fallback_id = str(engine_bundle.get("default_fallback_engine_id") or engine_bundle.get("active_engine_id") or "") engines = [item for item in engine_bundle.get("engines", []) if isinstance(item, dict)] providers = [] for item in engines: provider = str(item.get("provider") or "").strip().lower() + enabled = bool(item.get("enabled", True)) + is_default_fallback = str(item.get("id") or "") == default_fallback_id + connection_status = str(item.get("connection_status") or "untested").strip() or "untested" + status_label = self._provider_status_label( + enabled=enabled, + is_default_fallback=is_default_fallback, + connection_status=connection_status, + ) providers.append( { "id": str(item.get("id") or ""), @@ -335,17 +2069,70 @@ class AIRoutingGovernanceService: "model": str(item.get("model") or ""), "base_url": str(item.get("base_url") or ""), "api_key_masked": _mask_secret(str(item.get("api_key") or "")), - "active": str(item.get("id") or "") == active_id, - "status_label": "已启用" if str(item.get("id") or "") == active_id else "已配置", + "active": is_default_fallback, + "enabled": enabled, + "is_default_fallback": is_default_fallback, + "connection_status": connection_status, + "last_test_status": str(item.get("last_test_status") or ""), + "last_test_at": _iso(item.get("last_test_at")), + "last_error_summary": str(item.get("last_error_summary") or ""), + "status_label": status_label, "updated_at": _iso(item.get("updated_at")), + "available_actions": self._provider_action_permissions( + enabled=enabled, + is_default_fallback=is_default_fallback, + connection_status=connection_status, + ), + "capabilities": _normalize_engine_capabilities( + item.get("capabilities"), + provider=provider, + model=str(item.get("model") or ""), + ), } ) return providers - def _build_scene_routes(self, *, routing_bundle: dict[str, Any], config_bundle: dict[str, Any]) -> list[dict[str, Any]]: + @staticmethod + def _provider_action_permissions(*, enabled: bool, is_default_fallback: bool, connection_status: str) -> dict[str, bool]: + connection_available = str(connection_status or "untested").strip().lower() == "available" + return { + "enable": not enabled, + "disable": enabled, + "edit": True, + "test": enabled, + "capability_probe": enabled and connection_available, + "set_default_fallback": enabled and connection_available and not is_default_fallback, + "delete": (not enabled) and not is_default_fallback, + } + + @staticmethod + def _provider_status_label(*, enabled: bool, is_default_fallback: bool, connection_status: str) -> str: + _ = is_default_fallback + if not enabled: + return "已停用" + if connection_status == "available": + return "可用" + if connection_status == "test_failed": + return "测试失败" + return "未测试" + + def _build_scene_routes( + self, + *, + routing_bundle: dict[str, Any], + config_bundle: dict[str, Any], + function_runtime_profiles: dict[str, Any] | None = None, + function_observability: dict[str, Any] | None = None, + ) -> list[dict[str, Any]]: mappings = routing_bundle.get("mappings") if isinstance(routing_bundle.get("mappings"), dict) else {} + profile_release = function_runtime_profiles if isinstance(function_runtime_profiles, dict) else {} + active_profiles = profile_release.get("active_profiles") if isinstance(profile_release.get("active_profiles"), dict) else {} + draft_profiles = profile_release.get("draft_profiles") if isinstance(profile_release.get("draft_profiles"), dict) else {} + draft_smoke = profile_release.get("draft_last_smoke") if isinstance(profile_release.get("draft_last_smoke"), dict) else {} + observability = function_observability if isinstance(function_observability, dict) else {} routes: list[dict[str, Any]] = [] for scene_key, functions in SCENE_FUNCTIONS.items(): + contract = scene_contract(scene_key) function_rows: list[dict[str, Any]] = [] tiers: list[str] = [] for function_name in functions: @@ -362,26 +2149,1014 @@ class AIRoutingGovernanceService: "slot_label": SLOT_LABELS.get(tier, tier), "source": "override" if override_tier else "default", "rule_name": str((rule or {}).get("name") or "") or None, + "protected": function_name in PROTECTED_FUNCTIONS, + "runtime_profile": self._function_runtime_profile_summary( + function_name=function_name, + active_profile=active_profiles.get(function_name) if isinstance(active_profiles.get(function_name), dict) else None, + draft_profile=draft_profiles.get(function_name) if isinstance(draft_profiles.get(function_name), dict) else None, + active_version=str(profile_release.get("active_version") or ""), + draft_version=str(profile_release.get("draft_version") or "draft"), + dirty=bool(profile_release.get("dirty", False)), + draft_smoke=draft_smoke.get(function_name) if isinstance(draft_smoke.get(function_name), dict) else None, + observability=observability.get(function_name) if isinstance(observability.get(function_name), dict) else None, + ), } ) - primary_tier = "mixed" if len(set(tiers)) > 1 else (tiers[0] if tiers else "low") + default_slot = str(SCENE_DEFAULT_SLOT.get(scene_key) or "").strip().lower() + primary_tier = "mixed" if len(set(tiers)) > 1 else (tiers[0] if tiers else default_slot) routes.append( { "scene_key": scene_key, - "label": SCENE_LABELS[scene_key], + "label": contract["label"], "summary": DEFAULT_SCENE_POLICIES[scene_key], - "primary_slot_label": "混合策略" if primary_tier == "mixed" else SLOT_LABELS.get(primary_tier, "快速槽"), + "primary_slot_label": ( + "混合策略" + if primary_tier == "mixed" + else str((SLOT_SPECS.get(primary_tier) or {}).get("label") or SLOT_LABELS.get(primary_tier, contract["expected_slot_label"])) + ), + "scene_contract": contract, + "lifecycle": contract["lifecycle"], + "scene_family": contract["scene_family"], "functions": function_rows, } ) return routes + def _build_scene_slot_matrix( + self, + *, + scene_routes: list[dict[str, Any]], + capability_slot_release: dict[str, Any], + scene_failure_rates: dict[str, dict[str, Any]] | None = None, + ) -> list[dict[str, Any]]: + active_version = str(capability_slot_release.get("active_version") or "unpublished") + draft_version = str(capability_slot_release.get("draft_version") or "draft") + active_slots = [ + item + for item in capability_slot_release.get("active_slots", []) + if isinstance(item, dict) + ] + draft_slots = [ + item + for item in capability_slot_release.get("draft_slots", []) + if isinstance(item, dict) + ] + slot_by_key = { + str(item.get("slot_key") or item.get("tier") or "").strip().lower(): item + for item in active_slots + if str(item.get("slot_key") or item.get("tier") or "").strip() + } + draft_slot_by_key = { + str(item.get("slot_key") or item.get("tier") or "").strip().lower(): item + for item in draft_slots + if str(item.get("slot_key") or item.get("tier") or "").strip() + } + rows: list[dict[str, Any]] = [] + for route in scene_routes: + scene_key = str(route.get("scene_key") or "").strip() + contract = route.get("scene_contract") if isinstance(route.get("scene_contract"), dict) else scene_contract(scene_key) + lifecycle = str(contract.get("lifecycle") or route.get("lifecycle") or "active") + current_slot = str(SCENE_DEFAULT_SLOT.get(scene_key) or "").strip().lower() + required_slots = [ + item + for item in SCENE_SLOT_REQUIREMENTS.get(scene_key, []) + if str(item or "").strip() + ] + function_slots = [ + str(item.get("tier") or "").strip().lower() + for item in route.get("functions", []) + if isinstance(item, dict) and str(item.get("tier") or "").strip() + ] + base_slots = [] if not current_slot or current_slot == "mixed" else [current_slot] + ordered_slots = list(dict.fromkeys([*base_slots, *required_slots, *function_slots])) + slot_rows = [ + self._build_scene_slot_matrix_slot( + scene_key=scene_key, + slot_key=slot_key, + slot=slot_by_key.get(slot_key), + runtime_version=active_version, + is_required=slot_key in required_slots, + ) + for slot_key in ordered_slots + ] + route_risks = self._scene_route_slot_risks(scene_key=scene_key, route=route) + for risk in route_risks: + slot_key = str(risk.get("slot_key") or "").strip().lower() + target = next((item for item in slot_rows if item.get("slot_key") == slot_key), None) + if target is None: + target = self._build_scene_slot_matrix_slot( + scene_key=scene_key, + slot_key=slot_key, + slot=slot_by_key.get(slot_key), + runtime_version=active_version, + is_required=False, + ) + slot_rows.append(target) + target["risk_level"] = "critical" + target["risk_label"] = "保护场景降级" + target["risk_reasons"] = list(dict.fromkeys([*target.get("risk_reasons", []), str(risk.get("message") or "")])) + risk_order = {"critical": 3, "warning": 2, "ok": 1} + scene_risk_level = max( + (str(item.get("risk_level") or "ok") for item in slot_rows), + key=lambda value: risk_order.get(value, 0), + default="ok", + ) + active_slot = slot_by_key.get(current_slot) if current_slot != "mixed" else None + draft_slot = draft_slot_by_key.get(current_slot) if current_slot != "mixed" else None + failure_stats = (scene_failure_rates or {}).get(scene_key) if isinstance(scene_failure_rates, dict) else {} + required_capabilities = self._scene_required_capabilities(scene_key=scene_key, current_slot=current_slot) + scene_label = str(route.get("label") or SCENE_LABELS.get(scene_key, scene_key)) + active_runtime = self._scene_runtime_summary( + slot_key=current_slot, + slot=active_slot, + version=active_version, + source="active", + ) + draft_runtime = self._scene_runtime_summary( + slot_key=current_slot, + slot=draft_slot, + version=draft_version, + source="draft", + ) + capability_gap = self._scene_capability_gap(slot_rows) + recent_failed_calls = int((failure_stats or {}).get("failed_calls") or 0) + recent_total_calls = int((failure_stats or {}).get("total_calls") or 0) + failure_rate = float((failure_stats or {}).get("failure_rate") or 0.0) + scene_health = self._scene_health( + lifecycle=lifecycle, + active_runtime=active_runtime, + draft_dirty=bool(capability_slot_release.get("dirty", False)), + capability_gap=capability_gap, + risk_level=scene_risk_level, + recent_failed_calls=recent_failed_calls, + recent_total_calls=recent_total_calls, + failure_rate=failure_rate, + ) + release_gate = self._scene_release_gate( + lifecycle=lifecycle, + protected=scene_key in PROTECTED_SCENE_KEYS, + active_runtime=active_runtime, + draft_runtime=draft_runtime, + draft_dirty=bool(capability_slot_release.get("dirty", False)), + capability_gap=capability_gap, + risk_level=scene_risk_level, + recent_failed_calls=recent_failed_calls, + ) + impact_summary = ( + f"{scene_label} 是候选场景,当前仅展示运行合同,不参与 active 槽位发布影响。" + if lifecycle != "active" + else self._scene_impact_summary( + scene_label=scene_label, + current_slot=current_slot, + active_runtime=active_runtime, + draft_runtime=draft_runtime, + risk_level=scene_risk_level, + failure_rate=failure_rate, + ) + ) + rows.append( + { + "scene_key": scene_key, + "scene_label": scene_label, + "label": scene_label, + "summary": str(route.get("summary") or DEFAULT_SCENE_POLICIES.get(scene_key, "")), + "scene_contract": contract, + "lifecycle": lifecycle, + "scene_family": str(contract.get("scene_family") or route.get("scene_family") or "unknown"), + "protected": scene_key in PROTECTED_SCENE_KEYS, + "required_slot": current_slot, + "current_slot": current_slot, + "current_slot_label": ( + "复杂度动态分流" + if current_slot == "mixed" + else str((SLOT_SPECS.get(current_slot) or {}).get("label") or SLOT_LABELS.get(current_slot, current_slot or contract.get("expected_slot_label") or "-")) + ), + "required_capabilities": required_capabilities, + "active_slot_runtime": active_runtime, + "active_runtime": active_runtime, + "draft_slot_runtime": draft_runtime, + "draft_runtime": draft_runtime, + "active_version": active_version, + "draft_dirty": bool(capability_slot_release.get("dirty", False)), + "runtime_version": active_version, + "capability_gap": capability_gap, + "recent_failure_rate": float((failure_stats or {}).get("failure_rate") or 0.0), + "recent_failure_count": recent_failed_calls, + "recent_failed_calls": recent_failed_calls, + "recent_total_calls": recent_total_calls, + "scene_health": scene_health, + "release_gate": release_gate, + "primary_slot_label": str(route.get("primary_slot_label") or ""), + "risk_level": scene_risk_level, + "risk_label": self._scene_risk_label(scene_risk_level), + "impact_summary": impact_summary, + "slots": slot_rows, + "functions": route.get("functions", []), + "function_runtime_profiles": [ + item.get("runtime_profile") + for item in route.get("functions", []) + if isinstance(item, dict) and isinstance(item.get("runtime_profile"), dict) + ], + } + ) + return rows + + @staticmethod + def _scene_capability_gap(slot_rows: list[dict[str, Any]]) -> dict[str, Any]: + missing: list[str] = [] + missing_evidence: list[str] = [] + stale_evidence: list[str] = [] + reasons: list[str] = [] + for slot in slot_rows: + coverage = slot.get("capability_coverage") if isinstance(slot.get("capability_coverage"), dict) else {} + missing.extend(str(item) for item in coverage.get("missing_labels", []) if str(item or "").strip()) + missing_evidence.extend(str(item) for item in coverage.get("missing_evidence_labels", []) if str(item or "").strip()) + stale_evidence.extend(str(item) for item in coverage.get("stale_evidence_labels", []) if str(item or "").strip()) + reasons.extend(str(item) for item in slot.get("risk_reasons", []) if str(item or "").strip()) + unique_missing = list(dict.fromkeys(missing)) + unique_missing_evidence = list(dict.fromkeys(missing_evidence)) + unique_stale_evidence = list(dict.fromkeys(stale_evidence)) + unique_reasons = list(dict.fromkeys(reasons)) + return { + "missing": unique_missing, + "missing_evidence": unique_missing_evidence, + "stale_evidence": unique_stale_evidence, + "reasons": unique_reasons, + "has_gap": bool(unique_missing or unique_missing_evidence or unique_stale_evidence or unique_reasons), + "summary": ";".join(unique_reasons[:3]) + or ( + "、".join( + [ + *[f"缺能力:{item}" for item in unique_missing], + *[f"缺证据:{item}" for item in unique_missing_evidence], + *[f"证据过期:{item}" for item in unique_stale_evidence], + ][:3] + ) + ) + or "无能力缺口", + } + + @staticmethod + def _scene_health( + *, + lifecycle: str, + active_runtime: dict[str, Any], + draft_dirty: bool, + capability_gap: dict[str, Any], + risk_level: str, + recent_failed_calls: int, + recent_total_calls: int, + failure_rate: float, + ) -> dict[str, Any]: + if lifecycle != "active": + return { + "status": "unknown", + "score": 0, + "blockers": [], + "warnings": ["候选场景仅展示运行合同,暂不参与 active 发布门禁。"], + "evidence_summary": "candidate scene,无运行样本要求。", + "last_observed_at": None, + } + blockers: list[str] = [] + warnings: list[str] = [] + if not bool(active_runtime.get("configured")): + blockers.append("Active runtime 未绑定") + if bool(capability_gap.get("has_gap")): + blockers.extend(str(item) for item in capability_gap.get("reasons", []) if str(item or "").strip()) + for label in capability_gap.get("missing", []) or []: + blockers.append(f"缺能力:{label}") + for label in capability_gap.get("missing_evidence", []) or []: + blockers.append(f"缺证据:{label}") + for label in capability_gap.get("stale_evidence", []) or []: + blockers.append(f"证据过期:{label}") + if risk_level == "critical": + blockers.append("保护场景存在 critical 路由或槽位风险") + if recent_total_calls == 0: + warnings.append("暂无 7d 运行样本") + elif failure_rate >= 0.3: + warnings.append(f"7d 失败率 {round(failure_rate * 100)}%") + elif recent_failed_calls > 0: + warnings.append(f"7d 失败样本 {recent_failed_calls} 次") + if draft_dirty: + warnings.append("存在未发布 draft 影响") + unique_blockers = list(dict.fromkeys(blockers)) + unique_warnings = list(dict.fromkeys(warnings)) + if unique_blockers: + status = "blocked" + score = 30 + elif recent_total_calls == 0: + status = "unknown" + score = 55 + elif unique_warnings: + status = "warning" + score = 70 + else: + status = "healthy" + score = 95 + evidence_summary = ( + ";".join(unique_blockers[:2] or unique_warnings[:2]) + or f"7d 样本 {recent_total_calls} 次,失败 {recent_failed_calls} 次。" + ) + return { + "status": status, + "score": score, + "blockers": unique_blockers, + "warnings": unique_warnings, + "evidence_summary": evidence_summary, + "last_observed_at": None, + } + + @staticmethod + def _scene_release_gate( + *, + lifecycle: str, + protected: bool, + active_runtime: dict[str, Any], + draft_runtime: dict[str, Any], + draft_dirty: bool, + capability_gap: dict[str, Any], + risk_level: str, + recent_failed_calls: int, + ) -> dict[str, Any]: + affected_by_draft = bool(draft_dirty) + active_label = str(active_runtime.get("runtime_label") or "") + draft_label = str(draft_runtime.get("runtime_label") or "") + if draft_label and draft_label != active_label: + affected_by_draft = True + if lifecycle != "active": + return { + "publish_blocked": False, + "blocking_reason": None, + "affected_by_draft": False, + "protected_scene_regression": False, + "suggested_action": "none", + } + protected_scene_regression = bool(protected and (risk_level == "critical" or capability_gap.get("has_gap") or not active_runtime.get("configured"))) + publish_blocked = bool(protected_scene_regression) + blocking_reason = None + suggested_action = "none" + if not bool(active_runtime.get("configured")): + blocking_reason = "Active runtime 未绑定" + suggested_action = "configure_slot" + elif (capability_gap.get("missing_evidence") or capability_gap.get("stale_evidence")): + blocking_reason = capability_gap.get("summary") or "能力证据缺失或过期" + suggested_action = "probe_capability" + elif capability_gap.get("missing"): + blocking_reason = capability_gap.get("summary") or "缺少场景要求的 runtime 能力" + suggested_action = "add_provider" + elif recent_failed_calls > 0: + suggested_action = "inspect_failures" + return { + "publish_blocked": publish_blocked, + "blocking_reason": blocking_reason, + "affected_by_draft": affected_by_draft, + "protected_scene_regression": protected_scene_regression, + "suggested_action": suggested_action, + } + + def _scene_required_capabilities(self, *, scene_key: str, current_slot: str) -> list[str]: + slot_keys = list(SCENE_SLOT_REQUIREMENTS.get(scene_key, [])) + if current_slot and current_slot != "mixed": + slot_keys.insert(0, current_slot) + labels: list[str] = [] + for slot_key in dict.fromkeys(slot_keys): + requirement_labels = self._slot_requirement_summary(str(slot_key)).get("labels") or [] + if requirement_labels: + labels.extend(str(item) for item in requirement_labels if str(item or "").strip()) + else: + slot_label = str((SLOT_SPECS.get(str(slot_key)) or {}).get("label") or SLOT_LABELS.get(str(slot_key), str(slot_key))) + labels.append(f"{slot_label}可用连接") + return list(dict.fromkeys(labels)) + + def _scene_runtime_summary( + self, + *, + slot_key: str, + slot: dict[str, Any] | None, + version: str, + source: str, + ) -> dict[str, Any]: + normalized_slot = str(slot_key or "").strip().lower() + slot_label = "复杂度动态分流" if normalized_slot == "mixed" else str((SLOT_SPECS.get(normalized_slot) or {}).get("label") or SLOT_LABELS.get(normalized_slot, normalized_slot or "-")) + if normalized_slot == "mixed": + return { + "slot_key": normalized_slot, + "slot_label": slot_label, + "version": version, + "configured": True, + "runtime_label": "按问题复杂度在快速槽/深度槽之间分流", + "runtime_source": source, + "primary_engine_id": None, + } + primary_engine = (slot or {}).get("primary_engine") if isinstance((slot or {}).get("primary_engine"), dict) else None + primary_provider = (slot or {}).get("primary_provider") if isinstance((slot or {}).get("primary_provider"), dict) else None + runtime_label = ( + f"{primary_engine.get('name')} · {primary_engine.get('provider_label')} / {primary_engine.get('model')}" + if primary_engine + else ( + f"{primary_provider.get('label')} · external_query_service" + if primary_provider + else ( + f"{(slot or {}).get('provider_label')} / {(slot or {}).get('model')}" + if bool((slot or {}).get("configured")) + else "未绑定运行时" + ) + ) + ) + return { + "slot_key": normalized_slot, + "slot_label": slot_label, + "version": version, + "configured": bool((slot or {}).get("configured")), + "runtime_label": runtime_label, + "runtime_source": str((slot or {}).get("config_source") or source), + "primary_engine_id": str((slot or {}).get("primary_engine_id") or "") or None, + "primary_provider_id": str((slot or {}).get("primary_provider_id") or "") or None, + } + + @staticmethod + def _function_runtime_profile_summary( + *, + function_name: str, + active_profile: dict[str, Any] | None, + draft_profile: dict[str, Any] | None, + active_version: str, + draft_version: str, + dirty: bool, + draft_smoke: dict[str, Any] | None, + observability: dict[str, Any] | None, + ) -> dict[str, Any]: + active = _safe_dict(active_profile) + draft = _safe_dict(draft_profile) or active + profile_dirty = active != draft + return { + "function_name": function_name, + "label": str(draft.get("label") or function_name), + "scene_key": str(draft.get("scene_key") or active.get("scene_key") or ""), + "prompt_key": str(draft.get("prompt_key") or active.get("prompt_key") or ""), + "required_slot": str(draft.get("required_slot") or active.get("required_slot") or ""), + "response_contract": str(draft.get("response_contract") or active.get("response_contract") or ""), + "active_version": active_version, + "draft_version": draft_version, + "dirty": bool(dirty or profile_dirty), + "active": { + "max_tokens": int(active.get("max_tokens") or 0), + "repair_max_tokens": int(active.get("repair_max_tokens") or 0), + "temperature": float(active.get("temperature") or 0.0), + "foreground_wait_seconds": int(active.get("foreground_wait_seconds") or 0), + "provider_timeout_seconds": int(active.get("provider_timeout_seconds") or 0), + "job_timeout_seconds": int(active.get("job_timeout_seconds") or 0), + "max_retries": int(active.get("max_retries") or 0), + "large_input_policy": str(active.get("large_input_policy") or ""), + "response_mode": str(active.get("response_mode") or ""), + "response_format": str(active.get("response_format") or ""), + }, + "draft": { + "max_tokens": int(draft.get("max_tokens") or active.get("max_tokens") or 0), + "repair_max_tokens": int(draft.get("repair_max_tokens") or active.get("repair_max_tokens") or 0), + "temperature": float(draft.get("temperature") or active.get("temperature") or 0.0), + "foreground_wait_seconds": int(draft.get("foreground_wait_seconds") or active.get("foreground_wait_seconds") or 0), + "provider_timeout_seconds": int(draft.get("provider_timeout_seconds") or active.get("provider_timeout_seconds") or 0), + "job_timeout_seconds": int(draft.get("job_timeout_seconds") or active.get("job_timeout_seconds") or 0), + "max_retries": int(draft.get("max_retries") or active.get("max_retries") or 0), + "large_input_policy": str(draft.get("large_input_policy") or active.get("large_input_policy") or ""), + "response_mode": str(draft.get("response_mode") or active.get("response_mode") or ""), + "response_format": str(draft.get("response_format") or active.get("response_format") or ""), + }, + "limits": { + "max_tokens_min": int(draft.get("max_tokens_min") or active.get("max_tokens_min") or 0), + "max_tokens_max": int(draft.get("max_tokens_max") or active.get("max_tokens_max") or 0), + "recommended_min_tokens": int(draft.get("recommended_min_tokens") or active.get("recommended_min_tokens") or 0), + }, + "retry_policy": _safe_dict(draft.get("retry_policy") or active.get("retry_policy")), + "health_policy": _safe_dict(draft.get("health_policy") or active.get("health_policy")), + "cost_policy": _safe_dict(draft.get("cost_policy") or active.get("cost_policy")), + "draft_smoke": _safe_dict(draft_smoke), + "observability": _safe_dict(observability), + } + + @staticmethod + def _scene_impact_summary( + *, + scene_label: str, + current_slot: str, + active_runtime: dict[str, Any], + draft_runtime: dict[str, Any], + risk_level: str, + failure_rate: float, + ) -> str: + slot_label = str(active_runtime.get("slot_label") or current_slot or "-") + active_label = str(active_runtime.get("runtime_label") or "未绑定运行时") + draft_label = str(draft_runtime.get("runtime_label") or "未绑定运行时") + if risk_level == "critical": + return f"{scene_label} 当前依赖 {slot_label},存在阻塞风险;发布前必须先修复槽位能力或运行时绑定。" + if active_label != draft_label: + return f"发布草稿会把 {scene_label} 的 {slot_label} 从 {active_label} 切换为 {draft_label}。" + if failure_rate > 0: + return f"{scene_label} 当前由 {slot_label} 承载,近 7 天失败率 {round(failure_rate * 100)}%,发布前建议复核证据。" + return f"{scene_label} 当前由 {slot_label} 承载,active runtime 为 {active_label}。" + + def _build_scene_slot_matrix_slot( + self, + *, + scene_key: str, + slot_key: str, + slot: dict[str, Any] | None, + runtime_version: str, + is_required: bool, + ) -> dict[str, Any]: + slot_key = str(slot_key or "").strip().lower() + slot_label = str((slot or {}).get("label") or (SLOT_SPECS.get(slot_key) or {}).get("label") or SLOT_LABELS.get(slot_key, slot_key)) + coverage = (slot or {}).get("capability_coverage") if isinstance((slot or {}).get("capability_coverage"), dict) else {} + risk_reasons: list[str] = [] + configured = bool((slot or {}).get("configured")) + if not configured: + risk_reasons.append("未发布 active slot 绑定") + if coverage and not bool(coverage.get("satisfied", True)): + missing = [str(item) for item in coverage.get("missing_labels", []) if str(item or "").strip()] + missing_evidence = [str(item) for item in coverage.get("missing_evidence_labels", []) if str(item or "").strip()] + stale_evidence = [str(item) for item in coverage.get("stale_evidence_labels", []) if str(item or "").strip()] + if missing: + risk_reasons.append(f"缺能力:{'、'.join(missing)}") + if missing_evidence: + risk_reasons.append(f"缺证据:{'、'.join(missing_evidence)}") + if stale_evidence: + risk_reasons.append(f"证据过期:{'、'.join(stale_evidence)}") + risk_level = "ok" + if risk_reasons: + risk_level = "critical" if scene_key in PROTECTED_SCENE_KEYS or is_required else "warning" + primary_engine = (slot or {}).get("primary_engine") if isinstance((slot or {}).get("primary_engine"), dict) else None + primary_provider = (slot or {}).get("primary_provider") if isinstance((slot or {}).get("primary_provider"), dict) else None + return { + "slot_key": slot_key, + "slot_label": slot_label, + "required": bool(is_required), + "configured": configured, + "runtime_version": runtime_version, + "runtime_source": str((slot or {}).get("config_source") or "未发布"), + "primary_engine_id": str((slot or {}).get("primary_engine_id") or "") or None, + "primary_provider_id": str((slot or {}).get("primary_provider_id") or "") or None, + "primary_engine_label": ( + f"{primary_engine.get('name')} · {primary_engine.get('provider_label')} / {primary_engine.get('model')}" + if primary_engine + else ( + f"{primary_provider.get('label')} · external_query_service" + if primary_provider + else ( + f"{(slot or {}).get('provider_label')} / {(slot or {}).get('model')}" + if configured + else "未发布 active runtime" + ) + ) + ), + "risk_level": risk_level, + "risk_label": self._scene_risk_label(risk_level), + "risk_reasons": risk_reasons, + "capability_coverage": coverage, + } + + @staticmethod + def _scene_route_slot_risks(*, scene_key: str, route: dict[str, Any]) -> list[dict[str, Any]]: + allowed = SCENE_FUNCTION_SLOT_ALLOWLIST.get(scene_key) + if not allowed: + return [] + risks: list[dict[str, Any]] = [] + for item in route.get("functions", []): + if not isinstance(item, dict) or not bool(item.get("protected")): + continue + slot_key = str(item.get("tier") or "").strip().lower() + if slot_key and slot_key not in allowed: + risks.append( + { + "slot_key": slot_key, + "message": f"{item.get('display_name') or item.get('function_name')} 被路由到 {SLOT_LABELS.get(slot_key, slot_key)},不满足该保护场景的能力要求。", + } + ) + return risks + + @staticmethod + def _scene_risk_label(level: str) -> str: + if level == "critical": + return "阻塞" + if level == "warning": + return "需关注" + return "正常" + async def _build_metrics(self) -> dict[str, Any]: return { "last_24h": await self._window_metrics(hours=24), "last_7d": await self._window_metrics(hours=24 * 7), } + def _build_slot_recommendations( + self, + *, + slots: list[dict[str, Any]], + slot_observability: list[dict[str, Any]], + ) -> list[dict[str, Any]]: + observability_by_slot = { + str(item.get("slot_key") or "").strip().lower(): item + for item in slot_observability + if isinstance(item, dict) + } + recommendations: list[dict[str, Any]] = [] + for slot in slots: + if not isinstance(slot, dict): + continue + slot_key = str(slot.get("slot_key") or slot.get("tier") or "").strip().lower() + if slot_key not in OBSERVABLE_RUNTIME_SLOTS: + continue + recommendations.extend( + self._slot_evidence_recommendations(slot_key=slot_key, slot=slot) + ) + recommendations.extend( + self._slot_observability_recommendations( + slot_key=slot_key, + slot=slot, + observability=observability_by_slot.get(slot_key) or {}, + ) + ) + if slot_key in {"structured", "tool", "long_context"} and int(slot.get("candidate_engine_count") or 0) == 0: + recommendations.append( + self._slot_recommendation( + severity="warning", + slot_key=slot_key, + code=f"{slot_key}_no_candidate_runtime", + reason="该槽位当前没有满足能力要求和证据要求的候选 runtime。", + evidence_summary="候选 runtime 数量为 0;不满足要求的 runtime 需要补能力、补证据或启用连接。", + suggested_action="回到供应商与凭据补齐能力标签、API Key、连接状态,或执行 capability probe;必要时接入新的 runtime。", + ) + ) + return recommendations[:20] + + def _slot_evidence_recommendations(self, *, slot_key: str, slot: dict[str, Any]) -> list[dict[str, Any]]: + coverage = slot.get("capability_coverage") if isinstance(slot.get("capability_coverage"), dict) else {} + missing_evidence_labels = [str(item) for item in coverage.get("missing_evidence_labels", []) if str(item or "").strip()] + stale_evidence_labels = [str(item) for item in coverage.get("stale_evidence_labels", []) if str(item or "").strip()] + items: list[dict[str, Any]] = [] + if missing_evidence_labels: + items.append( + self._slot_recommendation( + severity="critical" if slot_key in {"structured", "tool", "long_context"} else "warning", + slot_key=slot_key, + code=f"{slot_key}_probe_evidence_missing", + reason="槽位主 runtime 缺少 verified capability evidence。", + evidence_summary="缺证据:" + "、".join(missing_evidence_labels), + suggested_action="先在供应商与凭据执行 capability probe,再回到槽位页校验发布。", + target_engine_id=str(slot.get("primary_engine_id") or "") or None, + ) + ) + if stale_evidence_labels: + items.append( + self._slot_recommendation( + severity="warning", + slot_key=slot_key, + code=f"{slot_key}_probe_evidence_stale", + reason="槽位主 runtime 的 capability evidence 已过期。", + evidence_summary="证据过期:" + "、".join(stale_evidence_labels), + suggested_action="重新执行 capability probe,确认 runtime 仍满足该槽位能力要求。", + target_engine_id=str(slot.get("primary_engine_id") or "") or None, + ) + ) + return items + + def _slot_observability_recommendations( + self, + *, + slot_key: str, + slot: dict[str, Any], + observability: dict[str, Any], + ) -> list[dict[str, Any]]: + windows = observability.get("windows") if isinstance(observability.get("windows"), dict) else {} + last_7d = windows.get("last_7d") if isinstance(windows.get("last_7d"), dict) else {} + if last_7d.get("status") != "sampled": + return [] + total_calls = int(last_7d.get("total_calls") or 0) + recommendations: list[dict[str, Any]] = [] + if total_calls >= SLOT_RECOMMENDATION_MIN_CALLS: + failure_rate = float(last_7d.get("failure_rate") or 0) + if failure_rate >= SLOT_RECOMMENDATION_FAILURE_RATE_THRESHOLD: + target = self._first_candidate_engine_id(slot) + recommendations.append( + self._slot_recommendation( + severity="warning", + slot_key=slot_key, + code=f"{slot_key}_high_failure_rate", + reason="该 active slot 近 7 天失败率偏高。", + evidence_summary=f"7d 调用 {total_calls} 次,失败率 {round(failure_rate * 100)}%。", + suggested_action="优先补 fallback;若已有候选 runtime,评估切换主 runtime 或调整 fallback chain。", + target_engine_id=target, + ) + ) + fallback_hits = int(last_7d.get("fallback_hits") or 0) + fallback_rate = float(last_7d.get("fallback_rate") or 0) + if fallback_hits > 0 and fallback_rate >= SLOT_RECOMMENDATION_FALLBACK_RATE_THRESHOLD: + target = self._first_fallback_engine_id(slot) + recommendations.append( + self._slot_recommendation( + severity="info", + slot_key=slot_key, + code=f"{slot_key}_fallback_primary_candidate", + reason="该 active slot 近 7 天频繁命中 fallback。", + evidence_summary=f"7d fallback 命中 {fallback_hits} 次,占比 {round(fallback_rate * 100)}%。", + suggested_action="评估将表现稳定的 fallback runtime 提升为 primary,或保留当前主运行时并补充备用链路。", + target_engine_id=target, + ) + ) + degraded_calls = int(last_7d.get("degraded_calls") or 0) + degraded_rate = float(last_7d.get("degraded_rate") or 0) + if degraded_calls > 0 and degraded_rate >= SLOT_RECOMMENDATION_DEGRADED_RATE_THRESHOLD: + recommendations.append( + self._slot_recommendation( + severity="critical", + slot_key=slot_key, + code=f"{slot_key}_active_slot_degraded", + reason="该 active slot 发生 degraded runtime 路径。", + evidence_summary=f"7d degraded 调用 {degraded_calls} 次,占比 {round(degraded_rate * 100)}%。", + suggested_action="修复当前 active slot 的 runtime/证据,必要时回滚到上一稳定 active 版本。", + ) + ) + if slot_key == "low": + recommendations.extend(self._low_slot_cost_latency_recommendations(slot=slot, window=last_7d)) + return recommendations + + def _low_slot_cost_latency_recommendations(self, *, slot: dict[str, Any], window: dict[str, Any]) -> list[dict[str, Any]]: + total_calls = int(window.get("total_calls") or 0) + if total_calls < SLOT_RECOMMENDATION_MIN_CALLS: + return [] + average_latency_ms = window.get("average_latency_ms") + estimated_cost = float(window.get("estimated_cost") or 0) + latency_high = isinstance(average_latency_ms, (int, float)) and float(average_latency_ms) >= LOW_SLOT_LATENCY_WARNING_MS + cost_high = estimated_cost >= LOW_SLOT_COST_WARNING + if not latency_high and not cost_high: + return [] + target = self._low_cost_latency_candidate(slot) + if not target: + return [] + reasons = [] + if latency_high: + reasons.append(f"平均延迟 {round(float(average_latency_ms))}ms") + if cost_high: + reasons.append(f"7d 估算成本 ¥{estimated_cost:.2f}") + return [ + self._slot_recommendation( + severity="info", + slot_key="low", + code="low_cost_latency_candidate", + reason="快速槽的成本或延迟出现异常。", + evidence_summary=";".join(reasons) + ";成本/延迟来自 ai_call_logs 估算。", + suggested_action="评估切换到低成本或低延迟候选 runtime;先保存 draft 并通过发布校验,不要直接发布。", + target_engine_id=target, + ) + ] + + @staticmethod + def _slot_recommendation( + *, + severity: str, + slot_key: str, + code: str, + reason: str, + evidence_summary: str, + suggested_action: str, + target_engine_id: str | None = None, + ) -> dict[str, Any]: + normalized_severity = str(severity or "info").strip().lower() + if normalized_severity not in {"critical", "warning", "info"}: + normalized_severity = "info" + slot_label = str((SLOT_SPECS.get(slot_key) or {}).get("label") or SLOT_LABELS.get(slot_key, slot_key)) + return { + "severity": normalized_severity, + "slot_key": slot_key, + "slot_label": slot_label, + "code": code, + "reason": reason, + "evidence_summary": evidence_summary, + "suggested_action": suggested_action, + "target_engine_id": target_engine_id, + } + + @staticmethod + def _first_candidate_engine_id(slot: dict[str, Any]) -> str | None: + candidates = slot.get("candidate_engines") if isinstance(slot.get("candidate_engines"), list) else [] + current = str(slot.get("primary_engine_id") or "") + for candidate in candidates: + if not isinstance(candidate, dict): + continue + candidate_id = str(candidate.get("id") or "").strip() + if candidate_id and candidate_id != current: + return candidate_id + return None + + @staticmethod + def _first_fallback_engine_id(slot: dict[str, Any]) -> str | None: + fallback_ids = slot.get("fallback_engine_ids") if isinstance(slot.get("fallback_engine_ids"), list) else [] + for fallback_id in fallback_ids: + value = str(fallback_id or "").strip() + if value: + return value + fallback_engines = slot.get("fallback_engines") if isinstance(slot.get("fallback_engines"), list) else [] + for fallback in fallback_engines: + if isinstance(fallback, dict) and str(fallback.get("id") or "").strip(): + return str(fallback.get("id") or "").strip() + return None + + def _low_cost_latency_candidate(self, slot: dict[str, Any]) -> str | None: + candidates = slot.get("candidate_engines") if isinstance(slot.get("candidate_engines"), list) else [] + current = str(slot.get("primary_engine_id") or "") + for candidate in candidates: + if not isinstance(candidate, dict): + continue + candidate_id = str(candidate.get("id") or "").strip() + if not candidate_id or candidate_id == current: + continue + capabilities = candidate.get("capabilities") if isinstance(candidate.get("capabilities"), dict) else {} + cost_tier = str(capabilities.get("cost_tier") or "").strip().lower() + avg_latency = capabilities.get("avg_latency_ms") + low_latency = isinstance(avg_latency, (int, float)) and float(avg_latency) < LOW_SLOT_LATENCY_WARNING_MS + if cost_tier == "low" or low_latency: + return candidate_id + return self._first_candidate_engine_id(slot) + + async def _build_slot_observability(self, *, capability_slot_release: dict[str, Any]) -> list[dict[str, Any]]: + active_slots = { + str(slot.get("slot_key") or slot.get("tier") or "").strip().lower(): slot + for slot in capability_slot_release.get("active_slots", []) + if isinstance(slot, dict) + } + active_version = str(capability_slot_release.get("active_version") or "unpublished").strip() + return [ + { + "slot_key": slot_key, + "slot_label": str((SLOT_SPECS.get(slot_key) or {}).get("label") or SLOT_LABELS.get(slot_key, slot_key)), + "active_version": active_version, + "active_runtime": self._scene_runtime_summary( + slot_key=slot_key, + slot=active_slots.get(slot_key), + version=active_version, + source="active", + ), + "windows": { + "last_24h": await self._slot_observability_window(slot_key=slot_key, hours=24, sample_limit=4), + "last_7d": await self._slot_observability_window(slot_key=slot_key, hours=24 * 7, sample_limit=4), + }, + } + for slot_key in OBSERVABLE_RUNTIME_SLOTS + ] + + async def _slot_observability_window(self, *, slot_key: str, hours: int, sample_limit: int) -> dict[str, Any]: + since = now_cn_naive() - timedelta(hours=hours) + rows = ( + await self.db.execute( + select(AICallLog) + .where( + and_( + AICallLog.tenant_id == self.tenant_id, + AICallLog.created_at >= since, + ) + ) + .order_by(AICallLog.created_at.desc(), AICallLog.id.desc()) + ) + ).scalars().all() + matched: list[tuple[AICallLog, dict[str, Any]]] = [] + for row in rows: + signal = self._row_slot_observability_signal(row) + if signal.get("slot_key") == slot_key: + matched.append((row, signal)) + if not matched: + return self._empty_slot_observability_window(hours=hours) + + total = len(matched) + failed = sum(1 for row, _signal in matched if self._is_failed_status(str(row.status or ""))) + fallback_hits = sum(1 for _row, signal in matched if int(signal.get("slot_fallback_index") or 0) > 0) + degraded_calls = sum( + 1 + for _row, signal in matched + if bool(signal.get("slot_degraded")) or signal.get("slot_runtime_source") == "active_slot_degraded" + ) + active_slot_calls = sum(1 for _row, signal in matched if signal.get("slot_runtime_source") == "active_slot") + degraded_source_calls = sum(1 for _row, signal in matched if signal.get("slot_runtime_source") == "active_slot_degraded") + total_cost = sum(float(row.cost or Decimal("0")) for row, _signal in matched) + latencies = [ + latency + for latency in (self._row_latency_ms(row) for row, _signal in matched) + if latency is not None + ] + failure_samples: list[dict[str, Any]] = [] + for row, signal in matched: + if not self._is_failed_status(str(row.status or "")): + continue + failure_samples.append(self._slot_failure_sample(row=row, signal=signal)) + if len(failure_samples) >= sample_limit: + break + return { + "window_hours": hours, + "status": "sampled", + "sample_state_label": "已采集运行样本", + "total_calls": total, + "failed_calls": failed, + "failure_rate": round(failed / total, 4) if total else 0.0, + "fallback_hits": fallback_hits, + "fallback_rate": round(fallback_hits / total, 4) if total else 0.0, + "degraded_calls": degraded_calls, + "degraded_rate": round(degraded_calls / total, 4) if total else 0.0, + "active_slot_calls": active_slot_calls, + "active_slot_degraded_calls": degraded_source_calls, + "average_latency_ms": round(sum(latencies) / len(latencies), 2) if latencies else None, + "estimated_cost": round(total_cost, 6), + "estimated_cost_source": "ai_call_logs.cost 估算值,非供应商精确账单", + "latency_source": "ai_call_logs.latency_ms" if latencies else "当前调用日志未记录延迟", + "recent_failure_samples": failure_samples, + } + + @staticmethod + def _empty_slot_observability_window(*, hours: int) -> dict[str, Any]: + return { + "window_hours": hours, + "status": "empty", + "sample_state_label": "暂无运行样本", + "total_calls": 0, + "failed_calls": 0, + "failure_rate": 0.0, + "fallback_hits": 0, + "fallback_rate": 0.0, + "degraded_calls": 0, + "degraded_rate": 0.0, + "active_slot_calls": 0, + "active_slot_degraded_calls": 0, + "average_latency_ms": None, + "estimated_cost": 0.0, + "estimated_cost_source": "ai_call_logs.cost 估算值,非供应商精确账单", + "latency_source": "当前调用日志未记录延迟", + "recent_failure_samples": [], + } + + def _row_slot_observability_signal(self, row: AICallLog) -> dict[str, Any]: + meta = self._row_routing_meta(row) + slot_key = str( + meta.get("slot_key") + or getattr(row, "slot_key", None) + or row.executed_tier + or row.planned_tier + or "" + ).strip().lower() + if slot_key not in OBSERVABLE_RUNTIME_SLOTS: + return {} + runtime_source = str(meta.get("slot_runtime_source") or getattr(row, "slot_runtime_source", None) or "").strip().lower() + routing_decision = str(row.routing_decision or "").strip().lower() + degraded = bool(meta.get("slot_degraded", False) or getattr(row, "slot_degraded", False)) + if routing_decision == "structured_slot_degraded": + runtime_source = "active_slot_degraded" + degraded = True + if runtime_source not in {"active_slot", "active_slot_degraded"}: + runtime_source = "active_slot" + fallback_index = self._safe_int(meta.get("slot_fallback_index") if "slot_fallback_index" in meta else getattr(row, "slot_fallback_index", None)) + return { + "slot_key": slot_key, + "slot_runtime_source": runtime_source, + "slot_fallback_index": fallback_index, + "slot_degraded": degraded, + } + + @staticmethod + def _row_routing_meta(row: AICallLog) -> dict[str, Any]: + raw = getattr(row, "routing_meta", None) + if isinstance(raw, dict): + return raw + if isinstance(raw, str) and raw.strip(): + try: + parsed = json.loads(raw) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + return {} + + @staticmethod + def _row_latency_ms(row: AICallLog) -> float | None: + raw = getattr(row, "latency_ms", None) or getattr(row, "duration_ms", None) + try: + value = float(raw) + except (TypeError, ValueError): + return None + return value if value >= 0 else None + + @staticmethod + def _safe_int(raw: Any) -> int: + try: + return int(raw) + except (TypeError, ValueError): + return 0 + + def _slot_failure_sample(self, *, row: AICallLog, signal: dict[str, Any]) -> dict[str, Any]: + return { + "function_label": self._function_display_name(str(row.function_name or "")), + "function_name": str(row.function_name or ""), + "status_label": self._status_label(str(row.status or "")), + "slot_runtime_source": str(signal.get("slot_runtime_source") or "active_slot"), + "slot_fallback_index": int(signal.get("slot_fallback_index") or 0), + "created_at": _iso(row.created_at), + } + async def _window_metrics(self, *, hours: int) -> dict[str, Any]: since = now_cn_naive() - timedelta(hours=hours) rows = ( @@ -416,6 +3191,101 @@ class AIRoutingGovernanceService: "average_latency_ms": None, } + async def _scene_failure_rates(self, *, hours: int) -> dict[str, dict[str, Any]]: + since = now_cn_naive() - timedelta(hours=hours) + rows = ( + await self.db.execute( + select(AICallLog).where( + and_( + AICallLog.tenant_id == self.tenant_id, + AICallLog.created_at >= since, + ) + ) + ) + ).scalars().all() + function_to_scene = { + function_name: scene_key + for scene_key, functions in SCENE_FUNCTIONS.items() + for function_name in functions + } + stats: dict[str, dict[str, int]] = { + scene_key: {"total_calls": 0, "failed_calls": 0} + for scene_key in SCENE_LABELS + } + for row in rows: + scene_key = function_to_scene.get(str(row.function_name or "")) + if not scene_key: + continue + stats[scene_key]["total_calls"] += 1 + if self._is_failed_status(str(row.status or "")): + stats[scene_key]["failed_calls"] += 1 + return { + scene_key: { + "total_calls": values["total_calls"], + "failed_calls": values["failed_calls"], + "failure_rate": round(values["failed_calls"] / values["total_calls"], 4) if values["total_calls"] else 0.0, + } + for scene_key, values in stats.items() + } + + async def _function_runtime_observability(self, *, hours: int) -> dict[str, dict[str, Any]]: + since = now_cn_naive() - timedelta(hours=hours) + function_names = sorted({function for functions in SCENE_FUNCTIONS.values() for function in functions}) + rows = ( + await self.db.execute( + select(AICallLog).where( + and_( + AICallLog.tenant_id.in_([self.tenant_id, PLATFORM_RUNTIME_TENANT_ID]), + AICallLog.function_name.in_(function_names), + AICallLog.created_at >= since, + ) + ) + ) + ).scalars().all() + grouped: dict[str, list[AICallLog]] = {} + for row in rows: + grouped.setdefault(str(row.function_name or ""), []).append(row) + + def _completion_tokens(row: AICallLog) -> int: + payload = row.provider_response_payload if isinstance(row.provider_response_payload, dict) else {} + usage = payload.get("usage") if isinstance(payload.get("usage"), dict) else {} + return int(usage.get("completion_tokens") or 0) + + def _finish_reason(row: AICallLog) -> str: + payload = row.provider_response_payload if isinstance(row.provider_response_payload, dict) else {} + choices = payload.get("choices") if isinstance(payload.get("choices"), list) else [] + choice = choices[0] if choices and isinstance(choices[0], dict) else {} + return str(choice.get("finish_reason") or "").strip() + + def _percentile(values: list[int], percentile: float) -> int: + ordered = sorted(value for value in values if value > 0) + if not ordered: + return 0 + index = min(len(ordered) - 1, max(0, int(round((len(ordered) - 1) * percentile)))) + return ordered[index] + + result: dict[str, dict[str, Any]] = {} + for function_name, function_rows in grouped.items(): + latest = max(function_rows, key=lambda item: (item.created_at, item.id)) + failed = [row for row in function_rows if self._is_failed_status(str(row.status or ""))] + finish_reasons = [_finish_reason(row) for row in function_rows] + completion_tokens = [_completion_tokens(row) for row in function_rows] + result[function_name] = { + "window_hours": hours, + "total_calls": len(function_rows), + "failed_calls": len(failed), + "failure_rate": round(len(failed) / len(function_rows), 4) if function_rows else 0.0, + "length_finish_count": sum(1 for item in finish_reasons if item in {"length", "max_tokens"}), + "structured_json_invalid_count": sum(1 for row in function_rows if str(row.status or "") in {"structured_empty", "structured_json_invalid"}), + "completion_tokens_p50": _percentile(completion_tokens, 0.5), + "completion_tokens_p95": _percentile(completion_tokens, 0.95), + "latest_status": str(latest.status or ""), + "latest_finish_reason": _finish_reason(latest), + "latest_call_id": int(latest.id), + "latest_created_at": latest.created_at.isoformat() if latest.created_at else None, + } + return result + async def _build_diagnostics( self, *, @@ -425,7 +3295,6 @@ class AIRoutingGovernanceService: metrics: dict[str, Any], ) -> list[dict[str, Any]]: items: list[dict[str, Any]] = [] - items.extend(self._env_override_diagnostics(engine_bundle=engine_bundle)) if any(not slot.get("configured") for slot in slots if slot.get("tier") == "high"): items.append(self._risk("high_unavailable", "critical", "深度槽未配置可用供应商", "项目启动、需求结构化和复杂问答可能显式失败。")) if len(slots) == 2 and self._same_slot_runtime(slots[0], slots[1]): @@ -442,31 +3311,6 @@ class AIRoutingGovernanceService: items.append({"code": "ok", "severity": "ok", "title": "未发现明显配置风险", "message": "当前供应商、槽位和路由配置未触发内置风险规则。"}) return items - def _env_override_diagnostics(self, *, engine_bundle: dict[str, Any]) -> list[dict[str, Any]]: - items: list[dict[str, Any]] = [] - defaults = get_provider_defaults() - engines = [item for item in engine_bundle.get("engines", []) if isinstance(item, dict)] - for engine in engines: - provider = str(engine.get("provider") or "").strip().lower() - provider_defaults = defaults.get(provider) if isinstance(defaults.get(provider), dict) else {} - api_env = str(provider_defaults.get("api_env") or "").strip() - if not api_env: - continue - env_value = str(os.getenv(api_env, "") or "").strip() - db_value = str(engine.get("api_key") or "").strip() - if not env_value or not db_value or env_value == db_value: - continue - severity = "critical" if _looks_placeholder(env_value) else "warning" - items.append( - self._risk( - "env_key_override", - severity, - f"{PROVIDER_LABELS.get(provider, provider)} 环境变量正在覆盖租户密钥", - "运行时会优先使用环境变量中的密钥;若该值是占位符,会导致供应商请求失败。", - ) - ) - return items - def _protected_downgrade_diagnostics(self, *, routing_bundle: dict[str, Any]) -> list[dict[str, Any]]: mappings = routing_bundle.get("mappings") if isinstance(routing_bundle.get("mappings"), dict) else {} downgraded = [] @@ -499,6 +3343,15 @@ class AIRoutingGovernanceService: == str(b.get("base_url") or "").strip().rstrip("/").lower() ) + @staticmethod + def _same_engine_identity(a: dict[str, Any], b: dict[str, Any]) -> bool: + return ( + str(a.get("provider") or "").strip().lower() == str(b.get("provider") or "").strip().lower() + and str(a.get("model") or "").strip().lower() == str(b.get("model") or "").strip().lower() + and str(a.get("base_url") or "").strip().rstrip("/").lower() + == str(b.get("base_url") or "").strip().rstrip("/").lower() + ) + @staticmethod def _ollama_runtime_risk(*, engine_bundle: dict[str, Any], slots: list[dict[str, Any]]) -> bool: settings = engine_bundle.get("settings") if isinstance(engine_bundle.get("settings"), dict) else {} @@ -530,14 +3383,22 @@ class AIRoutingGovernanceService: .limit(80) ) ).scalars().all() + function_to_scene = { + function_name: scene_key + for scene_key, functions in SCENE_FUNCTIONS.items() + for function_name in functions + } failures: list[dict[str, Any]] = [] for row in rows: if not self._is_failed_status(str(row.status or "")): continue + scene_key = function_to_scene.get(str(row.function_name or "")) failures.append( { "function_label": self._function_display_name(str(row.function_name or "")), "function_name": str(row.function_name or ""), + "scene_key": scene_key, + "scene_label": SCENE_LABELS.get(scene_key or "", ""), "status_label": self._status_label(str(row.status or "")), "planned_slot": SLOT_LABELS.get(str(row.planned_tier or "").strip().lower(), str(row.planned_tier or "") or "-"), "executed_slot": SLOT_LABELS.get(str(row.executed_tier or "").strip().lower(), str(row.executed_tier or "") or "-"), diff --git a/backend/app/services/ai_routing_scene_contracts.py b/backend/app/services/ai_routing_scene_contracts.py new file mode 100644 index 00000000..3a342b4b --- /dev/null +++ b/backend/app/services/ai_routing_scene_contracts.py @@ -0,0 +1,349 @@ +"""Business-scene contracts for the platform AI routing console.""" + +from __future__ import annotations + +from typing import Any + + +AI_SCENE_REGISTRY: dict[str, dict[str, Any]] = { + "project_launch": { + "label": "项目启动草案", + "summary": "快速起草,失败后深度修复,结果必须进入确认", + "scene_family": "project_lifecycle", + "scene_owner": "PMFlow 项目启动链", + "lifecycle": "active", + "function_names": ["pmflow_launch_draft_v1", "project_from_text_analysis", "project_from_text_charter"], + "default_slot": "structured", + "required_slots": ["structured"], + "allowed_slots": ["structured"], + "protected": True, + "business_goal": "把一句话想法转成可确认的项目草案、初始任务和项目真相种子。", + "input_contract": "原始想法、空间上下文、已有项目约束", + "output_contract": "结构化草案 + 人工确认", + "success_criteria": ["草案字段完整", "缺失问题显式列出", "确认前不创建项目"], + "failure_policy": "结构化失败必须显式失败或进入待确认,禁止模板句冒充成功。", + "confirmation_policy": "必须进入人工确认后才写入项目主链。", + "evidence_policy": "需要结构化输出与 JSON Schema 探测证据。", + "protected_reason": "该场景会生成项目初始事实和任务,错误写入会污染后续执行主链。", + "expected_slot_key": "structured", + "expected_slot_label": "结构化槽", + }, + "requirement_structuring": { + "label": "需求结构化", + "summary": "深度优先,保护项目真相层入口", + "scene_family": "project_lifecycle", + "scene_owner": "PMFlow 需求主链", + "lifecycle": "active", + "function_names": ["project_requirement_extraction", "pmflow_requirement_plan_draft", "pmflow_requirement_plan_round"], + "default_slot": "structured", + "required_slots": ["high", "structured"], + "allowed_slots": ["high", "structured"], + "protected": True, + "business_goal": "把用户需求沉淀为可追踪、可确认、可拆解的结构化需求草案。", + "input_contract": "需求文本、追问上下文、reply/thread 锚点", + "output_contract": "JSON Schema + 缺字段追问", + "success_criteria": ["业务字段来自 requirement_card", "缺字段进入追问", "不从 prose 反推业务字段"], + "failure_policy": "结构化不足时返回待确认或追问,禁止掉回普通问答链。", + "confirmation_policy": "需求 draft confirm 必须有明确锚点或卡片按钮。", + "evidence_policy": "需要结构化输出、JSON Schema 与上下文锚点证据。", + "protected_reason": "需求草案是后续任务拆解和执行确认的业务真源。", + "expected_slot_key": "structured", + "expected_slot_label": "结构化槽", + }, + "task_breakdown": { + "label": "任务拆解", + "summary": "快速结构化,失败后深度修复,仍失败则标记待补充", + "scene_family": "project_lifecycle", + "scene_owner": "PMFlow 执行队列", + "lifecycle": "active", + "function_names": ["generate_task_breakdown", "decompose_tasks_from_charter", "sstc_ai_breakdown"], + "default_slot": "structured", + "required_slots": ["structured"], + "allowed_slots": ["structured"], + "protected": True, + "business_goal": "把已确认需求拆成可执行任务、依赖、责任边界和 pending 项。", + "input_contract": "需求草案、项目目标、团队能力图谱", + "output_contract": "结构化任务 + pending 标记", + "success_criteria": ["任务边界清晰", "不确定项标记 pending", "失败不冒充拆解完成"], + "failure_policy": "低阶结构化失败可升级;仍失败时必须显式 pending。", + "confirmation_policy": "高风险任务写入前需要人工确认。", + "evidence_policy": "需要结构化输出、JSON Schema 和任务字段完整性证据。", + "protected_reason": "任务拆解会直接影响执行队列和后续协同。", + "expected_slot_key": "structured", + "expected_slot_label": "结构化槽", + }, + "assignee_matching": { + "label": "人员匹配", + "summary": "深度分析,必须结合人员能力图谱", + "scene_family": "execution", + "scene_owner": "PMFlow 人员能力图谱", + "lifecycle": "active", + "function_names": ["recommend_assignees_for_task"], + "default_slot": "high", + "required_slots": ["high"], + "allowed_slots": ["high"], + "protected": True, + "business_goal": "基于人员能力图谱、负载和任务要求推荐可解释负责人。", + "input_contract": "任务要求、技能图谱、团队负载", + "output_contract": "能力图谱 + 可解释推荐", + "success_criteria": ["推荐理由可解释", "能力缺口显式展示", "不跳过人员能力图谱"], + "failure_policy": "证据不足时只给待确认候选,不自动派发。", + "confirmation_policy": "负责人变更属于执行决策,需要用户确认或 reviewer 审核。", + "evidence_policy": "需要人员能力图谱、负载和任务匹配证据。", + "expected_slot_key": "high", + "expected_slot_label": "深度槽", + }, + "internal_qa": { + "label": "内部问答", + "summary": "先走受控内部查询,再按复杂度组织答案", + "scene_family": "knowledge", + "scene_owner": "PMFlow 内部问答链", + "lifecycle": "active", + "function_names": ["adaptive_rag_answer_normal", "adaptive_rag_answer_deep", "rag_db_project_semantic_retrieval"], + "default_slot": "mixed", + "required_slots": ["low", "high"], + "allowed_slots": ["low", "high"], + "protected": False, + "business_goal": "基于项目真相、语义事实和知识库回答内部问题。", + "input_contract": "问题、对象绑定、内部上下文包", + "output_contract": "内部证据优先 + 来源摘要", + "success_criteria": ["先走内部证据", "来源中文化展示", "证据不足时澄清或显式不确定"], + "failure_policy": "不得让 LLM 自由编造内部结论;证据不足时返回 uncertain/clarify。", + "confirmation_policy": "只读回答不需要确认,涉及写入动作必须进入待确认。", + "evidence_policy": "需要 semantic facts、项目真相或受控检索证据。", + "expected_slot_key": "mixed", + "expected_slot_label": "快速/深度混合", + }, + "external_query": { + "label": "外部实时查询", + "summary": "外部查询必须经过计划、来源校验和答案验证", + "scene_family": "external", + "scene_owner": "PMFlow 外部查询链", + "lifecycle": "active", + "function_names": ["external_query_plan", "adaptive_rag_answer"], + "default_slot": "external_query", + "required_slots": ["external_query"], + "allowed_slots": ["external_query", "high"], + "protected": True, + "business_goal": "在需要新鲜外部信息时检索、验证并用中文业务摘要呈现。", + "input_contract": "外部问题、freshness 需求、来源策略", + "output_contract": "实时来源 + verifier + freshness guard", + "success_criteria": ["来源过滤 fail-closed", "verifier 可用", "无法确认时不编造最新结论"], + "failure_policy": "外部查询失败必须显式失败或返回 freshness guard。", + "confirmation_policy": "只读外部回答不确认;接入或入库动作需要确认。", + "evidence_policy": "需要外部 provider、source filtering、verifier 和 freshness 证据。", + "protected_reason": "外部事实错误会直接影响策略判断和推荐质量。", + "expected_slot_key": "external_query", + "expected_slot_label": "外部查询槽", + }, + "feishu_understanding": { + "label": "飞书理解", + "summary": "快速理解与分流,低置信度进入澄清", + "scene_family": "collaboration", + "scene_owner": "PMFlow 飞书集成", + "lifecycle": "active", + "function_names": ["feishu_level_classification", "feishu_signal_extraction", "pmflow_semantic_context_decision"], + "default_slot": "low", + "required_slots": ["low"], + "allowed_slots": ["low"], + "protected": False, + "business_goal": "从飞书消息、回复线程和卡片回流中恢复正确业务上下文。", + "input_contract": "飞书消息、reply/thread、卡片按钮、source_message_id", + "output_contract": "上下文锚点 + 主链选择", + "success_criteria": ["优先 canonical reply/thread", "不按同群旧草稿回捞", "主链选择可审计"], + "failure_policy": "锚点不足时澄清或回到中性问答,不强行进入需求链。", + "confirmation_policy": "draft confirm 必须来自显式锚点或卡片动作。", + "evidence_policy": "需要 reply/thread、work_item_id 或 active draft workspace 证据。", + "expected_slot_key": "low", + "expected_slot_label": "快速槽", + }, + "feishu_reply": { + "label": "飞书回复", + "summary": "快速润色,不改变业务结论", + "scene_family": "collaboration", + "scene_owner": "PMFlow 飞书集成", + "lifecycle": "active", + "function_names": ["feishu_humanized_reply", "feishu_qa_direct_natural_reply"], + "default_slot": "low", + "required_slots": ["low"], + "allowed_slots": ["low"], + "protected": False, + "business_goal": "把业务结论改写成适合飞书对话的中文自然表达。", + "input_contract": "业务结论、回复场景、对象上下文", + "output_contract": "中文自然表达 + 不改变结论", + "success_criteria": ["不新增未证实事实", "不改变业务结论", "语气适合协作场景"], + "failure_policy": "润色失败时返回原结论或显式失败,不编造补充信息。", + "confirmation_policy": "只读回复不确认;写入项目状态仍需确认。", + "evidence_policy": "需要上游业务结论与来源摘要。", + "expected_slot_key": "low", + "expected_slot_label": "快速槽", + }, + "release_review": { + "label": "发布评审", + "summary": "候选场景:发布前汇总变更、风险、验证和回滚建议", + "scene_family": "project_lifecycle", + "scene_owner": "PMFlow 发布增长链", + "lifecycle": "candidate", + "function_names": [], + "default_slot": "", + "required_slots": [], + "allowed_slots": [], + "protected": False, + "business_goal": "把一次发布前的变更、验证证据、风险和回滚方案组织成可审查结论。", + "input_contract": "变更集、验证记录、失败样本、项目目标", + "output_contract": "发布评审摘要 + 风险清单 + 回滚建议", + "success_criteria": ["风险可追踪", "验证证据可解释", "回滚建议不冒充自动发布"], + "failure_policy": "证据不足时标记待补充,不生成发布通过结论。", + "confirmation_policy": "发布结论必须由负责人确认。", + "evidence_policy": "需要变更集、测试/验证和失败样本证据。", + "expected_slot_key": "high", + "expected_slot_label": "深度槽", + }, + "retrospective_summary": { + "label": "项目复盘摘要", + "summary": "候选场景:从执行记录和结果信号生成复盘草案", + "scene_family": "project_lifecycle", + "scene_owner": "PMFlow 复盘链", + "lifecycle": "candidate", + "function_names": [], + "default_slot": "", + "required_slots": [], + "allowed_slots": [], + "protected": False, + "business_goal": "把项目结果、执行轨迹和关键偏差整理为可确认复盘。", + "input_contract": "项目目标、任务结果、发布数据、团队反馈", + "output_contract": "结构化复盘草案 + 改进行动", + "success_criteria": ["结论有证据", "改进项可执行", "不把推测写成事实"], + "failure_policy": "证据不足时只输出待补充项。", + "confirmation_policy": "复盘写回项必须人工确认。", + "evidence_policy": "需要执行、发布和反馈证据。", + "expected_slot_key": "long_context", + "expected_slot_label": "长上下文槽", + }, + "change_review": { + "label": "变更审查", + "summary": "从项目主链消息抽取待审变更,写回前必须人工确认", + "scene_family": "project_lifecycle", + "scene_owner": "PMFlow Change Review", + "lifecycle": "active", + "function_names": ["pmflow_change_review_extraction_v1"], + "default_slot": "structured", + "required_slots": ["structured"], + "allowed_slots": ["structured"], + "protected": True, + "business_goal": "把项目主链里的新增诉求、风险、任务和决策抽取成 ChangeSet 待审对象。", + "input_contract": "项目线程消息、附件摘要、可选对象锚点", + "output_contract": "结构化 proposed_items + evidence + missing_info", + "success_criteria": ["候选对象来自本轮消息证据", "风险分级清楚", "不自动提交变更"], + "failure_policy": "结构化失败或证据不足时显式失败/待补充,禁止创建模板 ChangeSet。", + "confirmation_policy": "写回或提交变更必须确认。", + "evidence_policy": "需要项目线程消息证据和结构化输出证据。", + "protected_reason": "该场景会生成可写回项目事实和任务的待审对象,误抽取会污染项目主链。", + "expected_slot_key": "structured", + "expected_slot_label": "结构化槽", + }, + "project_memory_writeback": { + "label": "项目记忆写回", + "summary": "候选场景:把已确认结论写入项目记忆和语义事实", + "scene_family": "knowledge", + "scene_owner": "PMFlow 记忆层", + "lifecycle": "candidate", + "function_names": [], + "default_slot": "", + "required_slots": [], + "allowed_slots": [], + "protected": False, + "business_goal": "把已确认业务结论转成可审计、可撤销的项目记忆写回计划。", + "input_contract": "确认后的业务结论、来源摘要、对象绑定", + "output_contract": "写回计划 + 证据引用 + 待确认", + "success_criteria": ["只写确认事实", "来源可追踪", "可撤销"], + "failure_policy": "来源不足时禁止写回。", + "confirmation_policy": "写回动作必须人工确认。", + "evidence_policy": "需要确认卡片、来源摘要和对象绑定证据。", + "expected_slot_key": "structured", + "expected_slot_label": "结构化槽", + }, + "mcp_execution_planning": { + "label": "MCP 执行规划", + "summary": "候选场景:为本地或外部工具执行生成受控计划", + "scene_family": "execution", + "scene_owner": "PMFlow 工具执行层", + "lifecycle": "candidate", + "function_names": [], + "default_slot": "", + "required_slots": [], + "allowed_slots": [], + "protected": False, + "business_goal": "把用户意图转成受控工具执行计划、权限边界和确认点。", + "input_contract": "用户意图、工具目录、权限和环境约束", + "output_contract": "工具执行计划 + 权限确认", + "success_criteria": ["命令可解释", "权限边界清楚", "危险动作确认"], + "failure_policy": "工具能力或权限不足时显式失败。", + "confirmation_policy": "本机执行、写操作和外部副作用必须确认。", + "evidence_policy": "需要工具目录、权限和运行环境证据。", + "expected_slot_key": "tool", + "expected_slot_label": "工具槽", + }, + "contract_sync": { + "label": "合同同步", + "summary": "候选场景:把外部规范或契约同步为内部可用上下文", + "scene_family": "knowledge", + "scene_owner": "PMFlow 契约同步", + "lifecycle": "candidate", + "function_names": [], + "default_slot": "", + "required_slots": [], + "allowed_slots": [], + "protected": False, + "business_goal": "把外部文档、接口契约或流程规范同步成可审计上下文。", + "input_contract": "外部文档、接口契约、同步来源", + "output_contract": "结构化契约摘要 + 差异说明", + "success_criteria": ["来源明确", "差异可审查", "不覆盖未确认契约"], + "failure_policy": "解析或来源校验失败时不写入。", + "confirmation_policy": "同步写入必须确认。", + "evidence_policy": "需要来源、版本和解析证据。", + "expected_slot_key": "long_context", + "expected_slot_label": "长上下文槽", + }, +} + + +def scene_contract(scene_key: str) -> dict[str, Any]: + scene = AI_SCENE_REGISTRY.get(str(scene_key or "").strip()) + if not scene: + return { + "scene_key": scene_key, + "label": scene_key, + "business_goal": "该场景由 PMFlow AI 运行层按业务语义触发。", + "input_contract": "业务上下文", + "output_contract": "显式成功 / 显式失败 / 待确认", + "success_criteria": ["输出可解释", "失败显式表达", "不绕过保护边界"], + "failure_policy": "证据不足或结构化不足时必须显式失败、澄清或进入待确认。", + "confirmation_policy": "涉及写入、派发或发布的动作必须人工确认。", + "evidence_policy": "需要对应场景的上下文、能力槽位和运行证据。", + "protected_reason": None, + "expected_slot_key": "", + "expected_slot_label": "按场景所需能力选择槽位", + "scene_family": "unknown", + "scene_owner": "PMFlow AI 运行层", + "lifecycle": "active", + "protected": False, + } + return { + "scene_key": scene_key, + "label": scene["label"], + "business_goal": scene["business_goal"], + "input_contract": scene["input_contract"], + "output_contract": scene["output_contract"], + "success_criteria": list(scene["success_criteria"]), + "failure_policy": scene["failure_policy"], + "confirmation_policy": scene["confirmation_policy"], + "evidence_policy": scene["evidence_policy"], + "protected_reason": scene.get("protected_reason"), + "expected_slot_key": scene.get("expected_slot_key", ""), + "expected_slot_label": scene.get("expected_slot_label", "按场景所需能力选择槽位"), + "scene_family": scene.get("scene_family", "unknown"), + "scene_owner": scene.get("scene_owner", "PMFlow AI 运行层"), + "lifecycle": scene.get("lifecycle", "active"), + "protected": bool(scene.get("protected", False)), + } diff --git a/backend/app/services/ai_scene_runtime_policy.py b/backend/app/services/ai_scene_runtime_policy.py new file mode 100644 index 00000000..ba97fb93 --- /dev/null +++ b/backend/app/services/ai_scene_runtime_policy.py @@ -0,0 +1,167 @@ +"""Scene-level AI runtime policy. + +This module keeps product SLA decisions out of the low-level transport layer. +The transport only consumes explicit routing metadata such as +``provider_timeout_seconds``; scene services decide those values from business +context and input scale. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +POLICY_VERSION = "scene-timeout-v1" + + +@dataclass(frozen=True) +class SceneRuntimePolicy: + scene_key: str + function_name: str + input_bucket: str + foreground_wait_seconds: int + provider_timeout_seconds: int + job_timeout_seconds: int + max_retries: int + fallback_policy: str + large_input_policy: str + source: str = "default_scene_policy" + version: str = POLICY_VERSION + + def routing_meta(self, *, input_tokens_estimate: int, reason: str = "") -> dict[str, Any]: + return { + "scene_key": self.scene_key, + "scene_timeout_policy_id": f"{self.scene_key}:{self.function_name}:{self.input_bucket}", + "scene_timeout_policy_version": self.version, + "input_bucket": self.input_bucket, + "input_tokens_estimate": int(input_tokens_estimate), + "foreground_wait_seconds": self.foreground_wait_seconds, + "provider_timeout_seconds": self.provider_timeout_seconds, + "job_timeout_seconds": self.job_timeout_seconds, + "max_retries": self.max_retries, + "scene_fallback_policy": self.fallback_policy, + "large_input_policy": self.large_input_policy, + "timeout_policy_source": self.source, + "timeout_policy_reason": reason or self._default_reason(input_tokens_estimate), + } + + def _default_reason(self, input_tokens_estimate: int) -> str: + return ( + f"{self.scene_key}/{self.function_name} uses {self.input_bucket} policy " + f"for about {int(input_tokens_estimate)} input tokens" + ) + + +class AISceneRuntimePolicyService: + """Resolve timeout and job policies for AI business scenes.""" + + PROJECT_LAUNCH_FUNCTION = "pmflow_launch_draft_v1" + + @classmethod + def estimate_tokens(cls, text: str) -> int: + value = str(text or "") + if not value: + return 0 + # Conservative estimate used only for policy selection and audit. + return max(1, len(value) // 4) + + @classmethod + def input_bucket_for_tokens(cls, tokens: int) -> str: + count = max(int(tokens or 0), 0) + if count < 1500: + return "small" + if count <= 8000: + return "normal" + return "large" + + @classmethod + def resolve( + cls, + *, + scene_key: str, + function_name: str, + input_text: str, + attachment_count: int = 0, + ) -> tuple[SceneRuntimePolicy, int]: + normalized_scene = str(scene_key or "").strip().lower() + normalized_function = str(function_name or "").strip().lower() + input_tokens = cls.estimate_tokens(input_text) + bucket = cls.input_bucket_for_tokens(input_tokens) + if int(attachment_count or 0) > 0 and bucket == "small": + bucket = "normal" + + if normalized_scene == "project_launch" and normalized_function == cls.PROJECT_LAUNCH_FUNCTION: + return cls._project_launch_policy(function_name=normalized_function, bucket=bucket), input_tokens + + return ( + SceneRuntimePolicy( + scene_key=normalized_scene or "unknown", + function_name=normalized_function or "unknown", + input_bucket=bucket, + foreground_wait_seconds=10, + provider_timeout_seconds=60, + job_timeout_seconds=180, + max_retries=1, + fallback_policy="same_slot_fallback", + large_input_policy="preprocess_then_generate", + ), + input_tokens, + ) + + @staticmethod + def _project_launch_policy(*, function_name: str, bucket: str) -> SceneRuntimePolicy: + specs = { + "small": { + "foreground": 8, + "provider": 60, + "job": 180, + "large_input_policy": "direct_generate", + }, + "normal": { + "foreground": 10, + "provider": 90, + "job": 180, + "large_input_policy": "direct_generate", + }, + "large": { + "foreground": 12, + "provider": 120, + "job": 300, + "large_input_policy": "preprocess_then_generate", + }, + } + spec = specs.get(bucket, specs["normal"]) + return SceneRuntimePolicy( + scene_key="project_launch", + function_name=function_name, + input_bucket=bucket, + foreground_wait_seconds=spec["foreground"], + provider_timeout_seconds=spec["provider"], + job_timeout_seconds=spec["job"], + max_retries=1, + fallback_policy="same_slot_fallback", + large_input_policy=spec["large_input_policy"], + ) + + @staticmethod + def timeout_recommendation(*, status: str, routing_meta: dict[str, Any]) -> dict[str, Any] | None: + text = str(status or "").strip().lower() + if text != "structured_timeout": + return None + current_timeout = routing_meta.get("provider_timeout_seconds") + bucket = str(routing_meta.get("input_bucket") or "unknown") + scene_key = str(routing_meta.get("scene_key") or "unknown") + return { + "code": "model_read_timeout", + "label": "模型读超时", + "summary": "上游模型在场景策略规定时间内没有返回可解析结构化结果。", + "recommended_actions": [ + "检查同场景 p95/p99 延迟后调整 provider timeout", + "为 structured 槽配置同能力 fallback runtime", + "输入进入 large 桶时先做材料预处理或分段结构化", + ], + "current_provider_timeout_seconds": current_timeout, + "input_bucket": bucket, + "scene_key": scene_key, + } diff --git a/backend/app/services/ai_service_core/mixin_runtime_core.py b/backend/app/services/ai_service_core/mixin_runtime_core.py index a9967753..937d5e91 100644 --- a/backend/app/services/ai_service_core/mixin_runtime_core.py +++ b/backend/app/services/ai_service_core/mixin_runtime_core.py @@ -57,8 +57,8 @@ class AIServiceRuntimeCoreMixin: } provider = str(os.getenv("LLM_PROVIDER", "openai") or "openai").strip().lower() - if provider == "glm": - defaults = dict(PROVIDER_DEFAULTS.get("glm", {})) + if provider == "dashscope": + defaults = dict(PROVIDER_DEFAULTS.get("dashscope", {})) return { "api_key": str( api_key @@ -68,7 +68,7 @@ class AIServiceRuntimeCoreMixin: "model": str( model or defaults.get("model") - or DEFAULT_GLM_MODEL + or DEFAULT_DASHSCOPE_LOW_MODEL or "" ).strip(), "base_url": str( diff --git a/backend/app/services/ai_service_core/shared.py b/backend/app/services/ai_service_core/shared.py index 05645b2c..73e9cf15 100644 --- a/backend/app/services/ai_service_core/shared.py +++ b/backend/app/services/ai_service_core/shared.py @@ -52,9 +52,9 @@ DEFAULT_OPENAI_MODEL = str( or _TIER_DEFAULTS.get("high", {}).get("model") or "GLM-5" ) -DEFAULT_DASHSCOPE_BASE_URL = str(_PROVIDER_DEFAULTS.get("glm", {}).get("base_url") or "https://dashscope.aliyuncs.com/compatible-mode/v1") -DEFAULT_DASHSCOPE_MODEL = str(_PROVIDER_DEFAULTS.get("glm", {}).get("model") or "qwen-plus") -DEFAULT_GLM_MODEL = DEFAULT_DASHSCOPE_MODEL +DEFAULT_DASHSCOPE_BASE_URL = str(_PROVIDER_DEFAULTS.get("dashscope", {}).get("base_url") or "https://dashscope.aliyuncs.com/compatible-mode/v1") +DEFAULT_DASHSCOPE_MODEL = str(_PROVIDER_DEFAULTS.get("dashscope", {}).get("model") or "qwen-plus") +DEFAULT_DASHSCOPE_LOW_MODEL = DEFAULT_DASHSCOPE_MODEL from ...config.ai_prompts import build_default_role_prompt_bundle from ...core.database import AsyncSessionLocal diff --git a/backend/app/services/auth/service.py b/backend/app/services/auth/service.py index b14ab657..d4b64453 100644 --- a/backend/app/services/auth/service.py +++ b/backend/app/services/auth/service.py @@ -237,14 +237,14 @@ def can_assign_tasks(user_role: str) -> bool: def can_view_ai_policy(user_role: str) -> bool: """AI policy pages are visible to governance roles only.""" - return has_any_role(user_role, ["OWNER", "REVIEWER", "AI_CONFIG_VIEWER", "AI_OPERATOR", "AI_ADMIN"]) + return has_any_role(user_role, ["OWNER", "REVIEWER"]) def can_operate_ai_routing(user_role: str) -> bool: """AI routing diagnostics/tests are available to reviewer-level operators.""" - return has_any_role(user_role, ["OWNER", "REVIEWER", "AI_OPERATOR", "AI_ADMIN"]) + return has_any_role(user_role, ["OWNER", "REVIEWER"]) def can_approve_ai_routing_change(user_role: str) -> bool: """High-risk AI routing/policy changes require owner approval in v1.""" - return has_any_role(user_role, ["OWNER", "AI_ADMIN"]) + return has_any_role(user_role, ["OWNER"]) diff --git a/backend/app/services/external_query_service/presenter.py b/backend/app/services/external_query_service/presenter.py index 66f246f6..725d5c7f 100644 --- a/backend/app/services/external_query_service/presenter.py +++ b/backend/app/services/external_query_service/presenter.py @@ -11,7 +11,7 @@ from ...config.ai_prompts import ( PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA, PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA, ) -from ..prompt_release import PromptReleaseService +from ..prompt_runtime_service import PromptRuntimeRenderResult, PromptRuntimeService from .schemas import ( ExternalContextPacket, SignalSourceRecommendationCandidate, @@ -392,7 +392,7 @@ class ExternalAnswerPresenter: signal_source_objectives = ";".join( [str(item).strip() for item in list(packet.plan.signal_source_objectives or []) if str(item).strip()][:4] ) or "无" - prompt = await self._render_prompt( + prompt_result = await self._render_prompt_result( prompt_key="PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET", fallback_template=PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET, question=str(question or "").strip(), @@ -405,6 +405,7 @@ class ExternalAnswerPresenter: uncertainties=";".join([str(item).strip() for item in list(packet.uncertainties or []) if str(item).strip()]) or "无", source_summaries=self._build_source_summaries(packet), ) + prompt = prompt_result.prompt try: structured = await generator( prompt=prompt, @@ -419,6 +420,7 @@ class ExternalAnswerPresenter: "response_contract": "structured", "fallback_policy": "fast_local", "execution_mode": "query", + **prompt_result.routing_meta(), }, ) payload = getattr(structured, "payload", None) @@ -512,9 +514,28 @@ class ExternalAnswerPresenter: fallback_template: str, **kwargs: Any, ) -> str: + return ( + await self._render_prompt_result( + prompt_key=prompt_key, + fallback_template=fallback_template, + **kwargs, + ) + ).prompt + + async def _render_prompt_result( + self, + *, + prompt_key: str, + fallback_template: str, + **kwargs: Any, + ) -> PromptRuntimeRenderResult: if not hasattr(self.db, "execute"): - return str(fallback_template).format(**kwargs).strip() - return await PromptReleaseService(self.db).render_prompt( + return PromptRuntimeRenderResult( + prompt=str(fallback_template).format(**kwargs).strip(), + prompt_key=prompt_key, + source="default_fallback", + ) + return await PromptRuntimeService(self.db).render( tenant_id=self.tenant_id, prompt_key=prompt_key, fallback_template=fallback_template, @@ -567,7 +588,7 @@ class ExternalAnswerPresenter: contract = self._normalize_contract(packet.plan.answer_contract) prompt_key, fallback_template, prompt_variant_id = self._PROMPT_CONFIG[contract] - prompt = await self._render_prompt( + prompt_result = await self._render_prompt_result( prompt_key=prompt_key, fallback_template=fallback_template, question=str(question or "").strip(), @@ -577,6 +598,7 @@ class ExternalAnswerPresenter: time_range=str(packet.plan.time_range_hint or "").strip() or "未指定", uncertainties=";".join([str(item).strip() for item in list(packet.uncertainties or []) if str(item).strip()]) or "无", ) + prompt = prompt_result.prompt deep_thinking = str(thinking_mode or "").strip().lower() == "deep" if deep_thinking: @@ -600,7 +622,10 @@ class ExternalAnswerPresenter: "fallback_policy": "low_to_high", "execution_mode": "query", "routing_decision": "force_low_first", + **prompt_result.routing_meta(), } + else: + routing_meta = prompt_result.routing_meta() answer = await self.llm_service.generate( prompt, db=self.db, diff --git a/backend/app/services/external_query_service/registry.py b/backend/app/services/external_query_service/registry.py index 93f9e736..8b5b1f11 100644 --- a/backend/app/services/external_query_service/registry.py +++ b/backend/app/services/external_query_service/registry.py @@ -1,11 +1,119 @@ from __future__ import annotations +import os from typing import Any +EXTERNAL_QUERY_PROVIDER_SPECS = { + "mcp": { + "id": "mcp", + "label": "MCP 外部查询 Adapter", + "kind": "mcp_adapter", + "summary": "优先通过已接入 MCP runtime 获取外部公开来源。", + }, + "live_search": { + "id": "live_search", + "label": "Bounded Live Search Fallback", + "kind": "bounded_live_search", + "summary": "MCP 不可用时使用受控实时搜索 fallback,并继续经过 verifier 与来源过滤。", + }, +} + + +def _env_enabled(name: str, default: bool = True) -> bool: + raw = str(os.getenv(name, "1" if default else "0") or "").strip().lower() + return raw not in {"0", "false", "no", "off", "disabled"} + + class ExternalProviderRegistry: - def __init__(self, *, adapters: list[Any] | None = None) -> None: + def __init__( + self, + *, + adapters: list[Any] | None = None, + provider_statuses: list[dict[str, Any]] | None = None, + ) -> None: self._adapters = list(adapters or []) + self._provider_statuses = list(provider_statuses or []) def iter_adapters(self) -> list[Any]: return list(self._adapters) + + def provider_statuses(self) -> list[dict[str, Any]]: + if self._provider_statuses: + return [dict(item) for item in self._provider_statuses if isinstance(item, dict)] + adapter_names = { + str(getattr(adapter, "adapter_name", "") or "").strip() + for adapter in self._adapters + if str(getattr(adapter, "adapter_name", "") or "").strip() + } + return self.default_provider_statuses( + mcp_available="mcp" in adapter_names, + live_search_available="live_search" in adapter_names, + ) + + @classmethod + def default_provider_statuses( + cls, + *, + mcp_available: bool | None = None, + live_search_available: bool | None = None, + ) -> list[dict[str, Any]]: + mcp_enabled = _env_enabled("AI_EXTERNAL_QUERY_MCP_ADAPTER_ENABLED", True) + live_search_enabled = _env_enabled("AI_EXTERNAL_QUERY_LIVE_SEARCH_FALLBACK_ENABLED", True) + if mcp_available is not None: + mcp_enabled = bool(mcp_available) + if live_search_available is not None: + live_search_enabled = bool(live_search_available) + return [ + cls._provider_status("mcp", available=mcp_enabled), + cls._provider_status("live_search", available=live_search_enabled), + ] + + @staticmethod + def _provider_status(provider_id: str, *, available: bool) -> dict[str, Any]: + spec = dict(EXTERNAL_QUERY_PROVIDER_SPECS.get(provider_id) or {}) + spec.setdefault("id", provider_id) + spec.setdefault("label", provider_id) + spec["available"] = bool(available) + spec["status"] = "available" if available else "unavailable" + return spec + + @classmethod + def status_snapshot(cls, *, binding: dict[str, Any] | None = None) -> dict[str, Any]: + providers = cls.default_provider_statuses() + provider_by_id = {str(item.get("id") or ""): item for item in providers} + raw_binding = dict(binding or {}) if isinstance(binding, dict) else {} + primary_provider_id = str(raw_binding.get("primary_provider_id") or "").strip() + fallback_provider_ids = [ + str(item or "").strip() + for item in list(raw_binding.get("fallback_provider_ids") or []) + if str(item or "").strip() + ] + verifier_available = _env_enabled("AI_EXTERNAL_QUERY_VERIFIER_ENABLED", True) + source_filtering_fail_closed = _env_enabled("AI_EXTERNAL_QUERY_SOURCE_FILTERING_FAIL_CLOSED", True) + selected_ids = [primary_provider_id, *fallback_provider_ids] + selected_available = [ + provider_by_id[item] + for item in selected_ids + if item in provider_by_id and bool(provider_by_id[item].get("available")) + ] + return { + "providers": providers, + "provider_ids": [str(item.get("id") or "") for item in providers], + "available_provider_count": len([item for item in providers if bool(item.get("available"))]), + "selected_available_provider_count": len(selected_available), + "primary_provider_id": primary_provider_id or None, + "fallback_provider_ids": fallback_provider_ids, + "mcp_adapter_available": bool(provider_by_id.get("mcp", {}).get("available")), + "live_search_fallback_available": bool(provider_by_id.get("live_search", {}).get("available")), + "verifier": { + "required": True, + "available": verifier_available, + "status": "available" if verifier_available else "unavailable", + }, + "source_filtering": { + "required": True, + "fail_closed": source_filtering_fail_closed, + "status": "fail_closed" if source_filtering_fail_closed else "open", + }, + } diff --git a/backend/app/services/llm_service/mixin_chat_openai.py b/backend/app/services/llm_service/mixin_chat_openai.py index 0490bbfb..ac8220f6 100644 --- a/backend/app/services/llm_service/mixin_chat_openai.py +++ b/backend/app/services/llm_service/mixin_chat_openai.py @@ -8,6 +8,26 @@ class LLMEmptyContentError(ValueError): class LLMChatOpenAIMixin: + @staticmethod + def _attach_provider_audit_to_exception( + exc: Exception, + *, + request_payload: Any = None, + response_payload: Any = None, + attempts: list[dict[str, Any]] | None = None, + ) -> Exception: + setattr(exc, "provider_request_payload", request_payload) + setattr(exc, "provider_response_payload", response_payload) + setattr(exc, "provider_attempts", attempts or []) + return exc + + @staticmethod + def _provider_audit_copy(value: Any) -> Any: + try: + return json.loads(json.dumps(value, ensure_ascii=False, default=str)) + except Exception: + return value + @staticmethod def _is_provider_billing_blocked_http_error(*, status_code: int, body_preview: str) -> bool: if int(status_code or 0) not in {400, 401, 402, 403}: @@ -79,6 +99,9 @@ class LLMChatOpenAIMixin: http_status_retry_attempt = 0 network_retry_budget = 1 network_retry_attempt = 0 + explicit_retry_budget = self._max_retries_from_routing_meta(routing_meta) + transient_retries_left = explicit_retry_budget + provider_attempts: list[dict[str, Any]] = [] empty_chat_retry_budget = self._empty_chat_retry_budget( function_name=function_label, current_max_tokens=current_max_tokens, @@ -100,18 +123,42 @@ class LLMChatOpenAIMixin: "timeoutSeconds": int(control_plane_agents_timeout_seconds), } } + attempt_record: dict[str, Any] = { + "provider": provider_name, + "model": model, + "endpoint": endpoint, + "request_payload": self._provider_audit_copy(payload), + } try: async with httpx.AsyncClient(timeout=request_timeout, trust_env=False) as client: resp = await client.post(endpoint, headers=headers, json=payload) except httpx.RequestError as exc: - if network_retry_budget > 0: + attempt_record.update( + { + "status": "request_error", + "error_type": type(exc).__name__, + "error": str(exc)[:2000], + } + ) + provider_attempts.append(attempt_record) + can_retry_network_error = ( + transient_retries_left > 0 + if transient_retries_left is not None + else network_retry_budget > 0 + ) + if can_retry_network_error: retry_delay = min(0.8 * (2 ** network_retry_attempt), 3.0) + retries_left_after = ( + max(int(transient_retries_left or 0) - 1, 0) + if transient_retries_left is not None + else network_retry_budget - 1 + ) _runtime_log( "LLM", ( "网络异常重试:" f"function={function_label}, " - f"retry_in={retry_delay:.2f}s, left={network_retry_budget - 1}, " + f"retry_in={retry_delay:.2f}s, left={retries_left_after}, " f"error={type(exc).__name__}" ), ) @@ -124,11 +171,19 @@ class LLMChatOpenAIMixin: type(exc).__name__, str(exc)[:260], ) - network_retry_budget -= 1 + if transient_retries_left is not None: + transient_retries_left -= 1 + else: + network_retry_budget -= 1 network_retry_attempt += 1 await asyncio.sleep(retry_delay) continue - raise + raise self._attach_provider_audit_to_exception( + exc, + request_payload=payload, + response_payload=None, + attempts=provider_attempts, + ) else: if is_control_plane_gateway_request: elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0 @@ -141,6 +196,19 @@ class LLMChatOpenAIMixin: ) if resp.status_code >= 400: body_preview = str(resp.text or "").replace("\n", " ").strip()[:260] + response_payload: Any + try: + response_payload = resp.json() + except Exception: + response_payload = {"raw_text": str(resp.text or "")} + attempt_record.update( + { + "status": "http_error", + "status_code": int(resp.status_code), + "response_payload": response_payload, + } + ) + provider_attempts.append(attempt_record) if is_control_plane_gateway_request: _runtime_log( "PMFlow 本地路由层调用", @@ -154,10 +222,17 @@ class LLMChatOpenAIMixin: status_code=status_code, body_preview=body_preview, ): - raise ValueError(f"provider_billing_blocked:{provider_name}:{body_preview}") - can_retry_http_status = ( - http_status_retry_budget > 0 - and self._is_retryable_http_status(status_code) + exc = ValueError(f"provider_billing_blocked:{provider_name}:{body_preview}") + raise self._attach_provider_audit_to_exception( + exc, + request_payload=payload, + response_payload=response_payload, + attempts=provider_attempts, + ) + can_retry_http_status = self._is_retryable_http_status(status_code) and ( + transient_retries_left > 0 + if transient_retries_left is not None + else http_status_retry_budget > 0 ) if can_retry_http_status: retry_delay = self._http_retry_delay_seconds( @@ -165,16 +240,24 @@ class LLMChatOpenAIMixin: attempt=http_status_retry_attempt, retry_after_header=resp.headers.get("Retry-After"), ) + retries_left_after = ( + max(int(transient_retries_left or 0) - 1, 0) + if transient_retries_left is not None + else http_status_retry_budget - 1 + ) _runtime_log( "LLM", ( "HTTP状态重试:" f"function={function_label}, " f"status={status_code}, retry_in={retry_delay:.2f}s, " - f"left={http_status_retry_budget - 1}" + f"left={retries_left_after}" ), - ) - http_status_retry_budget -= 1 + ) + if transient_retries_left is not None: + transient_retries_left -= 1 + else: + http_status_retry_budget -= 1 http_status_retry_attempt += 1 await asyncio.sleep(retry_delay) continue @@ -190,11 +273,49 @@ class LLMChatOpenAIMixin: ) use_json_response_format = False continue - resp.raise_for_status() - data = resp.json() + try: + resp.raise_for_status() + except Exception as exc: + raise self._attach_provider_audit_to_exception( + exc, + request_payload=payload, + response_payload=response_payload, + attempts=provider_attempts, + ) + try: + data = resp.json() + except Exception as exc: + attempt_record.update( + { + "status": "invalid_json_response", + "status_code": int(resp.status_code), + "response_payload": {"raw_text": str(resp.text or "")}, + } + ) + provider_attempts.append(attempt_record) + raise self._attach_provider_audit_to_exception( + exc, + request_payload=payload, + response_payload={"raw_text": str(resp.text or "")}, + attempts=provider_attempts, + ) + attempt_record.update( + { + "status": "success", + "status_code": int(resp.status_code), + "response_payload": data, + } + ) + provider_attempts.append(attempt_record) choices = data.get("choices") or [] if not choices: - raise ValueError("LLM 响应无 choices") + exc = ValueError("LLM 响应无 choices") + raise self._attach_provider_audit_to_exception( + exc, + request_payload=payload, + response_payload=data, + attempts=provider_attempts, + ) first_choice = choices[0] if isinstance(choices[0], dict) else {} message = first_choice.get("message") if isinstance(first_choice, dict) else {} text = self._extract_text_from_chat_response( @@ -281,7 +402,12 @@ class LLMChatOpenAIMixin: ), ) continue - raise empty_error + raise self._attach_provider_audit_to_exception( + empty_error, + request_payload=payload, + response_payload=data, + attempts=provider_attempts, + ) if _is_control_plane_timeout_message(text): elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0 _runtime_log( @@ -309,4 +435,9 @@ class LLMChatOpenAIMixin: f"function={function_label} elapsed_ms={elapsed_ms:.0f} text_len={len(text)}" ), ) - return ChatCompletionResult(text=text, raw_response=data) + return ChatCompletionResult( + text=text, + raw_request=payload, + raw_response=data, + provider_attempts=provider_attempts, + ) diff --git a/backend/app/services/llm_service/mixin_chat_transport.py b/backend/app/services/llm_service/mixin_chat_transport.py index f31015e8..681b5b07 100644 --- a/backend/app/services/llm_service/mixin_chat_transport.py +++ b/backend/app/services/llm_service/mixin_chat_transport.py @@ -199,4 +199,17 @@ class LLMChatTransportMixin: text = self._sanitize_visible_answer(self._extract_text_from_value(data.get("response"))) if not text and not isinstance(data, dict): raise ValueError("Ollama 返回空内容") - return ChatCompletionResult(text=text, raw_response=data) + return ChatCompletionResult( + text=text, + raw_request=payload, + raw_response=data, + provider_attempts=[ + { + "provider": "ollama", + "endpoint": endpoint, + "request_payload": payload, + "response_payload": data, + "status": "success", + } + ], + ) diff --git a/backend/app/services/llm_service/mixin_control_plane_routing.py b/backend/app/services/llm_service/mixin_control_plane_routing.py index 97ebfa7a..3c3b6d5c 100644 --- a/backend/app/services/llm_service/mixin_control_plane_routing.py +++ b/backend/app/services/llm_service/mixin_control_plane_routing.py @@ -55,6 +55,7 @@ class LLMControlPlaneRoutingMixin: function_name: str, engines: list[dict[str, Any]], active_engine: dict[str, Any], + slot_bundle: dict[str, Any] | None = None, routing_meta: dict[str, Any] | None = None, ) -> Optional[dict[str, Any]]: meta = _normalize_routing_meta(routing_meta) @@ -77,6 +78,7 @@ class LLMControlPlaneRoutingMixin: rule=rule, engines=engines, active_engine=active_engine, + slot_bundle=slot_bundle, ) if routed is None: return None @@ -111,13 +113,13 @@ class LLMControlPlaneRoutingMixin: rule: dict[str, Any], engines: list[dict[str, Any]], active_engine: dict[str, Any], + slot_bundle: dict[str, Any] | None = None, ) -> Optional[dict[str, Any]]: tier = str(rule.get("tier") or "").strip().lower() provider_raw = str(rule.get("provider") or "").strip().lower() engine_id = str(rule.get("engine_id") or "").strip() model_override = str(rule.get("model") or "").strip() base_url_override = str(rule.get("base_url") or "").strip().rstrip("/") - api_env_override = str(rule.get("api_env") or "").strip() routed: Optional[dict[str, Any]] = None if engine_id: @@ -126,6 +128,14 @@ class LLMControlPlaneRoutingMixin: routed = self._normalize_engine_runtime(item) break + if routed is None and tier in {"high", "low"}: + routed = self._resolve_tier_engine( + tier=tier, + engines=engines, + active_engine=active_engine, + slot_bundle=slot_bundle, + ) + if routed is None and provider_raw: try: provider = _normalize_provider(provider_raw) @@ -136,33 +146,28 @@ class LLMControlPlaneRoutingMixin: if str(item.get("provider") or "").strip().lower() == provider: routed = self._normalize_engine_runtime(item) break - if routed is None: - try: - routed = self._env_provider_runtime(provider) - except Exception: - routed = None - - if routed is None and tier in {"high", "low"}: - routed = self._resolve_tier_engine( - tier=tier, - engines=engines, - active_engine=active_engine, - ) if routed is None: return None - if model_override: + if model_override and not self._slot_binding_exists(slot_bundle=slot_bundle, tier=tier): routed["model"] = model_override - if base_url_override: + if base_url_override and not self._slot_binding_exists(slot_bundle=slot_bundle, tier=tier): routed["base_url"] = base_url_override - if api_env_override: - env_api_key = str(os.getenv(api_env_override, "") or "").strip() - if env_api_key: - routed["api_key"] = env_api_key - routed["api_env"] = api_env_override return routed + @staticmethod + def _slot_binding_exists(*, slot_bundle: dict[str, Any] | None, tier: str) -> bool: + slots = {} + if isinstance(slot_bundle, dict): + active = slot_bundle.get("active") if isinstance(slot_bundle.get("active"), dict) else {} + if isinstance(active.get("slots"), dict): + slots = active["slots"] + elif isinstance(slot_bundle.get("slots"), dict): + slots = slot_bundle["slots"] + slot = slots.get(str(tier or "").strip().lower()) + return isinstance(slot, dict) and bool(str(slot.get("primary_engine_id") or "").strip()) + def _resolve_function_tier(self, function_name: str, routing_meta: dict[str, Any] | None = None) -> str: meta = _normalize_routing_meta(routing_meta) explicit_tier = _optional_tier_alias( diff --git a/backend/app/services/llm_service/mixin_engine_admin.py b/backend/app/services/llm_service/mixin_engine_admin.py index 34fc2812..4c2ec62c 100644 --- a/backend/app/services/llm_service/mixin_engine_admin.py +++ b/backend/app/services/llm_service/mixin_engine_admin.py @@ -2,13 +2,818 @@ from __future__ import annotations from .shared import * +_CAPABILITY_PROBE_KEYS = ( + "basic_chat", + "model_self_report", + "structured_output", + "json_schema", + "tool_calling", + "streaming", + "long_context_32k", + "long_context_128k", + "vision_input", + "audio_input", + "private_deployment", + "external_search_ready", + "long_context", +) +_CAPABILITY_PROBE_STATUSES = {"untested", "running", "passed", "failed", "partial"} +_CAPABILITY_EVIDENCE_STATUSES = {"verified", "failed", "skipped", "not_probeable", "declared_only"} +_LIVE_EVIDENCE_STRENGTHS = {"live_probe", "smoke_probe", "provider_contract", "operator_verified"} +_DECLARED_EVIDENCE_STRENGTHS = {"declared_only", "manual", "inferred", "model_self_report"} +_CAPABILITY_TARGET_ALIASES = { + "basic": ["basic_chat"], + "chat": ["basic_chat"], + "basic_chat": ["basic_chat"], + "model_self_report": ["model_self_report"], + "self_report": ["model_self_report"], + "model_report": ["model_self_report"], + "structured": ["structured_output", "json_schema"], + "structured_output": ["structured_output"], + "json": ["json_schema"], + "json_schema": ["json_schema"], + "tool": ["tool_calling"], + "tool_calling": ["tool_calling"], + "function_calling": ["tool_calling"], + "stream": ["streaming"], + "streaming": ["streaming"], + "long_context": ["long_context"], + "long_context_32k": ["long_context_32k"], + "long_context_128k": ["long_context_128k"], + "vision": ["vision_input"], + "vision_input": ["vision_input"], + "audio": ["audio_input"], + "audio_input": ["audio_input"], +} +_DECLARED_CAPABILITY_FIELDS = { + "structured_output", + "json_schema", + "tool_calling", + "long_context", + "max_context_tokens", + "vision", + "audio", + "local_only", + "private_deployment", + "external_search_ready", + "avg_latency_ms", + "cost_tier", + "reliability_tier", +} +_DECLARED_PROBE_REQUIREMENT_FLAGS = { + "structured_output": "structured_output", + "json_schema": "json_schema", + "tool_calling": "tool_calling", + "long_context": "long_context", + "vision_input": "vision", + "audio_input": "audio", + "private_deployment": "private_deployment", + "external_search_ready": "external_search_ready", +} +_CAPABILITY_DECLARED_FIELDS_META = "capability_declared_fields" +_PROVIDER_MODEL_CONTEXT_CONTRACTS = { + ("dashscope", "qwen-plus"): { + "context_tokens": 1_000_000, + "max_input_tokens": 997_952, + "source": "aliyun_model_studio_official_spec", + "label": "阿里云百炼 Qwen-Plus 官方模型规格", + }, +} + + +def _provider_model_context_contract(provider: Any, model: Any) -> dict[str, Any] | None: + normalized_provider = str(provider or "").strip().lower() + normalized_model = str(model or "").strip().lower() + if not normalized_provider or not normalized_model: + return None + exact = _PROVIDER_MODEL_CONTEXT_CONTRACTS.get((normalized_provider, normalized_model)) + if exact: + return dict(exact) + if normalized_provider == "dashscope" and normalized_model.startswith("qwen-plus-"): + return dict(_PROVIDER_MODEL_CONTEXT_CONTRACTS[("dashscope", "qwen-plus")]) + return None + + +def _explicit_declared_capability_fields(source: dict[str, Any]) -> set[str]: + raw_fields = source.get(_CAPABILITY_DECLARED_FIELDS_META) + allowed = set(_DECLARED_PROBE_REQUIREMENT_FLAGS.values()) + if isinstance(raw_fields, list): + return {str(item) for item in raw_fields if str(item) in allowed} + return {field for field in allowed if field in source} + + +def _probe_status_declared_scope(declared: dict[str, Any], explicit_fields: set[str]) -> dict[str, Any]: + if not explicit_fields: + return {} + scoped = dict(declared) + for declared_flag in _DECLARED_PROBE_REQUIREMENT_FLAGS.values(): + if declared_flag not in explicit_fields: + scoped[declared_flag] = False + return scoped + + +def _empty_capability_probe(source: str = "inferred") -> dict[str, Any]: + normalized_source = str(source or "inferred").strip().lower() + if normalized_source not in {"inferred", "manual", "probed"}: + normalized_source = "inferred" + return { + "status": "untested", + "last_verified_at": None, + "verified_capabilities": {key: False for key in _CAPABILITY_PROBE_KEYS}, + "evidence": {}, + "source": normalized_source, + } + + +def _declared_capability_payload(source: Any) -> dict[str, Any]: + raw = source if isinstance(source, dict) else {} + return {key: raw.get(key) for key in _DECLARED_CAPABILITY_FIELDS if key in raw} + + +def _declared_capability_signature(source: Any, *, provider: str, model: str) -> str: + normalized = LLMEngineAdminMixin._normalize_engine_capabilities( + _declared_capability_payload(source), + provider=provider, + model=model, + ) + declared = normalized.get("declared_capabilities") if isinstance(normalized.get("declared_capabilities"), dict) else {} + return json.dumps(declared, ensure_ascii=False, sort_keys=True, default=str) + + +def _evidence_strength_from_row(row: dict[str, Any], *, key: str) -> str: + strength = str(row.get("evidence_strength") or "").strip().lower() + if strength: + return strength + source = str(row.get("source") or "").strip().lower() + status = str(row.get("status") or "").strip().lower() + message = str(row.get("message") or row.get("summary") or "").strip() + if source in {"manual", "default", "inferred"}: + return source + if key in {"long_context", "long_context_32k", "long_context_128k"} and ("声明" in message or "declared" in message.lower()): + return "declared_only" + if status == "verified" and source in {"probe", "probed"}: + return "live_probe" + return "unknown" + + +def _probe_row_is_verified(row: dict[str, Any], *, key: str) -> bool: + if not isinstance(row, dict): + return False + status = str(row.get("status") or "").strip().lower() + ok = row.get("ok") + if ok is not True and status != "verified": + return False + strength = _evidence_strength_from_row(row, key=key) + return strength in _LIVE_EVIDENCE_STRENGTHS + + +def _derive_effective_capabilities(*, declared: dict[str, Any], probe: dict[str, Any]) -> dict[str, bool]: + evidence = probe.get("evidence") if isinstance(probe.get("evidence"), dict) else {} + + def _row_for(key: str) -> dict[str, Any] | None: + row = evidence.get(key) + if isinstance(row, dict): + return row + flat = declared.get("capability_evidence") if isinstance(declared.get("capability_evidence"), dict) else {} + flat_row = flat.get(key) + return flat_row if isinstance(flat_row, dict) else None + + def _verified(key: str) -> bool: + row = _row_for(key) + if isinstance(row, dict) and _probe_row_is_verified(row, key=key): + return True + return False + + def _verified_min_tokens(key: str, min_tokens: int) -> bool: + row = _row_for(key) + if not isinstance(row, dict) or not _probe_row_is_verified(row, key=key): + return False + try: + tested_tokens = int(row.get("tested_tokens") or row.get("max_context_tokens") or 0) + except (TypeError, ValueError): + tested_tokens = 0 + return tested_tokens >= min_tokens + + long_context_verified = _verified("long_context") or _verified("long_context_32k") or _verified("long_context_128k") + long_32k_verified = _verified_min_tokens("long_context", 32768) or _verified_min_tokens("long_context_32k", 32768) + long_128k_verified = _verified_min_tokens("long_context", 128000) or _verified_min_tokens("long_context_128k", 128000) + return { + "basic_chat": _verified("basic_chat"), + "structured_output": _verified("structured_output"), + "json_schema": _verified("json_schema"), + "tool_calling": _verified("tool_calling"), + "streaming": _verified("streaming"), + "long_context": long_context_verified, + "long_context_32k": long_32k_verified, + "long_context_128k": long_128k_verified, + "vision_input": _verified("vision_input"), + "audio_input": _verified("audio_input"), + "private_deployment": _verified("private_deployment"), + "external_search_ready": _verified("external_search_ready"), + "vision": _verified("vision_input"), + "audio": _verified("audio_input"), + } + + +def _capability_evidence_is_in_scope(key: str, row: dict[str, Any], *, declared: dict[str, Any]) -> bool: + normalized_key = str(key or "").strip() + if normalized_key == "model_self_report": + return False + if normalized_key == "basic_chat": + return True + if normalized_key == "structured_output": + return bool(declared.get("structured_output")) + if normalized_key == "json_schema": + return bool(declared.get("json_schema")) + if normalized_key == "tool_calling": + return bool(declared.get("tool_calling")) + if normalized_key == "streaming": + return _probe_row_is_verified(row, key=normalized_key) + if normalized_key == "long_context": + return bool(declared.get("long_context")) or _probe_row_is_verified(row, key=normalized_key) + if normalized_key in {"long_context_32k", "long_context_128k"}: + try: + declared_tokens = int(declared.get("max_context_tokens") or 0) + except (TypeError, ValueError): + declared_tokens = 0 + required_tokens = 128000 if normalized_key == "long_context_128k" else 32768 + return (bool(declared.get("long_context")) and declared_tokens >= required_tokens) or _probe_row_is_verified(row, key=normalized_key) + if normalized_key == "vision_input": + return bool(declared.get("vision")) or _probe_row_is_verified(row, key=normalized_key) + if normalized_key == "audio_input": + return bool(declared.get("audio")) or _probe_row_is_verified(row, key=normalized_key) + return True + + +def _summarize_capability_probe_status( + *, + declared: dict[str, Any], + probe: dict[str, Any], + missing_scope_declared: dict[str, Any] | None = None, +) -> str: + evidence = probe.get("evidence") if isinstance(probe.get("evidence"), dict) else {} + long_context_row = evidence.get("long_context") if isinstance(evidence.get("long_context"), dict) else None + covered_long_context_thresholds: set[str] = set() + if isinstance(long_context_row, dict) and _probe_row_is_verified(long_context_row, key="long_context"): + try: + verified_tokens = int(long_context_row.get("tested_tokens") or long_context_row.get("max_context_tokens") or 0) + except (TypeError, ValueError): + verified_tokens = 0 + if verified_tokens >= 32768: + covered_long_context_thresholds.add("long_context_32k") + if verified_tokens >= 128000: + covered_long_context_thresholds.add("long_context_128k") + scoped = { + str(key): value + for key, value in evidence.items() + if isinstance(value, dict) + and str(key) not in covered_long_context_thresholds + and _capability_evidence_is_in_scope(str(key), value, declared=declared) + } + placeholder_scope = missing_scope_declared if isinstance(missing_scope_declared, dict) else declared + for key, declared_flag in _DECLARED_PROBE_REQUIREMENT_FLAGS.items(): + if bool(placeholder_scope.get(declared_flag)) and key not in scoped: + scoped[key] = { + "status": "declared_only", + "ok": None, + "evidence_strength": "declared_only", + "summary": "已声明但尚未获得真实探测或人工确认依据", + } + if not scoped: + return "untested" + verified = sum(1 for key, value in scoped.items() if _probe_row_is_verified(value, key=key)) + failed = sum( + 1 + for value in scoped.values() + if str(value.get("status") or "").strip().lower() == "failed" or (value.get("ok") is False and str(value.get("status") or "").strip().lower() not in {"skipped", "not_probeable", "declared_only"}) + ) + neutral = len(scoped) - verified - failed + if verified and not failed and not neutral: + return "passed" + if verified and (failed or neutral): + return "partial" + if failed: + return "failed" + return "untested" + + +def _normalize_legacy_long_context_probe(*, declared: dict[str, Any], probe: dict[str, Any], flat_evidence: dict[str, Any]) -> None: + if not bool(declared.get("long_context")): + return + try: + declared_tokens = int(declared.get("max_context_tokens") or 0) + except (TypeError, ValueError): + declared_tokens = 0 + if declared_tokens <= 0: + return + evidence = probe.get("evidence") if isinstance(probe.get("evidence"), dict) else {} + row = evidence.get("long_context") + if not isinstance(row, dict): + return + status = str(row.get("status") or "").strip().lower() + failure_code = str(row.get("failure_code") or "").strip() + text = " ".join(str(row.get(key) or "") for key in ("summary", "error_summary", "request_summary", "response_summary")) + if status != "failed" or failure_code != "declared_window_too_small" or "32768" not in text: + return + message = f"长上下文真实 smoke 需要高成本调用,本次只记录声明窗口 {declared_tokens} tokens,不作为槽位发布 verified 证据" + normalized_row = dict(row) + normalized_row.update( + { + "ok": False, + "status": "declared_only", + "summary": message, + "error_summary": "", + "evidence_strength": "declared_only", + "failure_code": None, + "tested_tokens": declared_tokens, + } + ) + evidence["long_context"] = normalized_row + flat_evidence["long_context"] = { + **dict(flat_evidence.get("long_context") or {}), + "status": "declared_only", + "source": "probe", + "message": message, + "evidence_strength": "declared_only", + "failure_code": None, + "max_context_tokens": declared_tokens, + } + + +def _probe_source_from_flat(source: Any) -> str: + normalized = str(source or "").strip().lower() + if normalized in {"probe", "mixed", "probed"}: + return "probed" + if normalized == "manual": + return "manual" + return "inferred" + + +def _flat_source_from_probe(source: Any, *, fallback: str = "default") -> str: + normalized = str(source or "").strip().lower() + if normalized == "probed": + return "probe" + if normalized == "manual": + return "manual" + return fallback if fallback in {"default", "manual", "probe", "mixed"} else "default" + + +def _normalize_capability_probe( + raw: Any, + *, + flat_evidence: dict[str, Any], + flat_status: Any, + flat_last_verified_at: Any, + flat_source: Any, +) -> dict[str, Any]: + source = raw if isinstance(raw, dict) else {} + probe = _empty_capability_probe(_probe_source_from_flat(flat_source)) + raw_source = str(source.get("source") or "").strip().lower() + if raw_source in {"inferred", "manual", "probed"}: + probe["source"] = raw_source + raw_status = str(source.get("status") or flat_status or "").strip().lower() + if raw_status in _CAPABILITY_PROBE_STATUSES: + probe["status"] = raw_status + raw_last_verified_at = str(source.get("last_verified_at") or flat_last_verified_at or "").strip() + probe["last_verified_at"] = raw_last_verified_at or None + + verified_capabilities = source.get("verified_capabilities") if isinstance(source.get("verified_capabilities"), dict) else {} + for key in _CAPABILITY_PROBE_KEYS: + probe["verified_capabilities"][key] = bool(verified_capabilities.get(key, False)) + + nested_evidence = source.get("evidence") if isinstance(source.get("evidence"), dict) else {} + evidence_keys = list(dict.fromkeys([*_CAPABILITY_PROBE_KEYS, *nested_evidence.keys(), *flat_evidence.keys()])) + normalized_evidence: dict[str, dict[str, Any]] = {} + for key in evidence_keys: + nested_row = nested_evidence.get(key) if isinstance(nested_evidence.get(key), dict) else {} + flat_row = flat_evidence.get(key) if isinstance(flat_evidence.get(key), dict) else {} + if not nested_row and not flat_row: + continue + raw_status = str(nested_row.get("status") or flat_row.get("status") or "").strip().lower() + ok_value = nested_row.get("ok") + if not isinstance(ok_value, bool): + ok_value = raw_status == "verified" + checked_at = str(nested_row.get("checked_at") or flat_row.get("verified_at") or probe["last_verified_at"] or "").strip() or None + summary = str(nested_row.get("summary") or flat_row.get("message") or "").strip() + error_summary = str(nested_row.get("error_summary") or ("" if ok_value else flat_row.get("message") or "")).strip() + evidence_strength = _evidence_strength_from_row({**flat_row, **nested_row, "status": raw_status}, key=str(key)) + if evidence_strength == "unknown" and ok_value is True and probe.get("source") == "probed": + message = str(summary or error_summary or "") + evidence_strength = ( + "declared_only" + if str(key) in {"long_context", "long_context_32k", "long_context_128k"} and ("声明" in message or "declared" in message.lower()) + else "live_probe" + ) + row = { + "ok": bool(ok_value), + "status": raw_status if raw_status in _CAPABILITY_EVIDENCE_STATUSES else ("verified" if ok_value else "failed"), + "method": str(nested_row.get("method") or flat_row.get("method") or "").strip() or None, + "evidence_strength": evidence_strength, + "checked_at": checked_at, + "summary": summary[:300], + "error_summary": error_summary[:300], + "failure_code": str(nested_row.get("failure_code") or flat_row.get("failure_code") or "").strip() or None, + "request_summary": str(nested_row.get("request_summary") or flat_row.get("request_summary") or "").strip()[:500], + "response_summary": str(nested_row.get("response_summary") or flat_row.get("response_summary") or "").strip()[:500], + "latency_ms": nested_row.get("latency_ms", flat_row.get("latency_ms")), + "source": str(nested_row.get("source") or flat_row.get("source") or "").strip() or None, + } + for passthrough_key in ("source_type", "source_url", "source_note", "verified_by", "verified_at", "expires_at"): + passthrough_value = nested_row.get(passthrough_key, flat_row.get(passthrough_key)) + if passthrough_value not in (None, ""): + row[passthrough_key] = passthrough_value + if key in {"long_context", "long_context_32k", "long_context_128k"}: + tested_tokens = nested_row.get("tested_tokens", flat_row.get("max_context_tokens")) + try: + row["tested_tokens"] = int(tested_tokens) if tested_tokens is not None and tested_tokens != "" else None + except (TypeError, ValueError): + row["tested_tokens"] = None + model_self_report = nested_row.get("model_self_report", flat_row.get("model_self_report")) + if str(key) == "model_self_report" and isinstance(model_self_report, dict): + row["model_self_report"] = model_self_report + normalized_evidence[key] = row + probe["verified_capabilities"][key] = _probe_row_is_verified(row, key=str(key)) + probe["evidence"] = normalized_evidence + + if probe["status"] == "untested" and normalized_evidence: + ok_count = sum(1 for item in normalized_evidence.values() if item.get("ok") is True) + failed_count = sum(1 for item in normalized_evidence.values() if item.get("ok") is not True) + probe["status"] = "passed" if ok_count and not failed_count else ("partial" if ok_count else "failed") + return probe + + +def _flat_evidence_from_probe(probe: dict[str, Any]) -> dict[str, dict[str, Any]]: + evidence = probe.get("evidence") if isinstance(probe.get("evidence"), dict) else {} + flattened: dict[str, dict[str, Any]] = {} + for key, value in evidence.items(): + if not isinstance(value, dict): + continue + flattened[str(key)] = { + "status": str(value.get("status") or ("verified" if value.get("ok") is True else "failed")), + "source": value.get("source") or "probe", + "verified_at": value.get("checked_at"), + "message": str(value.get("summary") or value.get("error_summary") or "")[:300], + "latency_ms": value.get("latency_ms"), + "method": value.get("method"), + "evidence_strength": value.get("evidence_strength"), + "failure_code": value.get("failure_code"), + "request_summary": value.get("request_summary"), + "response_summary": value.get("response_summary"), + } + if value.get("tested_tokens") is not None: + flattened[str(key)]["max_context_tokens"] = value.get("tested_tokens") + for passthrough_key in ("source_type", "source_url", "source_note", "verified_by", "expires_at"): + if value.get(passthrough_key) not in (None, ""): + flattened[str(key)][passthrough_key] = value.get(passthrough_key) + if str(key) == "model_self_report" and isinstance(value.get("model_self_report"), dict): + flattened[str(key)]["model_self_report"] = value.get("model_self_report") + return flattened + + class LLMEngineAdminMixin: + _PROBED_CAPABILITY_KEYS = frozenset(_CAPABILITY_PROBE_KEYS) + + @staticmethod + def _default_engine_capabilities(provider: str, model: str = "") -> dict[str, Any]: + normalized_provider = str(provider or "").strip().lower() + normalized_model = str(model or "").strip().lower() + is_local = normalized_provider == "ollama" + is_high_reasoning = normalized_provider in {"openai", "deepseek"} or "glm-5" in normalized_model + is_dashscope = normalized_provider == "dashscope" + context_contract = _provider_model_context_contract(normalized_provider, normalized_model) + default_context_tokens = ( + int(context_contract["context_tokens"]) + if context_contract + else (8192 if is_local else (128000 if is_high_reasoning else 32768)) + ) + return { + "structured_output": not is_local, + "json_schema": normalized_provider in {"openai", "deepseek"} or is_dashscope, + "tool_calling": normalized_provider in {"openai", "deepseek", "dashscope"}, + "long_context": not is_local, + "max_context_tokens": default_context_tokens, + "vision": False, + "audio": False, + "local_only": is_local, + "private_deployment": is_local, + "external_search_ready": False, + "avg_latency_ms": None, + "cost_tier": "low" if normalized_provider in {"dashscope", "ollama"} else "medium", + "reliability_tier": "local" if is_local else ("high" if is_high_reasoning else "standard"), + "capability_source": "default", + "capability_probe_status": "untested", + "capability_last_verified_at": None, + "capability_blocking_reasons": [], + "capability_evidence": {}, + "capability_probe": _empty_capability_probe("inferred"), + "declared_capabilities": {}, + "effective_capabilities": {}, + } + + @classmethod + def _normalize_engine_capabilities(cls, raw: Any, *, provider: str, model: str = "") -> dict[str, Any]: + defaults = cls._default_engine_capabilities(provider, model) + source = raw if isinstance(raw, dict) else {} + + def _bool(key: str) -> bool: + return bool(source[key]) if key in source else bool(defaults[key]) + + def _int_or_none(key: str) -> int | None: + value = source.get(key, defaults.get(key)) + if value is None or value == "": + return None + try: + return max(0, int(value)) + except (TypeError, ValueError): + return defaults.get(key) + + def _tier(key: str, allowed: set[str]) -> str: + value = str(source.get(key) or defaults.get(key) or "").strip().lower() + return value if value in allowed else str(defaults.get(key) or "") + + evidence = source.get("capability_evidence") if isinstance(source.get("capability_evidence"), dict) else {} + blocking_reasons = source.get("capability_blocking_reasons") + if not isinstance(blocking_reasons, list): + blocking_reasons = [] + metadata_keys = { + "capability_source", + "capability_probe_status", + "capability_last_verified_at", + "capability_blocking_reasons", + "capability_evidence", + "capability_probe", + "declared_capabilities", + "effective_capabilities", + _CAPABILITY_DECLARED_FIELDS_META, + } + has_saved_capability_fields = any( + key in source + for key in defaults + if key not in metadata_keys + ) + capability_source = str(source.get("capability_source") or "").strip().lower() + if capability_source not in {"default", "manual", "probe", "mixed"}: + capability_source = "manual" if has_saved_capability_fields else "default" + probe_status = str(source.get("capability_probe_status") or "").strip().lower() + if probe_status not in {"untested", "running", "passed", "partial", "failed"}: + probe_status = "untested" + capability_probe = _normalize_capability_probe( + source.get("capability_probe"), + flat_evidence=evidence, + flat_status=probe_status, + flat_last_verified_at=source.get("capability_last_verified_at"), + flat_source=capability_source, + ) + declared_capabilities = { + "structured_output": _bool("structured_output"), + "json_schema": _bool("json_schema"), + "tool_calling": _bool("tool_calling"), + "long_context": _bool("long_context"), + "max_context_tokens": _int_or_none("max_context_tokens"), + "vision": _bool("vision"), + "audio": _bool("audio"), + "local_only": _bool("local_only"), + "private_deployment": _bool("private_deployment"), + "external_search_ready": _bool("external_search_ready"), + "avg_latency_ms": _int_or_none("avg_latency_ms"), + "cost_tier": _tier("cost_tier", {"low", "medium", "high", "unknown"}), + "reliability_tier": _tier("reliability_tier", {"standard", "high", "local", "unknown"}), + } + explicit_declared_fields = _explicit_declared_capability_fields(source) + _normalize_legacy_long_context_probe(declared=declared_capabilities, probe=capability_probe, flat_evidence=evidence) + if not evidence and capability_probe.get("evidence"): + evidence = _flat_evidence_from_probe(capability_probe) + if not blocking_reasons: + blocking_reasons = [ + f"{key}: {str(value.get('error_summary') or '')[:180]}" + for key, value in (capability_probe.get("evidence") or {}).items() + if ( + isinstance(value, dict) + and _capability_evidence_is_in_scope(str(key), value, declared=declared_capabilities) + and value.get("ok") is not True + and str(value.get("error_summary") or "").strip() + ) + ] + missing_scope_declared = _probe_status_declared_scope(declared_capabilities, explicit_declared_fields) + scoped_probe_status = _summarize_capability_probe_status( + declared=declared_capabilities, + probe=capability_probe, + missing_scope_declared=missing_scope_declared, + ) + if scoped_probe_status != "untested" or probe_status == "untested": + probe_status = scoped_probe_status + capability_probe["status"] = probe_status + last_verified_at = str(source.get("capability_last_verified_at") or capability_probe.get("last_verified_at") or "").strip() or None + if capability_source == "default" and capability_probe.get("source") in {"manual", "probed"}: + capability_source = _flat_source_from_probe(capability_probe.get("source"), fallback=capability_source) + normalized = { + **declared_capabilities, + "capability_source": capability_source, + "capability_probe_status": probe_status, + "capability_last_verified_at": last_verified_at, + "capability_blocking_reasons": [str(item)[:220] for item in blocking_reasons if str(item or "").strip()][:8], + "capability_evidence": { + str(key): value + for key, value in evidence.items() + if str(key or "").strip() and isinstance(value, dict) + }, + "capability_probe": capability_probe, + "declared_capabilities": { + "structured_output": declared_capabilities["structured_output"], + "json_schema": declared_capabilities["json_schema"], + "tool_calling": declared_capabilities["tool_calling"], + "long_context": declared_capabilities["long_context"], + "max_context_tokens": declared_capabilities["max_context_tokens"], + "vision": declared_capabilities["vision"], + "audio": declared_capabilities["audio"], + "private_deployment": declared_capabilities["private_deployment"], + "external_search_ready": declared_capabilities["external_search_ready"], + }, + _CAPABILITY_DECLARED_FIELDS_META: sorted(explicit_declared_fields), + } + normalized["effective_capabilities"] = _derive_effective_capabilities(declared=normalized, probe=capability_probe) + return normalized + + @classmethod + def _merge_capability_probe_results( + cls, + *, + capabilities: dict[str, Any], + results: dict[str, dict[str, Any]], + verified_at: str, + ) -> dict[str, Any]: + merged = cls._normalize_engine_capabilities( + capabilities, + provider=str(capabilities.get("_provider") or ""), + model=str(capabilities.get("_model") or ""), + ) + evidence = dict(merged.get("capability_evidence") or {}) + capability_probe = _normalize_capability_probe( + merged.get("capability_probe"), + flat_evidence=evidence, + flat_status=merged.get("capability_probe_status"), + flat_last_verified_at=merged.get("capability_last_verified_at"), + flat_source=merged.get("capability_source"), + ) + probe_evidence = dict(capability_probe.get("evidence") or {}) + verified_capabilities = dict(capability_probe.get("verified_capabilities") or {}) + blocking_reasons: list[str] = [] + verified_count = 0 + failed_count = 0 + partial_evidence_count = 0 + neutral_count = 0 + aggregate_result_count = 0 + for key, result in results.items(): + normalized_key = str(key or "").strip() + status = str(result.get("status") or "").strip().lower() + ok = status == "verified" + evidence_status = status if status in _CAPABILITY_EVIDENCE_STATUSES else ("verified" if ok else "failed") + summary = str(result.get("message") or "")[:300] + evidence_strength = str(result.get("evidence_strength") or ("live_probe" if ok else "unknown")).strip().lower() + evidence[normalized_key] = { + "status": evidence_status, + "source": "probe", + "verified_at": verified_at, + "message": summary, + "latency_ms": result.get("latency_ms"), + "method": result.get("method"), + "evidence_strength": evidence_strength, + "failure_code": result.get("failure_code"), + "request_summary": result.get("request_summary"), + "response_summary": result.get("response_summary"), + } + if result.get("max_context_tokens") is not None: + evidence[normalized_key]["max_context_tokens"] = result.get("max_context_tokens") + if result.get("model_self_report") is not None: + evidence[normalized_key]["model_self_report"] = result.get("model_self_report") + probe_row = { + "ok": ok, + "status": evidence_status, + "method": result.get("method"), + "evidence_strength": evidence_strength, + "checked_at": verified_at, + "summary": summary if ok else "", + "error_summary": "" if ok else summary, + "failure_code": result.get("failure_code"), + "request_summary": str(result.get("request_summary") or "")[:500], + "response_summary": str(result.get("response_summary") or "")[:500], + "latency_ms": result.get("latency_ms"), + } + if normalized_key in {"long_context", "long_context_32k", "long_context_128k"}: + probe_row["tested_tokens"] = result.get("max_context_tokens") + if normalized_key == "long_context": + probe_evidence["long_context"] = dict(probe_row) + verified_capabilities["long_context"] = bool(ok and evidence_strength in _LIVE_EVIDENCE_STRENGTHS) + if result.get("model_self_report") is not None: + probe_row["model_self_report"] = result.get("model_self_report") + probe_evidence[normalized_key] = probe_row + verified_capabilities[normalized_key] = bool(ok and evidence_strength in _LIVE_EVIDENCE_STRENGTHS) + if normalized_key in {"structured_output", "json_schema"}: + merged[normalized_key] = ok or bool(merged.get(normalized_key)) + elif normalized_key == "tool_calling": + merged["tool_calling"] = ok or bool(merged.get("tool_calling")) + elif normalized_key in {"long_context", "long_context_32k", "long_context_128k"}: + merged["long_context"] = bool(merged.get("long_context")) or bool(result.get("declared_ok")) + if result.get("max_context_tokens"): + try: + merged["max_context_tokens"] = max(int(merged.get("max_context_tokens") or 0), int(result["max_context_tokens"])) + except (TypeError, ValueError): + pass + if normalized_key == "model_self_report": + continue + aggregate_result_count += 1 + if ok: + verified_count += 1 + elif evidence_status in {"skipped", "not_probeable", "declared_only"}: + neutral_count += 1 + else: + failed_count += 1 + message = str(result.get("message") or "").strip() + if message: + blocking_reasons.append(f"{normalized_key}: {message[:180]}") + if bool(result.get("partial_evidence")) or evidence_strength in _DECLARED_EVIDENCE_STRENGTHS: + partial_evidence_count += 1 + merged["capability_evidence"] = evidence + merged["capability_source"] = "probe" if verified_count and not failed_count else ("mixed" if verified_count else "probe") + merged["capability_probe_status"] = ( + "passed" + if aggregate_result_count and verified_count and not failed_count and not partial_evidence_count and not neutral_count + else ("partial" if (aggregate_result_count and (verified_count or partial_evidence_count or neutral_count)) or (results and not aggregate_result_count) else "failed") + ) + merged["capability_last_verified_at"] = verified_at + merged["capability_blocking_reasons"] = blocking_reasons[:8] + merged["capability_probe"] = { + "status": merged["capability_probe_status"], + "last_verified_at": verified_at, + "verified_capabilities": {key: bool(verified_capabilities.get(key)) for key in _CAPABILITY_PROBE_KEYS}, + "evidence": probe_evidence, + "source": "probed", + } + merged["declared_capabilities"] = { + "structured_output": bool(merged.get("structured_output")), + "json_schema": bool(merged.get("json_schema")), + "tool_calling": bool(merged.get("tool_calling")), + "long_context": bool(merged.get("long_context")), + "max_context_tokens": merged.get("max_context_tokens"), + "vision": bool(merged.get("vision")), + "audio": bool(merged.get("audio")), + "private_deployment": bool(merged.get("private_deployment")), + "external_search_ready": bool(merged.get("external_search_ready")), + } + explicit_declared_fields = _explicit_declared_capability_fields(merged) + merged[_CAPABILITY_DECLARED_FIELDS_META] = sorted(explicit_declared_fields) + summarized_status = _summarize_capability_probe_status( + declared=merged, + probe=merged["capability_probe"], + missing_scope_declared=_probe_status_declared_scope(merged, explicit_declared_fields), + ) + if summarized_status != "untested": + merged["capability_probe_status"] = summarized_status + merged["capability_probe"]["status"] = summarized_status + merged["effective_capabilities"] = _derive_effective_capabilities(declared=merged, probe=merged["capability_probe"]) + return merged + + def _sanitize_engine_for_admin_response(self, item: dict[str, Any]) -> dict[str, Any]: + sanitized = self._sanitize_engine_for_response(item) + sanitized["capabilities"] = self._normalize_engine_capabilities( + item.get("capabilities"), + provider=str(item.get("provider") or ""), + model=str(item.get("model") or ""), + ) + return sanitized + + def _engine_action_permissions(self, item: dict[str, Any], *, default_fallback_engine_id: str | None) -> dict[str, bool]: + enabled = self._engine_enabled(item) + connection_available = str(item.get("connection_status") or "untested").strip().lower() == "available" + is_default_fallback = str(item.get("id") or "") == str(default_fallback_engine_id or "") + return { + "enable": not enabled, + "disable": enabled, + "edit": True, + "test": enabled, + "capability_probe": enabled and connection_available, + "set_default_fallback": enabled and connection_available and not is_default_fallback, + "delete": (not enabled) and not is_default_fallback, + } + async def list_engines(self, db: AsyncSession, tenant_id: int = 1) -> dict[str, Any]: _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) engines = bundle.get("engines", []) + default_fallback_engine_id = bundle.get("default_fallback_engine_id") or bundle.get("active_engine_id") return { - "active_engine_id": bundle.get("active_engine_id"), - "engines": [self._sanitize_engine_for_response(item) for item in engines], + "active_engine_id": default_fallback_engine_id, + "default_fallback_engine_id": default_fallback_engine_id, + "engines": [ + { + **self._sanitize_engine_for_admin_response(item), + "is_default_fallback": str(item.get("id") or "") == str(default_fallback_engine_id or ""), + "available_actions": self._engine_action_permissions( + item, + default_fallback_engine_id=str(default_fallback_engine_id or ""), + ), + } + for item in engines + ], "settings": self._normalize_engine_settings(bundle.get("settings")), } @@ -39,6 +844,7 @@ class LLMEngineAdminMixin: model: Optional[str] = None, base_url: Optional[str] = None, set_active: bool = True, + capabilities: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: provider = _normalize_provider(provider) api_key = str(api_key or "").strip() @@ -56,18 +862,29 @@ class LLMEngineAdminMixin: "api_key": api_key, "model": (model or defaults["model"]).strip() or defaults["model"], "base_url": (base_url or defaults["base_url"]).strip().rstrip("/") or defaults["base_url"], + "enabled": True, + "connection_status": "untested", + "last_test_status": "", + "last_test_at": None, + "last_error_summary": "", + "capabilities": self._normalize_engine_capabilities( + _declared_capability_payload(capabilities), + provider=provider, + model=(model or defaults["model"]).strip() or defaults["model"], + ), "created_at": now, "updated_at": now, } engines = bundle.get("engines", []) engines.append(engine) bundle["engines"] = engines - if set_active or not bundle.get("active_engine_id"): + if set_active or not bundle.get("default_fallback_engine_id") or not bundle.get("active_engine_id"): + bundle["default_fallback_engine_id"] = engine_id bundle["active_engine_id"] = engine_id integration.config = json.loads(json.dumps(bundle)) integration.is_active = True await db.commit() - return self._sanitize_engine_for_response(engine) + return self._sanitize_engine_for_admin_response(engine) async def update_engine( self, @@ -80,6 +897,7 @@ class LLMEngineAdminMixin: api_key: Optional[str] = None, model: Optional[str] = None, base_url: Optional[str] = None, + capabilities: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) engines = bundle.get("engines", []) @@ -91,8 +909,24 @@ class LLMEngineAdminMixin: if not target: raise ValueError("engine 不存在") + old_provider = _normalize_provider(target.get("provider")) + old_model = str(target.get("model") or "").strip() + old_base_url = str(target.get("base_url") or "").strip().rstrip("/") + old_capability_signature = _declared_capability_signature( + target.get("capabilities"), + provider=old_provider, + model=old_model, + ) + evidence_invalidated = False + if provider is not None: - target["provider"] = _normalize_provider(provider) + new_provider = _normalize_provider(provider) + if new_provider != old_provider: + evidence_invalidated = True + target["connection_status"] = "untested" + target["last_test_status"] = "" + target["last_error_summary"] = "" + target["provider"] = new_provider active_provider = _normalize_provider(target.get("provider")) defaults = PROVIDER_DEFAULTS[active_provider] @@ -102,15 +936,50 @@ class LLMEngineAdminMixin: api_key_val = str(api_key).strip() if api_key_val: target["api_key"] = api_key_val + target["connection_status"] = "untested" + target["last_test_status"] = "" + target["last_error_summary"] = "" + evidence_invalidated = True if model is not None: - target["model"] = (model or "").strip() or defaults["model"] + next_model = (model or "").strip() or defaults["model"] + if next_model != str(target.get("model") or ""): + evidence_invalidated = True + target["connection_status"] = "untested" + target["last_test_status"] = "" + target["last_error_summary"] = "" + target["model"] = next_model if base_url is not None: - target["base_url"] = (base_url or "").strip().rstrip("/") or defaults["base_url"] + next_base_url = (base_url or "").strip().rstrip("/") or defaults["base_url"] + if next_base_url != old_base_url: + evidence_invalidated = True + target["connection_status"] = "untested" + target["last_test_status"] = "" + target["last_error_summary"] = "" + target["base_url"] = next_base_url + if capabilities is not None: + declared_payload = _declared_capability_payload(capabilities) + new_capability_signature = _declared_capability_signature( + declared_payload, + provider=str(target.get("provider") or ""), + model=str(target.get("model") or ""), + ) + evidence_invalidated = evidence_invalidated or new_capability_signature != old_capability_signature + target["capabilities"] = self._normalize_engine_capabilities( + declared_payload, + provider=str(target.get("provider") or ""), + model=str(target.get("model") or ""), + ) + elif evidence_invalidated: + target["capabilities"] = self._normalize_engine_capabilities( + _declared_capability_payload(target.get("capabilities")), + provider=str(target.get("provider") or ""), + model=str(target.get("model") or ""), + ) target["updated_at"] = now_cn_aware().isoformat() integration.config = json.loads(json.dumps(bundle)) await db.commit() - return self._sanitize_engine_for_response(target) + return self._sanitize_engine_for_admin_response(target) async def delete_engine( self, @@ -121,22 +990,26 @@ class LLMEngineAdminMixin: ) -> dict[str, Any]: integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) engines = bundle.get("engines", []) - kept = [item for item in engines if str(item.get("id")) != str(engine_id)] - if len(kept) == len(engines): + target = next((item for item in engines if str(item.get("id")) == str(engine_id)), None) + if not target: raise ValueError("engine 不存在") + default_fallback_engine_id = str(bundle.get("default_fallback_engine_id") or bundle.get("active_engine_id") or "") + if default_fallback_engine_id == str(engine_id): + raise ValueError("兜底默认供应商连接不能删除,请先切换兜底或停用后重试") + if self._engine_enabled(target): + raise ValueError("启用中的供应商连接不能删除,请先停用后再删除") + kept = [item for item in engines if str(item.get("id")) != str(engine_id)] bundle["engines"] = kept - active_engine_id = str(bundle.get("active_engine_id") or "") - if active_engine_id == str(engine_id): - bundle["active_engine_id"] = kept[0]["id"] if kept else None integration.config = json.loads(json.dumps(bundle)) await db.commit() return { "deleted_engine_id": str(engine_id), - "active_engine_id": bundle.get("active_engine_id"), + "active_engine_id": bundle.get("default_fallback_engine_id"), + "default_fallback_engine_id": bundle.get("default_fallback_engine_id"), "remaining_count": len(kept), } - async def activate_engine( + async def set_default_fallback_engine( self, db: AsyncSession, *, @@ -145,13 +1018,1129 @@ class LLMEngineAdminMixin: ) -> dict[str, Any]: integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) engines = bundle.get("engines", []) - exists = any(str(item.get("id")) == str(engine_id) for item in engines) - if not exists: + target = next((item for item in engines if str(item.get("id")) == str(engine_id)), None) + if not target: raise ValueError("engine 不存在") + if not self._engine_enabled(target): + raise ValueError("已停用的供应商连接不能设为兜底默认") + connection_status = str(target.get("connection_status") or "untested").strip().lower() + if connection_status != "available": + raise ValueError("未通过连接测试的供应商连接不能设为兜底默认") + bundle["default_fallback_engine_id"] = str(engine_id) bundle["active_engine_id"] = str(engine_id) integration.config = json.loads(json.dumps(bundle)) await db.commit() - return {"active_engine_id": str(engine_id)} + return {"active_engine_id": str(engine_id), "default_fallback_engine_id": str(engine_id)} + + async def activate_engine( + self, + db: AsyncSession, + *, + tenant_id: int, + engine_id: str, + ) -> dict[str, Any]: + return await self.set_default_fallback_engine(db, tenant_id=tenant_id, engine_id=engine_id) + + async def set_engine_enabled( + self, + db: AsyncSession, + *, + tenant_id: int, + engine_id: str, + enabled: bool, + ) -> dict[str, Any]: + integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + engines = bundle.get("engines", []) + target: Optional[dict[str, Any]] = None + for item in engines: + if str(item.get("id")) == str(engine_id): + target = item + break + if not target: + raise ValueError("engine 不存在") + target["enabled"] = bool(enabled) + target["updated_at"] = now_cn_aware().isoformat() + if enabled: + if not bundle.get("default_fallback_engine_id"): + bundle["default_fallback_engine_id"] = str(engine_id) + bundle["active_engine_id"] = str(engine_id) + else: + default_fallback_engine_id = str(bundle.get("default_fallback_engine_id") or bundle.get("active_engine_id") or "") + if default_fallback_engine_id == str(engine_id): + replacement = next( + (item.get("id") for item in engines if str(item.get("id")) != str(engine_id) and self._engine_enabled(item)), + None, + ) + bundle["default_fallback_engine_id"] = replacement + bundle["active_engine_id"] = replacement + integration.config = json.loads(json.dumps(bundle)) + await db.commit() + return { + "engine_id": str(engine_id), + "enabled": bool(enabled), + "active_engine_id": bundle.get("default_fallback_engine_id"), + "default_fallback_engine_id": bundle.get("default_fallback_engine_id"), + } + + async def _record_engine_test_result( + self, + db: AsyncSession, + *, + tenant_id: int, + engine_id: str, + ok: bool, + error_summary: str = "", + ) -> None: + integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + now = now_cn_aware().isoformat() + for item in bundle.get("engines", []): + if isinstance(item, dict) and str(item.get("id") or "") == str(engine_id): + item["connection_status"] = "available" if ok else "test_failed" + item["last_test_status"] = "success" if ok else "failed" + item["last_test_at"] = now + item["last_error_summary"] = "" if ok else str(error_summary or "")[:220] + item["updated_at"] = now + break + integration.config = json.loads(json.dumps(bundle, ensure_ascii=False)) + await db.commit() + + async def _record_engine_capability_probe_result( + self, + db: AsyncSession, + *, + tenant_id: int, + engine_id: str, + results: dict[str, dict[str, Any]], + ) -> dict[str, Any]: + integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + now = now_cn_aware().isoformat() + for item in bundle.get("engines", []): + if not isinstance(item, dict) or str(item.get("id") or "") != str(engine_id): + continue + raw_capabilities = dict(item.get("capabilities") or {}) + raw_capabilities["_provider"] = str(item.get("provider") or "") + raw_capabilities["_model"] = str(item.get("model") or "") + item["capabilities"] = self._merge_capability_probe_results( + capabilities=raw_capabilities, + results=results, + verified_at=now, + ) + item["updated_at"] = now + integration.config = json.loads(json.dumps(bundle, ensure_ascii=False)) + await db.commit() + return self._sanitize_engine_for_admin_response(item) + raise ValueError("engine 不存在") + + async def attest_engine_capability( + self, + db: AsyncSession, + *, + tenant_id: int, + engine_id: str, + capability_key: str, + tested_tokens: Optional[int] = None, + source_type: str = "manual_verification", + source_url: Optional[str] = None, + source_note: Optional[str] = None, + expires_at: Optional[str] = None, + user_id: Optional[int] = None, + ) -> dict[str, Any]: + normalized_key = str(capability_key or "").strip() + if normalized_key not in {"long_context"}: + raise ValueError("当前仅支持人工确认长上下文能力") + token_count = int(tested_tokens or 0) + if token_count <= 0: + raise ValueError("长上下文人工确认必须填写有效 tokens") + + integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + now = now_cn_aware().isoformat() + for item in bundle.get("engines", []): + if not isinstance(item, dict) or str(item.get("id") or "") != str(engine_id): + continue + raw_capabilities = dict(item.get("capabilities") or {}) + capabilities = self._normalize_engine_capabilities( + raw_capabilities, + provider=str(item.get("provider") or ""), + model=str(item.get("model") or ""), + ) + capabilities["long_context"] = True + capabilities["max_context_tokens"] = max(int(capabilities.get("max_context_tokens") or 0), token_count) + capabilities["capability_source"] = "mixed" + capabilities["capability_last_verified_at"] = now + capabilities["capability_blocking_reasons"] = [ + reason + for reason in (capabilities.get("capability_blocking_reasons") or []) + if "long_context" not in str(reason) and "长上下文" not in str(reason) + ] + + capability_evidence = dict(capabilities.get("capability_evidence") or {}) + capability_probe = _normalize_capability_probe( + capabilities.get("capability_probe"), + flat_evidence=capability_evidence, + flat_status=capabilities.get("capability_probe_status"), + flat_last_verified_at=capabilities.get("capability_last_verified_at"), + flat_source=capabilities.get("capability_source"), + ) + probe_evidence = dict(capability_probe.get("evidence") or {}) + verified_capabilities = dict(capability_probe.get("verified_capabilities") or {}) + summary = f"人工确认长上下文窗口 {token_count} tokens" + attestation = { + "source_type": str(source_type or "manual_verification").strip()[:80] or "manual_verification", + "source_url": str(source_url or "").strip()[:500], + "source_note": str(source_note or "").strip()[:500], + "verified_by": user_id, + "verified_at": now, + "expires_at": str(expires_at or "").strip()[:80], + } + flat_row = { + "status": "verified", + "source": "operator", + "verified_at": now, + "message": summary, + "latency_ms": None, + "method": "operator_attestation", + "evidence_strength": "operator_verified", + "failure_code": None, + "max_context_tokens": token_count, + "request_summary": "", + "response_summary": "", + **{key: value for key, value in attestation.items() if value not in (None, "")}, + } + probe_row = { + "ok": True, + "status": "verified", + "method": "operator_attestation", + "evidence_strength": "operator_verified", + "checked_at": now, + "summary": summary, + "error_summary": "", + "failure_code": None, + "request_summary": "", + "response_summary": "", + "latency_ms": None, + "tested_tokens": token_count, + "source": "operator", + **{key: value for key, value in attestation.items() if value not in (None, "")}, + } + for threshold_key, threshold_tokens in (("long_context_32k", 32768), ("long_context_128k", 128000)): + if token_count >= threshold_tokens: + capability_evidence.pop(threshold_key, None) + probe_evidence.pop(threshold_key, None) + capability_evidence["long_context"] = flat_row + probe_evidence["long_context"] = probe_row + verified_capabilities["long_context"] = True + verified_capabilities["long_context_32k"] = token_count >= 32768 + verified_capabilities["long_context_128k"] = token_count >= 128000 + capability_probe["evidence"] = probe_evidence + capability_probe["verified_capabilities"] = {key: bool(verified_capabilities.get(key)) for key in _CAPABILITY_PROBE_KEYS} + capability_probe["last_verified_at"] = now + capability_probe["source"] = "manual" + explicit_declared_fields = _explicit_declared_capability_fields(raw_capabilities) + explicit_declared_fields.add("long_context") + capability_probe["status"] = _summarize_capability_probe_status( + declared=capabilities, + probe=capability_probe, + missing_scope_declared=_probe_status_declared_scope(capabilities, explicit_declared_fields), + ) + + capabilities["capability_evidence"] = capability_evidence + capabilities["capability_probe"] = capability_probe + capabilities["capability_probe_status"] = capability_probe["status"] + capabilities[_CAPABILITY_DECLARED_FIELDS_META] = sorted(explicit_declared_fields) + capabilities["effective_capabilities"] = _derive_effective_capabilities(declared=capabilities, probe=capability_probe) + item["capabilities"] = self._normalize_engine_capabilities( + capabilities, + provider=str(item.get("provider") or ""), + model=str(item.get("model") or ""), + ) + item["updated_at"] = now + integration.config = json.loads(json.dumps(bundle, ensure_ascii=False)) + await db.commit() + return self._sanitize_engine_for_admin_response(item) + raise ValueError("engine 不存在") + + @staticmethod + def _normalize_capability_probe_targets( + targets: list[str] | None, + *, + mode: str = "full", + allow_high_cost: bool = False, + capabilities: dict[str, Any] | None = None, + ) -> list[str]: + explicit_targets = bool(targets) + if targets: + expanded: list[str] = [] + for item in targets: + expanded.extend(_CAPABILITY_TARGET_ALIASES.get(str(item or "").strip().lower(), [])) + else: + normalized_mode = str(mode or "full").strip().lower() + if normalized_mode == "quick": + expanded = ["basic_chat", "structured_output", "json_schema", "tool_calling"] + elif normalized_mode == "advanced": + expanded = [ + "basic_chat", + "model_self_report", + "structured_output", + "json_schema", + "tool_calling", + "streaming", + "long_context", + "vision_input", + "audio_input", + ] + else: + expanded = ["basic_chat", "model_self_report", "structured_output", "json_schema", "tool_calling", "streaming", "long_context"] + if allow_high_cost and "long_context_128k" not in expanded: + expanded.append("long_context_128k") + normalized: list[str] = [] + for item in expanded: + if item in _CAPABILITY_PROBE_KEYS and item not in normalized: + normalized.append(item) + declared = capabilities or {} + if not bool(declared.get("vision")) and "vision_input" in normalized: + normalized.remove("vision_input") + if not bool(declared.get("audio")) and "audio_input" in normalized: + normalized.remove("audio_input") + if not explicit_targets and not bool(declared.get("long_context")): + normalized = [item for item in normalized if item not in {"long_context", "long_context_32k", "long_context_128k"}] + if not explicit_targets: + declared_keys = { + "structured_output": bool(declared.get("structured_output")), + "json_schema": bool(declared.get("json_schema")), + "tool_calling": bool(declared.get("tool_calling")), + } + has_any_declared_core = any(declared_keys.values()) or bool(declared.get("long_context")) or bool(declared.get("vision")) or bool(declared.get("audio")) + if has_any_declared_core: + normalized = [ + item + for item in normalized + if item in {"basic_chat", "model_self_report", "streaming", "long_context", "long_context_32k", "long_context_128k", "vision_input", "audio_input"} + or declared_keys.get(item, False) + ] + return normalized + + @staticmethod + def _capability_probe_result( + *, + status: str, + message: str, + latency_ms: int | None, + method: str, + evidence_strength: str, + request_summary: str, + response_summary: str = "", + failure_code: str | None = None, + max_context_tokens: int | None = None, + partial_evidence: bool = False, + declared_ok: bool | None = None, + provider_request_payload: Any = None, + provider_response_payload: Any = None, + provider_attempts: Any = None, + provider_contract: Any = None, + model_self_report: Any = None, + upstream_request_sent: bool | None = None, + upstream_missing_reason: str | None = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "status": status, + "message": str(message or "")[:500], + "latency_ms": latency_ms, + "method": method, + "evidence_strength": evidence_strength, + "request_summary": str(request_summary or "")[:500], + "response_summary": str(response_summary or "")[:500], + "failure_code": failure_code, + "partial_evidence": partial_evidence, + } + if provider_request_payload is not None: + payload["provider_request_payload"] = provider_request_payload + if provider_response_payload is not None: + payload["provider_response_payload"] = provider_response_payload + if provider_attempts is not None: + payload["provider_attempts"] = provider_attempts + if provider_contract is not None: + payload["provider_contract"] = provider_contract + if model_self_report is not None: + payload["model_self_report"] = model_self_report + if upstream_request_sent is not None: + payload["upstream_request_sent"] = bool(upstream_request_sent) + if upstream_missing_reason: + payload["upstream_missing_reason"] = str(upstream_missing_reason or "")[:300] + if max_context_tokens is not None: + payload["max_context_tokens"] = max_context_tokens + if declared_ok is not None: + payload["declared_ok"] = declared_ok + return payload + + @staticmethod + def _response_audit_payload(response: Any) -> dict[str, Any]: + try: + parsed = response.json() + if isinstance(parsed, dict): + return parsed + return {"payload": parsed} + except Exception: + return {"raw_text": str(getattr(response, "text", "") or "")} + + @staticmethod + def _provider_attempt_audit( + *, + endpoint: str, + provider: str, + model: str, + request_payload: Any, + response_payload: Any = None, + status: str, + status_code: int | None = None, + error: str | None = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "provider": str(provider or ""), + "model": str(model or ""), + "endpoint": str(endpoint or ""), + "request_payload": request_payload, + "status": str(status or "unknown"), + } + if response_payload is not None: + payload["response_payload"] = response_payload + if status_code is not None: + payload["status_code"] = int(status_code) + if error: + payload["error"] = str(error or "")[:2000] + return payload + + async def _probe_basic_chat_capability(self, *, engine: dict[str, Any]) -> dict[str, Any]: + started = time.perf_counter() + request_summary = "发送最小连通 prompt,要求模型只返回 OK。" + try: + result = await self._chat_completion_raw( + provider=str(engine.get("provider") or ""), + prompt="你是连通性测试助手。请只返回:OK", + system_prompt="", + api_key=str(engine.get("api_key") or ""), + base_url=str(engine.get("base_url") or ""), + model=str(engine.get("model") or ""), + temperature=0.0, + max_tokens=16, + function_name="probe_capability_basic_chat", + routing_meta={ + "scene_key": "platform_ai_ops", + "direct_operation": True, + "routing_decision": "direct_capability_probe", + "route_source": "provider_console", + "provider": str(engine.get("provider") or ""), + "provider_code": str(engine.get("provider") or ""), + "provider_label": _provider_display_label(engine.get("provider")), + "model": str(engine.get("model") or ""), + "engine_id": str(engine.get("id") or ""), + "engine_name": str(engine.get("name") or ""), + "probe_target": "basic_chat", + "probe_method": "chat_completion", + }, + ) + ok = "OK" in str(result.text or "").upper() + return self._capability_probe_result( + status="verified" if ok else "failed", + message="基础聊天探测通过" if ok else "模型未返回预期 OK", + latency_ms=int((time.perf_counter() - started) * 1000), + method="chat_completion", + evidence_strength="live_probe" if ok else "unknown", + request_summary=request_summary, + response_summary=str(result.text or "")[:300], + failure_code=None if ok else "unexpected_response", + provider_request_payload=result.raw_request, + provider_response_payload=result.raw_response, + provider_attempts=result.provider_attempts, + upstream_request_sent=True, + ) + except Exception as exc: + return self._capability_probe_result( + status="failed", + message=str(exc), + latency_ms=int((time.perf_counter() - started) * 1000), + method="chat_completion", + evidence_strength="unknown", + request_summary=request_summary, + failure_code="request_failed", + provider_request_payload=getattr(exc, "provider_request_payload", None), + provider_response_payload=getattr(exc, "provider_response_payload", None), + provider_attempts=getattr(exc, "provider_attempts", None), + upstream_request_sent=True, + ) + + async def _probe_model_self_report_capability(self, *, engine: dict[str, Any]) -> dict[str, Any]: + started = time.perf_counter() + model = str(engine.get("model") or "").strip() + request_summary = "发送低成本模型自报 prompt,要求模型按 JSON 返回上下文窗口和输出上限的自我认知;该结果只作为辅助参考。" + prompt = ( + "你正在作为模型能力自报助手。请根据你对当前模型的自我认知返回 JSON,不要编造,不确定填 null。\n" + f"当前接口模型名:{model}\n" + "只返回 JSON,不要 Markdown,不要解释。字段如下:\n" + "{\n" + ' "model_name": string,\n' + ' "context_window_tokens": number|null,\n' + ' "max_input_tokens": number|null,\n' + ' "max_output_tokens": number|null,\n' + ' "context_tier": "below_32k"|"32k"|"128k"|"1m"|"unknown",\n' + ' "supports_32k": boolean|null,\n' + ' "supports_128k": boolean|null,\n' + ' "supports_1m": boolean|null,\n' + ' "confidence": "low"|"medium"|"high",\n' + ' "note": string\n' + "}" + ) + try: + result = await self._chat_completion_raw( + provider=str(engine.get("provider") or ""), + prompt=prompt, + system_prompt="", + api_key=str(engine.get("api_key") or ""), + base_url=str(engine.get("base_url") or ""), + model=model, + temperature=0.0, + max_tokens=384, + function_name="probe_capability_model_self_report", + routing_meta={ + "scene_key": "platform_ai_ops", + "direct_operation": True, + "routing_decision": "direct_capability_probe", + "route_source": "provider_console", + "provider": str(engine.get("provider") or ""), + "provider_code": str(engine.get("provider") or ""), + "provider_label": _provider_display_label(engine.get("provider")), + "model": model, + "engine_id": str(engine.get("id") or ""), + "engine_name": str(engine.get("name") or ""), + "probe_target": "model_self_report", + "probe_method": "model_self_report_json", + }, + ) + raw_text = str(result.text or "").strip() + parsed: dict[str, Any] | None = None + try: + candidate = self._strip_code_fence(raw_text) + parsed_value = json.loads(candidate) + except Exception: + start = raw_text.find("{") + end = raw_text.rfind("}") + if start >= 0 and end > start: + try: + parsed_value = json.loads(raw_text[start : end + 1]) + except Exception: + parsed_value = None + else: + parsed_value = None + if isinstance(parsed_value, dict): + parsed = parsed_value + message = "模型自报能力已记录,仅作为运营参考,不作为槽位发布 verified 证据" if parsed else "模型自报未返回可解析 JSON,仅保留原始响应作为运营参考" + return self._capability_probe_result( + status="declared_only", + message=message, + latency_ms=int((time.perf_counter() - started) * 1000), + method="model_self_report_json", + evidence_strength="model_self_report", + request_summary=request_summary, + response_summary=raw_text[:300], + failure_code=None if parsed else "self_report_unparseable", + partial_evidence=True, + model_self_report=parsed, + provider_request_payload=result.raw_request, + provider_response_payload=result.raw_response, + provider_attempts=result.provider_attempts, + upstream_request_sent=True, + ) + except Exception as exc: + return self._capability_probe_result( + status="declared_only", + message=f"模型自报调用失败,仅作为辅助能力未取得参考:{str(exc)[:220]}", + latency_ms=int((time.perf_counter() - started) * 1000), + method="model_self_report_json", + evidence_strength="model_self_report", + request_summary=request_summary, + response_summary="", + failure_code="self_report_request_failed", + partial_evidence=True, + provider_request_payload=getattr(exc, "provider_request_payload", None), + provider_response_payload=getattr(exc, "provider_response_payload", None), + provider_attempts=getattr(exc, "provider_attempts", None), + upstream_request_sent=True, + ) + + async def _probe_structured_output_capability(self, *, engine: dict[str, Any]) -> dict[str, Any]: + started = time.perf_counter() + request_summary = '自由文本响应中要求模型只返回 {"ok":true,"label":"pmflow"},验证可稳定输出可解析 JSON。' + try: + result = await self._chat_completion_raw( + provider=str(engine.get("provider") or ""), + prompt='请只返回 JSON:{"ok":true,"label":"pmflow"}', + system_prompt="", + api_key=str(engine.get("api_key") or ""), + base_url=str(engine.get("base_url") or ""), + model=str(engine.get("model") or ""), + temperature=0.0, + max_tokens=96, + function_name="probe_capability_structured_output", + routing_meta={ + "scene_key": "platform_ai_ops", + "direct_operation": True, + "routing_decision": "direct_capability_probe", + "route_source": "provider_console", + "provider": str(engine.get("provider") or ""), + "provider_code": str(engine.get("provider") or ""), + "provider_label": _provider_display_label(engine.get("provider")), + "model": str(engine.get("model") or ""), + "engine_id": str(engine.get("id") or ""), + "engine_name": str(engine.get("name") or ""), + "probe_target": "structured_output", + "probe_method": "json_parse", + "response_contract": "structured", + }, + ) + parsed = json.loads(self._strip_code_fence(result.text)) + ok = isinstance(parsed, dict) and parsed.get("ok") is True + return self._capability_probe_result( + status="verified" if ok else "failed", + message="结构化输出探测通过" if ok else "模型未返回预期 JSON 对象", + latency_ms=int((time.perf_counter() - started) * 1000), + method="json_parse", + evidence_strength="live_probe" if ok else "unknown", + request_summary=request_summary, + response_summary=str(result.text or "")[:300], + failure_code=None if ok else "unexpected_json", + provider_request_payload=result.raw_request, + provider_response_payload=result.raw_response, + provider_attempts=result.provider_attempts, + upstream_request_sent=True, + ) + except Exception as exc: + return self._capability_probe_result( + status="failed", + message=str(exc), + latency_ms=int((time.perf_counter() - started) * 1000), + method="json_parse", + evidence_strength="unknown", + request_summary=request_summary, + failure_code="request_or_parse_failed", + provider_request_payload=getattr(exc, "provider_request_payload", None), + provider_response_payload=getattr(exc, "provider_response_payload", None), + provider_attempts=getattr(exc, "provider_attempts", None), + upstream_request_sent=True, + ) + + async def _probe_json_schema_capability(self, *, engine: dict[str, Any]) -> dict[str, Any]: + provider = str(engine.get("provider") or "").strip().lower() + if provider == "ollama": + return self._capability_probe_result( + status="not_probeable", + message="Ollama OpenAI-compatible JSON Schema 参数未接入探测", + latency_ms=None, + method="response_format_json_schema", + evidence_strength="unknown", + request_summary="发送 response_format=json_schema 的 OpenAI-compatible chat/completions 请求。", + failure_code="adapter_not_probeable", + ) + key = str(engine.get("api_key") or "").strip() + if not key: + return self._capability_probe_result( + status="failed", + message="未配置 API Key", + latency_ms=None, + method="response_format_json_schema", + evidence_strength="unknown", + request_summary="发送 response_format=json_schema 的 OpenAI-compatible chat/completions 请求。", + failure_code="missing_api_key", + ) + endpoint = f"{str(engine.get('base_url') or '').rstrip('/')}/chat/completions" + payload = { + "model": str(engine.get("model") or ""), + "messages": [{"role": "user", "content": "返回 pmflow JSON schema 探测结果。"}], + "temperature": 0, + "max_tokens": 96, + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "pmflow_capability_probe", + "schema": { + "type": "object", + "properties": {"ok": {"type": "boolean"}, "label": {"type": "string"}}, + "required": ["ok", "label"], + "additionalProperties": False, + }, + }, + }, + } + started = time.perf_counter() + try: + async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client: + response = await client.post(endpoint, headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}, json=payload) + latency_ms = int((time.perf_counter() - started) * 1000) + if response.status_code >= 400: + return self._capability_probe_result( + status="failed", + message=f"json_schema 参数被供应商拒绝: HTTP {response.status_code}", + latency_ms=latency_ms, + method="response_format_json_schema", + evidence_strength="unknown", + request_summary="发送 response_format=json_schema 的 OpenAI-compatible chat/completions 请求。", + response_summary=response.text[:300], + failure_code="json_schema_param_rejected", + provider_request_payload=payload, + provider_response_payload=self._response_audit_payload(response), + provider_attempts=[self._provider_attempt_audit(endpoint=endpoint, provider=provider, model=str(engine.get("model") or ""), request_payload=payload, response_payload=self._response_audit_payload(response), status="http_error", status_code=response.status_code)], + upstream_request_sent=True, + ) + data = response.json() + content = str(((((data.get("choices") or [{}])[0] or {}).get("message") or {}).get("content") or "")) + parsed = json.loads(self._strip_code_fence(content)) + ok = isinstance(parsed, dict) and parsed.get("ok") is True + return self._capability_probe_result( + status="verified" if ok else "failed", + message="JSON Schema 参数探测通过" if ok else "供应商接受参数但未返回符合 schema 的 JSON", + latency_ms=latency_ms, + method="response_format_json_schema", + evidence_strength="live_probe" if ok else "unknown", + request_summary="发送 response_format=json_schema 的 OpenAI-compatible chat/completions 请求。", + response_summary=content[:300], + failure_code=None if ok else "schema_response_invalid", + provider_request_payload=payload, + provider_response_payload=data, + provider_attempts=[self._provider_attempt_audit(endpoint=endpoint, provider=provider, model=str(engine.get("model") or ""), request_payload=payload, response_payload=data, status="success", status_code=response.status_code)], + upstream_request_sent=True, + ) + except Exception as exc: + return self._capability_probe_result( + status="failed", + message=str(exc), + latency_ms=int((time.perf_counter() - started) * 1000), + method="response_format_json_schema", + evidence_strength="unknown", + request_summary="发送 response_format=json_schema 的 OpenAI-compatible chat/completions 请求。", + failure_code="request_or_parse_failed", + provider_request_payload=payload, + provider_response_payload=getattr(exc, "provider_response_payload", None), + provider_attempts=[self._provider_attempt_audit(endpoint=endpoint, provider=provider, model=str(engine.get("model") or ""), request_payload=payload, response_payload=getattr(exc, "provider_response_payload", None), status="request_error", error=str(exc)[:2000])], + upstream_request_sent=True, + ) + + async def _probe_structured_capability(self, *, engine: dict[str, Any]) -> dict[str, dict[str, Any]]: + return { + "structured_output": await self._probe_structured_output_capability(engine=engine), + "json_schema": await self._probe_json_schema_capability(engine=engine), + } + + async def _probe_tool_calling_capability(self, *, engine: dict[str, Any]) -> dict[str, Any]: + request_summary = "发送带 tools + tool_choice 的 OpenAI-compatible chat/completions 请求,验证响应是否包含 tool_calls。" + provider = str(engine.get("provider") or "").strip().lower() + if provider == "ollama": + return self._capability_probe_result( + status="not_probeable", + message="Ollama chat endpoint 未接入 OpenAI-compatible tools 探测", + latency_ms=None, + method="tools_tool_choice", + evidence_strength="unknown", + request_summary=request_summary, + failure_code="adapter_not_probeable", + ) + key = str(engine.get("api_key") or "").strip() + if not key: + return self._capability_probe_result( + status="failed", + message="未配置 API Key", + latency_ms=None, + method="tools_tool_choice", + evidence_strength="unknown", + request_summary=request_summary, + failure_code="missing_api_key", + ) + endpoint = f"{str(engine.get('base_url') or '').rstrip('/')}/chat/completions" + payload = { + "model": str(engine.get("model") or ""), + "messages": [{"role": "user", "content": "调用 echo 工具返回 pmflow"}], + "temperature": 0, + "max_tokens": 64, + "tools": [ + { + "type": "function", + "function": { + "name": "pmflow_capability_echo", + "description": "Echo capability probe.", + "parameters": { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + }, + }, + } + ], + "tool_choice": {"type": "function", "function": {"name": "pmflow_capability_echo"}}, + } + started = time.perf_counter() + try: + async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client: + response = await client.post(endpoint, headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}, json=payload) + if response.status_code >= 400: + return self._capability_probe_result( + status="failed", + message=f"tools 参数被供应商拒绝: HTTP {response.status_code}", + latency_ms=int((time.perf_counter() - started) * 1000), + method="tools_tool_choice", + evidence_strength="unknown", + request_summary=request_summary, + response_summary=response.text[:300], + failure_code="tools_param_rejected", + provider_request_payload=payload, + provider_response_payload=self._response_audit_payload(response), + provider_attempts=[self._provider_attempt_audit(endpoint=endpoint, provider=provider, model=str(engine.get("model") or ""), request_payload=payload, response_payload=self._response_audit_payload(response), status="http_error", status_code=response.status_code)], + upstream_request_sent=True, + ) + data = response.json() + message = (((data.get("choices") or [{}])[0] or {}).get("message") or {}) if isinstance(data, dict) else {} + tool_calls = message.get("tool_calls") if isinstance(message, dict) else None + ok = isinstance(tool_calls, list) and len(tool_calls) > 0 + return self._capability_probe_result( + status="verified" if ok else "failed", + message="工具调用探测通过" if ok else "供应商响应中没有 tool_calls", + latency_ms=int((time.perf_counter() - started) * 1000), + method="tools_tool_choice", + evidence_strength="live_probe" if ok else "unknown", + request_summary=request_summary, + response_summary=json.dumps(message, ensure_ascii=False)[:300], + failure_code=None if ok else "missing_tool_calls", + provider_request_payload=payload, + provider_response_payload=data, + provider_attempts=[self._provider_attempt_audit(endpoint=endpoint, provider=provider, model=str(engine.get("model") or ""), request_payload=payload, response_payload=data, status="success", status_code=response.status_code)], + upstream_request_sent=True, + ) + except Exception as exc: + return self._capability_probe_result( + status="failed", + message=str(exc), + latency_ms=int((time.perf_counter() - started) * 1000), + method="tools_tool_choice", + evidence_strength="unknown", + request_summary=request_summary, + failure_code="request_failed", + provider_request_payload=payload, + provider_response_payload=getattr(exc, "provider_response_payload", None), + provider_attempts=[self._provider_attempt_audit(endpoint=endpoint, provider=provider, model=str(engine.get("model") or ""), request_payload=payload, response_payload=getattr(exc, "provider_response_payload", None), status="request_error", error=str(exc)[:2000])], + upstream_request_sent=True, + ) + + async def _probe_streaming_capability(self, *, engine: dict[str, Any]) -> dict[str, Any]: + provider = str(engine.get("provider") or "").strip().lower() + request_summary = "发送 stream=true 的 OpenAI-compatible chat/completions 请求,验证是否返回流式分片。" + if provider == "ollama": + return self._capability_probe_result( + status="not_probeable", + message="Ollama 流式协议与当前 OpenAI-compatible 探测适配不同,暂不作为验证证据", + latency_ms=None, + method="stream_chat_completions", + evidence_strength="unknown", + request_summary=request_summary, + failure_code="adapter_not_probeable", + ) + key = str(engine.get("api_key") or "").strip() + if not key: + return self._capability_probe_result( + status="failed", + message="未配置 API Key", + latency_ms=None, + method="stream_chat_completions", + evidence_strength="unknown", + request_summary=request_summary, + failure_code="missing_api_key", + ) + endpoint = f"{str(engine.get('base_url') or '').rstrip('/')}/chat/completions" + payload = { + "model": str(engine.get("model") or ""), + "messages": [{"role": "user", "content": "请只返回 OK"}], + "temperature": 0, + "max_tokens": 16, + "stream": True, + } + started = time.perf_counter() + chunks = 0 + first_chunk = "" + try: + async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client: + async with client.stream("POST", endpoint, headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}, json=payload) as response: + if response.status_code >= 400: + body = await response.aread() + return self._capability_probe_result( + status="failed", + message=f"stream 参数被供应商拒绝: HTTP {response.status_code}", + latency_ms=int((time.perf_counter() - started) * 1000), + method="stream_chat_completions", + evidence_strength="unknown", + request_summary=request_summary, + response_summary=body.decode("utf-8", errors="ignore")[:300], + failure_code="stream_param_rejected", + provider_request_payload=payload, + provider_response_payload={"raw_text": body.decode("utf-8", errors="ignore")}, + provider_attempts=[self._provider_attempt_audit(endpoint=endpoint, provider=provider, model=str(engine.get("model") or ""), request_payload=payload, response_payload={"raw_text": body.decode("utf-8", errors="ignore")}, status="http_error", status_code=response.status_code)], + upstream_request_sent=True, + ) + async for line in response.aiter_lines(): + text = str(line or "").strip() + if not text: + continue + chunks += 1 + if not first_chunk: + first_chunk = text[:300] + if chunks >= 2: + break + ok = chunks > 0 + return self._capability_probe_result( + status="verified" if ok else "failed", + message="流式响应探测通过" if ok else "供应商未返回流式分片", + latency_ms=int((time.perf_counter() - started) * 1000), + method="stream_chat_completions", + evidence_strength="live_probe" if ok else "unknown", + request_summary=request_summary, + response_summary=first_chunk, + failure_code=None if ok else "missing_stream_chunks", + provider_request_payload=payload, + provider_response_payload={"first_chunk": first_chunk, "chunks_seen": chunks}, + provider_attempts=[self._provider_attempt_audit(endpoint=endpoint, provider=provider, model=str(engine.get("model") or ""), request_payload=payload, response_payload={"first_chunk": first_chunk, "chunks_seen": chunks}, status="success" if ok else "missing_stream_chunks")], + upstream_request_sent=True, + ) + except Exception as exc: + return self._capability_probe_result( + status="failed", + message=str(exc), + latency_ms=int((time.perf_counter() - started) * 1000), + method="stream_chat_completions", + evidence_strength="unknown", + request_summary=request_summary, + failure_code="request_failed", + provider_request_payload=payload, + provider_response_payload=getattr(exc, "provider_response_payload", None), + provider_attempts=[self._provider_attempt_audit(endpoint=endpoint, provider=provider, model=str(engine.get("model") or ""), request_payload=payload, response_payload=getattr(exc, "provider_response_payload", None), status="request_error", error=str(exc)[:2000])], + upstream_request_sent=True, + ) + + async def _probe_long_context_capability(self, *, engine: dict[str, Any], min_context_tokens: int | None = None) -> dict[str, Any]: + capabilities = self._normalize_engine_capabilities( + engine.get("capabilities"), + provider=str(engine.get("provider") or ""), + model=str(engine.get("model") or ""), + ) + declared_tokens = int(capabilities.get("max_context_tokens") or 0) + target_tokens = int(min_context_tokens) if min_context_tokens is not None else declared_tokens + has_declared_window = bool(capabilities.get("long_context")) and declared_tokens > 0 + declared_ok = has_declared_window and (min_context_tokens is None or declared_tokens >= target_tokens) + context_contract = _provider_model_context_contract(engine.get("provider"), engine.get("model")) + contract_context_tokens = int((context_contract or {}).get("context_tokens") or 0) + contract_covers_declared = bool(context_contract) and declared_tokens > 0 and contract_context_tokens >= declared_tokens + if declared_ok: + if contract_covers_declared: + message = ( + f"供应商官方模型规格覆盖声明窗口 {declared_tokens} tokens;" + "本探测不发送高成本长文本正文,记录为供应商合同证据" + ) + else: + message = f"长上下文真实 smoke 需要高成本调用,本次只记录声明窗口 {declared_tokens} tokens,不作为槽位发布 verified 证据" + elif not bool(capabilities.get("long_context")): + message = "未声明长上下文能力" + elif declared_tokens <= 0: + message = "已声明长上下文能力,但未配置 max_context_tokens" + else: + message = f"声明上下文窗口不足 {target_tokens} tokens" + return self._capability_probe_result( + status="verified" if declared_ok and contract_covers_declared else ("declared_only" if declared_ok else "failed"), + message=message, + latency_ms=None, + method="declared_window_gate", + evidence_strength="provider_contract" if declared_ok and contract_covers_declared else "declared_only", + request_summary=( + f"读取供应商配置中的 max_context_tokens,目标阈值 {target_tokens} tokens;" + "未向模型发送长上下文正文。" + ), + response_summary=( + f"{context_contract.get('label')}:上下文 {contract_context_tokens} tokens," + f"最大输入约 {int(context_contract.get('max_input_tokens') or 0)} tokens。" + if context_contract + else "" + ), + max_context_tokens=declared_tokens, + partial_evidence=not (declared_ok and contract_covers_declared), + declared_ok=declared_ok, + provider_contract=context_contract, + failure_code=None if declared_ok else "declared_window_too_small", + provider_attempts=[ + { + "status": "not_sent", + "provider": str(engine.get("provider") or ""), + "model": str(engine.get("model") or ""), + "reason": "declared_window_gate_only", + "summary": f"本探测只读取供应商声明窗口,未向上游发送长上下文请求;目标阈值 {target_tokens} tokens。", + } + ], + upstream_request_sent=False, + upstream_missing_reason="本探测只读取供应商声明窗口,未向上游发送请求。", + ) + + async def _log_capability_probe_item( + self, + *, + tenant_id: int, + user_id: int | None, + engine: dict[str, Any], + probe_run_id: str, + target: str, + result: dict[str, Any], + ) -> None: + if result.get("upstream_request_sent") is False: + return + if not result.get("provider_request_payload") and not result.get("provider_response_payload") and not result.get("provider_attempts"): + return + try: + await self._log_ai_call( + tenant_id=tenant_id, + function_name=f"probe_capability_{target}", + prompt=str(result.get("request_summary") or target), + response=str(result.get("response_summary") or result.get("message") or ""), + status=f"capability_{str(result.get('status') or 'unknown')}", + provider=str(engine.get("provider") or ""), + model=str(engine.get("model") or ""), + user_id=user_id, + provider_request_payload=result.get("provider_request_payload"), + provider_response_payload=result.get("provider_response_payload"), + provider_attempts=result.get("provider_attempts"), + routing_meta={ + "scene_key": "platform_ai_ops", + "direct_operation": True, + "routing_decision": "direct_capability_probe", + "route_source": "provider_console", + "engine_id": str(engine.get("id") or ""), + "engine_name": str(engine.get("name") or ""), + "provider": str(engine.get("provider") or ""), + "provider_code": str(engine.get("provider") or ""), + "provider_label": _provider_display_label(engine.get("provider")), + "model": str(engine.get("model") or ""), + "probe_run_id": probe_run_id, + "probe_target": target, + "probe_method": result.get("method"), + "probe_status": result.get("status"), + "evidence_strength": result.get("evidence_strength"), + "failure_code": result.get("failure_code"), + "latency_ms": result.get("latency_ms"), + "upstream_request_sent": result.get("upstream_request_sent"), + "upstream_missing_reason": result.get("upstream_missing_reason"), + }, + ) + except Exception as log_exc: + _runtime_log("LLM", f"能力子探测日志写入失败:{log_exc}") + + async def probe_engine_capabilities( + self, + db: AsyncSession, + *, + tenant_id: int, + engine_id: str, + targets: list[str] | None = None, + mode: str = "full", + allow_high_cost: bool = False, + user_id: int | None = None, + ) -> dict[str, Any]: + engine = await self._resolve_engine_for_test( + db, + tenant_id=tenant_id, + engine_id=engine_id, + ) + if not self._engine_enabled(engine): + raise ValueError("已停用的供应商连接不能执行能力探测") + connection_status = str(engine.get("connection_status") or "untested").strip().lower() + if connection_status != "available": + raise ValueError("供应商连接测试未通过,不能执行能力探测") + declared_capabilities = self._normalize_engine_capabilities( + engine.get("capabilities"), + provider=str(engine.get("provider") or ""), + model=str(engine.get("model") or ""), + ) + requested = self._normalize_capability_probe_targets( + targets, + mode=mode, + allow_high_cost=allow_high_cost, + capabilities=declared_capabilities, + ) + if not requested: + raise ValueError("至少选择一个 capability probe 目标") + probe_run_id = f"cap-{uuid4().hex}" + results: dict[str, dict[str, Any]] = {} + if "basic_chat" in requested: + results["basic_chat"] = await self._probe_basic_chat_capability(engine=engine) + await self._log_capability_probe_item(tenant_id=tenant_id, user_id=user_id, engine=engine, probe_run_id=probe_run_id, target="basic_chat", result=results["basic_chat"]) + dependency_failed = "basic_chat" in results and str(results["basic_chat"].get("status") or "") != "verified" + if dependency_failed: + for target in requested: + if target == "basic_chat": + continue + results[target] = self._capability_probe_result( + status="skipped", + message="基础聊天探测未通过,跳过后续能力探测", + latency_ms=None, + method="dependency_gate", + evidence_strength="unknown", + request_summary="依赖基础聊天能力通过后才执行该能力探测。", + failure_code="basic_chat_failed", + ) + await self._log_capability_probe_item(tenant_id=tenant_id, user_id=user_id, engine=engine, probe_run_id=probe_run_id, target=target, result=results[target]) + if not dependency_failed and "model_self_report" in requested: + results["model_self_report"] = await self._probe_model_self_report_capability(engine=engine) + await self._log_capability_probe_item(tenant_id=tenant_id, user_id=user_id, engine=engine, probe_run_id=probe_run_id, target="model_self_report", result=results["model_self_report"]) + if not dependency_failed and "structured_output" in requested: + results["structured_output"] = await self._probe_structured_output_capability(engine=engine) + await self._log_capability_probe_item(tenant_id=tenant_id, user_id=user_id, engine=engine, probe_run_id=probe_run_id, target="structured_output", result=results["structured_output"]) + if not dependency_failed and "json_schema" in requested: + results["json_schema"] = await self._probe_json_schema_capability(engine=engine) + await self._log_capability_probe_item(tenant_id=tenant_id, user_id=user_id, engine=engine, probe_run_id=probe_run_id, target="json_schema", result=results["json_schema"]) + if not dependency_failed and "tool_calling" in requested: + results["tool_calling"] = await self._probe_tool_calling_capability(engine=engine) + await self._log_capability_probe_item(tenant_id=tenant_id, user_id=user_id, engine=engine, probe_run_id=probe_run_id, target="tool_calling", result=results["tool_calling"]) + if not dependency_failed and "streaming" in requested: + results["streaming"] = await self._probe_streaming_capability(engine=engine) + await self._log_capability_probe_item(tenant_id=tenant_id, user_id=user_id, engine=engine, probe_run_id=probe_run_id, target="streaming", result=results["streaming"]) + if not dependency_failed and "long_context" in requested: + results["long_context"] = await self._probe_long_context_capability(engine=engine) + await self._log_capability_probe_item(tenant_id=tenant_id, user_id=user_id, engine=engine, probe_run_id=probe_run_id, target="long_context", result=results["long_context"]) + if not dependency_failed and "long_context_32k" in requested: + results["long_context_32k"] = await self._probe_long_context_capability(engine=engine, min_context_tokens=32768) + await self._log_capability_probe_item(tenant_id=tenant_id, user_id=user_id, engine=engine, probe_run_id=probe_run_id, target="long_context_32k", result=results["long_context_32k"]) + if not dependency_failed and "long_context_128k" in requested: + results["long_context_128k"] = await self._probe_long_context_capability(engine=engine, min_context_tokens=128000) + await self._log_capability_probe_item(tenant_id=tenant_id, user_id=user_id, engine=engine, probe_run_id=probe_run_id, target="long_context_128k", result=results["long_context_128k"]) + for target in ("vision_input", "audio_input"): + if not dependency_failed and target in requested: + results[target] = self._capability_probe_result( + status="not_probeable", + message="当前适配器尚未提供该多模态能力的真实探测实现,不能作为 verified 证据", + latency_ms=None, + method="adapter_capability_gap", + evidence_strength="unknown", + request_summary=f"检查 {target} 是否有已接入的真实探测器。", + failure_code="probe_not_implemented", + ) + await self._log_capability_probe_item(tenant_id=tenant_id, user_id=user_id, engine=engine, probe_run_id=probe_run_id, target=target, result=results[target]) + sanitized = await self._record_engine_capability_probe_result( + db, + tenant_id=tenant_id, + engine_id=str(engine.get("id") or ""), + results=results, + ) + status = str((sanitized.get("capabilities") or {}).get("capability_probe_status") or "untested") + return { + "ok": status in {"passed", "partial"}, + "engine_id": str(engine.get("id") or ""), + "provider": str(engine.get("provider") or ""), + "model": str(engine.get("model") or ""), + "status": status, + "probe_run_id": probe_run_id, + "mode": str(mode or "full"), + "targets": requested, + "results": results, + "capabilities": sanitized.get("capabilities") or {}, + } async def test_engine_connection( self, @@ -161,6 +2150,7 @@ class LLMEngineAdminMixin: engine_id: Optional[str] = None, prompt: Optional[str] = None, max_tokens: int = 64, + user_id: int | None = None, ) -> dict[str, Any]: test_prompt = (prompt or "你是连通性测试助手。请只返回: OK").strip() if not test_prompt: @@ -171,15 +2161,20 @@ class LLMEngineAdminMixin: tenant_id=tenant_id, engine_id=engine_id, ) + if not self._engine_enabled(engine): + raise ValueError("已停用的供应商连接不能执行连接测试") response_text = "" status = "success" + provider_request_payload: Any = None + provider_response_payload: Any = None + provider_attempts: Any = None started = time.perf_counter() try: transient_retry_budget = 2 transient_retry_attempt = 0 while True: try: - response_text = await self._chat_completion( + completion = await self._chat_completion_raw( provider=str(engine.get("provider") or ""), prompt=test_prompt, system_prompt="", @@ -189,8 +2184,15 @@ class LLMEngineAdminMixin: temperature=0.0, max_tokens=max_tokens, ) + response_text = completion.text + provider_request_payload = completion.raw_request + provider_response_payload = completion.raw_response + provider_attempts = completion.provider_attempts break except Exception as transient_exc: + provider_request_payload = provider_request_payload or getattr(transient_exc, "provider_request_payload", None) + provider_response_payload = provider_response_payload or getattr(transient_exc, "provider_response_payload", None) + provider_attempts = provider_attempts or getattr(transient_exc, "provider_attempts", None) status_code = self._http_status_from_exception(transient_exc) can_retry_transient = ( transient_retry_budget > 0 @@ -224,6 +2226,12 @@ class LLMEngineAdminMixin: if not response_text.strip(): raise ValueError("测试响应为空") latency_ms = int((time.perf_counter() - started) * 1000) + await self._record_engine_test_result( + db, + tenant_id=tenant_id, + engine_id=str(engine.get("id") or ""), + ok=True, + ) return { "ok": True, "engine_id": str(engine.get("id") or ""), @@ -238,6 +2246,16 @@ class LLMEngineAdminMixin: except Exception as exc: status = "failed" response_text = f"[error] {exc}" + try: + await self._record_engine_test_result( + db, + tenant_id=tenant_id, + engine_id=str(engine.get("id") or ""), + ok=False, + error_summary=str(exc), + ) + except Exception as record_exc: + _runtime_log("LLM", f"连通测试状态写入失败:{record_exc}") status_code = self._http_status_from_exception(exc) if int(status_code or 0) == 429: raise ValueError("引擎连通测试失败: 上游限流(429),请稍后重试或更换 API Key。") from exc @@ -252,7 +2270,20 @@ class LLMEngineAdminMixin: status=status, provider=str(engine.get("provider") or ""), model=str(engine.get("model") or ""), + user_id=user_id, + provider_request_payload=provider_request_payload, + provider_response_payload=provider_response_payload, + provider_attempts=provider_attempts, + routing_meta={ + "scene_key": "platform_ai_ops", + "engine_id": str(engine.get("id") or ""), + "engine_name": str(engine.get("name") or ""), + "provider": str(engine.get("provider") or ""), + "provider_code": str(engine.get("provider") or ""), + "provider_label": _provider_display_label(engine.get("provider")), + "model": str(engine.get("model") or ""), + "latency_ms": int((time.perf_counter() - started) * 1000), + }, ) except Exception as log_exc: _runtime_log("LLM", f"测试调用日志写入失败:{log_exc}") - diff --git a/backend/app/services/llm_service/mixin_engine_resolution_core.py b/backend/app/services/llm_service/mixin_engine_resolution_core.py index 67f56e42..5729fdf3 100644 --- a/backend/app/services/llm_service/mixin_engine_resolution_core.py +++ b/backend/app/services/llm_service/mixin_engine_resolution_core.py @@ -23,10 +23,19 @@ class LLMEngineResolutionCoreMixin: "model": str(item.get("model") or ""), "base_url": str(item.get("base_url") or ""), "api_key_masked": _mask_secret(str(item.get("api_key") or "")), + "enabled": bool(item.get("enabled", True)), + "connection_status": str(item.get("connection_status") or "untested"), + "last_test_status": str(item.get("last_test_status") or ""), + "last_test_at": item.get("last_test_at"), + "last_error_summary": str(item.get("last_error_summary") or ""), "updated_at": item.get("updated_at"), "created_at": item.get("created_at"), } + @staticmethod + def _engine_enabled(item: dict[str, Any]) -> bool: + return bool(item.get("enabled", True)) + @staticmethod def _normalize_engine_settings(raw: Any) -> dict[str, Any]: settings = dict(LLM_ENGINE_SETTINGS_DEFAULTS) @@ -87,10 +96,14 @@ class LLMEngineResolutionCoreMixin: *, tenant_id: int, ) -> tuple[Integration, dict[str, Any]]: + # Provider/runtime credentials are PMFlow platform-level infrastructure. + # The integrations table is tenant-scoped, so we pin this bundle to the + # internal platform runtime tenant instead of each customer workspace. + runtime_tenant_id = PLATFORM_RUNTIME_TENANT_ID result = await db.execute( select(Integration).where( and_( - Integration.tenant_id == tenant_id, + Integration.tenant_id == runtime_tenant_id, Integration.type == "llm_engine", ) ) @@ -101,7 +114,7 @@ class LLMEngineResolutionCoreMixin: type="llm_engine", config={"active_engine_id": None, "engines": [], "settings": self._normalize_engine_settings(None)}, is_active=True, - tenant_id=tenant_id, + tenant_id=runtime_tenant_id, ) db.add(integration) await db.flush() @@ -112,9 +125,29 @@ class LLMEngineResolutionCoreMixin: bundle["engines"] = engines if "active_engine_id" not in bundle: bundle["active_engine_id"] = None + if "default_fallback_engine_id" not in bundle: + bundle["default_fallback_engine_id"] = bundle.get("active_engine_id") bundle["settings"] = self._normalize_engine_settings(bundle.get("settings")) return integration, bundle + async def _load_capability_slot_bundle(self, db: AsyncSession) -> dict[str, Any]: + result = await db.execute( + select(Integration).where( + and_( + Integration.tenant_id == PLATFORM_RUNTIME_TENANT_ID, + Integration.type == CAPABILITY_SLOT_INTEGRATION_TYPE, + ) + ) + ) + integration = result.scalar_one_or_none() + bundle = _safe_json(integration.config if integration else {}) + active = bundle.get("active") if isinstance(bundle.get("active"), dict) else {} + slots = active.get("slots") if isinstance(active.get("slots"), dict) else bundle.get("slots") + if not isinstance(slots, dict): + slots = {} + bundle["slots"] = slots + return bundle + async def _resolve_active_engine( self, db: AsyncSession, @@ -136,7 +169,49 @@ class LLMEngineResolutionCoreMixin: settings = self._normalize_engine_settings(bundle.get("settings")) active_engine = self._resolve_active_engine_from_bundle(bundle) active_engine = self._ensure_control_plane_engine(active_engine) - engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)] + engines: list[dict[str, Any]] = [ + e for e in bundle.get("engines", []) if isinstance(e, dict) and self._engine_enabled(e) + ] + slot_bundle = await self._load_capability_slot_bundle(db) + normalized_routing_meta = _normalize_routing_meta(routing_meta) + requested_slot = self._requested_runtime_capability_slot(normalized_routing_meta) + requested_slot_configured = requested_slot in {"structured", "long_context", "tool"} and self._slot_binding_configured( + slot_bundle=slot_bundle, + tier=requested_slot, + ) + structured_slot_degraded_meta: dict[str, Any] = {} + + capability_slot_engine = self._resolve_requested_capability_slot_engine( + routing_meta=routing_meta, + engines=engines, + slot_bundle=slot_bundle, + ) + if capability_slot_engine is not None: + _runtime_log( + "LLM路由", + ( + "能力槽路由生效:" + f"function={function_name}, {_routing_meta_log_text(_read_engine_routing_meta(capability_slot_engine))}" + ), + ) + return self._ensure_engine_allowed_for_function( + engine=capability_slot_engine, + function_name=function_name, + settings=settings, + engine_label=f"{requested_slot}_slot_engine", + ) + if requested_slot_configured and requested_slot == "structured": + structured_slot_degraded_meta = { + "slot_key": "structured", + "slot_version": self._slot_bundle_active_version(slot_bundle), + "slot_runtime_source": "active_slot_degraded", + "slot_degraded": True, + "routing_decision": "structured_slot_degraded", + } + if str(function_name or "").strip().lower() in self._PROTECTED_STRUCTURED_FUNCTIONS: + raise RuntimeError("structured_slot_runtime_unavailable") + elif requested_slot_configured and requested_slot in {"long_context", "tool"}: + raise RuntimeError(f"{requested_slot}_slot_runtime_unavailable") override_engine = await self._resolve_function_override_engine( db, @@ -144,8 +219,11 @@ class LLMEngineResolutionCoreMixin: function_name=function_name, engines=engines, active_engine=active_engine, + slot_bundle=slot_bundle, ) if override_engine is not None: + if structured_slot_degraded_meta: + override_engine = _merge_engine_routing_meta(override_engine, **structured_slot_degraded_meta) return self._ensure_engine_allowed_for_function( engine=override_engine, function_name=function_name, @@ -157,10 +235,13 @@ class LLMEngineResolutionCoreMixin: function_name=function_name, engines=engines, active_engine=active_engine, + slot_bundle=slot_bundle, routing_meta=routing_meta, ) if control_plane_routed is not None: control_plane_routed = self._ensure_control_plane_engine(control_plane_routed) + if structured_slot_degraded_meta: + control_plane_routed = _merge_engine_routing_meta(control_plane_routed, **structured_slot_degraded_meta) return self._ensure_engine_allowed_for_function( engine=control_plane_routed, function_name=function_name, @@ -178,6 +259,8 @@ class LLMEngineResolutionCoreMixin: route_source="active_engine", routing_decision="active_engine", ) + if structured_slot_degraded_meta: + active_engine = _merge_engine_routing_meta(active_engine, **structured_slot_degraded_meta) return self._ensure_engine_allowed_for_function( engine=active_engine, function_name=function_name, @@ -195,6 +278,8 @@ class LLMEngineResolutionCoreMixin: route_source="active_engine", routing_decision="active_engine", ) + if structured_slot_degraded_meta: + active_engine = _merge_engine_routing_meta(active_engine, **structured_slot_degraded_meta) return self._ensure_engine_allowed_for_function( engine=active_engine, function_name=function_name, @@ -206,8 +291,11 @@ class LLMEngineResolutionCoreMixin: tier=tier, engines=engines, active_engine=active_engine, + slot_bundle=slot_bundle, ) if not routed: + if structured_slot_degraded_meta: + active_engine = _merge_engine_routing_meta(active_engine, **structured_slot_degraded_meta) return self._ensure_engine_allowed_for_function( engine=active_engine, function_name=function_name, @@ -225,6 +313,8 @@ class LLMEngineResolutionCoreMixin: routing_config_version=config_snapshot.get("routing_config_version"), routing_config_updated_at=config_snapshot.get("routing_config_updated_at"), ) + if structured_slot_degraded_meta: + routed = _merge_engine_routing_meta(routed, **structured_slot_degraded_meta) _runtime_log( "LLM路由", ( @@ -247,6 +337,7 @@ class LLMEngineResolutionCoreMixin: function_name: str, engines: list[dict[str, Any]], active_engine: dict[str, Any], + slot_bundle: dict[str, Any] | None = None, ) -> Optional[dict[str, Any]]: fn = str(function_name or "").strip().lower() if not fn: @@ -255,10 +346,10 @@ class LLMEngineResolutionCoreMixin: row = ( await db.execute( select(Integration).where( - and_( - Integration.tenant_id == int(tenant_id), - Integration.type == "model_routing", - ) + and_( + Integration.tenant_id == PLATFORM_RUNTIME_TENANT_ID, + Integration.type == "model_routing", + ) ) ) ).scalar_one_or_none() @@ -284,6 +375,7 @@ class LLMEngineResolutionCoreMixin: tier=tier_alias, engines=engines, active_engine=active_engine, + slot_bundle=slot_bundle, ) if routed is None: return None @@ -321,12 +413,14 @@ class LLMEngineResolutionCoreMixin: return None def _resolve_active_engine_from_bundle(self, bundle: dict[str, Any]) -> dict[str, Any]: - engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)] - active_engine_id = str(bundle.get("active_engine_id") or "") - if active_engine_id: + engines: list[dict[str, Any]] = [ + e for e in bundle.get("engines", []) if isinstance(e, dict) and self._engine_enabled(e) + ] + fallback_engine_id = str(bundle.get("default_fallback_engine_id") or bundle.get("active_engine_id") or "") + if fallback_engine_id: for engine in engines: - if str(engine.get("id") or "") == active_engine_id: + if str(engine.get("id") or "") == fallback_engine_id: return self._normalize_engine_runtime(engine) if engines: return self._normalize_engine_runtime(engines[0]) - return self._env_engine_runtime() + raise ValueError("未配置可用 LLM 引擎,请先在供应商与凭据中新增并启用供应商连接") diff --git a/backend/app/services/llm_service/mixin_generate_db_chat.py b/backend/app/services/llm_service/mixin_generate_db_chat.py index 13661811..d6ff0588 100644 --- a/backend/app/services/llm_service/mixin_generate_db_chat.py +++ b/backend/app/services/llm_service/mixin_generate_db_chat.py @@ -16,6 +16,9 @@ class LLMGenerateDbChatMixin: status = "success" routing_decision = "primary_engine" engine_meta: dict[str, Any] = {} + provider_request_payload: Any = None + provider_response_payload: Any = None + provider_attempts: Any = None prompt_profile = {"role_name": "", "system_prompt": ""} prompt_with_context = await self._inject_model_context_bundle_prompt( db=db, @@ -59,7 +62,7 @@ class LLMGenerateDbChatMixin: engine_meta = engine provider = str(engine.get("provider") or "") model = str(engine.get("model") or "") - response_text = await self._chat_completion( + completion = await self._chat_completion_raw( provider=provider, prompt=prompt_with_context, system_prompt=prompt_profile.get("system_prompt") or "", @@ -72,6 +75,10 @@ class LLMGenerateDbChatMixin: response_mode=response_mode, routing_meta=routing_meta, ) + response_text = completion.text + provider_request_payload = completion.raw_request + provider_response_payload = completion.raw_response + provider_attempts = completion.provider_attempts if not response_text.strip(): raise ValueError("primary_engine_empty_response") @@ -83,6 +90,9 @@ class LLMGenerateDbChatMixin: raise ValueError("invalid_structured_output") return response_text except Exception as exc: + provider_request_payload = provider_request_payload or getattr(exc, "provider_request_payload", None) + provider_response_payload = provider_response_payload or getattr(exc, "provider_response_payload", None) + provider_attempts = provider_attempts or getattr(exc, "provider_attempts", None) failover_response, failover_engine = await self._retry_with_high_tier_after_low_model_failure( db=db, tenant_id=tenant_id, @@ -101,6 +111,9 @@ class LLMGenerateDbChatMixin: status = "success_failover" response_text = failover_response engine_meta = failover_engine + provider_request_payload = failover_engine.get("_provider_request_payload") + provider_response_payload = failover_engine.get("_provider_response_payload") + provider_attempts = failover_engine.get("_provider_attempts") return failover_response status = "failed" reason_text = self._build_model_error_message( @@ -126,9 +139,18 @@ class LLMGenerateDbChatMixin: status=status, provider=str(engine_meta.get("provider") or ""), model=str(engine_meta.get("model") or ""), - routing_meta=_read_engine_routing_meta(engine_meta), + routing_meta=_merge_call_routing_meta( + caller_meta=routing_meta, + engine_meta=_read_engine_routing_meta(engine_meta), + ), + provider_request_payload=provider_request_payload or engine_meta.get("_provider_request_payload"), + provider_response_payload=provider_response_payload or engine_meta.get("_provider_response_payload"), + provider_attempts=provider_attempts or engine_meta.get("_provider_attempts"), + ) + routing_meta = _merge_call_routing_meta( + caller_meta=routing_meta, + engine_meta=_read_engine_routing_meta(engine_meta), ) - routing_meta = _read_engine_routing_meta(engine_meta) if ( routing_meta.get("planned_tier") and routing_meta.get("executed_tier") diff --git a/backend/app/services/llm_service/mixin_generate_db_structured.py b/backend/app/services/llm_service/mixin_generate_db_structured.py index 629ae6ff..7fee3311 100644 --- a/backend/app/services/llm_service/mixin_generate_db_structured.py +++ b/backend/app/services/llm_service/mixin_generate_db_structured.py @@ -30,6 +30,10 @@ class LLMGenerateDbStructuredMixin: engine_meta: dict[str, Any] = {} status = "success" response_text = "" + provider_request_payload: Any = None + provider_response_payload: Any = None + provider_attempts: Any = None + started_at_perf = time.perf_counter() try: if use_prompt_profile: prompt_profile = await self._resolve_active_prompt_profile(db, tenant_id=tenant_id) @@ -72,6 +76,10 @@ class LLMGenerateDbStructuredMixin: ) return result + effective_routing_meta = _merge_call_routing_meta( + caller_meta=routing_meta, + engine_meta=_read_engine_routing_meta(engine), + ) completion = await self._chat_completion_raw( provider=provider, prompt=prompt_with_context, @@ -83,9 +91,12 @@ class LLMGenerateDbStructuredMixin: max_tokens=max_tokens, function_name=function_name, response_mode=response_mode, - routing_meta=routing_meta, + routing_meta=effective_routing_meta, ) response_text = completion.text + provider_request_payload = completion.raw_request + provider_response_payload = completion.raw_response + provider_attempts = completion.provider_attempts extraction = self._extract_structured_object_from_response( function_name=function_name, response=completion.raw_response, @@ -128,6 +139,49 @@ class LLMGenerateDbStructuredMixin: status = extraction.error_code or "success" return extraction except Exception as exc: + provider_request_payload = provider_request_payload or getattr(exc, "provider_request_payload", None) + provider_response_payload = provider_response_payload or getattr(exc, "provider_response_payload", None) + provider_attempts = provider_attempts or getattr(exc, "provider_attempts", None) + engine_routing_meta = _read_engine_routing_meta(engine_meta) + if str(engine_routing_meta.get("planned_tier") or "").strip().lower() == "structured": + failover_result, failover_engine = await self._retry_structured_with_slot_fallback_after_primary_failure( + db=db, + tenant_id=tenant_id, + function_name=function_name, + failed_engine=engine_meta, + prompt=prompt_with_context, + system_prompt=str(prompt_profile.get("system_prompt") or ""), + temperature=temperature, + max_tokens=max_tokens, + response_mode=response_mode, + error=exc, + routing_meta=routing_meta, + ) + if isinstance(failover_result, StructuredExtractionResult) and isinstance(failover_engine, dict): + engine_meta = failover_engine + provider = str(failover_engine.get("provider") or "") + model = str(failover_engine.get("model") or "") + response_text = failover_result.raw_text or json.dumps(failover_result.payload or {}, ensure_ascii=False) + status = failover_result.error_code or "success_structured_slot_fallback" + provider_request_payload = failover_engine.get("_provider_request_payload") + provider_response_payload = failover_engine.get("_provider_response_payload") + provider_attempts = failover_engine.get("_provider_attempts") + return failover_result + error_code = self._classify_structured_exception(exc) + status = error_code + reason_text = self._build_model_error_message( + function_name=function_name, + routing_meta=_merge_call_routing_meta(caller_meta=routing_meta, engine_meta=engine_routing_meta), + error=exc, + ) + response_text = reason_text + _runtime_log( + "LLM结构化", + "function={function_name}, structured_source=none, validation=invalid, fallback=structured_slot_exhausted".format( + function_name=function_name + ), + ) + raise RuntimeError(reason_text) from exc failover_result, failover_engine = await self._retry_structured_with_high_tier_after_low_model_failure( db=db, tenant_id=tenant_id, @@ -147,12 +201,18 @@ class LLMGenerateDbStructuredMixin: model = str(failover_engine.get("model") or "") response_text = failover_result.raw_text or json.dumps(failover_result.payload or {}, ensure_ascii=False) status = failover_result.error_code or "success_failover" + provider_request_payload = failover_engine.get("_provider_request_payload") + provider_response_payload = failover_engine.get("_provider_response_payload") + provider_attempts = failover_engine.get("_provider_attempts") return failover_result error_code = self._classify_structured_exception(exc) status = error_code reason_text = self._build_model_error_message( function_name=function_name, - routing_meta=_read_engine_routing_meta(engine_meta), + routing_meta=_merge_call_routing_meta( + caller_meta=routing_meta, + engine_meta=_read_engine_routing_meta(engine_meta), + ), error=exc, ) response_text = reason_text @@ -164,6 +224,12 @@ class LLMGenerateDbStructuredMixin: finally: try: log_payload = response_text or status + duration_ms = int((time.perf_counter() - started_at_perf) * 1000) + call_routing_meta = _merge_call_routing_meta( + caller_meta=routing_meta, + engine_meta=_read_engine_routing_meta(engine_meta), + ) + call_routing_meta["duration_ms"] = duration_ms await self._log_ai_call( tenant_id=tenant_id, function_name=function_name, @@ -172,7 +238,10 @@ class LLMGenerateDbStructuredMixin: status=status, provider=provider, model=model, - routing_meta=_read_engine_routing_meta(engine_meta), + routing_meta=call_routing_meta, + provider_request_payload=provider_request_payload or engine_meta.get("_provider_request_payload"), + provider_response_payload=provider_response_payload or engine_meta.get("_provider_response_payload"), + provider_attempts=provider_attempts or engine_meta.get("_provider_attempts"), ) except Exception as log_exc: _runtime_log("LLM", f"结构化调用日志写入失败:{log_exc}") @@ -235,8 +304,15 @@ class LLMGenerateDbStructuredMixin: "4. 禁止输出解释、Markdown、代码块。" ) try: - repair_max_tokens = max(180, min(int(max_tokens or 0), 420)) - if str(function_name or "").strip().lower() == "pmflow_semantic_context_decision": + normalized_function = str(function_name or "").strip().lower() + configured_repair_tokens = int(_normalize_routing_meta(routing_meta).get("function_runtime_repair_max_tokens") or 0) + if configured_repair_tokens > 0: + repair_max_tokens = max(180, min(int(max_tokens or 0), configured_repair_tokens)) + elif normalized_function == "pmflow_launch_draft_v1": + repair_max_tokens = max(1200, min(int(max_tokens or 0), 3600)) + else: + repair_max_tokens = max(180, min(int(max_tokens or 0), 420)) + if normalized_function == "pmflow_semantic_context_decision": repair_max_tokens = min(repair_max_tokens, 220) repair_completion = await self._chat_completion_raw( provider=provider, diff --git a/backend/app/services/llm_service/mixin_guardrails_prompt.py b/backend/app/services/llm_service/mixin_guardrails_prompt.py index 3887d7fc..88959514 100644 --- a/backend/app/services/llm_service/mixin_guardrails_prompt.py +++ b/backend/app/services/llm_service/mixin_guardrails_prompt.py @@ -87,6 +87,14 @@ class LLMGuardrailsPromptMixin: routing_meta: dict[str, Any] | None = None, ) -> float: meta = _normalize_routing_meta(routing_meta) + explicit_timeout = meta.get("provider_timeout_seconds") + if explicit_timeout is not None: + try: + configured_timeout = float(explicit_timeout) + except (TypeError, ValueError): + configured_timeout = 0.0 + if configured_timeout > 0: + return min(max(configured_timeout, 1.0), 600.0) if meta.get("fallback_policy") != "fast_local": return self.request_timeout env_key = "LLM_FAST_LOCAL_FALLBACK_TIMEOUT_COMPLETE" @@ -149,6 +157,18 @@ class LLMGuardrailsPromptMixin: parsed = int(default_budget) return max(0, min(parsed, 3)) + @staticmethod + def _max_retries_from_routing_meta(routing_meta: dict[str, Any] | None) -> int | None: + meta = _normalize_routing_meta(routing_meta) + raw = meta.get("max_retries") + if raw is None: + return None + try: + parsed = int(raw) + except Exception: + return None + return max(0, min(parsed, 3)) + @staticmethod def _empty_chat_retry_budget(*, function_name: str, current_max_tokens: int) -> int: fn = str(function_name or "").strip().lower() diff --git a/backend/app/services/llm_service/mixin_prompt_profile_runtime.py b/backend/app/services/llm_service/mixin_prompt_profile_runtime.py index 745c92dd..4ec42553 100644 --- a/backend/app/services/llm_service/mixin_prompt_profile_runtime.py +++ b/backend/app/services/llm_service/mixin_prompt_profile_runtime.py @@ -84,45 +84,14 @@ class LLMPromptProfileRuntimeMixin: provider = _normalize_provider(primary_engine.get("provider")) current_model = str(primary_engine.get("model") or "").strip() - provider_runtime = self._env_provider_runtime(provider) - default_model = str(provider_runtime.get("model") or PROVIDER_DEFAULTS.get(provider, {}).get("model") or "").strip() + default_model = str(PROVIDER_DEFAULTS.get(provider, {}).get("model") or "").strip() if default_model and default_model != current_model: same_provider = dict(primary_engine) same_provider["model"] = default_model candidates.append(self._normalize_engine_runtime(same_provider)) - fallback_provider_raw = str(os.getenv("LLM_FALLBACK_PROVIDER", "ollama")).strip() - if fallback_provider_raw: - fallback_provider = _normalize_provider(fallback_provider_raw) - if fallback_provider != provider: - candidates.append(self._env_provider_runtime(fallback_provider)) - return candidates - def _env_provider_runtime(self, provider: str) -> dict[str, Any]: - provider_name = _normalize_provider(provider) - defaults = PROVIDER_DEFAULTS[provider_name] - api_env = str(defaults.get("api_env") or "").strip() - api_key = os.getenv(api_env, "") if api_env else "" - if provider_name == "glm": - api_key = os.getenv("DASHSCOPE_API_KEY", api_key) - - return { - "id": f"env-{provider_name}", - "name": f"{defaults['name']} (ENV)", - "provider": provider_name, - "api_env": api_env, - "api_key": str(api_key or "").strip(), - "base_url": str(defaults["base_url"]).strip().rstrip("/"), - "model": str(defaults["model"]).strip(), - } - - def _env_engine_runtime(self) -> dict[str, Any]: - provider = _normalize_provider(os.getenv("LLM_PROVIDER", "glm")) - runtime = self._env_provider_runtime(provider) - runtime["id"] = "env-default" - return runtime - def _normalize_engine_runtime(self, engine: dict[str, Any]) -> dict[str, Any]: provider = _normalize_provider(engine.get("provider")) defaults = PROVIDER_DEFAULTS[provider] @@ -132,9 +101,9 @@ class LLMPromptProfileRuntimeMixin: runtime["model"] = str(runtime.get("model") or defaults["model"]).strip() runtime["api_key"] = str(runtime.get("api_key") or "").strip() runtime["name"] = str(runtime.get("name") or defaults["name"]).strip() + runtime["enabled"] = bool(runtime.get("enabled", True)) if not runtime.get("id"): runtime["id"] = str(uuid4()) - runtime = _overlay_engine_api_key_from_env(runtime) return runtime async def _log_ai_call( @@ -148,19 +117,40 @@ class LLMPromptProfileRuntimeMixin: provider: str = "", model: str = "", routing_meta: dict[str, Any] | None = None, + user_id: int | None = None, + provider_request_payload: Any = None, + provider_response_payload: Any = None, + provider_attempts: Any = None, ) -> None: - tokens_used = max(len(prompt) // 4, 1) + max(len(response) // 4, 0) + provider_usage = self._extract_provider_usage(provider_response_payload) + tokens_used = int(provider_usage.get("total_tokens") or 0) + if tokens_used <= 0: + tokens_used = max(len(prompt) // 4, 1) + max(len(response) // 4, 0) cost = self._estimate_cost(provider=provider, model=model, tokens=tokens_used) meta = _normalize_routing_meta(routing_meta) + if provider_usage: + meta["provider_usage"] = provider_usage + trace_id = str(meta.get("trace_id") or meta.get("run_id") or "").strip() or f"ai-{uuid4().hex}" row_payload = { "tenant_id": tenant_id, - "user_id": None, + "user_id": int(user_id) if user_id is not None else None, "function_name": function_name, "prompt": prompt[:8000], "response": self._trim_ai_call_response(response), "tokens_used": tokens_used, "cost": cost, "status": status, + "trace_id": trace_id, + "provider": str(provider or meta.get("provider") or "").strip() or None, + "model": str(model or meta.get("model") or "").strip() or None, + "latency_ms": self._safe_int(meta.get("latency_ms") or meta.get("duration_ms")), + "routing_meta": meta or None, + "prompt_key": str(meta.get("prompt_key") or "").strip() or None, + "prompt_release_id": self._safe_int(meta.get("prompt_release_id")), + "prompt_source": str(meta.get("prompt_source") or "").strip() or None, + "provider_request_payload": self._json_safe_payload(provider_request_payload), + "provider_response_payload": self._json_safe_payload(provider_response_payload), + "provider_attempts": self._json_safe_payload(provider_attempts), "planned_tier": meta.get("planned_tier") or None, "executed_tier": meta.get("executed_tier") or None, "routing_decision": meta.get("routing_decision") or None, @@ -178,6 +168,16 @@ class LLMPromptProfileRuntimeMixin: "route_rule_name": meta.get("route_rule_name") or None, "routing_decision": meta.get("routing_decision") or None, "routing_config_version": meta.get("routing_config_version") or None, + "slot_key": meta.get("slot_key") or None, + "slot_version": meta.get("slot_version") or None, + "slot_runtime_source": meta.get("slot_runtime_source") or None, + "slot_fallback_index": meta.get("slot_fallback_index"), + "slot_degraded": bool(meta.get("slot_degraded", False)), + "prompt_key": meta.get("prompt_key") or None, + "prompt_release_id": meta.get("prompt_release_id"), + "prompt_source": meta.get("prompt_source") or None, + "prompt_format_error": bool(meta.get("prompt_format_error", False)), + "provider_usage": provider_usage, } async with AsyncSessionLocal() as session: for attempt in range(2): @@ -194,6 +194,28 @@ class LLMPromptProfileRuntimeMixin: await session.rollback() return + @staticmethod + def _safe_int(value: Any) -> int | None: + if value is None or value == "": + return None + try: + return int(float(value)) + except (TypeError, ValueError): + return None + + @classmethod + def _json_safe_payload(cls, value: Any) -> Any: + if value is None: + return None + try: + json.dumps(value, ensure_ascii=False) + return value + except Exception: + try: + return json.loads(json.dumps(value, ensure_ascii=False, default=str)) + except Exception: + return {"repr": str(value)} + @staticmethod def _trim_ai_call_response(response: str) -> str: text = str(response or "") @@ -212,12 +234,39 @@ class LLMPromptProfileRuntimeMixin: _ = model per_1k = { "deepseek": 0.0003, - "glm": 0.0006, + "dashscope": 0.0006, "openai": 0.0012, "ollama": 0.0, }.get(provider, 0.0005) return round((tokens / 1000.0) * per_1k, 6) + @staticmethod + def _extract_provider_usage(payload: Any) -> dict[str, int]: + if not isinstance(payload, dict): + return {} + usage = payload.get("usage") if isinstance(payload.get("usage"), dict) else {} + if not usage: + return {} + result: dict[str, int] = {} + for source_key, target_key in ( + ("prompt_tokens", "prompt_tokens"), + ("completion_tokens", "completion_tokens"), + ("total_tokens", "total_tokens"), + ("input_tokens", "prompt_tokens"), + ("output_tokens", "completion_tokens"), + ): + if source_key not in usage: + continue + try: + value = int(usage.get(source_key) or 0) + except (TypeError, ValueError): + continue + if value > 0: + result[target_key] = value + if "total_tokens" not in result and (result.get("prompt_tokens") or result.get("completion_tokens")): + result["total_tokens"] = int(result.get("prompt_tokens") or 0) + int(result.get("completion_tokens") or 0) + return result + @staticmethod def _parse_json_object(text: str) -> dict[str, Any]: raw = (text or "").strip() diff --git a/backend/app/services/llm_service/mixin_structured_extract_object.py b/backend/app/services/llm_service/mixin_structured_extract_object.py index 2446b8b3..b8437d95 100644 --- a/backend/app/services/llm_service/mixin_structured_extract_object.py +++ b/backend/app/services/llm_service/mixin_structured_extract_object.py @@ -217,6 +217,14 @@ class LLMStructuredExtractObjectMixin: if isinstance(exc, (httpx.HTTPError, httpx.RequestError)): return "structured_http_error" lowered = str(exc or "").strip().lower() + if lowered in { + "structured_json_invalid", + "structured_invalid", + "structured_schema_invalid", + "structured_json_not_found", + "structured_empty", + }: + return lowered if "timeout" in lowered: return "structured_timeout" if "status" in lowered or "http" in lowered: diff --git a/backend/app/services/llm_service/mixin_tier_engine_selection.py b/backend/app/services/llm_service/mixin_tier_engine_selection.py index 363f1113..e5070886 100644 --- a/backend/app/services/llm_service/mixin_tier_engine_selection.py +++ b/backend/app/services/llm_service/mixin_tier_engine_selection.py @@ -3,6 +3,21 @@ from __future__ import annotations from .shared import * class LLMTierEngineSelectionMixin: + _PROTECTED_STRUCTURED_FUNCTIONS = frozenset( + { + "pmflow_launch_draft_v1", + "pmflow_requirement_plan_draft", + "pmflow_requirement_plan_round", + "project_requirement_extraction", + "generate_task_breakdown", + "decompose_tasks_from_charter", + "sstc_ai_breakdown", + "pmflow_os_changeset_extraction", + "pmflow_change_set_extraction", + "project_changeset_extraction", + } + ) + @staticmethod def _is_same_runtime_engine(a: dict[str, Any], b: dict[str, Any]) -> bool: return ( @@ -21,11 +36,14 @@ class LLMTierEngineSelectionMixin: ) -> Optional[dict[str, Any]]: _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) active_engine = self._resolve_active_engine_from_bundle(bundle) - engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)] + engines: list[dict[str, Any]] = [ + e for e in bundle.get("engines", []) if isinstance(e, dict) and self._engine_enabled(e) + ] routed = self._resolve_tier_engine( tier="high", engines=engines, active_engine=active_engine, + slot_bundle=await self._load_capability_slot_bundle(db), ) if not routed: return None @@ -71,12 +89,31 @@ class LLMTierEngineSelectionMixin: tier: str, engines: list[dict[str, Any]], active_engine: dict[str, Any], + slot_bundle: dict[str, Any] | None = None, ) -> Optional[dict[str, Any]]: tier = str(tier or "").strip().lower() if tier not in {"low", "high"}: return None + + slot_routed = self._resolve_engine_from_slot_binding( + tier=tier, + engines=engines, + slot_bundle=slot_bundle, + ) + if slot_routed is not None: + return self._ensure_control_plane_engine( + _merge_engine_routing_meta( + slot_routed, + tier_alias=tier, + planned_tier=tier, + executed_tier=tier, + route_source="slot_binding", + routing_decision="capability_slot", + ) + ) + tier_defaults = dict(TIER_DEFAULTS.get(tier) or {}) - provider_env = str(tier_defaults.get("provider") or "glm").strip() + provider_env = str(tier_defaults.get("provider") or "dashscope").strip() routed: Optional[dict[str, Any]] = None if routed is None and provider_env: @@ -89,11 +126,6 @@ class LLMTierEngineSelectionMixin: if str(item.get("provider") or "").strip().lower() == provider: routed = self._normalize_engine_runtime(item) break - if routed is None: - try: - routed = self._env_provider_runtime(provider) - except Exception: - routed = None if routed is None: return None @@ -123,6 +155,201 @@ class LLMTierEngineSelectionMixin: ) return self._ensure_control_plane_engine(routed) + def _resolve_capability_slot_engine( + self, + *, + slot_key: str, + engines: list[dict[str, Any]], + slot_bundle: dict[str, Any] | None, + ) -> Optional[dict[str, Any]]: + slot_key = str(slot_key or "").strip().lower() + if slot_key not in CAPABILITY_SLOT_ALIASES: + return None + routed = self._resolve_engine_from_slot_binding( + tier=slot_key, + engines=engines, + slot_bundle=slot_bundle, + ) + if routed is None: + return None + return self._ensure_control_plane_engine( + _merge_engine_routing_meta( + routed, + tier_alias=slot_key, + planned_tier=slot_key, + executed_tier=slot_key, + route_source="slot_binding", + routing_decision="capability_slot", + slot_key=slot_key, + slot_version=self._slot_bundle_active_version(slot_bundle), + slot_runtime_source="active_slot", + slot_fallback_index=routed.get("_slot_fallback_index", 0), + slot_degraded=False, + ) + ) + + def _resolve_structured_slot_engine( + self, + *, + routing_meta: dict[str, Any] | None, + engines: list[dict[str, Any]], + slot_bundle: dict[str, Any] | None, + ) -> Optional[dict[str, Any]]: + meta = _normalize_routing_meta(routing_meta) + if str(meta.get("response_contract") or "").strip().lower() != "structured": + return None + return self._resolve_capability_slot_engine( + slot_key="structured", + engines=engines, + slot_bundle=slot_bundle, + ) + + @staticmethod + def _requested_runtime_capability_slot(routing_meta: dict[str, Any] | None) -> str: + meta = _normalize_routing_meta(routing_meta) + if str(meta.get("response_contract") or "").strip().lower() == "structured": + return "structured" + explicit_slot = _optional_tier_alias( + meta.get("slot_key") + or meta.get("planned_tier") + or meta.get("tier_alias") + or meta.get("executed_tier") + ) + if explicit_slot in {"long_context", "tool"}: + return explicit_slot + execution_mode = str(meta.get("execution_mode") or "").strip().lower() + if execution_mode in {"local_planner", "local_worker"}: + return "tool" + return "" + + def _resolve_requested_capability_slot_engine( + self, + *, + routing_meta: dict[str, Any] | None, + engines: list[dict[str, Any]], + slot_bundle: dict[str, Any] | None, + ) -> Optional[dict[str, Any]]: + slot_key = self._requested_runtime_capability_slot(routing_meta) + if slot_key not in {"structured", "long_context", "tool"}: + return None + return self._resolve_capability_slot_engine( + slot_key=slot_key, + engines=engines, + slot_bundle=slot_bundle, + ) + + def _resolve_engine_from_slot_binding( + self, + *, + tier: str, + engines: list[dict[str, Any]], + slot_bundle: dict[str, Any] | None, + ) -> Optional[dict[str, Any]]: + candidates = self._resolve_engines_from_slot_binding( + tier=tier, + engines=engines, + slot_bundle=slot_bundle, + ) + return candidates[0] if candidates else None + + @staticmethod + def _active_slot_map(slot_bundle: dict[str, Any] | None) -> dict[str, Any]: + if not isinstance(slot_bundle, dict): + return {} + active = slot_bundle.get("active") if isinstance(slot_bundle.get("active"), dict) else {} + if isinstance(active.get("slots"), dict): + return active["slots"] + if isinstance(slot_bundle.get("slots"), dict): + return slot_bundle["slots"] + return {} + + @staticmethod + def _slot_bundle_active_version(slot_bundle: dict[str, Any] | None) -> str: + if not isinstance(slot_bundle, dict): + return "" + return str(slot_bundle.get("active_version") or slot_bundle.get("version") or "").strip() + + def _slot_binding_configured( + self, + *, + slot_bundle: dict[str, Any] | None, + tier: str, + ) -> bool: + slot = self._active_slot_map(slot_bundle).get(str(tier or "").strip().lower()) + return isinstance(slot, dict) and bool(str(slot.get("primary_engine_id") or "").strip()) + + def _engine_satisfies_slot_runtime_requirements( + self, + *, + tier: str, + engine: dict[str, Any], + ) -> bool: + normalized_tier = str(tier or "").strip().lower() + if normalized_tier not in {"structured", "long_context", "tool"}: + return True + capabilities = self._normalize_engine_capabilities( + engine.get("capabilities"), + provider=str(engine.get("provider") or ""), + model=str(engine.get("model") or ""), + ) + if normalized_tier == "structured": + return bool(capabilities.get("structured_output")) and bool(capabilities.get("json_schema")) + if normalized_tier == "tool": + return bool(capabilities.get("tool_calling")) + context_tokens = capabilities.get("max_context_tokens") + try: + max_context_tokens = int(context_tokens) if context_tokens is not None else 0 + except (TypeError, ValueError): + max_context_tokens = 0 + return bool(capabilities.get("long_context")) and max_context_tokens >= 32768 + + def _resolve_engines_from_slot_binding( + self, + *, + tier: str, + engines: list[dict[str, Any]], + slot_bundle: dict[str, Any] | None, + ) -> list[dict[str, Any]]: + tier = str(tier or "").strip().lower() + slots = self._active_slot_map(slot_bundle) + slot = slots.get(tier) + if not isinstance(slot, dict): + return [] + ordered_ids = [str(slot.get("primary_engine_id") or "").strip()] + if isinstance(slot.get("fallback_engine_ids"), list): + ordered_ids.extend(str(item or "").strip() for item in slot.get("fallback_engine_ids", [])) + seen: set[str] = set() + candidates: list[dict[str, Any]] = [] + for fallback_index, engine_id in enumerate(ordered_ids): + if not engine_id or engine_id in seen: + continue + seen.add(engine_id) + for item in engines: + if str(item.get("id") or "").strip() != engine_id: + continue + candidate = self._normalize_engine_runtime(item) + provider = str(candidate.get("provider") or "").strip().lower() + if provider != "ollama" and not str(candidate.get("api_key") or "").strip(): + continue + if not self._engine_satisfies_slot_runtime_requirements(tier=tier, engine=candidate): + required = { + "structured": "structured_output/json_schema", + "long_context": "long_context/max_context_tokens>=32768", + "tool": "tool_calling", + }.get(tier, "slot_capability") + _runtime_log( + "LLM路由", + ( + "能力槽运行时因能力不足被跳过:" + f"slot={tier}, engine={engine_id}, required={required}" + ), + ) + continue + candidate["_slot_fallback_index"] = fallback_index + candidates.append(candidate) + break + return candidates + async def _resolve_engine_for_test( self, db: AsyncSession, diff --git a/backend/app/services/llm_service/mixin_tier_escalation.py b/backend/app/services/llm_service/mixin_tier_escalation.py index eedf7e2b..a718ea5f 100644 --- a/backend/app/services/llm_service/mixin_tier_escalation.py +++ b/backend/app/services/llm_service/mixin_tier_escalation.py @@ -192,6 +192,7 @@ class LLMTierEscalationMixin: active_engine: dict[str, Any], function_name: str, failed_engine: dict[str, Any], + slot_bundle: dict[str, Any] | None = None, ) -> Optional[dict[str, Any]]: rule = self._match_control_plane_high_tier_rule_for_escalation(function_name) if not isinstance(rule, dict): @@ -200,6 +201,7 @@ class LLMTierEscalationMixin: rule=rule, engines=engines, active_engine=active_engine, + slot_bundle=slot_bundle, ) if not isinstance(routed, dict): _runtime_log( @@ -282,8 +284,11 @@ class LLMTierEscalationMixin: return None, None try: _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) - engines = [item for item in bundle.get("engines", []) if isinstance(item, dict)] + engines = [ + item for item in bundle.get("engines", []) if isinstance(item, dict) and self._engine_enabled(item) + ] active_engine = self._resolve_active_engine_from_bundle(bundle) + slot_bundle = await self._load_capability_slot_bundle(db) _runtime_log( "LLM路由", ( @@ -297,6 +302,7 @@ class LLMTierEscalationMixin: active_engine=active_engine, function_name=function_name, failed_engine=failed_engine, + slot_bundle=slot_bundle, ) if not isinstance(high_engine, dict): _runtime_log( @@ -310,6 +316,7 @@ class LLMTierEscalationMixin: tier="high", engines=engines, active_engine=active_engine, + slot_bundle=slot_bundle, ) if not isinstance(high_engine, dict): return None, None @@ -349,7 +356,7 @@ class LLMTierEscalationMixin: f"decision=low_to_high_escalated, reason={str(error)[:120]}" ), ) - text = await self._chat_completion( + completion = await self._chat_completion_raw( provider=high_provider, prompt=prompt, system_prompt=system_prompt, @@ -362,6 +369,7 @@ class LLMTierEscalationMixin: response_mode=response_mode, routing_meta=routing_meta, ) + text = completion.text if not text.strip(): raise ValueError("high_tier_empty_response") if self._call_is_invalid_structured_output_for_retry( @@ -377,6 +385,9 @@ class LLMTierEscalationMixin: executed_tier="high", routing_decision="low_to_high_escalated", ) + high_engine["_provider_request_payload"] = completion.raw_request + high_engine["_provider_response_payload"] = completion.raw_response + high_engine["_provider_attempts"] = completion.provider_attempts _runtime_log( "LLM路由", ( @@ -420,8 +431,11 @@ class LLMTierEscalationMixin: return None, None try: _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) - engines = [item for item in bundle.get("engines", []) if isinstance(item, dict)] + engines = [ + item for item in bundle.get("engines", []) if isinstance(item, dict) and self._engine_enabled(item) + ] active_engine = self._resolve_active_engine_from_bundle(bundle) + slot_bundle = await self._load_capability_slot_bundle(db) _runtime_log( "LLM路由", ( @@ -436,6 +450,7 @@ class LLMTierEscalationMixin: active_engine=active_engine, function_name=function_name, failed_engine=failed_engine, + slot_bundle=slot_bundle, ) if not isinstance(high_engine, dict): _runtime_log( @@ -450,6 +465,7 @@ class LLMTierEscalationMixin: tier="high", engines=engines, active_engine=active_engine, + slot_bundle=slot_bundle, ) if not isinstance(high_engine, dict): return None, None @@ -488,6 +504,7 @@ class LLMTierEscalationMixin: tier=fallback_tier, engines=engines, active_engine=active_engine, + slot_bundle=slot_bundle, ) if not isinstance(candidate, dict): continue @@ -557,6 +574,9 @@ class LLMTierEscalationMixin: executed_tier="high", routing_decision="low_to_high_escalated", ) + high_engine["_provider_request_payload"] = completion.raw_request + high_engine["_provider_response_payload"] = completion.raw_response + high_engine["_provider_attempts"] = completion.provider_attempts _runtime_log( "LLM路由", ( @@ -575,3 +595,127 @@ class LLMTierEscalationMixin: ), ) return None, None + + async def _retry_structured_with_slot_fallback_after_primary_failure( + self, + *, + db: AsyncSession, + tenant_id: int, + function_name: str, + failed_engine: dict[str, Any], + prompt: str, + system_prompt: str, + temperature: float, + max_tokens: int, + response_mode: str, + error: Exception, + routing_meta: dict[str, Any] | None = None, + ) -> tuple[Optional[StructuredExtractionResult], Optional[dict[str, Any]]]: + failed_meta = _read_engine_routing_meta(failed_engine) + if str(failed_meta.get("planned_tier") or "").strip().lower() != "structured": + return None, None + try: + _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + settings = self._normalize_engine_settings(bundle.get("settings")) + engines = [ + item for item in bundle.get("engines", []) if isinstance(item, dict) and self._engine_enabled(item) + ] + slot_bundle = await self._load_capability_slot_bundle(db) + slot_version = self._slot_bundle_active_version(slot_bundle) + failed_engine_id = str(failed_engine.get("id") or "").strip() + failed_provider = str(failed_engine.get("provider") or "").strip().lower() + failed_model = str(failed_engine.get("model") or "").strip() + candidates = self._resolve_engines_from_slot_binding( + tier="structured", + engines=engines, + slot_bundle=slot_bundle, + ) + for candidate in candidates: + candidate_id = str(candidate.get("id") or "").strip() + candidate_provider = str(candidate.get("provider") or "").strip().lower() + candidate_model = str(candidate.get("model") or "").strip() + if candidate_id and candidate_id == failed_engine_id: + continue + if candidate_provider == failed_provider and candidate_model == failed_model: + continue + candidate = self._ensure_engine_allowed_for_function( + engine=candidate, + function_name=function_name, + settings=settings, + engine_label="structured_slot_fallback", + ) + candidate = _merge_engine_routing_meta( + candidate, + tier_alias="structured", + planned_tier="structured", + executed_tier="structured", + route_source="slot_binding", + routing_decision="capability_slot_fallback", + slot_key="structured", + slot_version=slot_version, + slot_runtime_source="active_slot", + slot_fallback_index=candidate.get("_slot_fallback_index", 0), + slot_degraded=False, + ) + _runtime_log( + "LLM路由", + ( + "结构化槽 fallback 重试:" + f"function={function_name}, failed={failed_engine_id or failed_provider}, " + f"fallback={candidate_id or candidate_provider}, reason={str(error)[:120]}" + ), + ) + completion = await self._chat_completion_raw( + provider=candidate_provider, + prompt=prompt, + system_prompt=system_prompt, + api_key=str(candidate.get("api_key") or ""), + base_url=str(candidate.get("base_url") or ""), + model=candidate_model, + temperature=temperature, + max_tokens=max_tokens, + function_name=function_name, + response_mode=response_mode, + routing_meta=_merge_call_routing_meta( + caller_meta=routing_meta, + engine_meta=_read_engine_routing_meta(candidate), + ), + ) + extraction = self._extract_structured_object_from_response( + function_name=function_name, + response=completion.raw_response, + fallback_text=completion.text, + provider=candidate_provider, + model=candidate_model, + ) + if extraction.payload is None: + _runtime_log( + "LLM结构化", + ( + "结构化槽 fallback 返回无效结构:" + f"function={function_name}, fallback={candidate_id or candidate_provider}, " + f"error={extraction.error_code or 'structured_schema_invalid'}" + ), + ) + continue + candidate["_provider_request_payload"] = completion.raw_request + candidate["_provider_response_payload"] = completion.raw_response + candidate["_provider_attempts"] = completion.provider_attempts + _runtime_log( + "LLM结构化", + ( + f"function={function_name}, structured_source={extraction.source_field or 'none'}, " + "validation=ok, fallback=structured_slot" + ), + ) + return extraction, candidate + return None, None + except Exception as retry_exc: + _runtime_log( + "LLM路由", + ( + "结构化槽 fallback 链失败:" + f"function={function_name}, error={type(retry_exc).__name__}:{str(retry_exc)[:180]}" + ), + ) + return None, None diff --git a/backend/app/services/llm_service/shared_part_01.py b/backend/app/services/llm_service/shared_part_01.py index d449a647..7402a683 100644 --- a/backend/app/services/llm_service/shared_part_01.py +++ b/backend/app/services/llm_service/shared_part_01.py @@ -55,16 +55,15 @@ except Exception: # pragma: no cover logger = logging.getLogger(__name__) -SUPPORTED_PROVIDERS = {"deepseek", "glm", "openai", "ollama"} +SUPPORTED_PROVIDERS = {"deepseek", "dashscope", "openai", "ollama"} MODEL_CONFIG_BUNDLE = load_model_config() PROVIDER_DEFAULTS: dict[str, dict[str, str]] = get_provider_defaults() TIER_DEFAULTS: dict[str, dict[str, str]] = get_tier_defaults() CONTROL_PLANE_DEFAULT_LOW_MODEL = str(TIER_DEFAULTS.get("low", {}).get("model") or "qwen-plus") CONTROL_PLANE_DEFAULT_HIGH_MODEL = str(TIER_DEFAULTS.get("high", {}).get("model") or "GLM-5") -CONTROL_PLANE_DASHSCOPE_BASE_URL = str(PROVIDER_DEFAULTS.get("glm", {}).get("base_url") or "https://dashscope.aliyuncs.com/compatible-mode/v1") +CONTROL_PLANE_DASHSCOPE_BASE_URL = str(PROVIDER_DEFAULTS.get("dashscope", {}).get("base_url") or "https://dashscope.aliyuncs.com/compatible-mode/v1") +PLATFORM_RUNTIME_TENANT_ID = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1") PROVIDER_ALIASES = { - "zhipu": "glm", - "chatglm": "glm", "deep-seek": "deepseek", "local": "ollama", } @@ -102,6 +101,7 @@ LLM_ENGINE_SETTINGS_DEFAULTS = { "enable_local_kb_qa": False, "local_kb_qa_scope": "kb_qa_only", } +CAPABILITY_SLOT_INTEGRATION_TYPE = "ai_capability_slots" LOCAL_KB_QA_FUNCTION_PREFIXES = ( "adaptive_rag_answer_", ) @@ -288,4 +288,22 @@ STRUCTURED_OBJECT_SCHEMAS: dict[str, dict[str, Any]] = { "warnings": "list", }, }, + "pmflow_change_review_extraction_v1": { + "required_keys": ( + "change_summary", + "proposed_items", + "evidence", + "missing_info", + "warnings", + "confidence", + ), + "type_checks": { + "change_summary": "str", + "proposed_items": "list", + "evidence": "list", + "missing_info": "list", + "warnings": "list", + "confidence": "number", + }, + }, } diff --git a/backend/app/services/llm_service/shared_part_02.py b/backend/app/services/llm_service/shared_part_02.py index 451dd34a..5a3f6dce 100644 --- a/backend/app/services/llm_service/shared_part_02.py +++ b/backend/app/services/llm_service/shared_part_02.py @@ -6,7 +6,7 @@ def _normalize_provider(raw: Optional[str]) -> str: provider = str(raw or "").strip().lower() provider = PROVIDER_ALIASES.get(provider, provider) if provider not in SUPPORTED_PROVIDERS: - raise ValueError("provider 仅支持 deepseek/glm/openai/ollama") + raise ValueError("provider 仅支持 deepseek/dashscope/openai/ollama") return provider def _mask_secret(secret: str) -> str: @@ -17,6 +17,20 @@ def _mask_secret(secret: str) -> str: return "*" * len(value) return f"{value[:4]}...{value[-4:]}" + +PROVIDER_DISPLAY_LABELS = { + "dashscope": "Qwen / DashScope", + "openai": "OpenAI 兼容网关", + "deepseek": "DeepSeek", + "ollama": "本地 Ollama", +} + + +def _provider_display_label(raw: Any) -> str: + provider = str(raw or "").strip().lower() + provider = PROVIDER_ALIASES.get(provider, provider) + return PROVIDER_DISPLAY_LABELS.get(provider, provider) + def _safe_json(value: Any) -> dict[str, Any]: if isinstance(value, dict): try: @@ -33,9 +47,19 @@ def _safe_json(value: Any) -> dict[str, Any]: return {} +CAPABILITY_SLOT_ALIASES = { + "structured", + "long_context", + "tool", + "external_query", + "private", + "multimodal", +} + + def _optional_tier_alias(raw: Any) -> str: value = str(raw or "").strip().lower() - return value if value in VALID_TIER_ALIASES else "" + return value if value in VALID_TIER_ALIASES or value in CAPABILITY_SLOT_ALIASES else "" def _optional_response_contract(raw: Any) -> str: @@ -53,13 +77,35 @@ def _optional_execution_mode(raw: Any) -> str: return value if value in {"remote", "local_planner", "local_worker"} else "" +def _optional_meta_text(raw: Any) -> str: + return str(raw or "").strip() + + +def _optional_meta_int(raw: Any) -> int | None: + if raw is None or raw == "": + return None + try: + return int(raw) + except (TypeError, ValueError): + return None + + +def _optional_meta_float(raw: Any) -> float | None: + if raw is None or raw == "": + return None + try: + return float(raw) + except (TypeError, ValueError): + return None + + def _normalize_routing_meta(raw: Any) -> dict[str, Any]: payload = _safe_json(raw) planned_tier = _optional_tier_alias(payload.get("planned_tier")) executed_tier = _optional_tier_alias(payload.get("executed_tier")) or planned_tier tier_alias = _optional_tier_alias(payload.get("tier_alias")) or planned_tier or executed_tier route_source = str(payload.get("route_source") or payload.get("source") or "").strip().lower() - if route_source not in {"default", "override", "active_engine"}: + if route_source not in {"default", "override", "active_engine", "slot_binding", "provider_console", "probe_runner"}: route_source = "default" if tier_alias else "" rule_name = str(payload.get("route_rule_name") or payload.get("rule_name") or "").strip() decision = str(payload.get("routing_decision") or payload.get("decision") or "").strip() @@ -73,43 +119,111 @@ def _normalize_routing_meta(raw: Any) -> dict[str, Any]: response_contract = _optional_response_contract(payload.get("response_contract")) fallback_policy = _optional_fallback_policy(payload.get("fallback_policy")) execution_mode = _optional_execution_mode(payload.get("execution_mode")) + slot_key = _optional_tier_alias(payload.get("slot_key")) + slot_version = str(payload.get("slot_version") or "").strip() + slot_runtime_source = str(payload.get("slot_runtime_source") or "").strip().lower() + if slot_runtime_source not in {"active_slot", "active_slot_degraded"}: + slot_runtime_source = "" + slot_fallback_index_raw = payload.get("slot_fallback_index") + try: + slot_fallback_index = int(slot_fallback_index_raw) if slot_fallback_index_raw is not None else None + except (TypeError, ValueError): + slot_fallback_index = None + slot_degraded = bool(payload.get("slot_degraded", False)) + provider_code = _optional_meta_text(payload.get("provider_code") or payload.get("provider")).lower() + prompt_key = _optional_meta_text(payload.get("prompt_key")) + prompt_source = _optional_meta_text(payload.get("prompt_source")) + prompt_release_id = _optional_meta_int(payload.get("prompt_release_id")) + prompt_format_error = bool(payload.get("prompt_format_error", False)) + is_direct_operation = bool(payload.get("direct_operation", False)) + normalized_decision = decision + if not normalized_decision and (tier_alias or slot_key or planned_tier or executed_tier): + normalized_decision = "primary_tier" return { + "trace_id": _optional_meta_text(payload.get("trace_id")), + "run_id": _optional_meta_text(payload.get("run_id")), + "scene_key": _optional_meta_text(payload.get("scene_key")), + "provider": provider_code, + "provider_code": provider_code, + "provider_label": _optional_meta_text(payload.get("provider_label")) or _provider_display_label(provider_code), + "model": _optional_meta_text(payload.get("model")), + "engine_id": _optional_meta_text(payload.get("engine_id")), + "engine_name": _optional_meta_text(payload.get("engine_name")), + "latency_ms": _optional_meta_int(payload.get("latency_ms")), + "duration_ms": _optional_meta_int(payload.get("duration_ms")), "tier_alias": tier_alias, "planned_tier": planned_tier or tier_alias, "executed_tier": executed_tier or planned_tier or tier_alias, "route_source": route_source, "route_rule_name": rule_name, - "routing_decision": decision or "primary_tier", + "routing_decision": normalized_decision, "routing_config_version": config_version, "routing_config_updated_at": config_updated_at, "rule_priority": rule_priority, "response_contract": response_contract, "fallback_policy": fallback_policy, "execution_mode": execution_mode, + "slot_key": slot_key, + "slot_version": slot_version, + "slot_runtime_source": slot_runtime_source, + "slot_fallback_index": slot_fallback_index, + "slot_degraded": slot_degraded, + "prompt_key": prompt_key, + "prompt_release_id": prompt_release_id, + "prompt_source": prompt_source, + "prompt_format_error": prompt_format_error, + "direct_operation": is_direct_operation, + "probe_run_id": _optional_meta_text(payload.get("probe_run_id")), + "probe_mode": _optional_meta_text(payload.get("probe_mode")), + "probe_target": _optional_meta_text(payload.get("probe_target")), + "probe_targets": payload.get("probe_targets") if isinstance(payload.get("probe_targets"), list) else [], + "probe_method": _optional_meta_text(payload.get("probe_method")), + "probe_status": _optional_meta_text(payload.get("probe_status")), + "probe_summary": payload.get("probe_summary") if isinstance(payload.get("probe_summary"), dict) else {}, + "evidence_strength": _optional_meta_text(payload.get("evidence_strength")), + "failure_code": _optional_meta_text(payload.get("failure_code")), + "upstream_request_sent": payload.get("upstream_request_sent") if isinstance(payload.get("upstream_request_sent"), bool) else None, + "upstream_missing_reason": _optional_meta_text(payload.get("upstream_missing_reason")), + "scene_timeout_policy_id": _optional_meta_text(payload.get("scene_timeout_policy_id")), + "scene_timeout_policy_version": _optional_meta_text(payload.get("scene_timeout_policy_version")), + "input_bucket": _optional_meta_text(payload.get("input_bucket")), + "input_tokens_estimate": _optional_meta_int(payload.get("input_tokens_estimate")), + "foreground_wait_seconds": _optional_meta_float(payload.get("foreground_wait_seconds")), + "provider_timeout_seconds": _optional_meta_float(payload.get("provider_timeout_seconds")), + "job_timeout_seconds": _optional_meta_float(payload.get("job_timeout_seconds")), + "max_retries": _optional_meta_int(payload.get("max_retries")), + "function_runtime_profile_version": _optional_meta_text(payload.get("function_runtime_profile_version")), + "function_runtime_profile_source": _optional_meta_text(payload.get("function_runtime_profile_source")), + "function_runtime_max_tokens": _optional_meta_int(payload.get("function_runtime_max_tokens")), + "function_runtime_repair_max_tokens": _optional_meta_int(payload.get("function_runtime_repair_max_tokens")), + "provider_usage": payload.get("provider_usage") if isinstance(payload.get("provider_usage"), dict) else {}, + "scene_fallback_policy": _optional_meta_text(payload.get("scene_fallback_policy")), + "large_input_policy": _optional_meta_text(payload.get("large_input_policy")), + "timeout_policy_source": _optional_meta_text(payload.get("timeout_policy_source")), + "timeout_policy_reason": _optional_meta_text(payload.get("timeout_policy_reason")), } -def _overlay_engine_api_key_from_env(engine: dict[str, Any]) -> dict[str, Any]: - runtime = dict(engine or {}) - provider = str(runtime.get("provider") or runtime.get("source_provider") or "").strip().lower() - if provider not in PROVIDER_DEFAULTS: - return runtime - defaults = dict(PROVIDER_DEFAULTS.get(provider) or {}) - api_env = str(runtime.get("api_env") or defaults.get("api_env") or "").strip() - if not api_env: - return runtime - env_api_key = str(os.getenv(api_env, "") or "").strip() - if env_api_key: - runtime["api_key"] = env_api_key - runtime["api_env"] = api_env - return runtime - - def _read_engine_routing_meta(engine: Any) -> dict[str, Any]: payload = engine.get("_routing_meta") if isinstance(engine, dict) else {} return _normalize_routing_meta(payload) +def _merge_call_routing_meta(*, caller_meta: Any, engine_meta: Any) -> dict[str, Any]: + caller = _normalize_routing_meta(caller_meta) + engine = _normalize_routing_meta(engine_meta) + merged = dict(caller) + for key, value in engine.items(): + if value is None or value == "": + continue + merged[key] = value + if engine.get("slot_degraded"): + merged["slot_degraded"] = True + if engine.get("prompt_format_error"): + merged["prompt_format_error"] = True + return _normalize_routing_meta(merged) + + def _merge_engine_routing_meta(engine: dict[str, Any], **updates: Any) -> dict[str, Any]: runtime = dict(engine or {}) current = _read_engine_routing_meta(runtime) @@ -134,6 +248,16 @@ def _routing_meta_log_text(meta: Any) -> str: parts.append(f"rule={data['route_rule_name']}") if data.get("routing_decision"): parts.append(f"decision={data['routing_decision']}") + if data.get("slot_key"): + parts.append(f"slot_key={data['slot_key']}") + if data.get("slot_version"): + parts.append(f"slot_version={data['slot_version']}") + if data.get("slot_runtime_source"): + parts.append(f"slot_runtime_source={data['slot_runtime_source']}") + if data.get("slot_fallback_index") is not None: + parts.append(f"slot_fallback_index={data['slot_fallback_index']}") + if data.get("slot_degraded"): + parts.append("slot_degraded=true") return ", ".join(parts) def _env_bool(name: str, default: bool = False) -> bool: @@ -172,7 +296,9 @@ def _is_control_plane_timeout_message(text: str) -> bool: @dataclass class ChatCompletionResult: text: str + raw_request: Any = None raw_response: Any = None + provider_attempts: Optional[list[dict[str, Any]]] = None @dataclass class StructuredExtractionResult: diff --git a/backend/app/services/model/model_config.py b/backend/app/services/model/model_config.py index bddff538..ac6136f1 100644 --- a/backend/app/services/model/model_config.py +++ b/backend/app/services/model/model_config.py @@ -11,8 +11,6 @@ _WORKSPACE_ROOT = Path(__file__).resolve().parents[4] _DEFAULT_ROUTING_RULES_PATH = (_WORKSPACE_ROOT / DEFAULT_ROUTING_RULES_WORKSPACE_PATH).resolve() _PROVIDER_ALIASES = { - "zhipu": "glm", - "chatglm": "glm", "deep-seek": "deepseek", "local": "ollama", } @@ -24,7 +22,7 @@ _DEFAULT_PROVIDER_DEFAULTS: dict[str, dict[str, str]] = { "model": "deepseek-chat", "api_env": "DEEPSEEK_API_KEY", }, - "glm": { + "dashscope": { "name": "Qwen (DashScope)", "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", "model": "qwen-plus", @@ -46,7 +44,7 @@ _DEFAULT_PROVIDER_DEFAULTS: dict[str, dict[str, str]] = { _DEFAULT_TIER_DEFAULTS: dict[str, dict[str, str]] = { "low": { - "provider": "glm", + "provider": "dashscope", "model": "qwen-plus", "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", "api_env": "DASHSCOPE_API_KEY", @@ -67,7 +65,7 @@ def _normalize_provider(provider: str) -> str: value = str(provider or "").strip().lower() value = _PROVIDER_ALIASES.get(value, value) if value not in _DEFAULT_PROVIDER_DEFAULTS: - raise ValueError("provider 仅支持 deepseek/glm/openai/ollama") + raise ValueError("provider 仅支持 deepseek/dashscope/openai/ollama") return value @@ -297,7 +295,7 @@ def build_tier_runtime( ) -> dict[str, Any]: tier_name = _normalize_tier(tier) tier_defaults = get_tier_defaults(tier_name) - provider_name = _normalize_provider(str(tier_defaults.get("provider") or "glm")) + provider_name = _normalize_provider(str(tier_defaults.get("provider") or "dashscope")) provider_defaults = get_provider_defaults(provider_name) return { "id": f"tier-{tier_name}", diff --git a/backend/app/services/model/model_routing/__init__.py b/backend/app/services/model/model_routing/__init__.py index f0adc64f..e87acc96 100644 --- a/backend/app/services/model/model_routing/__init__.py +++ b/backend/app/services/model/model_routing/__init__.py @@ -1,5 +1,5 @@ """Model routing service exports.""" -from .service import ModelRoutingService +from .service import ModelRoutingService, PLATFORM_RUNTIME_TENANT_ID -__all__ = ["ModelRoutingService"] +__all__ = ["ModelRoutingService", "PLATFORM_RUNTIME_TENANT_ID"] diff --git a/backend/app/services/model/model_routing/service.py b/backend/app/services/model/model_routing/service.py index 2a0a5ad0..9396a33b 100644 --- a/backend/app/services/model/model_routing/service.py +++ b/backend/app/services/model/model_routing/service.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os from typing import Any from sqlalchemy import and_, select @@ -17,6 +18,13 @@ from ..model_config import ( from ....utils.timezone import now_cn_naive ALLOWED_TIERS = set(VALID_TIER_ALIASES) +PROTECTED_HIGH_FUNCTIONS = { + "adaptive_rag_answer_deep", + "pmflow_requirement_plan_draft", + "pmflow_requirement_plan_round", + "project_requirement_extraction", + "recommend_assignees_for_task", +} DEFAULT_FUNCTIONS = ( "adaptive_rag_answer_deep", "adaptive_rag_answer_normal", @@ -33,6 +41,7 @@ DEFAULT_FUNCTIONS = ( "signal_source_onboarding_plan", ) INTEGRATION_TYPE = "model_routing" +PLATFORM_RUNTIME_TENANT_ID = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1") class ModelRoutingService: @@ -86,11 +95,13 @@ class ModelRoutingService: @classmethod async def _get_or_init_bundle(cls, db: AsyncSession, *, tenant_id: int) -> tuple[Integration, dict[str, Any]]: + _ = tenant_id + runtime_tenant_id = PLATFORM_RUNTIME_TENANT_ID row = ( await db.execute( select(Integration).where( and_( - Integration.tenant_id == int(tenant_id), + Integration.tenant_id == runtime_tenant_id, Integration.type == INTEGRATION_TYPE, ) ) @@ -98,7 +109,7 @@ class ModelRoutingService: ).scalar_one_or_none() if row is None: row = Integration( - tenant_id=int(tenant_id), + tenant_id=runtime_tenant_id, type=INTEGRATION_TYPE, is_active=True, config={"mappings": {}}, @@ -263,6 +274,8 @@ class ModelRoutingService: ) -> dict[str, Any]: fn = cls._normalize_function_name(function_name) normalized_tier = cls._normalize_tier_alias(tier_alias) + if fn in PROTECTED_HIGH_FUNCTIONS and normalized_tier != "high": + raise ValueError("保护场景不能降级到不满足能力要求的槽位") row, bundle = await cls._get_or_init_bundle(db, tenant_id=int(tenant_id)) mappings = bundle.get("mappings") if isinstance(bundle.get("mappings"), dict) else {} diff --git a/backend/app/services/phone_auth_service.py b/backend/app/services/phone_auth_service.py new file mode 100644 index 00000000..7c2fbc84 --- /dev/null +++ b/backend/app/services/phone_auth_service.py @@ -0,0 +1,282 @@ +"""Phone-first authentication service.""" + +from __future__ import annotations + +from datetime import timedelta +import hashlib +import os +import random +import re +from typing import Any + +from fastapi import HTTPException +from sqlalchemy import and_, desc, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import PhoneVerificationCode, User, UserStatus +from ..services.auth import AuthService +from ..services.auth.service import SECRET_KEY +from ..services.platform_access_service import get_platform_context +from ..services.workspace_service import ( + accept_pending_invitations_for_phone, + ensure_personal_workspace, + list_user_workspaces, +) +from ..utils.timezone import now_cn_naive + +PHONE_CODE_TTL_MINUTES = 10 +PHONE_CODE_MAX_ATTEMPTS = 5 +PHONE_AUTH_PURPOSES = {"login", "register", "accept_invitation", "bind_phone"} + + +def normalize_phone(country_code: str | None, phone_number: str | None) -> dict[str, str]: + code = str(country_code or "+86").strip() + if not code.startswith("+"): + code = f"+{code}" + code_digits = re.sub(r"\D", "", code) + code = f"+{code_digits}" + number = re.sub(r"\D", "", str(phone_number or "")) + if not code or code == "+": + raise HTTPException(status_code=400, detail="手机号国家区号无效") + if len(number) < 6 or len(number) > 15: + raise HTTPException(status_code=400, detail="手机号格式无效") + if code == "+86" and len(number) != 11: + raise HTTPException(status_code=400, detail="中国大陆手机号需要 11 位") + return { + "phone_country_code": code, + "phone_number": number, + "phone_e164": f"{code}{number}", + } + + +def mask_phone(phone_e164: str) -> str: + raw = str(phone_e164 or "") + if len(raw) <= 7: + return raw + return f"{raw[:3]}****{raw[-4:]}" + + +def _normalize_purpose(purpose: str | None) -> str: + value = str(purpose or "login").strip().lower() + return value if value in PHONE_AUTH_PURPOSES else "login" + + +def _hash_code(phone_e164: str, purpose: str, code: str) -> str: + payload = f"{phone_e164}:{purpose}:{code}:{SECRET_KEY}" + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _generate_code() -> str: + configured = str(os.getenv("PMFLOW_PHONE_OTP_DEV_CODE", "")).strip() + if configured and configured.isdigit() and len(configured) == 6: + return configured + return f"{random.randint(0, 999999):06d}" + + +def _should_return_dev_code() -> bool: + env = str(os.getenv("APP_ENV") or os.getenv("ENV") or os.getenv("NODE_ENV") or "development").lower() + return env not in {"production", "prod"} + + +async def send_phone_verification_code( + db: AsyncSession, + *, + country_code: str | None, + phone_number: str | None, + purpose: str | None = None, + request_ip: str | None = None, +) -> dict[str, Any]: + normalized = normalize_phone(country_code, phone_number) + normalized_purpose = _normalize_purpose(purpose) + code = _generate_code() + verification = PhoneVerificationCode( + phone_e164=normalized["phone_e164"], + purpose=normalized_purpose, + code_hash=_hash_code(normalized["phone_e164"], normalized_purpose, code), + expires_at=now_cn_naive() + timedelta(minutes=PHONE_CODE_TTL_MINUTES), + request_ip=request_ip, + ) + db.add(verification) + await db.commit() + payload: dict[str, Any] = { + "phone_e164": normalized["phone_e164"], + "masked_phone": mask_phone(normalized["phone_e164"]), + "purpose": normalized_purpose, + "expires_in_seconds": PHONE_CODE_TTL_MINUTES * 60, + } + if _should_return_dev_code(): + payload["dev_code"] = code + return payload + + +async def verify_phone_code( + db: AsyncSession, + *, + country_code: str | None, + phone_number: str | None, + code: str, + purpose: str | None = None, +) -> dict[str, str]: + normalized = normalize_phone(country_code, phone_number) + normalized_purpose = _normalize_purpose(purpose) + clean_code = str(code or "").strip() + if not re.fullmatch(r"\d{6}", clean_code): + raise HTTPException(status_code=400, detail="验证码格式无效") + verification = ( + await db.execute( + select(PhoneVerificationCode) + .where( + and_( + PhoneVerificationCode.phone_e164 == normalized["phone_e164"], + PhoneVerificationCode.purpose == normalized_purpose, + PhoneVerificationCode.consumed_at.is_(None), + PhoneVerificationCode.expires_at >= now_cn_naive(), + ) + ) + .order_by(desc(PhoneVerificationCode.created_at), desc(PhoneVerificationCode.id)) + .limit(1) + ) + ).scalar_one_or_none() + if not verification: + raise HTTPException(status_code=400, detail="验证码不存在或已过期") + if int(verification.attempt_count or 0) >= PHONE_CODE_MAX_ATTEMPTS: + raise HTTPException(status_code=429, detail="验证码尝试次数过多,请重新获取") + verification.attempt_count = int(verification.attempt_count or 0) + 1 + if verification.code_hash != _hash_code(normalized["phone_e164"], normalized_purpose, clean_code): + await db.commit() + raise HTTPException(status_code=400, detail="验证码不正确") + verification.consumed_at = now_cn_naive() + await db.flush() + return normalized + + +def _phone_placeholder_email(phone_e164: str) -> str: + token = re.sub(r"\D", "", phone_e164) + return f"{token}@phone.pmflow.local" + + +def _phone_username(phone_e164: str) -> str: + token = re.sub(r"\D", "", phone_e164) + suffix = token[-4:] if token else "user" + digest = hashlib.sha1(phone_e164.encode("utf-8")).hexdigest()[:8] + return f"user_{suffix}_{digest}" + + +async def get_or_create_phone_user( + db: AsyncSession, + *, + phone_country_code: str, + phone_number: str, + phone_e164: str, + name: str | None = None, +) -> User: + user = (await db.execute(select(User).where(User.phone_e164 == phone_e164))).scalar_one_or_none() + if user: + user.phone_country_code = phone_country_code + user.phone_number = phone_number + user.phone_verified_at = user.phone_verified_at or now_cn_naive() + user.last_login_at = now_cn_naive() + user.login_method = "phone_otp" + await ensure_personal_workspace(db, user=user, commit=False) + return user + + username = _phone_username(phone_e164) + existing_username = (await db.execute(select(User.id).where(User.username == username))).scalar_one_or_none() + if existing_username: + username = f"{username}_{random.randint(1000, 9999)}" + display_name = str(name or f"用户{phone_number[-4:]}").strip() + from ..models import Tenant, TenantMembership # avoid circular import in module load + from .workspace_service import ACTIVE_MEMBERSHIP_STATUS, PERSONAL_WORKSPACE_TYPE + + tenant = Tenant( + name=f"{display_name} 的个人空间", + workspace_type=PERSONAL_WORKSPACE_TYPE, + slug=None, + owner_user_id=None, + status="active", + ) + db.add(tenant) + await db.flush() + user = User( + tenant_id=int(tenant.id), + username=username, + password_hash=AuthService.hash_password(hashlib.sha256(os.urandom(32)).hexdigest()[:16]), + name=display_name, + email=_phone_placeholder_email(phone_e164), + phone_country_code=phone_country_code, + phone_number=phone_number, + phone_e164=phone_e164, + phone_verified_at=now_cn_naive(), + last_login_at=now_cn_naive(), + login_method="phone_otp", + role="OWNER", + assigned_domains=[], + workload=0, + status=UserStatus.ACTIVE.value, + is_active=True, + ) + db.add(user) + await db.flush() + tenant.owner_user_id = int(user.id) + tenant.slug = f"personal-{int(user.id)}" + db.add( + TenantMembership( + tenant_id=int(tenant.id), + user_id=int(user.id), + role="OWNER", + status=ACTIVE_MEMBERSHIP_STATUS, + is_personal_owner=True, + ) + ) + return user + + +async def verify_phone_and_login( + db: AsyncSession, + *, + country_code: str | None, + phone_number: str | None, + code: str, + purpose: str | None = "login", + invite_token: str | None = None, + name: str | None = None, +) -> dict[str, Any]: + normalized = await verify_phone_code( + db, + country_code=country_code, + phone_number=phone_number, + code=code, + purpose=purpose, + ) + user = await get_or_create_phone_user(db, name=name, **normalized) + accepted = [] + if invite_token: + from .workspace_service import accept_invitation_by_verified_phone + + accepted.append( + await accept_invitation_by_verified_phone( + db, + token=invite_token, + user=user, + phone_e164=normalized["phone_e164"], + switch_to_workspace=True, + ) + ) + else: + accepted = await accept_pending_invitations_for_phone( + db, + user=user, + phone_e164=normalized["phone_e164"], + auto_accept=False, + ) + await db.commit() + await db.refresh(user) + access_token = AuthService.create_access_token(data={"sub": user.username}) + workspaces = await list_user_workspaces(db, user=user) + platform_context = await get_platform_context(db, user=user) + return { + "access_token": access_token, + "token_type": "bearer", + "user": {**user.to_dict(), "workspace_context": workspaces, "platform_context": platform_context}, + "pending_invitations": accepted, + } diff --git a/backend/app/services/platform_access_service.py b/backend/app/services/platform_access_service.py new file mode 100644 index 00000000..da0c9b9a --- /dev/null +++ b/backend/app/services/platform_access_service.py @@ -0,0 +1,132 @@ +"""Platform access policy. + +Platform roles are PMFlow-internal identities and must not be inferred from a +customer workspace role such as OWNER or REVIEWER. +""" + +from __future__ import annotations + +import os +from typing import Any + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import PlatformMembership, User + +ACTIVE_PLATFORM_STATUS = "active" + +PLATFORM_OWNER = "PLATFORM_OWNER" +PLATFORM_ADMIN = "PLATFORM_ADMIN" +AI_OPS = "AI_OPS" +SUPPORT = "SUPPORT" +BILLING_OPS = "BILLING_OPS" +SECURITY_AUDITOR = "SECURITY_AUDITOR" + +PLATFORM_ROLE_CAPABILITIES: dict[str, set[str]] = { + PLATFORM_OWNER: { + "platform.admin.access", + "platform.members.manage", + "platform.ai_provider.manage", + "platform.ai_routing.manage", + "platform.route_governance.manage", + "platform.semantic_facts.manage", + "platform.tenant_metrics.view", + "platform.support.read", + "platform.billing.manage", + "platform.audit.view", + }, + PLATFORM_ADMIN: { + "platform.admin.access", + "platform.ai_provider.manage", + "platform.ai_routing.manage", + "platform.route_governance.manage", + "platform.semantic_facts.manage", + "platform.tenant_metrics.view", + "platform.support.read", + "platform.audit.view", + }, + AI_OPS: { + "platform.admin.access", + "platform.ai_provider.manage", + "platform.ai_routing.manage", + "platform.route_governance.manage", + "platform.semantic_facts.manage", + "platform.audit.view", + }, + SUPPORT: { + "platform.admin.access", + "platform.tenant_metrics.view", + "platform.support.read", + }, + BILLING_OPS: { + "platform.admin.access", + "platform.tenant_metrics.view", + "platform.billing.manage", + }, + SECURITY_AUDITOR: { + "platform.admin.access", + "platform.audit.view", + }, +} + + +def _normalize_role(value: object) -> str: + return str(value or "").strip().upper() + + +def _env_platform_roles(user: User) -> list[str]: + """Bootstrap-only platform access from env, useful before DB grants exist.""" + username = str(getattr(user, "username", "") or "").strip().lower() + email = str(getattr(user, "email", "") or "").strip().lower() + if not username and not email: + return [] + + admin_tokens = { + item.strip().lower() + for item in os.getenv("PMFLOW_PLATFORM_ADMIN_USERS", "").split(",") + if item.strip() + } + ai_ops_tokens = { + item.strip().lower() + for item in os.getenv("PMFLOW_AI_OPS_USERS", "").split(",") + if item.strip() + } + identities = {username, email} + roles: list[str] = [] + if identities.intersection(admin_tokens): + roles.append(PLATFORM_ADMIN) + if identities.intersection(ai_ops_tokens): + roles.append(AI_OPS) + return roles + + +def _capabilities_for_roles(roles: list[str]) -> list[str]: + capabilities: set[str] = set() + for role in roles: + capabilities.update(PLATFORM_ROLE_CAPABILITIES.get(_normalize_role(role), set())) + return sorted(capabilities) + + +async def get_platform_context(db: AsyncSession, *, user: User) -> dict[str, Any]: + result = await db.execute( + select(PlatformMembership.role).where( + and_( + PlatformMembership.user_id == int(user.id), + PlatformMembership.status == ACTIVE_PLATFORM_STATUS, + ) + ) + ) + roles = [_normalize_role(role) for role in result.scalars().all()] + roles.extend(role for role in _env_platform_roles(user) if role not in roles) + capabilities = _capabilities_for_roles(roles) + return { + "has_platform_access": bool(capabilities), + "roles": roles, + "capabilities": capabilities, + } + + +async def has_platform_capability(db: AsyncSession, *, user: User, capability: str) -> bool: + context = await get_platform_context(db, user=user) + return str(capability) in set(context.get("capabilities") or []) diff --git a/backend/app/services/pmflow_ai_feedback_service.py b/backend/app/services/pmflow_ai_feedback_service.py new file mode 100644 index 00000000..592e2a4a --- /dev/null +++ b/backend/app/services/pmflow_ai_feedback_service.py @@ -0,0 +1,433 @@ +"""PMFlow OS AI answer feedback service.""" + +from __future__ import annotations + +from typing import Any, Optional + +from fastapi import HTTPException +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm.attributes import flag_modified + +from ..models import AICallLog, JudgeRun, OptimizerCandidate, PMFlowAIFeedbackEvent, PMFlowChangeSet, PMFlowThreadMessage +from ..schemas.pmflow_os import PMFlowAIFeedbackCreateRequest, PMFlowAIFeedbackResponse +from .pmflow_change_review_service import PMFlowChangeReviewService + + +class PMFlowAIFeedbackService: + def __init__(self, db: AsyncSession, *, tenant_id: int): + self.db = db + self.tenant_id = int(tenant_id) + + async def create_or_update( + self, + *, + project_id: int, + body: PMFlowAIFeedbackCreateRequest, + user_id: int, + ) -> PMFlowAIFeedbackResponse: + target_type = body.target_type + target_id = body.target_id.strip() + message = await self._resolve_message(project_id=project_id, body=body) + change_set = await self._resolve_change_set(project_id=project_id, body=body) + if target_type == "thread_message" and message is None: + raise HTTPException(status_code=404, detail="feedback_target_message_not_found") + if target_type == "change_set" and change_set is None: + raise HTTPException(status_code=404, detail="feedback_target_change_set_not_found") + if target_type == "copilot_answer" and message is None and change_set is None: + raise HTTPException(status_code=422, detail="feedback_target_anchor_required") + + prompt_meta = await self._prompt_meta(body=body, message=message, change_set=change_set) + result = await self.db.execute( + select(PMFlowAIFeedbackEvent).where( + and_( + PMFlowAIFeedbackEvent.tenant_id == self.tenant_id, + PMFlowAIFeedbackEvent.project_id == int(project_id), + PMFlowAIFeedbackEvent.actor_user_id == int(user_id), + PMFlowAIFeedbackEvent.target_type == target_type, + PMFlowAIFeedbackEvent.target_id == target_id, + ) + ) + ) + row = result.scalar_one_or_none() + if row is None: + row = PMFlowAIFeedbackEvent( + tenant_id=self.tenant_id, + project_id=int(project_id), + actor_user_id=int(user_id), + target_type=target_type, + target_id=target_id, + ) + self.db.add(row) + + row.thread_id = self._int_or_none(body.thread_id) or (int(message.thread_id) if message is not None else None) or ( + int(change_set.thread_id) if change_set is not None else None + ) + row.message_id = self._int_or_none(body.message_id) or (int(message.id) if message is not None else None) + row.change_set_id = self._int_or_none(body.change_set_id) or (int(change_set.id) if change_set is not None else None) + row.ai_call_log_id = prompt_meta.get("ai_call_log_id") + row.answer_kind = body.answer_kind + row.rating = body.rating + row.reason_tags = [tag.strip()[:80] for tag in body.reason_tags if tag and tag.strip()][:12] + row.comment = body.comment.strip() if body.comment else None + row.scene_key = prompt_meta.get("scene_key") + row.prompt_key = prompt_meta.get("prompt_key") + row.prompt_release_id = prompt_meta.get("prompt_release_id") + row.runtime_profile_id = prompt_meta.get("runtime_profile_id") + row.model_provider = prompt_meta.get("model_provider") + row.model_name = prompt_meta.get("model_name") + current_metadata = row.feedback_metadata if isinstance(row.feedback_metadata, dict) else {} + row.feedback_metadata = { + **current_metadata, + **(body.feedback_metadata or {}), + "promptOptimizationUse": "quality_signal_for_scene_prompt_iteration", + "source": "pmflow_os_command_center", + } + await self.db.flush() + await self._ensure_prompt_optimization_candidate(row) + await self.db.commit() + await self.db.refresh(row) + return self.serialize(row) + + async def list_project_feedback(self, *, project_id: int, user_id: Optional[int] = None) -> list[PMFlowAIFeedbackResponse]: + clauses = [ + PMFlowAIFeedbackEvent.tenant_id == self.tenant_id, + PMFlowAIFeedbackEvent.project_id == int(project_id), + ] + if user_id is not None: + clauses.append(PMFlowAIFeedbackEvent.actor_user_id == int(user_id)) + result = await self.db.execute( + select(PMFlowAIFeedbackEvent) + .where(and_(*clauses)) + .order_by(PMFlowAIFeedbackEvent.updated_at.desc(), PMFlowAIFeedbackEvent.id.desc()) + ) + return [self.serialize(row) for row in result.scalars().all()] + + async def _resolve_message( + self, + *, + project_id: int, + body: PMFlowAIFeedbackCreateRequest, + ) -> Optional[PMFlowThreadMessage]: + message_id = self._int_or_none(body.message_id) or ( + self._int_or_none(body.target_id) if body.target_type == "thread_message" else None + ) + if message_id is None: + return None + result = await self.db.execute( + select(PMFlowThreadMessage).where( + and_( + PMFlowThreadMessage.tenant_id == self.tenant_id, + PMFlowThreadMessage.project_id == int(project_id), + PMFlowThreadMessage.id == int(message_id), + ) + ) + ) + return result.scalar_one_or_none() + + async def _resolve_change_set( + self, + *, + project_id: int, + body: PMFlowAIFeedbackCreateRequest, + ) -> Optional[PMFlowChangeSet]: + change_set_id = self._int_or_none(body.change_set_id) or ( + self._int_or_none(body.target_id) if body.target_type == "change_set" else None + ) + if change_set_id is None: + return None + result = await self.db.execute( + select(PMFlowChangeSet).where( + and_( + PMFlowChangeSet.tenant_id == self.tenant_id, + PMFlowChangeSet.project_id == int(project_id), + PMFlowChangeSet.id == int(change_set_id), + ) + ) + ) + return result.scalar_one_or_none() + + async def _prompt_meta( + self, + *, + body: PMFlowAIFeedbackCreateRequest, + message: Optional[PMFlowThreadMessage], + change_set: Optional[PMFlowChangeSet], + ) -> dict[str, Any]: + meta: dict[str, Any] = { + "scene_key": body.scene_key, + "prompt_key": body.prompt_key, + "prompt_release_id": body.prompt_release_id, + "runtime_profile_id": body.runtime_profile_id, + "model_provider": body.model_provider, + "model_name": body.model_name, + "ai_call_log_id": self._int_or_none(body.ai_call_log_id), + } + if message is not None and isinstance(message.message_metadata, dict): + msg_meta = message.message_metadata + meta["scene_key"] = meta["scene_key"] or msg_meta.get("scene_key") or msg_meta.get("scene") + meta["prompt_key"] = meta["prompt_key"] or msg_meta.get("prompt_key") + meta["prompt_release_id"] = meta["prompt_release_id"] or self._int_or_none(msg_meta.get("prompt_release_id")) + meta["runtime_profile_id"] = meta["runtime_profile_id"] or msg_meta.get("runtime_profile_id") + meta["model_provider"] = meta["model_provider"] or msg_meta.get("provider") + meta["model_name"] = meta["model_name"] or msg_meta.get("model") + + routing_meta = change_set.routing_meta if change_set is not None and isinstance(change_set.routing_meta, dict) else {} + if change_set is not None: + meta["scene_key"] = meta["scene_key"] or PMFlowChangeReviewService.AI_EXTRACTION_SCENE_KEY + meta["prompt_key"] = meta["prompt_key"] or PMFlowChangeReviewService.AI_EXTRACTION_PROMPT_KEY + meta["prompt_release_id"] = meta["prompt_release_id"] or self._int_or_none(routing_meta.get("prompt_release_id")) + meta["runtime_profile_id"] = ( + meta["runtime_profile_id"] + or routing_meta.get("function_runtime_profile_version") + or routing_meta.get("runtime_profile_id") + or routing_meta.get("profile_id") + ) + meta["model_provider"] = meta["model_provider"] or routing_meta.get("provider") + meta["model_name"] = meta["model_name"] or routing_meta.get("model") + meta["ai_call_log_id"] = meta["ai_call_log_id"] or self._int_or_none(getattr(change_set, "llm_call_id", None)) + + if meta["ai_call_log_id"] is None and change_set is not None and change_set.llm_call_id: + trace_result = await self.db.execute( + select(AICallLog) + .where( + and_( + AICallLog.tenant_id == self.tenant_id, + AICallLog.trace_id == str(change_set.llm_call_id), + ) + ) + .order_by(AICallLog.created_at.desc(), AICallLog.id.desc()) + ) + ai_call = trace_result.scalars().first() + if ai_call is not None: + meta["ai_call_log_id"] = int(ai_call.id) + meta["prompt_key"] = meta["prompt_key"] or ai_call.prompt_key + meta["prompt_release_id"] = meta["prompt_release_id"] or ai_call.prompt_release_id + meta["model_provider"] = meta["model_provider"] or ai_call.provider + meta["model_name"] = meta["model_name"] or ai_call.model + routing = ai_call.routing_meta if isinstance(ai_call.routing_meta, dict) else {} + meta["runtime_profile_id"] = ( + meta["runtime_profile_id"] + or routing.get("function_runtime_profile_version") + or routing.get("runtime_profile_id") + ) + return meta + + async def _ensure_prompt_optimization_candidate(self, row: PMFlowAIFeedbackEvent) -> None: + metadata = dict(row.feedback_metadata or {}) + prompt_optimization = dict(metadata.get("promptOptimization") or {}) + candidate_id = self._int_or_none(prompt_optimization.get("candidateId")) + if row.rating != "negative": + if candidate_id is not None: + candidate = ( + await self.db.execute( + select(OptimizerCandidate).where( + and_( + OptimizerCandidate.tenant_id == self.tenant_id, + OptimizerCandidate.id == int(candidate_id), + ) + ) + ) + ).scalar_one_or_none() + if candidate is not None and candidate.status == "draft": + candidate.status = "superseded" + candidate.notes = "用户将 PMFlow OS AI 回答反馈改为赞同,自动挂起此前负反馈生成的优化候选。" + prompt_optimization["status"] = "superseded_by_positive_feedback" + metadata["promptOptimization"] = prompt_optimization + row.feedback_metadata = metadata + flag_modified(row, "feedback_metadata") + return + if not row.prompt_key: + return + judge_run_id = self._int_or_none(prompt_optimization.get("judgeRunId")) + findings = self._feedback_findings(row) + suggestion = self._feedback_prompt_suggestion(row, findings=findings) + evidence = { + "feedback_event_id": int(row.id), + "target_type": row.target_type, + "target_id": row.target_id, + "answer_kind": row.answer_kind, + "rating": row.rating, + "reason_tags": list(row.reason_tags or []), + "comment": row.comment, + "scene_key": row.scene_key, + "prompt_release_id": row.prompt_release_id, + "runtime_profile_id": row.runtime_profile_id, + "ai_call_log_id": row.ai_call_log_id, + "findings": findings, + } + + judge: Optional[JudgeRun] = None + if judge_run_id is not None: + judge = ( + await self.db.execute( + select(JudgeRun).where( + and_( + JudgeRun.tenant_id == self.tenant_id, + JudgeRun.id == int(judge_run_id), + ) + ) + ) + ).scalar_one_or_none() + if judge is None: + judge = JudgeRun( + tenant_id=self.tenant_id, + project_id=int(row.project_id), + prompt_key=row.prompt_key, + workflow_type=row.scene_key or row.answer_kind, + asked_via="pmflow_os", + judge_status="feedback_review", + bucket_key=f"pmflow_ai_feedback:{row.prompt_key}:{row.answer_kind}", + rule_score=0.2, + overall_score=0.2, + findings=findings, + metadata_json=evidence, + ) + self.db.add(judge) + await self.db.flush() + else: + judge.findings = findings + judge.metadata_json = evidence + flag_modified(judge, "findings") + flag_modified(judge, "metadata_json") + + candidate: Optional[OptimizerCandidate] = None + if candidate_id is not None: + candidate = ( + await self.db.execute( + select(OptimizerCandidate).where( + and_( + OptimizerCandidate.tenant_id == self.tenant_id, + OptimizerCandidate.id == int(candidate_id), + ) + ) + ) + ).scalar_one_or_none() + if candidate is None: + candidate = OptimizerCandidate( + tenant_id=self.tenant_id, + project_id=int(row.project_id), + judge_run_id=int(judge.id), + candidate_type="prompt_change", + target_type="prompt_key", + target_ref=row.prompt_key, + status="draft", + suggestion=suggestion, + evidence=evidence, + notes="PMFlow OS AI feedback 自动生成的 prompt 优化候选,需平台 AI Ops 人工复核后再创建 release。", + ) + self.db.add(candidate) + await self.db.flush() + else: + candidate.suggestion = suggestion + candidate.evidence = evidence + flag_modified(candidate, "suggestion") + flag_modified(candidate, "evidence") + + metadata["promptOptimization"] = { + "status": "draft_optimizer_candidate", + "judgeRunId": int(judge.id), + "candidateId": int(candidate.id), + "targetPromptKey": row.prompt_key, + } + row.feedback_metadata = metadata + flag_modified(row, "feedback_metadata") + + @staticmethod + def _feedback_findings(row: PMFlowAIFeedbackEvent) -> list[dict[str, Any]]: + tags = [str(tag).strip() for tag in (row.reason_tags or []) if str(tag).strip()] + if not tags and row.comment: + tags = ["用户补充说明"] + return [ + { + "code": PMFlowAIFeedbackService._reason_code(tag), + "label": tag, + "severity": "high" if tag in {"不准确", "不该生成这个结果"} else "medium", + "message": f"用户对 {row.answer_kind} 反馈为不赞同:{tag}", + } + for tag in tags + ] or [ + { + "code": "negative_without_reason", + "label": "未填写原因", + "severity": "medium", + "message": f"用户对 {row.answer_kind} 反馈为不赞同,但未选择原因标签。", + } + ] + + @staticmethod + def _feedback_prompt_suggestion(row: PMFlowAIFeedbackEvent, *, findings: list[dict[str, Any]]) -> dict[str, Any]: + labels = [str(item.get("label") or "").strip() for item in findings if isinstance(item, dict)] + actions: list[str] = [] + if "不准确" in labels: + actions.append("强化事实约束、证据引用和不确定性表达,避免把候选对象说成已确认结论。") + if "漏掉重点" in labels: + actions.append("在抽取 prompt 中补充关键对象覆盖检查,优先覆盖用户最新输入里的需求、风险、决策和任务。") + if "不够可执行" in labels: + actions.append("要求输出包含可审查对象、可执行下一步、边界条件和验收信号。") + if "不符合项目上下文" in labels: + actions.append("提高项目目标、当前阶段、已有 ChangeSet 与 Evidence 的上下文权重。") + if "表达不清" in labels: + actions.append("约束回答结构,先给结论,再给依据和待确认动作。") + if "不该生成这个结果" in labels: + actions.append("增加生成前 gate:证据不足时显式待补充,不创建候选对象。") + if not actions: + actions.append("复核负反馈样本,补充场景化约束和回归 golden case。") + return { + "summary": f"基于 PMFlow OS 用户负反馈优化 {row.prompt_key}", + "reason_tags": labels, + "recommended_actions": actions, + "target_answer_kind": row.answer_kind, + "scene_key": row.scene_key, + } + + @staticmethod + def _reason_code(label: str) -> str: + mapping = { + "不准确": "inaccurate", + "漏掉重点": "missed_key_point", + "不够可执行": "not_actionable", + "不符合项目上下文": "context_mismatch", + "表达不清": "unclear_expression", + "不该生成这个结果": "should_not_generate", + "用户补充说明": "user_comment", + } + return mapping.get(label, "negative_feedback") + + @staticmethod + def _int_or_none(value: Any) -> Optional[int]: + if value is None: + return None + try: + text = str(value).strip() + if not text: + return None + return int(text) + except (TypeError, ValueError): + return None + + @staticmethod + def serialize(row: PMFlowAIFeedbackEvent) -> PMFlowAIFeedbackResponse: + return PMFlowAIFeedbackResponse( + id=str(row.id), + projectId=str(row.project_id), + threadId=str(row.thread_id) if row.thread_id is not None else None, + messageId=str(row.message_id) if row.message_id is not None else None, + changeSetId=str(row.change_set_id) if row.change_set_id is not None else None, + aiCallLogId=str(row.ai_call_log_id) if row.ai_call_log_id is not None else None, + targetType=row.target_type, + targetId=row.target_id, + answerKind=row.answer_kind, + rating=row.rating, + reasonTags=list(row.reason_tags or []), + comment=row.comment, + sceneKey=row.scene_key, + promptKey=row.prompt_key, + promptReleaseId=row.prompt_release_id, + runtimeProfileId=row.runtime_profile_id, + modelProvider=row.model_provider, + modelName=row.model_name, + metadata=dict(row.feedback_metadata or {}), + createdAt=row.created_at.isoformat() if row.created_at else "", + updatedAt=row.updated_at.isoformat() if row.updated_at else "", + ) diff --git a/backend/app/services/pmflow_change_review_service.py b/backend/app/services/pmflow_change_review_service.py index 5dc58b5d..ca93a2c4 100644 --- a/backend/app/services/pmflow_change_review_service.py +++ b/backend/app/services/pmflow_change_review_service.py @@ -2,12 +2,15 @@ from __future__ import annotations +import json from typing import Any, Optional from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm.attributes import flag_modified +from ..config.ai_prompts import PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION +from ..config.ai_function_runtime_profiles import DEFAULT_AI_FUNCTION_RUNTIME_PROFILES from ..models import ( PMFlowApproval, PMFlowChangeItem, @@ -25,9 +28,13 @@ from ..schemas.pmflow_os import ( PMFlowChangeItemEditRequest, PMFlowChangeSetCommitRequest, PMFlowExtractionCreateRequest, + PMFlowProposedChangeItem, PMFlowReviewDecisionRequest, PMFlowThreadMessageCreateRequest, ) +from .ai_function_runtime_profile_service import PLATFORM_RUNTIME_TENANT_ID, AIFunctionRuntimeProfile, AIFunctionRuntimeProfileService +from .llm_service import llm_service +from .prompt_runtime_service import PromptRuntimeService from ..utils.timezone import now_cn_naive @@ -41,6 +48,9 @@ class PMFlowChangeReviewError(RuntimeError): class PMFlowChangeReviewService: COMMIT_OBJECT_TYPES = {"Requirement", "Decision", "Task", "Risk"} + AI_EXTRACTION_FUNCTION_NAME = "pmflow_change_review_extraction_v1" + AI_EXTRACTION_SCENE_KEY = "change_review" + AI_EXTRACTION_PROMPT_KEY = "PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION" def __init__(self, db: AsyncSession, *, tenant_id: int): self.db = db @@ -78,6 +88,9 @@ class PMFlowChangeReviewService: body: PMFlowExtractionCreateRequest, user_id: int, ) -> dict[str, Any]: + if not body.proposed_items: + return await self.create_ai_extraction(project_id=project_id, body=body, user_id=user_id) + thread, message = await self._load_thread_message( project_id=project_id, thread_id=body.thread_id, @@ -144,6 +157,208 @@ class PMFlowChangeReviewService: await self.db.commit() return await self.get_change_set_detail(project_id=project_id, change_set_id=int(change_set.id)) + async def create_ai_extraction( + self, + *, + project_id: int, + body: PMFlowExtractionCreateRequest, + user_id: int, + ) -> dict[str, Any]: + thread, message = await self._load_thread_message( + project_id=project_id, + thread_id=body.thread_id, + message_id=body.message_id, + ) + message_text = str(message.content or "").strip() + if not message_text: + raise PMFlowChangeReviewError("extraction_evidence_insufficient", "结构化失败:项目线程消息为空,待补充。", status_code=409) + + structured, runtime_profile, routing_meta = await self._generate_ai_extraction_structured( + message_text=message_text, + attachments=list(message.attachments or []), + profile_stage="active", + routing_meta_extra=dict(body.routing_meta or {}), + ) + payload = structured.payload if structured is not None and isinstance(structured.payload, dict) else {} + proposed_items = self._normalize_ai_extraction_payload(payload) + if not proposed_items: + await self._ensure_extraction_clarification_message( + thread=thread, + source_message=message, + user_id=user_id, + code="extraction_evidence_insufficient", + missing_info=payload.get("missing_info") if isinstance(payload.get("missing_info"), list) else [], + warnings=payload.get("warnings") if isinstance(payload.get("warnings"), list) else [], + ) + raise PMFlowChangeReviewError("extraction_evidence_insufficient", "证据不足:AI 未能抽取可审查对象,待补充。", status_code=409) + + ai_body = PMFlowExtractionCreateRequest( + thread_id=body.thread_id, + message_id=body.message_id, + scope=body.scope, + headline=body.headline or str(payload.get("change_summary") or payload.get("title") or "AI 结构化提取待确认")[:255], + summary=body.summary or self._ai_extraction_summary(payload=payload, item_count=len(proposed_items)), + routing_meta={ + **routing_meta, + "aiExtraction": { + "functionName": self.AI_EXTRACTION_FUNCTION_NAME, + "confidence": payload.get("confidence"), + "warnings": payload.get("warnings") if isinstance(payload.get("warnings"), list) else [], + "sourceMessageId": str(message.id), + "runtimeProfileVersion": runtime_profile.profile_version, + "maxTokens": runtime_profile.max_tokens, + }, + }, + llm_call_id=body.llm_call_id, + proposed_items=proposed_items, + ) + return await self.create_extraction(project_id=project_id, body=ai_body, user_id=user_id) + + async def _ensure_extraction_clarification_message( + self, + *, + thread: PMFlowThread, + source_message: PMFlowThreadMessage, + user_id: int, + code: str, + missing_info: list[Any], + warnings: list[Any], + ) -> None: + source_message_id = str(source_message.id) + existing_result = await self.db.execute( + select(PMFlowThreadMessage.id).where( + and_( + PMFlowThreadMessage.tenant_id == self.tenant_id, + PMFlowThreadMessage.project_id == int(source_message.project_id), + PMFlowThreadMessage.thread_id == int(thread.id), + PMFlowThreadMessage.role == "assistant", + PMFlowThreadMessage.message_metadata.op("->>")("source") == "change_review_extraction_clarification", + PMFlowThreadMessage.message_metadata.op("->>")("source_message_id") == source_message_id, + ) + ) + ) + if existing_result.scalar_one_or_none() is not None: + return + + clean_missing = [str(item).strip() for item in missing_info if str(item).strip()][:3] + clean_warnings = [str(item).strip() for item in warnings if str(item).strip()][:3] + detail = clean_missing or clean_warnings + if detail: + detail_text = "还缺少:" + "、".join(detail) + "。" + else: + detail_text = "还缺少明确的项目目标、对象、约束或下一步动作。" + row = PMFlowThreadMessage( + tenant_id=self.tenant_id, + thread_id=int(thread.id), + project_id=int(source_message.project_id), + role="assistant", + content=( + "我收到了。这条消息暂时没有形成可审查的需求、任务、风险或决策,所以不会写入项目记忆。\n\n" + f"{detail_text}继续补充后,我会重新提取待确认变更。" + ), + source="pmflow_os", + attachments=[], + message_metadata={ + "source": "change_review_extraction_clarification", + "source_message_id": source_message_id, + "extraction_error_code": code, + "missing_info": clean_missing, + "warnings": clean_warnings, + "created_by": int(user_id), + }, + status="resolved", + ) + self.db.add(row) + await self.db.commit() + + async def generate_extraction_payload_for_readiness_check(self, *, user_id: int, profile_stage: str = "draft") -> dict[str, Any]: + _ = user_id + structured, _profile, _routing_meta = await self._generate_ai_extraction_structured( + message_text="增加一个场景评估功能,用于开始前先收集家长最需要解决的高频问题场景。", + attachments=[], + profile_stage=profile_stage, + routing_meta_extra={"readiness_check": True, "source": "system_smoke"}, + ) + payload = structured.payload if structured is not None and isinstance(structured.payload, dict) else {} + proposed_items = self._normalize_ai_extraction_payload(payload) + if not proposed_items: + raise PMFlowChangeReviewError("extraction_evidence_insufficient", "变更抽取 smoke 未产出可审查对象", status_code=409) + return {"payload": payload, "proposed_items": proposed_items} + + async def _generate_ai_extraction_structured( + self, + *, + message_text: str, + attachments: list[Any], + profile_stage: str = "active", + routing_meta_extra: dict[str, Any] | None = None, + ): + prompt_result = await PromptRuntimeService(self.db).render( + tenant_id=PLATFORM_RUNTIME_TENANT_ID, + prompt_key=self.AI_EXTRACTION_PROMPT_KEY, + fallback_template=PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION, + message_text=str(message_text or "")[:4000], + attachments_json=json.dumps(attachments or [], ensure_ascii=False), + ) + runtime_profile = await self._resolve_ai_extraction_runtime_profile(stage=profile_stage) + routing_meta = { + "scene": "pmflow_os_thread_extraction", + "scene_key": self.AI_EXTRACTION_SCENE_KEY, + "source": "project_thread_message", + "response_contract": "structured", + "change_review_contract": "proposed_items_v1", + "prompt_runtime_tenant_id": PLATFORM_RUNTIME_TENANT_ID, + **(routing_meta_extra if isinstance(routing_meta_extra, dict) else {}), + **runtime_profile.routing_meta(), + **prompt_result.routing_meta(), + } + try: + structured = await llm_service.generate_structured_object( + prompt_result.prompt, + db=self.db, + tenant_id=self.tenant_id, + function_name=self.AI_EXTRACTION_FUNCTION_NAME, + temperature=runtime_profile.temperature, + max_tokens=runtime_profile.max_tokens, + response_mode=runtime_profile.response_mode, + routing_meta=routing_meta, + ) + except PMFlowChangeReviewError: + raise + except Exception as exc: + raise PMFlowChangeReviewError("ai_extraction_failed", f"结构化失败:AI 提取失败,待补充。{exc}", status_code=502) from exc + return structured, runtime_profile, routing_meta + + async def _resolve_ai_extraction_runtime_profile(self, *, stage: str = "active") -> AIFunctionRuntimeProfile: + try: + return await AIFunctionRuntimeProfileService(self.db).resolve_profile( + function_name=self.AI_EXTRACTION_FUNCTION_NAME, + scene_key=self.AI_EXTRACTION_SCENE_KEY, + prompt_key=self.AI_EXTRACTION_PROMPT_KEY, + stage=stage, + ) + except Exception: + profile = dict(DEFAULT_AI_FUNCTION_RUNTIME_PROFILES[self.AI_EXTRACTION_FUNCTION_NAME]) + return AIFunctionRuntimeProfile( + function_name=self.AI_EXTRACTION_FUNCTION_NAME, + scene_key=self.AI_EXTRACTION_SCENE_KEY, + prompt_key=self.AI_EXTRACTION_PROMPT_KEY, + required_slot=str(profile.get("required_slot") or "structured"), + response_contract=str(profile.get("response_contract") or "structured"), + max_tokens=int(profile.get("max_tokens") or 7200), + repair_max_tokens=int(profile.get("repair_max_tokens") or 3600), + temperature=float(profile.get("temperature") or 0.0), + foreground_wait_seconds=int(profile.get("foreground_wait_seconds") or 10), + provider_timeout_seconds=int(profile.get("provider_timeout_seconds") or 90), + job_timeout_seconds=int(profile.get("job_timeout_seconds") or 180), + max_retries=int(profile.get("max_retries") or 1), + large_input_policy=str(profile.get("large_input_policy") or "preprocess_then_generate"), + response_mode=str(profile.get("response_mode") or "fast"), + response_format=str(profile.get("response_format") or "json_object"), + profile_version="code-default", + profile_source="code_default", + ) + async def get_change_set_detail(self, *, project_id: int, change_set_id: int) -> dict[str, Any]: change_set = await self._load_change_set(project_id=project_id, change_set_id=change_set_id) items = await self._load_items(project_id=project_id, change_set_id=int(change_set.id)) @@ -255,6 +470,7 @@ class PMFlowChangeReviewService: now = now_cn_naive() for item in eligible_items: item.status = "committed" + change_set.committed_by = int(user_id) remaining_open = [ item for item in items @@ -272,19 +488,30 @@ class PMFlowChangeReviewService: if item.id not in {committed["changeItemIdRaw"] for committed in committed_items} and item.status in {"deferred", "skipped"} ] + committed_by = await self._load_user_display_name(user_id=user_id) + serialized_committed_items = [ + { + **{key: value for key, value in item.items() if key != "changeItemIdRaw"}, + "committedAt": now.isoformat(), + "committedById": str(user_id), + "committedBy": committed_by, + } + for item in committed_items + ] return { "changeSet": { "id": str(change_set.id), "status": change_set.status, "rawStatus": change_set.status, "committedAt": change_set.committed_at.isoformat() if change_set.committed_at else None, + "writebackAt": now.isoformat(), + "committedById": str(user_id), + "committedBy": committed_by, "committedItemIds": [item["changeItemId"] for item in committed_items], "skippedItemIds": [item["changeItemId"] for item in skipped_items], "failedItemIds": [], }, - "committedItems": [ - {key: value for key, value in item.items() if key != "changeItemIdRaw"} for item in committed_items - ], + "committedItems": serialized_committed_items, "skippedItems": skipped_items, "failedItems": [], } @@ -406,6 +633,164 @@ class PMFlowChangeReviewService: await self.db.rollback() raise + def _normalize_ai_extraction_payload(self, payload: dict[str, Any]) -> list[PMFlowProposedChangeItem]: + direct = payload.get("proposed_items") + if isinstance(direct, list): + return self._coerce_proposed_items(direct) + + items: list[dict[str, Any]] = [] + confidence = self._confidence(payload.get("confidence"), default=0.72) + for raw in self._list_of_dicts(payload.get("requirements")): + title = self._clean_text(raw.get("title")) + summary = self._clean_text(raw.get("summary") or raw.get("description")) + if not title or not summary: + continue + acceptance = raw.get("acceptance_criteria") or raw.get("acceptanceCriteria") or [] + items.append( + { + "group": "new_object", + "object_type": "Requirement", + "title": self._prefixed_title(title, "新增需求"), + "summary": summary, + "diff": f"新增待确认需求:{summary}", + "payload": { + "summary": summary, + "acceptanceCriteria": self._string_list(acceptance), + "stage": "planning", + }, + "confidence": confidence, + "risk_level": "medium", + } + ) + for raw in self._list_of_dicts(payload.get("tasks")): + title = self._clean_text(raw.get("title")) + summary = self._clean_text(raw.get("summary") or raw.get("description")) + if not title or not summary: + continue + priority = self._priority(raw.get("priority")) + items.append( + { + "group": "new_object", + "object_type": "Task", + "title": self._prefixed_title(title, "新增任务"), + "summary": summary, + "diff": f"新增待确认任务:{summary}", + "payload": { + "summary": summary, + "description": summary, + "priority": priority, + "acceptanceCriteria": self._string_list(raw.get("acceptance_criteria") or raw.get("acceptanceCriteria")), + "requirementIndex": raw.get("requirement_index"), + "stage": "development", + }, + "confidence": confidence, + "risk_level": "medium" if priority != "high" else "high", + } + ) + for raw in self._list_of_dicts(payload.get("risks")): + title = self._clean_text(raw.get("title")) + summary = self._clean_text(raw.get("summary") or raw.get("description")) + if not title or not summary: + continue + level = self._risk_level(raw.get("level") or raw.get("risk_level")) + mitigation = self._clean_text(raw.get("mitigation") or raw.get("mitigation_plan")) or "待补充缓解策略。" + items.append( + { + "group": "risk", + "object_type": "Risk", + "title": self._prefixed_title(title, "新增风险"), + "summary": summary, + "diff": f"新增待确认风险与缓解策略:{mitigation}", + "payload": { + "summary": summary, + "mitigation": mitigation, + "stage": "planning", + }, + "confidence": confidence, + "risk_level": level, + } + ) + for raw in self._list_of_dicts(payload.get("decisions")): + title = self._clean_text(raw.get("title")) + summary = self._clean_text(raw.get("summary") or raw.get("reason")) + if not title or not summary: + continue + items.append( + { + "group": "decision", + "object_type": "Decision", + "title": self._prefixed_title(title, "待确认决策"), + "summary": summary, + "diff": f"新增待确认决策:{summary}", + "payload": { + "summary": summary, + "owner": self._clean_text(raw.get("owner")) or "未分配", + "stage": "planning", + }, + "confidence": confidence, + "risk_level": "medium", + } + ) + return self._coerce_proposed_items(items) + + @staticmethod + def _coerce_proposed_items(items: list[Any]) -> list[PMFlowProposedChangeItem]: + proposed: list[PMFlowProposedChangeItem] = [] + for raw in items[:24]: + if not isinstance(raw, dict): + continue + try: + proposed.append(PMFlowProposedChangeItem.model_validate(raw)) + except Exception: + continue + return proposed + + @staticmethod + def _ai_extraction_summary(*, payload: dict[str, Any], item_count: int) -> str: + warnings = payload.get("warnings") if isinstance(payload.get("warnings"), list) else [] + suffix = f";警告:{';'.join(str(item) for item in warnings[:3])}" if warnings else "" + return f"AI 从本轮项目线程生成 {item_count} 个待审变更项,尚未写入项目记忆{suffix}。" + + @staticmethod + def _list_of_dicts(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, dict)] + + @staticmethod + def _clean_text(value: Any) -> str: + return str(value or "").strip() + + @classmethod + def _string_list(cls, value: Any) -> list[str]: + if isinstance(value, list): + return [cls._clean_text(item) for item in value if cls._clean_text(item)] + text = cls._clean_text(value) + return [text] if text else [] + + @staticmethod + def _prefixed_title(title: str, prefix: str) -> str: + if title.startswith(prefix): + return title[:255] + return f"{prefix}:{title}"[:255] + + @staticmethod + def _confidence(value: Any, *, default: float) -> float: + try: + number = float(value) + except Exception: + number = default + return max(0.0, min(1.0, number)) + + @staticmethod + def _risk_level(value: Any) -> str: + text = str(value or "medium").strip().lower() + if text in {"low", "medium", "high"}: + return text + if text in {"urgent", "critical", "p0"}: + return "high" + return "medium" + async def _resolve_or_create_thread(self, *, project_id: int, thread_id: str) -> PMFlowThread: cleaned = str(thread_id or "").strip() if cleaned.isdigit(): @@ -657,9 +1042,9 @@ class PMFlowChangeReviewService: reason: Optional[str] = None, before_payload_override: Optional[dict[str, Any]] = None, ) -> None: - if change_set.status != "open": + if change_set.status not in {"open", "reviewed"}: raise PMFlowChangeReviewError("changeset_not_open", "changeset_not_open", status_code=409) - if item.status != "pending_review": + if item.status in {"committed", "skipped"}: raise PMFlowChangeReviewError("item_already_reviewed", "item_already_reviewed", status_code=409) before_status = item.status before_payload = dict(before_payload_override or item.edited_payload or item.payload or {}) @@ -1007,9 +1392,19 @@ class PMFlowChangeReviewService: "sourceMessageId": str(row.source_message_id), "rawStatus": row.status, "committedAt": row.committed_at.isoformat() if row.committed_at else None, + "committedById": str(row.committed_by) if row.committed_by else None, "routingMeta": row.routing_meta or {}, } + async def _load_user_display_name(self, *, user_id: int) -> str: + result = await self.db.execute( + select(User).where(and_(User.tenant_id == self.tenant_id, User.id == int(user_id))) + ) + user = result.scalar_one_or_none() + if user is None: + return str(user_id) + return str(user.name or user.username or user.email or user.id) + @staticmethod def serialize_change_item(row: PMFlowChangeItem, evidence: list[PMFlowEvidenceLink]) -> dict[str, Any]: evidence_ids = [str(item.id) for item in evidence if item.change_item_id in {None, row.id}] diff --git a/backend/app/services/pmflow_launch_service.py b/backend/app/services/pmflow_launch_service.py index 6cceff65..769fb395 100644 --- a/backend/app/services/pmflow_launch_service.py +++ b/backend/app/services/pmflow_launch_service.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import hashlib import json from datetime import timedelta @@ -14,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from ..config.ai_prompts import PROMPT_PMFLOW_LAUNCH_DRAFT from ..models import ( PMFlowLaunchDraft, + PMFlowLaunchDraftJob, PMFlowThread, PMFlowThreadMessage, Project, @@ -22,15 +24,22 @@ from ..models import ( UserProject, ) from ..schemas.pmflow_launch import ( + PMFlowLaunchAttachment, PMFlowLaunchConfirmResponse, + PMFlowLaunchDraftJobRequest, + PMFlowLaunchDraftJobResponse, PMFlowLaunchDraftPayload, PMFlowLaunchDraftRequest, PMFlowLaunchDraftResponse, ) +from ..services.ai_function_runtime_profile_service import AIFunctionRuntimeProfile, AIFunctionRuntimeProfileService from ..services.ai_pmo.ai_pmo_kernel import AIPMOKernelService +from ..services.ai_scene_runtime_policy import AISceneRuntimePolicyService from ..services.context_cache_service import context_cache_service from ..services.llm_service import llm_service from ..services.operation_log_service import log_operation +from ..services.ai_routing_governance_service import PLATFORM_RUNTIME_TENANT_ID +from ..services.prompt_runtime_service import PromptRuntimeRenderResult, PromptRuntimeService from ..services.task_persistence_core import build_task_create_draft, persist_task from ..utils.sequence import ensure_project_id_sequence from ..utils.timezone import now_cn_naive @@ -49,36 +58,82 @@ class PMFlowLaunchService: """Owns the two-step Project Launch write chain.""" DRAFT_TTL_HOURS = 24 + JOB_TTL_HOURS = 24 + DRAFT_GENERATION_MAX_TOKENS = 7200 + DRAFT_PROMPT_KEY = "PROMPT_PMFLOW_LAUNCH_DRAFT" + DRAFT_SCENE_KEY = "project_launch" + DRAFT_FUNCTION_NAME = "pmflow_launch_draft_v1" def __init__(self, db: AsyncSession, *, tenant_id: int): self.db = db self.tenant_id = int(tenant_id) + async def _generate_draft_structured( + self, + *, + body: PMFlowLaunchDraftRequest, + profile_stage: str = "active", + routing_meta_extra: dict[str, Any] | None = None, + ): + timeout_policy_meta = self._draft_timeout_policy_meta(body=body) + runtime_profile = await self._resolve_draft_runtime_profile(stage=profile_stage) + effective_runtime_meta = { + **timeout_policy_meta, + **runtime_profile.routing_meta(), + } + prompt_body, preprocess_meta = self._apply_large_input_policy( + body=body, + routing_meta=effective_runtime_meta, + ) + prompt_result = await self._render_draft_prompt(body=prompt_body) + routing_meta = { + "scene": "pmflow_project_launch", + "tier_alias": "low", + "response_contract": "structured", + "prompt_runtime_tenant_id": PLATFORM_RUNTIME_TENANT_ID, + **(routing_meta_extra if isinstance(routing_meta_extra, dict) else {}), + **effective_runtime_meta, + **preprocess_meta, + **prompt_result.routing_meta(), + } + job_timeout_seconds = self._bounded_seconds( + routing_meta.get("job_timeout_seconds"), + default=runtime_profile.job_timeout_seconds, + minimum=10, + maximum=900, + ) + try: + return await asyncio.wait_for( + llm_service.generate_structured_object( + prompt_result.prompt, + db=self.db, + tenant_id=self.tenant_id, + function_name=self.DRAFT_FUNCTION_NAME, + temperature=runtime_profile.temperature, + max_tokens=runtime_profile.max_tokens, + response_mode=runtime_profile.response_mode, + routing_meta=routing_meta, + ), + timeout=float(job_timeout_seconds), + ) + except asyncio.TimeoutError as exc: + raise PMFlowLaunchError( + "draft_job_timeout", + f"项目草案生成超过 Job 超时 {job_timeout_seconds} 秒,已停止本次生成。", + status_code=504, + ) from exc + async def create_draft( self, *, body: PMFlowLaunchDraftRequest, user_id: int, ) -> PMFlowLaunchDraftResponse: - prompt = PROMPT_PMFLOW_LAUNCH_DRAFT.format( - input_text=str(body.input or "")[:4000], - attachments_json=json.dumps([item.model_dump(mode="json") for item in body.attachments], ensure_ascii=False), - ) + self._assert_input_ready(body) try: - structured = await llm_service.generate_structured_object( - prompt, - db=self.db, - tenant_id=self.tenant_id, - function_name="pmflow_launch_draft_v1", - temperature=0.0, - max_tokens=2200, - response_mode="fast", - routing_meta={ - "scene": "pmflow_project_launch", - "tier_alias": "low", - "response_contract": "structured", - }, - ) + structured = await self._generate_draft_structured(body=body) + except PMFlowLaunchError: + raise except RuntimeError as exc: raise PMFlowLaunchError("draft_generation_failed", str(exc), status_code=502) from exc except Exception as exc: @@ -138,6 +193,513 @@ class PMFlowLaunchService: expires_at=expires_at.isoformat(), ) + async def generate_draft_payload_for_readiness_check(self, *, user_id: int, profile_stage: str = "active") -> PMFlowLaunchDraftPayload: + """Run the protected Project Launch structured chain without writing a draft.""" + _ = user_id + body = PMFlowLaunchDraftRequest( + input=( + "我想做一个 AI 会议纪要助手,帮助个人开发者和小团队把会议文本整理成目标、" + "需求、风险和第一批任务。" + ), + input_type="text", + attachments=[], + source="system_smoke", + ) + try: + structured = await self._generate_draft_structured( + body=body, + profile_stage=profile_stage, + routing_meta_extra={ + "tier_alias": "structured", + "readiness_check": True, + "source": "system_smoke", + }, + ) + except PMFlowLaunchError: + raise + except RuntimeError as exc: + raise PMFlowLaunchError("draft_smoke_failed", str(exc), status_code=502) from exc + except Exception as exc: + raise PMFlowLaunchError("draft_smoke_failed", f"项目草案 smoke test 失败: {exc}", status_code=502) from exc + + payload = structured.payload if structured is not None and isinstance(structured.payload, dict) else {} + draft_payload = self._normalize_draft_payload(payload) + self._validate_draft_payload(draft_payload, require_confirmable=False) + draft_payload.warnings = self._merge_warnings(draft_payload.warnings, self._draft_count_warnings(draft_payload)) + return draft_payload + + async def create_draft_job( + self, + *, + body: PMFlowLaunchDraftJobRequest, + user_id: int, + ) -> PMFlowLaunchDraftJobResponse: + self._assert_input_ready(body) + parent_draft_id = str(body.parent_draft_id or "").strip() or None + if parent_draft_id: + await self._load_draft_for_update(draft_id=parent_draft_id, user_id=user_id) + + job_id = f"job_{uuid4().hex[:16]}" + revision_id = f"rev_{uuid4().hex[:16]}" + expires_at = now_cn_naive() + timedelta(hours=self.JOB_TTL_HOURS) + await self._supersede_active_jobs(user_id=user_id, superseded_by_job_id=job_id) + row = PMFlowLaunchDraftJob( + tenant_id=self.tenant_id, + user_id=int(user_id), + job_id=job_id, + revision_id=revision_id, + parent_draft_id=parent_draft_id, + superseded_by_job_id=None, + result_draft_id=None, + input_text=str(body.input or "").strip(), + input_type=str(body.input_type or "text"), + source=str(body.source or "web")[:32], + attachments=[item.model_dump(mode="json") for item in body.attachments], + mode=str(body.mode or "create"), + revision_note=str(body.revision_note or "").strip() or None, + status="queued", + result_payload=None, + changed_fields=[], + error_code=None, + error_message=None, + expires_at=expires_at, + ) + self.db.add(row) + await log_operation( + self.db, + tenant_id=self.tenant_id, + user_id=int(user_id), + project_id=None, + entity_type="pmflow_launch_draft_job", + entity_id=job_id, + action="pmflow_launch.draft_job.create", + before_data=None, + after_data={ + "job_id": job_id, + "revision_id": revision_id, + "mode": row.mode, + "parent_draft_id": parent_draft_id, + }, + interface_path="/pmflow-launch/draft-jobs", + caller="user", + ) + try: + await self.db.commit() + except Exception: + await self.db.rollback() + raise + return self._job_response(row, runtime_policy=await self._draft_job_runtime_policy()) + + async def get_draft_job(self, *, job_id: str, user_id: int) -> PMFlowLaunchDraftJobResponse: + row = await self._load_job(job_id=job_id, user_id=user_id) + return self._job_response(row, runtime_policy=await self._draft_job_runtime_policy()) + + async def cancel_draft_job(self, *, job_id: str, user_id: int) -> PMFlowLaunchDraftJobResponse: + row = await self._load_job_for_update(job_id=job_id, user_id=user_id) + if row.status in {"queued", "running", "user_paused"}: + row.status = "cancelled" + row.completed_at = now_cn_naive() + row.error_code = "cancelled" + row.error_message = "用户已取消本次生成。" + await log_operation( + self.db, + tenant_id=self.tenant_id, + user_id=int(user_id), + project_id=None, + entity_type="pmflow_launch_draft_job", + entity_id=row.job_id, + action="pmflow_launch.draft_job.cancel", + before_data={"status": "running"}, + after_data={"status": "cancelled"}, + interface_path=f"/pmflow-launch/draft-jobs/{row.job_id}/cancel", + caller="user", + ) + await self.db.commit() + return self._job_response(row, runtime_policy=await self._draft_job_runtime_policy()) + + async def run_draft_job(self, *, job_id: str) -> None: + row = await self._load_job_for_worker(job_id=job_id) + if row.status != "queued": + return + row.status = "running" + row.started_at = now_cn_naive() + await self.db.commit() + + try: + body = await self._job_to_draft_request(row) + structured = await self._generate_draft_structured( + body=body, + routing_meta_extra={ + "launch_job_id": row.job_id, + "launch_revision_id": row.revision_id, + "launch_job_mode": row.mode, + }, + ) + payload = structured.payload if structured is not None and isinstance(structured.payload, dict) else {} + draft_payload = self._normalize_draft_payload(payload) + self._validate_draft_payload(draft_payload, require_confirmable=False) + draft_payload.warnings = self._merge_warnings(draft_payload.warnings, self._draft_count_warnings(draft_payload)) + changed_fields = await self._changed_fields(parent_draft_id=row.parent_draft_id, payload=draft_payload, user_id=int(row.user_id)) + await self._complete_draft_job(row=row, payload=draft_payload, changed_fields=changed_fields) + except PMFlowLaunchError as exc: + await self._fail_draft_job(row=row, code=exc.code, message=exc.message) + except RuntimeError as exc: + await self._fail_draft_job(row=row, code="draft_generation_failed", message=str(exc)) + except Exception as exc: + await self._fail_draft_job(row=row, code="draft_generation_failed", message=f"项目草案生成失败: {exc}") + + async def _render_draft_prompt(self, *, body: PMFlowLaunchDraftRequest) -> PromptRuntimeRenderResult: + return await PromptRuntimeService(self.db).render( + tenant_id=PLATFORM_RUNTIME_TENANT_ID, + prompt_key=self.DRAFT_PROMPT_KEY, + fallback_template=PROMPT_PMFLOW_LAUNCH_DRAFT, + input_text=str(body.input or "")[:4000], + attachments_json=json.dumps([item.model_dump(mode="json") for item in body.attachments], ensure_ascii=False), + ) + + async def _resolve_draft_runtime_profile(self, *, stage: str = "active") -> AIFunctionRuntimeProfile: + try: + return await AIFunctionRuntimeProfileService(self.db).resolve_profile( + function_name=self.DRAFT_FUNCTION_NAME, + scene_key=self.DRAFT_SCENE_KEY, + prompt_key=self.DRAFT_PROMPT_KEY, + stage=stage, + ) + except Exception: + return AIFunctionRuntimeProfile( + function_name=self.DRAFT_FUNCTION_NAME, + scene_key=self.DRAFT_SCENE_KEY, + prompt_key=self.DRAFT_PROMPT_KEY, + required_slot="structured", + response_contract="structured", + max_tokens=self.DRAFT_GENERATION_MAX_TOKENS, + repair_max_tokens=3600, + temperature=0.0, + foreground_wait_seconds=10, + provider_timeout_seconds=90, + job_timeout_seconds=180, + max_retries=1, + large_input_policy="preprocess_then_generate", + response_mode="fast", + response_format="json_object", + profile_version="code-default", + profile_source="code_default", + ) + + def _draft_timeout_policy_meta(self, *, body: PMFlowLaunchDraftRequest) -> dict[str, Any]: + attachments = list(body.attachments or []) + attachment_text = "\n".join( + str(item.summary or item.name or "").strip() + for item in attachments + if str(item.summary or item.name or "").strip() + ) + policy, input_tokens = AISceneRuntimePolicyService.resolve( + scene_key=self.DRAFT_SCENE_KEY, + function_name=self.DRAFT_FUNCTION_NAME, + input_text="\n".join([str(body.input or ""), attachment_text]).strip(), + attachment_count=len(attachments), + ) + return policy.routing_meta(input_tokens_estimate=input_tokens) + + @staticmethod + def _bounded_seconds(value: Any, *, default: int, minimum: int, maximum: int) -> int: + try: + parsed = int(float(value)) + except Exception: + parsed = int(default) + return max(int(minimum), min(parsed, int(maximum))) + + @staticmethod + def _compact_material(value: Any, *, limit: int) -> str: + text = " ".join(str(value or "").split()) + if len(text) <= limit: + return text + head = max(0, int(limit * 0.72)) + tail = max(0, limit - head - 24) + return f"{text[:head]} ... [中间材料已压缩] ... {text[-tail:]}" if tail > 0 else text[:limit] + + def _apply_large_input_policy( + self, + *, + body: PMFlowLaunchDraftRequest, + routing_meta: dict[str, Any], + ) -> tuple[PMFlowLaunchDraftRequest, dict[str, Any]]: + policy = str(routing_meta.get("large_input_policy") or "").strip() + input_bucket = str(routing_meta.get("input_bucket") or "").strip() + if policy != "preprocess_then_generate" or input_bucket != "large": + return body, {"large_input_preprocessed": False} + + original_input = str(body.input or "") + compact_input = self._compact_material(original_input, limit=2800) + compact_attachments = [] + original_attachment_chars = 0 + for item in list(body.attachments or [])[:8]: + item_payload = item.model_dump(mode="json") + raw_material = item_payload.get("summary") or item_payload.get("name") or item_payload.get("payload") or "" + if isinstance(raw_material, dict): + raw_material = json.dumps(raw_material, ensure_ascii=False, default=str) + raw_text = str(raw_material or "") + original_attachment_chars += len(raw_text) + item_payload["summary"] = self._compact_material(raw_text, limit=520) + item_payload["payload"] = None + compact_attachments.append(PMFlowLaunchAttachment(**item_payload)) + + preprocessed_input = ( + f"{compact_input}\n\n" + "[大型输入预处理]\n" + "已按运行合同压缩原始材料,只保留项目目标、目标用户、问题、方案、范围、风险、约束和待确认线索。" + ).strip() + preprocessed_input = self._compact_material(preprocessed_input, limit=3900) + return ( + body.model_copy( + update={ + "input": preprocessed_input, + "attachments": compact_attachments, + } + ), + { + "large_input_preprocessed": True, + "large_input_preprocess_strategy": "bounded_material_compaction", + "large_input_original_chars": len(original_input) + original_attachment_chars, + "large_input_preprocessed_chars": len(preprocessed_input) + + sum(len(str(item.summary or "")) for item in compact_attachments), + }, + ) + + def _assert_input_ready(self, body: PMFlowLaunchDraftRequest) -> None: + input_text = str(body.input or "").strip() + attachment_summaries = [ + str(item.summary or item.name or "").strip() + for item in body.attachments + if str(item.summary or item.name or "").strip() + ] + evidence_text = " ".join([input_text, *attachment_summaries]).strip() + meaningful_chars = [char for char in evidence_text if char.isalnum() or "\u4e00" <= char <= "\u9fff"] + if len(meaningful_chars) >= 12: + return + raise PMFlowLaunchError( + "input_needs_more_info", + ( + "当前输入还不足以生成稳定的项目草案。请至少补充:想做什么产品、" + "面向谁、解决什么问题,或导入一份包含这些信息的材料。" + ), + status_code=422, + ) + + async def _supersede_active_jobs(self, *, user_id: int, superseded_by_job_id: str) -> None: + result = await self.db.execute( + select(PMFlowLaunchDraftJob).where( + and_( + PMFlowLaunchDraftJob.tenant_id == self.tenant_id, + PMFlowLaunchDraftJob.user_id == int(user_id), + PMFlowLaunchDraftJob.status.in_(["queued", "running", "user_paused"]), + ) + ) + ) + now = now_cn_naive() + for row in result.scalars().all(): + row.status = "superseded" + row.superseded_by_job_id = superseded_by_job_id + row.completed_at = now + row.error_code = "superseded" + row.error_message = "已有新的补充或重新生成请求,本次生成结果不再采用。" + + async def _load_job(self, *, job_id: str, user_id: int) -> PMFlowLaunchDraftJob: + result = await self.db.execute( + select(PMFlowLaunchDraftJob).where( + and_( + PMFlowLaunchDraftJob.tenant_id == self.tenant_id, + PMFlowLaunchDraftJob.user_id == int(user_id), + PMFlowLaunchDraftJob.job_id == str(job_id), + ) + ) + ) + row = result.scalar_one_or_none() + if row is None: + raise PMFlowLaunchError("job_not_found", "草案生成任务不存在或无权访问", status_code=404) + return row + + async def _load_job_for_update(self, *, job_id: str, user_id: int) -> PMFlowLaunchDraftJob: + result = await self.db.execute( + select(PMFlowLaunchDraftJob) + .where( + and_( + PMFlowLaunchDraftJob.tenant_id == self.tenant_id, + PMFlowLaunchDraftJob.user_id == int(user_id), + PMFlowLaunchDraftJob.job_id == str(job_id), + ) + ) + .with_for_update() + ) + row = result.scalar_one_or_none() + if row is None: + raise PMFlowLaunchError("job_not_found", "草案生成任务不存在或无权访问", status_code=404) + return row + + async def _load_job_for_worker(self, *, job_id: str) -> PMFlowLaunchDraftJob: + result = await self.db.execute( + select(PMFlowLaunchDraftJob) + .where(and_(PMFlowLaunchDraftJob.tenant_id == self.tenant_id, PMFlowLaunchDraftJob.job_id == str(job_id))) + .with_for_update() + ) + row = result.scalar_one_or_none() + if row is None: + raise PMFlowLaunchError("job_not_found", "草案生成任务不存在", status_code=404) + return row + + async def _job_to_draft_request(self, row: PMFlowLaunchDraftJob) -> PMFlowLaunchDraftRequest: + input_text = str(row.input_text or "").strip() + if row.parent_draft_id: + parent = await self._load_draft_for_update(draft_id=row.parent_draft_id, user_id=int(row.user_id)) + input_text = ( + "请基于当前项目草案做增量修订,优先保留未被补充信息推翻的已有结构,不要无依据重写。\n\n" + f"原始输入:\n{parent.input_text}\n\n" + f"当前草案 JSON:\n{json.dumps(parent.draft_payload or {}, ensure_ascii=False)}\n\n" + f"用户补充:\n{input_text}\n" + ) + if row.revision_note: + input_text = f"{input_text}\n修订要求:\n{row.revision_note}" + return PMFlowLaunchDraftRequest( + input=input_text[:4000], + input_type=row.input_type or "text", + attachments=list(row.attachments or []), + source=row.source or "web", + ) + + async def _changed_fields( + self, + *, + parent_draft_id: Optional[str], + payload: PMFlowLaunchDraftPayload, + user_id: int, + ) -> list[str]: + if not parent_draft_id: + return ["title", "one_liner", "target_user", "requirements", "risks", "decisions", "tasks"] + parent = await self._load_draft_for_update(draft_id=parent_draft_id, user_id=user_id) + previous = parent.draft_payload or {} + current = payload.model_dump(mode="json") + fields = ["title", "one_liner", "target_user", "problem", "solution", "requirements", "risks", "decisions", "tasks", "warnings"] + return [field for field in fields if previous.get(field) != current.get(field)] + + async def _complete_draft_job( + self, + *, + row: PMFlowLaunchDraftJob, + payload: PMFlowLaunchDraftPayload, + changed_fields: list[str], + ) -> None: + await self.db.refresh(row) + if row.status != "running": + return + draft_id = f"draft_{uuid4().hex[:16]}" + expires_at = now_cn_naive() + timedelta(hours=self.DRAFT_TTL_HOURS) + draft = PMFlowLaunchDraft( + tenant_id=self.tenant_id, + user_id=int(row.user_id), + draft_id=draft_id, + input_text=str(row.input_text or "").strip(), + input_type=str(row.input_type or "text"), + source=str(row.source or "web")[:32], + attachments=list(row.attachments or []), + draft_payload=payload.model_dump(mode="json"), + edited_payload=None, + status="draft", + confidence=float(payload.confidence), + warnings=list(payload.warnings or []), + expires_at=expires_at, + ) + self.db.add(draft) + row.status = "completed" + row.result_draft_id = draft_id + row.result_payload = payload.model_dump(mode="json") + row.changed_fields = list(changed_fields or []) + row.error_code = None + row.error_message = None + row.completed_at = now_cn_naive() + await log_operation( + self.db, + tenant_id=self.tenant_id, + user_id=int(row.user_id), + project_id=None, + entity_type="pmflow_launch_draft_job", + entity_id=row.job_id, + action="pmflow_launch.draft_job.complete", + before_data={"status": "running"}, + after_data={ + "status": "completed", + "draft_id": draft_id, + "changed_fields": list(changed_fields or []), + }, + interface_path=f"/pmflow-launch/draft-jobs/{row.job_id}", + caller="system", + ) + await self.db.commit() + + async def _fail_draft_job(self, *, row: PMFlowLaunchDraftJob, code: str, message: str) -> None: + await self.db.refresh(row) + if row.status not in {"queued", "running", "user_paused"}: + return + row.status = "failed" + row.error_code = str(code or "draft_generation_failed")[:64] + row.error_message = str(message or "项目草案生成失败")[:1000] + row.completed_at = now_cn_naive() + await log_operation( + self.db, + tenant_id=self.tenant_id, + user_id=int(row.user_id), + project_id=None, + entity_type="pmflow_launch_draft_job", + entity_id=row.job_id, + action="pmflow_launch.draft_job.fail", + before_data={"status": "running"}, + after_data={"status": "failed", "error_code": row.error_code}, + interface_path=f"/pmflow-launch/draft-jobs/{row.job_id}", + caller="system", + ) + await self.db.commit() + + async def _draft_job_runtime_policy(self) -> dict[str, Any]: + profile = await self._resolve_draft_runtime_profile(stage="active") + return { + "foreground_wait_seconds": int(profile.foreground_wait_seconds), + "provider_timeout_seconds": int(profile.provider_timeout_seconds), + "job_timeout_seconds": int(profile.job_timeout_seconds), + "max_retries": int(profile.max_retries), + "large_input_policy": str(profile.large_input_policy or "preprocess_then_generate"), + } + + def _job_response( + self, + row: PMFlowLaunchDraftJob, + *, + runtime_policy: dict[str, Any] | None = None, + ) -> PMFlowLaunchDraftJobResponse: + draft = None + if row.result_draft_id and isinstance(row.result_payload, dict): + draft = PMFlowLaunchDraftResponse( + **row.result_payload, + draft_id=row.result_draft_id, + status="draft", + expires_at=row.expires_at.isoformat(), + ) + return PMFlowLaunchDraftJobResponse( + job_id=row.job_id, + revision_id=row.revision_id, + status=row.status, + mode=row.mode, + parent_draft_id=row.parent_draft_id, + superseded_by_job_id=row.superseded_by_job_id, + draft_id=row.result_draft_id, + draft=draft, + changed_fields=self._safe_str_list(row.changed_fields), + error_code=row.error_code, + error_message=row.error_message, + started_at=row.started_at.isoformat() if row.started_at else None, + completed_at=row.completed_at.isoformat() if row.completed_at else None, + expires_at=row.expires_at.isoformat(), + runtime_policy=runtime_policy, + ) + async def confirm_draft( self, *, @@ -358,14 +920,14 @@ class PMFlowLaunchService: def _draft_count_warnings(self, payload: PMFlowLaunchDraftPayload) -> list[str]: warnings: list[str] = [] - if not 3 <= len(payload.requirements) <= 5: - warnings.append(f"requirements_count_out_of_range: expected 3-5, got {len(payload.requirements)}") - if not 3 <= len(payload.tasks) <= 7: - warnings.append(f"tasks_count_out_of_range: expected 3-7, got {len(payload.tasks)}") - if not 2 <= len(payload.risks) <= 4: - warnings.append(f"risks_count_out_of_range: expected 2-4, got {len(payload.risks)}") - if not 1 <= len(payload.decisions) <= 3: - warnings.append(f"decisions_count_out_of_range: expected 1-3, got {len(payload.decisions)}") + if not 7 <= len(payload.requirements) <= 10: + warnings.append(f"requirements_count_out_of_range: expected 7-10, got {len(payload.requirements)}") + if not 10 <= len(payload.tasks) <= 16: + warnings.append(f"tasks_count_out_of_range: expected 10-16, got {len(payload.tasks)}") + if not 4 <= len(payload.risks) <= 7: + warnings.append(f"risks_count_out_of_range: expected 4-7, got {len(payload.risks)}") + if not 3 <= len(payload.decisions) <= 6: + warnings.append(f"decisions_count_out_of_range: expected 3-6, got {len(payload.decisions)}") return warnings async def _create_truth_nodes( diff --git a/backend/app/services/pmflow_os_projection_service.py b/backend/app/services/pmflow_os_projection_service.py index 9803256d..728335e7 100644 --- a/backend/app/services/pmflow_os_projection_service.py +++ b/backend/app/services/pmflow_os_projection_service.py @@ -11,12 +11,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm.attributes import flag_modified from ..models import ( + ComplianceCheckRun, FeedbackIngest, IterationRecommendation, ModelCatalogItem, ModelEvalRun, ModelRecommendationSnapshot, PMFlowApproval, + PMFlowAIFeedbackEvent, PMFlowChangeItem, PMFlowChangeSet, PMFlowEvidenceLink, @@ -61,6 +63,7 @@ from ..schemas.pmflow_os import ( ProjectTruthStateProjection, ) from .ai_project_control_service import AIProjectControlService +from .pmflow_ai_feedback_service import PMFlowAIFeedbackService from .pmflow_change_review_service import PMFlowChangeReviewService from ..utils.timezone import now_cn_naive @@ -100,6 +103,7 @@ class PMFlowOSProjectionService: ) review_projection = await self._load_review_projection(project_id=project_id) active_change_set_id = self._active_change_set_id(review_projection["change_sets"]) + latest_writeback = self._latest_writeback_meta(review_projection["change_sets"]) project_members = await self._load_project_members(project=project) reviewer_routing, reviewer_warnings = self._project_reviewer_routing( project_id=project_id, @@ -121,10 +125,14 @@ class PMFlowOSProjectionService: warnings.append("truth_nodes_empty: no confirmed project truth nodes were found for this project.") if not tasks: warnings.append("tasks_empty: no task rows were found for this project.") + ai_feedback = await self._load_ai_feedback(project_id=project_id, current_user_id=current_user_id) return ProjectTruthStateProjection( loopStage=loop_stage, - lastWritebackAt=self._latest_iso([node.updated_at for node in truth_nodes] + [task.updated_at for task, _ in tasks]), + lastWritebackAt=latest_writeback.get("at") + or self._latest_iso([node.updated_at for node in truth_nodes] + [task.updated_at for task, _ in tasks]), + lastWritebackBy=latest_writeback.get("by"), + lastWritebackById=latest_writeback.get("by_id"), project=PMFlowProjectSummary( id=str(project.id), name=project.title or project.name, @@ -152,6 +160,7 @@ class PMFlowOSProjectionService: testRuns=test_runs, releases=releases, metricSignals=metric_signals, + aiFeedback=ai_feedback, feed=self._project_feed( review_projection=review_projection, reviewer_routing=reviewer_routing, @@ -169,6 +178,225 @@ class PMFlowOSProjectionService: projectionWarnings=warnings + reviewer_warnings + execution_summary.warnings, ) + async def get_project_memory_overview( + self, + *, + project_id: int, + current_user_id: Optional[int] = None, + ) -> dict[str, Any]: + project = await self._load_project(project_id=project_id) + items_payload = await self.get_project_memory_items(project_id=project_id, limit=500, offset=0) + assets_payload = await self.get_project_memory_assets(project_id=project_id, limit=200, offset=0) + context_payload = await self.get_project_memory_context_packs(project_id=project_id) + writebacks_payload = await self.get_project_memory_writebacks(project_id=project_id, limit=8, offset=0) + items = list(items_payload.get("items") or []) + assets = list(assets_payload.get("assets") or []) + context_packs = list(context_payload.get("contextPacks") or []) + writebacks = list(writebacks_payload.get("writebacks") or []) + metric_counts = self._memory_metric_counts(items=items, assets=assets, context_packs=context_packs) + return { + "projectId": str(project.id), + "projectName": project.title or project.name, + "generatedAt": now_cn_naive().isoformat(), + "metrics": [ + {"key": "confirmed_facts", "label": "已确认事实", "value": metric_counts["confirmed_facts"]}, + {"key": "requirements", "label": "需求与范围", "value": metric_counts["requirements"]}, + {"key": "decisions", "label": "决策", "value": metric_counts["decisions"]}, + {"key": "constraints", "label": "约束条件", "value": metric_counts["constraints"]}, + {"key": "evidence", "label": "证据来源", "value": metric_counts["evidence"]}, + {"key": "context_packs", "label": "上下文包", "value": metric_counts["context_packs"]}, + {"key": "assets", "label": "项目资产", "value": metric_counts["assets"]}, + ], + "facts": [item for item in items if item.get("category") == "fact"][:5], + "requirements": [item for item in items if item.get("category") == "requirement"][:5], + "decisions": [item for item in items if item.get("category") == "decision"][:5], + "constraints": [item for item in items if item.get("category") in {"constraint", "risk"}][:5], + "evidence": assets[:5], + "contextPacks": context_packs[:5], + "writebacks": writebacks[:5], + "aiSuggestions": self._memory_ai_suggestions( + items=items, + assets=assets, + context_packs=context_packs, + writebacks=writebacks, + ), + "warnings": [], + } + + async def get_project_memory_items( + self, + *, + project_id: int, + category: Optional[str] = None, + stage: Optional[str] = None, + source_type: Optional[str] = None, + status: Optional[str] = None, + query: Optional[str] = None, + limit: int = 50, + offset: int = 0, + ) -> dict[str, Any]: + project = await self._load_project(project_id=project_id) + truth_nodes = await self._load_truth_nodes(project_id=project_id) + tasks = await self._load_tasks(project_id=project_id) + semantic_facts = await self._load_project_memory_semantic_facts(project_id=project_id, truth_nodes=truth_nodes, tasks=tasks) + evidence_by_item = await self._memory_evidence_by_item(project_id=project_id) + items: list[dict[str, Any]] = [] + for node in truth_nodes: + items.append(self._memory_item_from_truth_node(node, evidence_by_item=evidence_by_item)) + for task, user in tasks: + items.append(self._memory_item_from_task(task, user=user)) + for fact in semantic_facts: + items.append(self._memory_item_from_semantic_fact(fact)) + filtered = self._filter_memory_rows(items, category=category, stage=stage, source_type=source_type, status=status, query=query) + filtered.sort(key=lambda item: item.get("updatedAt") or item.get("createdAt") or "", reverse=True) + safe_limit = max(1, min(int(limit or 50), 200)) + safe_offset = max(0, int(offset or 0)) + return { + "projectId": str(project.id), + "total": len(filtered), + "limit": safe_limit, + "offset": safe_offset, + "items": filtered[safe_offset : safe_offset + safe_limit], + } + + async def get_project_memory_item(self, *, project_id: int, item_id: str) -> dict[str, Any]: + items_payload = await self.get_project_memory_items(project_id=project_id, limit=500, offset=0) + item_id_value = str(item_id or "").strip() + for item in items_payload.get("items") or []: + if str(item.get("id")) == item_id_value: + return {"projectId": str(project_id), "item": item} + raise ValueError("project_memory_item_not_found") + + async def get_project_memory_assets( + self, + *, + project_id: int, + asset_type: Optional[str] = None, + source_type: Optional[str] = None, + status: Optional[str] = None, + query: Optional[str] = None, + limit: int = 50, + offset: int = 0, + ) -> dict[str, Any]: + project = await self._load_project(project_id=project_id) + assets: list[dict[str, Any]] = [] + evidence_result = await self.db.execute( + select(PMFlowEvidenceLink) + .where(and_(PMFlowEvidenceLink.tenant_id == self.tenant_id, PMFlowEvidenceLink.project_id == int(project_id))) + .order_by(PMFlowEvidenceLink.updated_at.desc(), PMFlowEvidenceLink.created_at.desc(), PMFlowEvidenceLink.id.desc()) + .limit(500) + ) + for evidence in evidence_result.scalars().all(): + assets.append(self._memory_asset_from_evidence(evidence)) + message_result = await self.db.execute( + select(PMFlowThreadMessage) + .where(and_(PMFlowThreadMessage.tenant_id == self.tenant_id, PMFlowThreadMessage.project_id == int(project_id))) + .order_by(PMFlowThreadMessage.updated_at.desc(), PMFlowThreadMessage.created_at.desc(), PMFlowThreadMessage.id.desc()) + .limit(300) + ) + for message in message_result.scalars().all(): + assets.append(self._memory_asset_from_thread_message(message)) + filtered = [asset for asset in assets if not asset_type or str(asset.get("assetType")) == str(asset_type)] + filtered = self._filter_memory_rows(filtered, category=None, stage=None, source_type=source_type, status=status, query=query) + filtered.sort(key=lambda item: item.get("updatedAt") or item.get("createdAt") or "", reverse=True) + safe_limit = max(1, min(int(limit or 50), 200)) + safe_offset = max(0, int(offset or 0)) + return { + "projectId": str(project.id), + "total": len(filtered), + "limit": safe_limit, + "offset": safe_offset, + "assets": filtered[safe_offset : safe_offset + safe_limit], + } + + async def get_project_memory_context_packs(self, *, project_id: int) -> dict[str, Any]: + project = await self._load_project(project_id=project_id) + items_payload = await self.get_project_memory_items(project_id=project_id, limit=500, offset=0) + assets_payload = await self.get_project_memory_assets(project_id=project_id, limit=200, offset=0) + items = list(items_payload.get("items") or []) + assets = list(assets_payload.get("assets") or []) + packs = [ + self._memory_context_pack( + key="project-truth", + title="项目可信真相包", + summary="已确认事实、需求、决策与约束,供项目问答和执行主链优先引用。", + usage="项目问答 / 需求拆解 / 执行前上下文", + item_ids=[item["id"] for item in items if item.get("category") in {"fact", "requirement", "decision", "constraint", "risk"}], + asset_ids=[asset["id"] for asset in assets[:20]], + ), + self._memory_context_pack( + key="development", + title="开发执行上下文包", + summary="面向开发 Agent 的需求、决策、风险和任务上下文。", + usage="开发任务交接 / Prompt Package / PR 前检查", + item_ids=[item["id"] for item in items if item.get("category") in {"requirement", "decision", "constraint", "risk", "task"}], + asset_ids=[asset["id"] for asset in assets if asset.get("assetType") in {"conversation", "evidence", "code"}][:20], + ), + self._memory_context_pack( + key="testing", + title="测试与验收上下文包", + summary="面向测试计划、验收标准和风险检查的上下文。", + usage="测试计划 / 验收用例 / 发布门禁", + item_ids=[item["id"] for item in items if item.get("category") in {"requirement", "constraint", "risk", "task", "test"}], + asset_ids=[asset["id"] for asset in assets if asset.get("assetType") in {"test", "evidence", "conversation"}][:20], + ), + self._memory_context_pack( + key="release-growth", + title="发布与增长上下文包", + summary="面向发布判断、反馈回流和下一轮迭代的上下文。", + usage="发布检查 / 增长复盘 / 迭代建议", + item_ids=[item["id"] for item in items if item.get("category") in {"release", "metric", "decision", "risk"}], + asset_ids=[asset["id"] for asset in assets if asset.get("assetType") in {"release", "metric", "feedback", "evidence"}][:20], + ), + ] + return {"projectId": str(project.id), "total": len(packs), "contextPacks": packs} + + async def get_project_memory_writebacks( + self, + *, + project_id: int, + limit: int = 50, + offset: int = 0, + ) -> dict[str, Any]: + project = await self._load_project(project_id=project_id) + result = await self.db.execute( + select(PMFlowChangeSet) + .where(and_(PMFlowChangeSet.tenant_id == self.tenant_id, PMFlowChangeSet.project_id == int(project_id))) + .order_by(PMFlowChangeSet.committed_at.desc(), PMFlowChangeSet.updated_at.desc(), PMFlowChangeSet.created_at.desc()) + .limit(300) + ) + change_sets = list(result.scalars().all()) + if not change_sets: + return {"projectId": str(project.id), "total": 0, "limit": limit, "offset": offset, "writebacks": []} + change_set_ids = [int(change_set.id) for change_set in change_sets] + committed_user_ids = [int(change_set.committed_by) for change_set in change_sets if change_set.committed_by] + committed_users = await self._load_user_display_map(user_ids=committed_user_ids) + items_result = await self.db.execute( + select(PMFlowChangeItem) + .where(and_(PMFlowChangeItem.tenant_id == self.tenant_id, PMFlowChangeItem.change_set_id.in_(change_set_ids))) + .order_by(PMFlowChangeItem.updated_at.desc(), PMFlowChangeItem.created_at.desc(), PMFlowChangeItem.id.desc()) + ) + by_change_set: dict[int, list[PMFlowChangeItem]] = {} + for item in items_result.scalars().all(): + by_change_set.setdefault(int(item.change_set_id), []).append(item) + rows = [ + self._memory_writeback_from_change_set( + change_set, + items=by_change_set.get(int(change_set.id), []), + committed_by=committed_users.get(int(change_set.committed_by)) if change_set.committed_by else None, + ) + for change_set in change_sets + ] + safe_limit = max(1, min(int(limit or 50), 200)) + safe_offset = max(0, int(offset or 0)) + return { + "projectId": str(project.id), + "total": len(rows), + "limit": safe_limit, + "offset": safe_offset, + "writebacks": rows[safe_offset : safe_offset + safe_limit], + } + async def get_execution_summary( self, *, @@ -1045,40 +1273,86 @@ class PMFlowOSProjectionService: revenue_rows = list(revenue_result.scalars().all()) revenue_events = [self._serialize_revenue_event(item) for item in revenue_rows] + roi_result = await self.db.execute( + select(ROISnapshot) + .where(and_(ROISnapshot.tenant_id == self.tenant_id, ROISnapshot.project_id == int(project_id))) + .order_by(ROISnapshot.updated_at.desc(), ROISnapshot.created_at.desc(), ROISnapshot.id.desc()) + .limit(10) + ) + roi_rows = list(roi_result.scalars().all()) + roi_snapshots = [self._serialize_roi_snapshot(item) for item in roi_rows] + + compliance_result = await self.db.execute( + select(ComplianceCheckRun) + .where(and_(ComplianceCheckRun.tenant_id == self.tenant_id, ComplianceCheckRun.project_id == int(project_id))) + .order_by(ComplianceCheckRun.updated_at.desc(), ComplianceCheckRun.created_at.desc(), ComplianceCheckRun.id.desc()) + .limit(10) + ) + compliance_rows = list(compliance_result.scalars().all()) + compliance_checks = [self._serialize_compliance_check(item) for item in compliance_rows] + warnings: list[str] = [] if not feedback_items: warnings.append("release_feedback_empty: no post-release feedback has been ingested.") if not revenue_events: warnings.append("release_revenue_empty: no revenue event has been recorded.") + if not roi_snapshots: + warnings.append("release_roi_empty: no ROI snapshot has been recorded.") + if not compliance_checks: + warnings.append("release_compliance_empty: no compliance check has been recorded.") if not recommendations: warnings.append("iteration_recommendations_empty: no iteration recommendation has been generated.") + if any(item.get("riskLevel") == "high" for item in roi_snapshots): + warnings.append("release_roi_risk: latest ROI snapshot indicates negative or missing return.") + if any(item.get("riskLevel") == "high" for item in compliance_checks): + warnings.append("release_compliance_risk: compliance check is blocked or has open issues.") serialized_times = [ str(item.get("updatedAt") or item.get("createdAt")) - for item in [*feedback_items, *recommendations, *revenue_events] + for item in [*feedback_items, *recommendations, *revenue_events, *roi_snapshots, *compliance_checks] if item.get("updatedAt") or item.get("createdAt") ] return { - "status": "active" if feedback_items or revenue_events or recommendations else "empty", + "status": "active" if feedback_items or revenue_events or roi_snapshots or compliance_checks or recommendations else "empty", "relationship": { "releaseGateStatus": release_gate.get("status") or "empty", "latestValidationRunId": None if latest_release_validation is None else str(latest_release_validation.id), "latestValidationRunStatus": None if latest_release_validation is None else latest_release_validation.status, "source": "pmflow_os_release_growth", "changeSetCreated": any(bool(item.get("changeSetCreated")) for item in recommendations), + "businessSignalTypes": self._unique_strings( + [ + *("feedback" for _ in feedback_items), + *("revenue" for _ in revenue_events), + *("roi" for _ in roi_snapshots), + *("compliance" for _ in compliance_checks), + ] + ), }, "summary": { "feedbackCount": len(feedback_items), "revenueEventCount": len(revenue_events), + "roiSnapshotCount": len(roi_snapshots), + "complianceCheckCount": len(compliance_checks), "iterationRecommendationCount": len(recommendations), + "riskSignalCount": sum( + 1 + for item in [*roi_snapshots, *compliance_checks] + if str(item.get("riskLevel") or "low") in {"medium", "high"} + ), "latestSignalAt": max(serialized_times) if serialized_times else None, }, "feedback": feedback_items, "revenueEvents": revenue_events, + "roiSnapshots": roi_snapshots, + "complianceChecks": compliance_checks, "recommendations": recommendations, "recommendationGroups": recommendation_groups, "nextActions": self._release_signal_next_actions( feedback_items=feedback_items, + revenue_events=revenue_events, + roi_snapshots=roi_snapshots, + compliance_checks=compliance_checks, recommendations=recommendations, release_gate=release_gate, ), @@ -1217,6 +1491,22 @@ class PMFlowOSProjectionService: for row in rows ] + async def _load_ai_feedback(self, *, project_id: int, current_user_id: Optional[int]) -> list[Any]: + if current_user_id is None: + return [] + result = await self.db.execute( + select(PMFlowAIFeedbackEvent) + .where( + and_( + PMFlowAIFeedbackEvent.tenant_id == self.tenant_id, + PMFlowAIFeedbackEvent.project_id == int(project_id), + PMFlowAIFeedbackEvent.actor_user_id == int(current_user_id), + ) + ) + .order_by(PMFlowAIFeedbackEvent.updated_at.desc(), PMFlowAIFeedbackEvent.id.desc()) + ) + return [PMFlowAIFeedbackService.serialize(row) for row in result.scalars().all()] + async def _load_review_projection(self, *, project_id: int) -> dict[str, list[dict[str, Any]]]: change_set_result = await self.db.execute( select(PMFlowChangeSet) @@ -1263,6 +1553,8 @@ class PMFlowOSProjectionService: .order_by(PMFlowApproval.created_at.asc(), PMFlowApproval.id.asc()) ) approval_rows = list(approval_result.scalars().all()) + committed_user_ids = [int(row.committed_by) for row in change_sets if row.committed_by] + committed_users = await self._load_user_display_map(user_ids=committed_user_ids) items_by_set: dict[int, list[PMFlowChangeItem]] = {} evidence_by_set: dict[int, list[PMFlowEvidenceLink]] = {} for item in item_rows: @@ -1271,7 +1563,11 @@ class PMFlowOSProjectionService: evidence_by_set.setdefault(int(evidence.change_set_id), []).append(evidence) return { "change_sets": [ - PMFlowChangeReviewService.serialize_change_set(row, items_by_set.get(int(row.id), [])) + self._serialize_change_set_projection( + row, + items=items_by_set.get(int(row.id), []), + committed_by=committed_users.get(int(row.committed_by)) if row.committed_by else None, + ) for row in change_sets ], "change_items": [ @@ -1282,6 +1578,47 @@ class PMFlowOSProjectionService: "approvals": [PMFlowChangeReviewService.serialize_approval(row) for row in approval_rows], } + async def _load_user_display_map(self, *, user_ids: list[int]) -> dict[int, str]: + normalized_ids = sorted({int(user_id) for user_id in user_ids if user_id}) + if not normalized_ids: + return {} + result = await self.db.execute( + select(User).where(and_(User.tenant_id == self.tenant_id, User.id.in_(normalized_ids))) + ) + return {int(user.id): self._user_display_name(user) for user in result.scalars().all()} + + def _serialize_change_set_projection( + self, + row: PMFlowChangeSet, + *, + items: list[PMFlowChangeItem], + committed_by: Optional[str] = None, + ) -> dict[str, Any]: + payload = PMFlowChangeReviewService.serialize_change_set(row, items) + payload["committedBy"] = committed_by + payload["committedById"] = str(row.committed_by) if row.committed_by else None + return payload + + @staticmethod + def _latest_writeback_meta(change_sets: list[dict[str, Any]]) -> dict[str, Optional[str]]: + committed = [ + row + for row in change_sets + if row.get("committedAt") and (row.get("committedBy") or row.get("committedById")) + ] + if not committed: + return {"at": None, "by": None, "by_id": None} + latest = max(committed, key=lambda row: str(row.get("committedAt") or "")) + return { + "at": str(latest.get("committedAt") or "") or None, + "by": str(latest.get("committedBy") or "") or None, + "by_id": str(latest.get("committedById") or "") or None, + } + + @staticmethod + def _user_display_name(user: User) -> str: + return str(user.name or user.username or user.email or user.id) + def _project_reviewer_routing( self, *, @@ -1806,6 +2143,413 @@ class PMFlowOSProjectionService: ) return signals + async def _load_project_memory_semantic_facts( + self, + *, + project_id: int, + truth_nodes: list[TruthNode], + tasks: list[tuple[Task, Optional[User]]], + ) -> list[SemanticFact]: + truth_ids = [int(node.id) for node in truth_nodes] + task_ids = [int(task.id) for task, _ in tasks] + clauses = [and_(SemanticFact.entity_type == "project", SemanticFact.entity_id == int(project_id))] + if truth_ids: + clauses.append(and_(SemanticFact.entity_type.in_(["truth_node", "truth_nodes"]), SemanticFact.entity_id.in_(truth_ids))) + if task_ids: + clauses.append(and_(SemanticFact.entity_type == "task", SemanticFact.entity_id.in_(task_ids))) + result = await self.db.execute( + select(SemanticFact) + .where(and_(SemanticFact.tenant_id == self.tenant_id, SemanticFact.is_active.is_(True), or_(*clauses))) + .order_by(SemanticFact.updated_at.desc(), SemanticFact.created_at.desc(), SemanticFact.id.desc()) + .limit(500) + ) + return list(result.scalars().all()) + + async def _memory_evidence_by_item(self, *, project_id: int) -> dict[str, list[dict[str, Any]]]: + result = await self.db.execute( + select(PMFlowEvidenceLink) + .where(and_(PMFlowEvidenceLink.tenant_id == self.tenant_id, PMFlowEvidenceLink.project_id == int(project_id))) + .order_by(PMFlowEvidenceLink.updated_at.desc(), PMFlowEvidenceLink.created_at.desc(), PMFlowEvidenceLink.id.desc()) + .limit(500) + ) + evidence_by_item: dict[str, list[dict[str, Any]]] = {} + evidence_links = list(result.scalars().all()) + change_item_ids = [int(evidence.change_item_id) for evidence in evidence_links if evidence.change_item_id is not None] + change_items_by_id: dict[int, PMFlowChangeItem] = {} + if change_item_ids: + items_result = await self.db.execute( + select(PMFlowChangeItem).where( + and_( + PMFlowChangeItem.tenant_id == self.tenant_id, + PMFlowChangeItem.project_id == int(project_id), + PMFlowChangeItem.id.in_(change_item_ids), + ) + ) + ) + change_items_by_id = {int(item.id): item for item in items_result.scalars().all()} + for evidence in evidence_links: + if evidence.change_item_id is None: + continue + evidence_ref = { + "id": f"evidence-{evidence.id}", + "sourceType": evidence.source_type, + "sourceId": evidence.source_id, + "title": evidence.title, + "summary": evidence.excerpt, + "displayLabel": evidence.display_label, + } + evidence_by_item.setdefault(str(evidence.change_item_id), []).append(evidence_ref) + change_item = change_items_by_id.get(int(evidence.change_item_id)) + if change_item is not None and change_item.object_id: + evidence_by_item.setdefault(str(change_item.object_id), []).append(evidence_ref) + return evidence_by_item + + def _memory_item_from_truth_node(self, node: TruthNode, *, evidence_by_item: dict[str, list[dict[str, Any]]]) -> dict[str, Any]: + content = self._payload(node.content) + node_type = self._node_type(node) + category = self._memory_category_from_truth_type(node_type) + return { + "id": f"truth-node-{node.id}", + "objectId": str(node.id), + "objectType": node_type or "truth_node", + "category": category, + "title": node.title, + "summary": self._node_summary(node), + "status": self._truth_status(node.status), + "stage": self._stage_from_payload(content, default="planning"), + "riskLevel": self._risk_level(content.get("level") or content.get("risk_level") or node.status) if category in {"risk", "constraint"} else None, + "sourceType": "truth_nodes", + "sourceId": str(node.id), + "confidence": content.get("confidence"), + "tags": [node_type] + self._list_from_payload(content, "tags", "labels", "keywords"), + "evidenceRefs": evidence_by_item.get(str(node.id), []), + "impactRefs": self._memory_impact_refs(category=category), + "createdAt": node.created_at.isoformat() if node.created_at else None, + "updatedAt": node.updated_at.isoformat() if node.updated_at else None, + "metadata": { + "version": node.version, + "rawStatus": node.status, + "truthNodeType": node.node_type, + "content": self._jsonable(content), + }, + } + + def _memory_item_from_task(self, task: Task, *, user: Optional[User]) -> dict[str, Any]: + return { + "id": f"task-{task.id}", + "objectId": str(task.id), + "objectType": "task", + "category": "task", + "title": task.title, + "summary": task.description or "", + "status": self._object_status(task.status), + "stage": self._task_stage(task.status), + "riskLevel": None, + "sourceType": "tasks", + "sourceId": str(task.id), + "confidence": self._jsonable(task.quality_score) if task.quality_score is not None else None, + "tags": ["task", task.priority], + "evidenceRefs": [], + "impactRefs": [{"type": "truth_node", "id": str(task.truth_node_id), "label": "关联真相节点"}], + "createdAt": task.created_at.isoformat() if task.created_at else None, + "updatedAt": task.updated_at.isoformat() if task.updated_at else None, + "metadata": { + "assignee": user.name if user is not None else "未分配", + "priority": task.priority, + "dueDate": task.due_date.isoformat() if task.due_date else None, + "completedAt": task.completed_at.isoformat() if task.completed_at else None, + }, + } + + def _memory_item_from_semantic_fact(self, fact: SemanticFact) -> dict[str, Any]: + title = f"{fact.fact_namespace}.{fact.fact_key}" if fact.fact_namespace else fact.fact_key + return { + "id": f"semantic-fact-{fact.id}", + "objectId": str(fact.id), + "objectType": "semantic_fact", + "category": "fact", + "title": title, + "summary": fact.fact_value_text, + "status": "confirmed" if fact.confidence >= 0.7 else "draft", + "stage": "planning", + "riskLevel": None, + "sourceType": "semantic_facts", + "sourceId": str(fact.id), + "confidence": fact.confidence, + "tags": [fact.fact_namespace, fact.fact_key, fact.entity_type], + "evidenceRefs": [ + { + "id": f"{fact.evidence_source_type or 'source'}-{fact.evidence_source_id}", + "sourceType": fact.evidence_source_type, + "sourceId": None if fact.evidence_source_id is None else str(fact.evidence_source_id), + "title": fact.evidence_source_type or "语义证据", + "summary": fact.evidence_excerpt or "", + "displayLabel": fact.evidence_source_type or "语义证据", + } + ] if fact.evidence_source_type or fact.evidence_excerpt else [], + "impactRefs": [{"type": fact.entity_type, "id": str(fact.entity_id), "label": "语义绑定对象"}], + "createdAt": fact.created_at.isoformat() if fact.created_at else None, + "updatedAt": fact.updated_at.isoformat() if fact.updated_at else None, + "metadata": { + "entityType": fact.entity_type, + "entityId": str(fact.entity_id), + "valueType": fact.value_type, + "extractVersion": fact.extract_version, + "freshnessAt": fact.freshness_at, + }, + } + + def _memory_asset_from_evidence(self, evidence: PMFlowEvidenceLink) -> dict[str, Any]: + metadata = self._payload(evidence.evidence_metadata) + asset_type = self._memory_asset_type(evidence.source_type) + return { + "id": f"evidence-{evidence.id}", + "assetType": asset_type, + "sourceType": evidence.source_type, + "sourceId": evidence.source_id, + "title": evidence.title, + "summary": evidence.excerpt, + "status": "indexed", + "displayLabel": evidence.display_label, + "linkedItemId": f"change-item-{evidence.change_item_id}" if evidence.change_item_id is not None else None, + "changeSetId": str(evidence.change_set_id), + "createdAt": evidence.created_at.isoformat() if evidence.created_at else None, + "updatedAt": evidence.updated_at.isoformat() if evidence.updated_at else None, + "metadata": self._jsonable(metadata), + } + + def _memory_asset_from_thread_message(self, message: PMFlowThreadMessage) -> dict[str, Any]: + return { + "id": f"thread-message-{message.id}", + "assetType": "conversation", + "sourceType": message.source, + "sourceId": str(message.id), + "title": "项目线程消息", + "summary": self._short_text(message.content), + "status": message.status, + "displayLabel": "对话", + "linkedItemId": None, + "changeSetId": None, + "createdAt": message.created_at.isoformat() if message.created_at else None, + "updatedAt": message.updated_at.isoformat() if message.updated_at else None, + "metadata": { + "threadId": str(message.thread_id), + "role": message.role, + "attachments": self._jsonable(message.attachments), + "messageMetadata": self._jsonable(message.message_metadata), + }, + } + + def _memory_context_pack( + self, + *, + key: str, + title: str, + summary: str, + usage: str, + item_ids: list[str], + asset_ids: list[str], + ) -> dict[str, Any]: + return { + "id": f"context-pack-{key}", + "key": key, + "title": title, + "summary": summary, + "usage": usage, + "status": "ready" if item_ids or asset_ids else "empty", + "itemCount": len(item_ids), + "assetCount": len(asset_ids), + "sourceItemIds": item_ids[:80], + "sourceAssetIds": asset_ids[:80], + "updatedAt": now_cn_naive().isoformat() if item_ids or asset_ids else None, + "warnings": [] if item_ids or asset_ids else ["context_pack_empty"], + } + + def _memory_writeback_from_change_set( + self, + change_set: PMFlowChangeSet, + *, + items: list[PMFlowChangeItem], + committed_by: Optional[str] = None, + ) -> dict[str, Any]: + committed = [item for item in items if item.status == "committed"] + skipped = [item for item in items if item.status in {"deferred", "skipped"}] + pending = [item for item in items if item.status in {"pending_review", "accepted", "edited"}] + return { + "id": f"writeback-{change_set.id}", + "changeSetId": str(change_set.id), + "title": change_set.headline, + "summary": change_set.summary, + "status": change_set.status, + "committedAt": change_set.committed_at.isoformat() if change_set.committed_at else None, + "committedBy": committed_by, + "committedById": str(change_set.committed_by) if change_set.committed_by else None, + "createdAt": change_set.created_at.isoformat() if change_set.created_at else None, + "updatedAt": change_set.updated_at.isoformat() if change_set.updated_at else None, + "itemCounts": { + "total": len(items), + "committed": len(committed), + "skipped": len(skipped), + "pending": len(pending), + }, + "items": [ + { + "id": f"change-item-{item.id}", + "objectType": item.object_type, + "title": item.title, + "summary": item.summary, + "status": item.status, + "riskLevel": item.risk_level, + } + for item in items[:20] + ], + "metadata": { + "extractionScope": change_set.extraction_scope, + "routingMeta": self._jsonable(change_set.routing_meta), + }, + } + + def _filter_memory_rows( + self, + rows: list[dict[str, Any]], + *, + category: Optional[str], + stage: Optional[str], + source_type: Optional[str], + status: Optional[str], + query: Optional[str], + ) -> list[dict[str, Any]]: + normalized_category = str(category or "").strip() + normalized_stage = str(stage or "").strip() + normalized_source_type = str(source_type or "").strip() + normalized_status = str(status or "").strip() + normalized_query = str(query or "").strip().lower() + filtered = [ + row + for row in rows + if not normalized_category or str(row.get("category") or row.get("assetType") or "") == normalized_category + ] + if normalized_stage: + filtered = [row for row in filtered if str(row.get("stage") or "") == normalized_stage] + if normalized_status: + filtered = [row for row in filtered if str(row.get("status") or "") == normalized_status] + if normalized_source_type: + filtered = [row for row in filtered if self._memory_row_matches_source(row, normalized_source_type)] + if not normalized_query: + return filtered + return [ + row + for row in filtered + if normalized_query in str(row.get("title") or "").lower() + or normalized_query in str(row.get("summary") or "").lower() + or normalized_query in " ".join(str(tag) for tag in row.get("tags") or []).lower() + ] + + def _memory_row_matches_source(self, row: dict[str, Any], source_type: str) -> bool: + expected = self._memory_source_bucket(source_type) + source_values = {str(row.get("sourceType") or "").strip().lower()} + for evidence in row.get("evidenceRefs") or []: + if isinstance(evidence, dict): + source_values.add(str(evidence.get("sourceType") or "").strip().lower()) + source_values.add(str(evidence.get("displayLabel") or "").strip().lower()) + return bool(source_values.intersection(expected)) + + def _memory_source_bucket(self, source_type: str) -> set[str]: + value = str(source_type or "").strip().lower() + buckets = { + "conversation": {"conversation", "thread", "message", "web", "feishu", "对话"}, + "document": {"document", "doc", "file", "upload", "文档"}, + "pr": {"pr", "pull_request", "commit", "github", "code", "代码"}, + "test": {"test", "test_log", "validation", "测试"}, + "external": {"external", "research", "web_search", "url", "外部资料"}, + "ai_extraction": {"ai_extraction", "change_review", "semantic_facts", "truth_nodes", "ai 提取", "ai提取"}, + } + return buckets.get(value, {value}) + + def _memory_metric_counts( + self, + *, + items: list[dict[str, Any]], + assets: list[dict[str, Any]], + context_packs: list[dict[str, Any]], + ) -> dict[str, int]: + return { + "confirmed_facts": len([item for item in items if item.get("category") == "fact" and item.get("status") == "confirmed"]), + "requirements": len([item for item in items if item.get("category") == "requirement"]), + "decisions": len([item for item in items if item.get("category") == "decision"]), + "constraints": len([item for item in items if item.get("category") in {"constraint", "risk"}]), + "evidence": len(assets), + "context_packs": len([pack for pack in context_packs if pack.get("status") == "ready"]), + "assets": len(assets), + } + + def _memory_ai_suggestions( + self, + *, + items: list[dict[str, Any]], + assets: list[dict[str, Any]], + context_packs: list[dict[str, Any]], + writebacks: list[dict[str, Any]], + ) -> list[dict[str, Any]]: + suggestions: list[dict[str, Any]] = [] + if any(item.get("category") == "requirement" for item in items): + suggestions.append({"key": "generate_tasks", "title": "生成开发任务与验收标准", "target": "execution-bridge"}) + if any(item.get("category") in {"risk", "constraint"} for item in items): + suggestions.append({"key": "generate_test_plan", "title": "生成测试计划和风险检查", "target": "release-growth"}) + if assets and context_packs: + suggestions.append({"key": "refresh_context_pack", "title": "组织下一轮项目上下文包", "target": "context-packs"}) + if writebacks: + suggestions.append({"key": "inspect_writebacks", "title": "查看最近写入影响", "target": "writebacks"}) + return suggestions[:4] + + def _memory_category_from_truth_type(self, node_type: str) -> str: + if node_type in {"requirement", "charter", "scope"}: + return "requirement" + if node_type in {"decision", "model_decision"}: + return "decision" + if node_type == "risk": + return "risk" + if node_type in {"constraint", "compliance", "policy"}: + return "constraint" + if node_type in {"release"}: + return "release" + if node_type in {"metric", "signal"}: + return "metric" + if node_type in {"test", "test_run"}: + return "test" + return "fact" + + def _memory_asset_type(self, source_type: str) -> str: + value = str(source_type or "").strip().lower() + if value in {"conversation", "thread", "message", "feishu"}: + return "conversation" + if value in {"pr", "pull_request", "commit", "github", "code"}: + return "code" + if value in {"test", "test_log", "validation"}: + return "test" + if value in {"feedback", "user_feedback"}: + return "feedback" + if value in {"metric", "analytics", "revenue", "roi"}: + return "metric" + if value in {"release", "delivery"}: + return "release" + if value in {"document", "doc", "file", "upload"}: + return "document" + return "evidence" + + def _memory_impact_refs(self, *, category: str) -> list[dict[str, str]]: + mapping: dict[str, list[dict[str, str]]] = { + "requirement": [ + {"type": "task", "id": "derived", "label": "任务拆解"}, + {"type": "test", "id": "derived", "label": "测试计划"}, + ], + "decision": [{"type": "execution", "id": "derived", "label": "执行判断"}], + "risk": [{"type": "release_gate", "id": "derived", "label": "发布门禁"}], + "constraint": [{"type": "release_gate", "id": "derived", "label": "约束检查"}], + "fact": [{"type": "query_answer", "id": "derived", "label": "项目问答"}], + } + return mapping.get(category, []) + async def _project_memory( self, *, @@ -2086,6 +2830,15 @@ class PMFlowOSProjectionService: def _node_summary(self, node: TruthNode) -> str: return self._short_text(node.content) or node.title + def _list_from_payload(self, payload: dict[str, Any], *keys: str) -> list[str]: + for key in keys: + value = payload.get(key) + if isinstance(value, list): + return [str(item) for item in value if str(item).strip()] + if isinstance(value, str) and value.strip(): + return [value.strip()] + return [] + def _node_type(self, node: TruthNode) -> str: return str(node.node_type or "").strip().lower() @@ -3151,16 +3904,73 @@ class PMFlowOSProjectionService: "updatedAt": event.updated_at.isoformat() if event.updated_at else None, } + def _serialize_roi_snapshot(self, snapshot: ROISnapshot) -> dict[str, Any]: + roi_ratio = self._safe_number(snapshot.roi_ratio) + total_cost = self._safe_number(snapshot.total_cost_usd) + risk_level = "high" if roi_ratio < 0 else ("medium" if roi_ratio == 0 and total_cost > 0 else "low") + return { + "id": str(snapshot.id), + "projectId": str(snapshot.project_id), + "rangeKey": snapshot.range_key, + "windowStart": snapshot.window_start.isoformat() if snapshot.window_start else None, + "windowEnd": snapshot.window_end.isoformat() if snapshot.window_end else None, + "totalRevenueUsd": self._jsonable(snapshot.total_revenue_usd), + "totalCostUsd": self._jsonable(snapshot.total_cost_usd), + "roiRatio": self._jsonable(snapshot.roi_ratio), + "snapshotPayload": dict(snapshot.snapshot_payload or {}), + "riskLevel": risk_level, + "status": "negative" if risk_level == "high" else ("watch" if risk_level == "medium" else "healthy"), + "createdAt": snapshot.created_at.isoformat() if snapshot.created_at else None, + "updatedAt": snapshot.updated_at.isoformat() if snapshot.updated_at else None, + } + + def _serialize_compliance_check(self, run: ComplianceCheckRun) -> dict[str, Any]: + issues = list(run.issues or []) + status_text = str(run.status or "pending").lower() + risk_level = ( + "high" + if status_text in {"blocked", "failed", "rejected"} or issues + else ("medium" if status_text in {"pending", "review_required"} else "low") + ) + return { + "id": str(run.id), + "projectId": str(run.project_id), + "charterId": None if run.charter_id is None else str(run.charter_id), + "releaseNodeId": None if run.release_node_id is None else str(run.release_node_id), + "projectModelBindingId": None if run.project_model_binding_id is None else str(run.project_model_binding_id), + "status": run.status, + "score": self._jsonable(run.score), + "checklist": list(run.checklist or []), + "issues": issues, + "summary": dict(run.summary or {}), + "riskLevel": risk_level, + "createdAt": run.created_at.isoformat() if run.created_at else None, + "updatedAt": run.updated_at.isoformat() if run.updated_at else None, + } + def _release_signal_next_actions( self, *, feedback_items: list[dict[str, Any]], + revenue_events: list[dict[str, Any]], + roi_snapshots: list[dict[str, Any]], + compliance_checks: list[dict[str, Any]], recommendations: list[dict[str, Any]], release_gate: dict[str, Any], ) -> list[str]: actions: list[str] = [] if not feedback_items: actions.append("发布后先摄取用户反馈、运营记录或人工观察,避免用 mock 增长数据判断下一轮。") + if not revenue_events: + actions.append("缺少真实 revenue event,暂不能用收入增长判断发布效果。") + if not roi_snapshots: + actions.append("缺少 ROI snapshot,暂不能判断投入产出是否健康。") + if not compliance_checks: + actions.append("缺少 compliance check,发布增长判断不能替代合规审查。") + if any(item.get("riskLevel") == "high" for item in roi_snapshots): + actions.append("ROI 信号处于风险态,下一轮建议应优先解释成本、收入或转化缺口。") + if any(item.get("riskLevel") == "high" for item in compliance_checks): + actions.append("存在合规风险信号,先进入 Change Review 明确缓解动作,不自动推进发布。") if feedback_items and recommendations: if any(bool(item.get("changeSetCreated")) for item in recommendations): actions.append("下一轮 ChangeSet draft 已进入 Change Review,继续 accept / edit / defer 后再决定是否写入 Project Memory。") @@ -3169,7 +3979,7 @@ class PMFlowOSProjectionService: if str(release_gate.get("status") or "") == "blocked": actions.append("发布门禁仍阻塞时,先处理 release gate,再把反馈作为下一轮规划输入。") if not actions: - actions.append("继续沉淀 feedback / revenue / compliance 信号,为下一轮 iteration recommendation 做准备。") + actions.append("继续沉淀 feedback / revenue / ROI / compliance 信号,为下一轮 iteration recommendation 做准备。") return actions def _normalize_group_key(self, value: Any) -> str: diff --git a/backend/app/services/project_knowledge_graph_service/mixin_guardrails.py b/backend/app/services/project_knowledge_graph_service/mixin_guardrails.py index 331569a2..45d100ba 100644 --- a/backend/app/services/project_knowledge_graph_service/mixin_guardrails.py +++ b/backend/app/services/project_knowledge_graph_service/mixin_guardrails.py @@ -36,7 +36,7 @@ class PKGGuardrailsMixin: specs: dict[str, dict[str, str]] = {} for tier in ("low", "high"): tier_spec = tier_defaults.get(tier) if isinstance(tier_defaults.get(tier), dict) else {} - provider = str(tier_spec.get("provider") or "glm").strip() + provider = str(tier_spec.get("provider") or "dashscope").strip() provider_spec = provider_defaults.get(provider) if isinstance(provider_defaults.get(provider), dict) else {} specs[tier] = { "tier": tier, diff --git a/backend/app/services/prompt_assets_service.py b/backend/app/services/prompt_assets_service.py new file mode 100644 index 00000000..495af67b --- /dev/null +++ b/backend/app/services/prompt_assets_service.py @@ -0,0 +1,1765 @@ +"""Prompt asset read model for platform AI Ops.""" + +from __future__ import annotations + +import hashlib +import json +import time +import string +from datetime import datetime, timedelta +from importlib import import_module +from typing import Any + +from sqlalchemy import and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..config.ai_prompt_registry import get_prompt_registry, get_prompt_registry_entry +from ..config.ai_prompt_eval_rubrics import get_prompt_eval_rubric +from ..models import ( + AICallLog, + OptimizerCandidate, + PMFlowAIFeedbackEvent, + PromptGoldenCase, + PromptRelease, + PromptReleaseCheck, + PromptReplayCaseResult, + PromptReplayRun, +) +from ..utils.timezone import now_cn_naive +from .llm_service import llm_service +from .prompt_release import PromptReleaseService + + +RELEASE_CHECK_TYPES = ("variable_contract", "schema_smoke", "golden_replay") +QUALITY_WINDOWS_HOURS = {"24h": 24, "7d": 168, "30d": 720} +REPLAY_MODES = {"contract_smoke", "compare", "llm_compare"} +GOLDEN_CASE_TYPES = {"contract", "regression", "risk", "edge", "business"} +GOLDEN_CASE_PRIORITIES = {"blocking", "important", "observation"} + + +def _hash_text(text: str) -> str: + if not text: + return "" + return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16] + + +def _preview(text: str, limit: int = 420) -> str: + value = str(text or "").strip() + if len(value) <= limit: + return value + return f"{value[:limit].rstrip()}..." + + +def _template_variables(text: str) -> list[str]: + variables: set[str] = set() + formatter = string.Formatter() + try: + for _, field_name, _, _ in formatter.parse(str(text or "")): + if not field_name: + continue + head = str(field_name).split(".", 1)[0].split("[", 1)[0].strip() + if head and head.isidentifier(): + variables.add(head) + except ValueError: + return [] + return sorted(variables) + + +def _template_variables_or_error(text: str) -> list[str]: + variables: set[str] = set() + formatter = string.Formatter() + for _, field_name, _, _ in formatter.parse(str(text or "")): + if not field_name: + continue + head = str(field_name).split(".", 1)[0].split("[", 1)[0].strip() + if head and head.isidentifier(): + variables.add(head) + return sorted(variables) + + +class PromptAssetService: + """Combines static registry metadata with tenant prompt release state.""" + + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def list_assets( + self, + *, + tenant_id: int | None, + family: str | None = None, + runtime_source: str | None = None, + editable_policy: str | None = None, + keyword: str | None = None, + ) -> dict[str, Any]: + releases = await self._release_index(tenant_id=tenant_id) + items = [] + for entry in get_prompt_registry(): + if family and str(entry.get("prompt_family") or "") != family: + continue + if runtime_source and str(entry.get("runtime_source") or "") != runtime_source: + continue + if editable_policy and str(entry.get("editable_policy") or "") != editable_policy: + continue + haystack = " ".join( + [ + str(entry.get("prompt_key") or ""), + str(entry.get("business_scenario") or ""), + str(entry.get("module") or ""), + str(entry.get("responsibility_scope") or ""), + ] + ).lower() + if keyword and str(keyword).strip().lower() not in haystack: + continue + items.append(self._asset_payload(entry, releases=releases, include_body=False)) + + family_counts: dict[str, int] = {} + policy_counts: dict[str, int] = {} + source_counts: dict[str, int] = {} + for item in items: + family_counts[item["prompt_family"]] = family_counts.get(item["prompt_family"], 0) + 1 + policy_counts[item["editable_policy"]] = policy_counts.get(item["editable_policy"], 0) + 1 + source_counts[item["runtime_source"]] = source_counts.get(item["runtime_source"], 0) + 1 + + return { + "items": items, + "total": len(items), + "summary": { + "total_assets": len(items), + "active_release_count": sum(1 for item in items if item.get("active_release")), + "needs_migration_count": sum(1 for item in items if item.get("editable_policy") == "needs_migration"), + "guarded_count": sum(1 for item in items if item.get("editable_policy") == "guarded_release"), + "family_counts": family_counts, + "editable_policy_counts": policy_counts, + "runtime_source_counts": source_counts, + }, + } + + async def get_asset(self, *, tenant_id: int, prompt_key: str) -> dict[str, Any]: + entry = get_prompt_registry_entry(prompt_key) + if entry is None: + raise ValueError("prompt asset not found") + releases = await self._release_index(tenant_id=tenant_id, prompt_key=prompt_key, include_body=True) + payload = self._asset_payload(entry, releases=releases, include_body=True) + payload["quality_summary"] = await self.get_quality_summary( + tenant_id=tenant_id, + prompt_key=prompt_key, + window="7d", + ) + return payload + + async def create_release( + self, + *, + tenant_id: int, + prompt_key: str, + body_snapshot: str, + created_by: int | None, + notes: str | None = None, + ) -> dict[str, Any]: + entry = self._releaseable_entry(prompt_key) + default_template = self._default_template(str(entry.get("prompt_key") or "")) + self._validate_release_body(default_template=default_template, body_snapshot=body_snapshot) + return await PromptReleaseService(self.db).create_release( + tenant_id=tenant_id, + prompt_key=str(entry.get("prompt_key") or "").strip(), + body_snapshot=body_snapshot, + created_by=created_by, + notes=notes, + ) + + async def list_releases(self, *, tenant_id: int, prompt_key: str) -> dict[str, Any]: + entry = get_prompt_registry_entry(prompt_key) + if entry is None: + raise ValueError("prompt asset not found") + return await PromptReleaseService(self.db).list_releases(tenant_id=tenant_id, prompt_key=prompt_key) + + async def activate_release( + self, + *, + tenant_id: int, + prompt_key: str, + release_id: int, + actor_id: int | None, + ) -> dict[str, Any]: + entry = self._releaseable_entry(prompt_key) + await self._assert_activation_allowed( + tenant_id=tenant_id, + prompt_key=prompt_key, + release_id=release_id, + entry=entry, + ) + return await PromptReleaseService(self.db).activate_release( + tenant_id=tenant_id, + prompt_key=prompt_key, + release_id=release_id, + actor_id=actor_id, + ) + + async def rollback_release( + self, + *, + tenant_id: int, + prompt_key: str, + release_id: int, + actor_id: int | None, + ) -> dict[str, Any]: + entry = self._releaseable_entry(prompt_key) + await self._assert_activation_allowed( + tenant_id=tenant_id, + prompt_key=prompt_key, + release_id=release_id, + entry=entry, + ) + return await PromptReleaseService(self.db).rollback_release( + tenant_id=tenant_id, + prompt_key=prompt_key, + release_id=release_id, + actor_id=actor_id, + ) + + async def list_release_checks(self, *, tenant_id: int, prompt_key: str, release_id: int) -> dict[str, Any]: + release = await self._get_release(tenant_id=tenant_id, prompt_key=prompt_key, release_id=release_id) + rows = ( + await self.db.execute( + select(PromptReleaseCheck) + .where( + PromptReleaseCheck.tenant_id == int(tenant_id), + PromptReleaseCheck.prompt_key == str(prompt_key or "").strip(), + PromptReleaseCheck.release_id == int(release_id), + ) + .order_by(PromptReleaseCheck.id.desc()) + ) + ).scalars().all() + items = [self._check_payload(row) for row in rows] + return { + "release_id": int(release.id), + "prompt_key": str(release.prompt_key or ""), + "items": items, + "total": len(items), + "gate": self._gate_summary_from_checks(items), + } + + async def run_release_checks( + self, + *, + tenant_id: int, + prompt_key: str, + release_id: int, + actor_id: int | None, + reason: str | None = None, + ) -> dict[str, Any]: + entry = self._releaseable_entry(prompt_key) + release = await self._get_release(tenant_id=tenant_id, prompt_key=prompt_key, release_id=release_id) + default_template = self._default_template(str(entry.get("prompt_key") or "")) + active_case_count = await self._active_golden_case_count( + tenant_id=tenant_id, + prompt_key=prompt_key, + ) + latest_replay = await self._latest_replay_for_release( + tenant_id=tenant_id, + release_id=int(release_id), + ) + check_specs = self._build_release_check_specs( + tenant_id=tenant_id, + entry=entry, + release=release, + default_template=default_template, + reason=reason, + active_case_count=active_case_count, + latest_replay=latest_replay, + ) + rows: list[PromptReleaseCheck] = [] + completed_at = now_cn_naive().isoformat() + for spec in check_specs: + row = PromptReleaseCheck( + tenant_id=int(tenant_id), + prompt_key=str(prompt_key or "").strip(), + release_id=int(release_id), + check_type=spec["check_type"], + status=spec["status"], + severity=spec["severity"], + summary=spec["summary"], + details_json=spec.get("details_json") or {}, + created_by=actor_id, + completed_at=completed_at, + ) + self.db.add(row) + rows.append(row) + await self.db.commit() + for row in rows: + await self.db.refresh(row) + items = [self._check_payload(row) for row in rows] + return { + "release_id": int(release.id), + "prompt_key": str(release.prompt_key or ""), + "items": items, + "total": len(items), + "gate": self._gate_summary_from_checks(items), + } + + async def get_quality_summary( + self, + *, + tenant_id: int, + prompt_key: str, + window: str = "7d", + status: str | None = None, + release_id: int | None = None, + feedback_tenant_id: int | None = None, + include_all_pmflow_feedback: bool = False, + ) -> dict[str, Any]: + if get_prompt_registry_entry(prompt_key) is None: + raise ValueError("prompt asset not found") + hours = QUALITY_WINDOWS_HOURS.get(str(window or "7d").strip(), QUALITY_WINDOWS_HOURS["7d"]) + filters = [ + AICallLog.tenant_id == int(tenant_id), + AICallLog.prompt_key == str(prompt_key or "").strip(), + AICallLog.created_at >= datetime.now() - timedelta(hours=hours), + ] + if status and status != "all": + filters.append(AICallLog.status == status) + if release_id is not None: + filters.append(AICallLog.prompt_release_id == int(release_id)) + where_clause = and_(*filters) + summary_stmt = select( + func.count(AICallLog.id), + func.sum(AICallLog.cost), + func.avg(AICallLog.latency_ms), + ).where(where_clause) + total, cost, latency = (await self.db.execute(summary_stmt)).one() + total_calls = int(total or 0) + rows = ( + await self.db.execute( + select( + AICallLog.status, + AICallLog.prompt_source, + AICallLog.prompt_release_id, + func.count(AICallLog.id), + ) + .where(where_clause) + .group_by(AICallLog.status, AICallLog.prompt_source, AICallLog.prompt_release_id) + ) + ).all() + failed_calls = 0 + fallback_calls = 0 + active_release_calls = 0 + default_fallback_calls = 0 + release_breakdown: dict[str, dict[str, Any]] = {} + for row_status, prompt_source, row_release_id, count in rows: + amount = int(count or 0) + if self._is_failed_status(str(row_status or "")): + failed_calls += amount + source = str(prompt_source or "unknown") + if source == "active_release": + active_release_calls += amount + if source == "default_fallback": + default_fallback_calls += amount + fallback_calls += amount + release_key = str(row_release_id) if row_release_id is not None else source + bucket = release_breakdown.setdefault( + release_key, + { + "prompt_release_id": int(row_release_id) if row_release_id is not None else None, + "prompt_source": source, + "total_calls": 0, + "failed_calls": 0, + }, + ) + bucket["total_calls"] += amount + if self._is_failed_status(str(row_status or "")): + bucket["failed_calls"] += amount + recent_failures = [ + self._call_log_summary(row) + for row in ( + await self.db.execute( + select(AICallLog) + .where(where_clause) + .order_by(AICallLog.created_at.desc(), AICallLog.id.desc()) + .limit(20) + ) + ).scalars().all() + if self._is_failed_status(str(row.status or "")) + ][:5] + feedback_summary = await self._pmflow_feedback_summary( + tenant_id=None if include_all_pmflow_feedback else feedback_tenant_id if feedback_tenant_id is not None else tenant_id, + prompt_key=str(prompt_key or "").strip(), + since=datetime.now() - timedelta(hours=hours), + release_id=release_id, + ) + optimizer_candidates = await self._prompt_optimizer_candidates( + tenant_id=None if include_all_pmflow_feedback else feedback_tenant_id if feedback_tenant_id is not None else tenant_id, + prompt_key=str(prompt_key or "").strip(), + ) + return { + "prompt_key": str(prompt_key or "").strip(), + "window": str(window or "7d"), + "total_calls": total_calls, + "failed_calls": failed_calls, + "failure_rate": round(failed_calls / total_calls, 4) if total_calls else 0, + "fallback_calls": fallback_calls, + "active_release_calls": active_release_calls, + "default_fallback_calls": default_fallback_calls, + "average_latency_ms": round(float(latency), 2) if latency is not None else None, + "estimated_cost": float(cost or 0), + "recent_failures": recent_failures, + "release_breakdown": list(release_breakdown.values()), + "pmflow_feedback": feedback_summary, + "optimizer_candidates": optimizer_candidates, + } + + async def list_golden_cases( + self, + *, + tenant_id: int, + prompt_key: str, + status: str | None = None, + ) -> dict[str, Any]: + if get_prompt_registry_entry(prompt_key) is None: + raise ValueError("prompt asset not found") + stmt = select(PromptGoldenCase).where( + PromptGoldenCase.tenant_id == int(tenant_id), + PromptGoldenCase.prompt_key == str(prompt_key or "").strip(), + ) + if status and status != "all": + stmt = stmt.where(PromptGoldenCase.status == status) + rows = list((await self.db.execute(stmt.order_by(PromptGoldenCase.id.desc()))).scalars().all()) + return { + "prompt_key": str(prompt_key or "").strip(), + "items": [self._golden_case_payload(row) for row in rows], + "total": len(rows), + "active_count": sum(1 for row in rows if str(row.status or "") == "active"), + } + + async def create_golden_case( + self, + *, + tenant_id: int, + prompt_key: str, + case_name: str, + input_payload: dict[str, Any], + expected_contract: dict[str, Any] | None = None, + expected_contains: str | None = None, + case_type: str = "regression", + priority: str = "blocking", + scenario_key: str | None = None, + tags: list[str] | None = None, + source: str = "manual", + source_ai_call_log_id: int | None = None, + created_by: int | None = None, + notes: str | None = None, + ) -> dict[str, Any]: + if get_prompt_registry_entry(prompt_key) is None: + raise ValueError("prompt asset not found") + name = str(case_name or "").strip() + if not name: + raise ValueError("case_name is required") + if not isinstance(input_payload, dict): + raise ValueError("input_payload must be a JSON object") + effective_case_type = str(case_type or "regression").strip() or "regression" + if effective_case_type not in GOLDEN_CASE_TYPES: + raise ValueError("unsupported golden case type") + effective_priority = str(priority or "blocking").strip() or "blocking" + if effective_priority not in GOLDEN_CASE_PRIORITIES: + raise ValueError("unsupported golden case priority") + row = PromptGoldenCase( + tenant_id=int(tenant_id), + prompt_key=str(prompt_key or "").strip(), + case_name=name, + status="active", + input_payload=input_payload, + expected_contract=expected_contract or {}, + expected_contains=str(expected_contains or "").strip() or None, + source=str(source or "manual").strip() or "manual", + source_ai_call_log_id=source_ai_call_log_id, + case_type=effective_case_type, + priority=effective_priority, + scenario_key=str(scenario_key or "").strip() or None, + tags=[str(item).strip() for item in list(tags or []) if str(item).strip()], + created_by=created_by, + notes=str(notes or "").strip() or None, + ) + self.db.add(row) + await self.db.commit() + await self.db.refresh(row) + return self._golden_case_payload(row) + + async def create_golden_case_from_call( + self, + *, + tenant_id: int, + prompt_key: str, + ai_call_log_id: int, + created_by: int | None = None, + case_name: str | None = None, + notes: str | None = None, + ) -> dict[str, Any]: + if get_prompt_registry_entry(prompt_key) is None: + raise ValueError("prompt asset not found") + row = ( + await self.db.execute( + select(AICallLog).where( + AICallLog.tenant_id == int(tenant_id), + AICallLog.id == int(ai_call_log_id), + ) + ) + ).scalar_one_or_none() + if row is None: + raise ValueError("AI call log not found") + if str(row.prompt_key or "").strip() != str(prompt_key or "").strip(): + raise ValueError("AI call log is not linked to this prompt_key") + status_text = str(row.status or "").lower() + priority = "blocking" if "structured_invalid" in status_text or "invalid" in status_text else "important" + return await self.create_golden_case( + tenant_id=tenant_id, + prompt_key=prompt_key, + case_name=case_name or f"trace-{row.trace_id or row.id}", + input_payload={ + "ai_call_log_id": int(row.id), + "trace_id": row.trace_id, + "function_name": row.function_name, + "prompt_release_id": row.prompt_release_id, + "prompt_source": row.prompt_source, + "prompt_preview": _preview(row.prompt or "", limit=1000), + }, + expected_contract={ + "status": row.status, + "response_preview": _preview(row.response or "", limit=1000), + }, + source="ai_call_log", + source_ai_call_log_id=int(row.id), + case_type="regression", + priority=priority, + scenario_key=str(row.function_name or "").strip() or None, + tags=["from_ai_call_log", status_text] if status_text else ["from_ai_call_log"], + created_by=created_by, + notes=notes, + ) + + async def create_replay_run( + self, + *, + tenant_id: int, + prompt_key: str, + release_id: int, + created_by: int | None, + mode: str = "contract_smoke", + case_status: str = "active", + max_cases: int = 20, + include_llm: bool = False, + judge_enabled: bool = True, + reason: str | None = None, + ) -> dict[str, Any]: + entry = self._releaseable_entry(prompt_key) + release = await self._get_release(tenant_id=tenant_id, prompt_key=prompt_key, release_id=release_id) + run_mode = str(mode or "contract_smoke").strip() or "contract_smoke" + if include_llm and run_mode in {"contract_smoke", "compare"}: + run_mode = "llm_compare" + if run_mode not in REPLAY_MODES: + raise ValueError("unsupported replay mode") + if include_llm and int(max_cases or 20) > 50: + raise ValueError("LLM replay max_cases cannot exceed 50") + limit = max(1, min(int(max_cases or 20), 50 if include_llm else 100)) + cases = await self._active_golden_cases( + tenant_id=tenant_id, + prompt_key=prompt_key, + status=case_status, + limit=limit, + ) + if not cases: + raise ValueError("no active golden cases for replay") + baseline = await self._active_release_for_prompt(tenant_id=tenant_id, prompt_key=prompt_key) + started_at = now_cn_naive().isoformat() + run = PromptReplayRun( + tenant_id=int(tenant_id), + prompt_key=str(prompt_key or "").strip(), + release_id=int(release.id), + baseline_release_id=int(baseline.id) if baseline is not None else None, + run_status="running", + run_mode=run_mode, + case_count=0, + passed_count=0, + failed_count=0, + warning_count=0, + summary="回放执行中", + risk_flags_json=[], + created_by=created_by, + started_at=started_at, + ) + self.db.add(run) + await self.db.flush() + + passed = 0 + failed = 0 + warning = 0 + risk_flags: list[str] = [] + for golden_case in cases: + started = time.perf_counter() + result = await self._evaluate_replay_case( + tenant_id=tenant_id, + release=release, + baseline=baseline, + golden_case=golden_case, + entry=entry, + include_llm=include_llm or run_mode == "llm_compare", + judge_enabled=judge_enabled, + run_id=int(run.id), + ) + latency_ms = max(0, int((time.perf_counter() - started) * 1000)) + status = self._effective_replay_status(str(result["status"]), priority=str(golden_case.priority or "blocking")) + if status == "passed": + passed += 1 + elif status == "failed": + failed += 1 + else: + warning += 1 + risk_flags.extend([str(item) for item in result.get("risk_flags") or [] if str(item).strip()]) + self.db.add( + PromptReplayCaseResult( + tenant_id=int(tenant_id), + run_id=int(run.id), + golden_case_id=int(golden_case.id), + prompt_key=str(prompt_key or "").strip(), + release_id=int(release.id), + baseline_output=result.get("baseline_output"), + candidate_output=result.get("candidate_output"), + status=status, + contract_result_json=result.get("contract_result_json") or {}, + diff_summary=result.get("diff_summary"), + risk_flags_json=result.get("risk_flags") or [], + baseline_ai_call_log_id=result.get("baseline_ai_call_log_id"), + candidate_ai_call_log_id=result.get("candidate_ai_call_log_id"), + judge_score=result.get("judge_score"), + judge_label=result.get("judge_label"), + judge_reason=result.get("judge_reason"), + latency_ms=latency_ms, + estimated_cost=float(result.get("estimated_cost") or 0.0), + error_summary=result.get("error_summary"), + ) + ) + golden_case.last_run_status = status + if status == "failed": + golden_case.last_failed_at = now_cn_naive().isoformat() + unique_risks = sorted(set(risk_flags)) + completed_at = now_cn_naive().isoformat() + run.run_status = "completed" + run.case_count = len(cases) + run.passed_count = passed + run.failed_count = failed + run.warning_count = warning + run.score_delta = 0.0 + run.risk_flags_json = unique_risks + if failed: + run.summary = f"回放完成:{failed} 个样本阻塞,{warning} 个样本需复核。" + elif warning: + run.summary = f"回放完成:无阻塞失败,{warning} 个样本需人工复核。" + elif include_llm or run_mode == "llm_compare": + run.summary = "LLM 回放完成:所有样本通过评估,建议进入发布评审。" + else: + run.summary = "回放完成:所有样本通过确定性契约冒烟。" + run.completed_at = completed_at + await self.db.commit() + await self.db.refresh(run) + return await self.get_replay_run(tenant_id=tenant_id, prompt_key=prompt_key, run_id=int(run.id)) + + async def list_replay_runs(self, *, tenant_id: int, prompt_key: str, release_id: int) -> dict[str, Any]: + release = await self._get_release(tenant_id=tenant_id, prompt_key=prompt_key, release_id=release_id) + rows = list( + ( + await self.db.execute( + select(PromptReplayRun) + .where( + PromptReplayRun.tenant_id == int(tenant_id), + PromptReplayRun.prompt_key == str(prompt_key or "").strip(), + PromptReplayRun.release_id == int(release.id), + ) + .order_by(PromptReplayRun.id.desc()) + ) + ).scalars().all() + ) + return { + "prompt_key": str(prompt_key or "").strip(), + "release_id": int(release.id), + "items": [self._replay_run_payload(row, include_results=False) for row in rows], + "total": len(rows), + } + + async def get_replay_run(self, *, tenant_id: int, prompt_key: str, run_id: int) -> dict[str, Any]: + run = ( + await self.db.execute( + select(PromptReplayRun).where( + PromptReplayRun.tenant_id == int(tenant_id), + PromptReplayRun.prompt_key == str(prompt_key or "").strip(), + PromptReplayRun.id == int(run_id), + ) + ) + ).scalar_one_or_none() + if run is None: + raise ValueError("prompt replay run not found") + results = list( + ( + await self.db.execute( + select(PromptReplayCaseResult) + .where( + PromptReplayCaseResult.tenant_id == int(tenant_id), + PromptReplayCaseResult.run_id == int(run.id), + ) + .order_by(PromptReplayCaseResult.id.asc()) + ) + ).scalars().all() + ) + return self._replay_run_payload(run, include_results=True, results=results) + + async def get_release_watch_summary(self, *, tenant_id: int, prompt_key: str, release_id: int) -> dict[str, Any]: + release = await self._get_release(tenant_id=tenant_id, prompt_key=prompt_key, release_id=release_id) + quality_24h = await self.get_quality_summary(tenant_id=tenant_id, prompt_key=prompt_key, window="24h", release_id=int(release.id)) + quality_7d = await self.get_quality_summary(tenant_id=tenant_id, prompt_key=prompt_key, window="7d", release_id=int(release.id)) + failure_rate = float(quality_24h.get("failure_rate") or 0) + fallback_rate = round(float(quality_24h.get("default_fallback_calls") or 0) / max(1, int(quality_24h.get("total_calls") or 0)), 4) + structured_invalid_count = int( + ( + await self.db.execute( + select(func.count(AICallLog.id)).where( + AICallLog.tenant_id == int(tenant_id), + AICallLog.prompt_key == str(prompt_key or "").strip(), + AICallLog.prompt_release_id == int(release.id), + AICallLog.status == "structured_invalid", + AICallLog.created_at >= datetime.now() - timedelta(hours=24), + ) + ) + ).scalar() + or 0 + ) + if int(quality_24h.get("total_calls") or 0) <= 0: + recommendation = "watch" + summary = "观察中,暂无 release 级运行样本。" + elif failure_rate >= 0.2 or structured_invalid_count >= 3: + recommendation = "rollback_suggested" + summary = "24h 失败或结构化异常偏高,建议复核并准备回滚。" + elif failure_rate >= 0.08 or fallback_rate >= 0.15 or structured_invalid_count > 0: + recommendation = "needs_review" + summary = "已出现可疑失败、结构化异常或默认回退,建议复核失败样本并补充 golden case。" + else: + recommendation = "healthy" + summary = "当前观察窗口未发现明显异常。" + return { + "prompt_key": str(prompt_key or "").strip(), + "release_id": int(release.id), + "release_version": int(release.version or 0), + "status": str(release.status or ""), + "windows": {"24h": quality_24h, "7d": quality_7d}, + "structured_invalid_24h": structured_invalid_count, + "fallback_rate_24h": fallback_rate, + "recommendation": recommendation, + "summary": summary, + "recent_failures": quality_24h.get("recent_failures") or [], + } + + async def _release_index(self, *, tenant_id: int, prompt_key: str | None = None, include_body: bool = False) -> dict[str, Any]: + stmt = select(PromptRelease).where(PromptRelease.tenant_id == int(tenant_id)) + if prompt_key: + stmt = stmt.where(PromptRelease.prompt_key == str(prompt_key).strip()) + rows = list((await self.db.execute(stmt.order_by(PromptRelease.prompt_key.asc(), PromptRelease.version.desc()))).scalars().all()) + checks_by_release = await self._latest_checks_by_release( + tenant_id=tenant_id, + release_ids=[int(row.id) for row in rows], + ) + replay_by_release = await self._latest_replay_by_release( + tenant_id=tenant_id, + release_ids=[int(row.id) for row in rows], + ) + by_key: dict[str, dict[str, Any]] = {} + for row in rows: + key = str(row.prompt_key or "").strip() + if not key: + continue + bucket = by_key.setdefault(key, {"releases": [], "active": None}) + snapshot = self._release_payload( + row, + include_body=include_body, + checks=checks_by_release.get(int(row.id), []), + latest_replay=replay_by_release.get(int(row.id)), + ) + bucket["releases"].append(snapshot) + if row.status == "active" and bucket["active"] is None: + bucket["active"] = snapshot + return by_key + + def _asset_payload(self, entry: dict[str, Any], *, releases: dict[str, Any], include_body: bool) -> dict[str, Any]: + prompt_key = str(entry.get("prompt_key") or "").strip() + default_template = self._default_template(prompt_key) + release_bucket = releases.get(prompt_key, {}) + active_release = release_bucket.get("active") + all_releases = list(release_bucket.get("releases") or []) + payload = { + "prompt_key": prompt_key, + "module": str(entry.get("module") or ""), + "business_scenario": str(entry.get("business_scenario") or ""), + "prompt_family": str(entry.get("prompt_family") or ""), + "responsibility_scope": str(entry.get("responsibility_scope") or ""), + "priority": str(entry.get("priority") or ""), + "audience": str(entry.get("audience") or ""), + "risk_level": str(entry.get("risk_level") or ""), + "runtime_source": str(entry.get("runtime_source") or ""), + "editable_policy": str(entry.get("editable_policy") or ""), + "input_contract": str(entry.get("input_contract") or ""), + "output_contract": str(entry.get("output_contract") or ""), + "call_sites": list(entry.get("call_sites") or []), + "default_template_available": bool(default_template), + "default_template_hash": _hash_text(default_template), + "default_template_preview": _preview(default_template), + "template_variables": _template_variables(default_template), + "active_release": active_release, + "release_count": len(all_releases), + "draft_release_count": sum(1 for item in all_releases if item.get("status") == "draft"), + "latest_release": all_releases[0] if all_releases else None, + "migration_status": self._migration_status(entry, default_template), + } + if include_body: + payload["default_template_body"] = default_template + payload["releases"] = all_releases + return payload + + @staticmethod + def _release_payload( + row: PromptRelease, + *, + include_body: bool = False, + checks: list[dict[str, Any]] | None = None, + latest_replay: dict[str, Any] | None = None, + ) -> dict[str, Any]: + body = str(row.body_snapshot or "") + check_items = list(checks or []) + payload = { + "id": int(row.id), + "prompt_key": str(row.prompt_key or ""), + "version": int(row.version or 0), + "status": str(row.status or ""), + "notes": row.notes, + "body_hash": _hash_text(body), + "body_preview": _preview(body), + "created_by": int(row.created_by) if row.created_by is not None else None, + "activated_by": int(row.activated_by) if row.activated_by is not None else None, + "activated_at": row.activated_at, + "release_gate": PromptAssetService._gate_summary_from_checks(check_items), + "latest_checks": check_items, + "latest_replay_run": latest_replay, + } + if include_body: + payload["body_snapshot"] = body + return payload + + def _releaseable_entry(self, prompt_key: str) -> dict[str, Any]: + entry = get_prompt_registry_entry(str(prompt_key or "").strip()) + if entry is None: + raise ValueError("prompt asset not found") + policy = str(entry.get("editable_policy") or "") + if policy == "needs_migration": + raise ValueError("prompt call sites must be migrated before release operations") + if policy not in {"managed_release", "guarded_release"}: + raise ValueError("prompt asset is not editable") + if str(entry.get("runtime_source") or "") == "inline_discovered": + raise ValueError("inline prompt must be migrated before release operations") + if not self._default_template(str(entry.get("prompt_key") or "")): + raise ValueError("prompt asset has no default template") + return entry + + async def _assert_activation_allowed( + self, + *, + tenant_id: int, + prompt_key: str, + release_id: int, + entry: dict[str, Any], + ) -> None: + release = await self._get_release(tenant_id=tenant_id, prompt_key=prompt_key, release_id=release_id) + if str(entry.get("editable_policy") or "") != "guarded_release": + return + checks = await self._latest_checks_for_release( + tenant_id=tenant_id, + release_id=int(release.id), + ) + gate = self._gate_summary_from_checks(checks) + if gate.get("status") != "passed": + raise ValueError(f"guarded prompt release is blocked by gate: {gate.get('summary') or 'checks not passed'}") + + async def _get_release(self, *, tenant_id: int, prompt_key: str, release_id: int) -> PromptRelease: + row = ( + await self.db.execute( + select(PromptRelease).where( + PromptRelease.tenant_id == int(tenant_id), + PromptRelease.prompt_key == str(prompt_key or "").strip(), + PromptRelease.id == int(release_id), + ) + ) + ).scalar_one_or_none() + if row is None: + raise ValueError("prompt release not found") + return row + + async def _latest_checks_by_release(self, *, tenant_id: int, release_ids: list[int]) -> dict[int, list[dict[str, Any]]]: + ids = [int(item) for item in release_ids if item is not None] + if not ids: + return {} + rows = ( + await self.db.execute( + select(PromptReleaseCheck) + .where( + PromptReleaseCheck.tenant_id == int(tenant_id), + PromptReleaseCheck.release_id.in_(ids), + ) + .order_by(PromptReleaseCheck.release_id.asc(), PromptReleaseCheck.check_type.asc(), PromptReleaseCheck.id.desc()) + ) + ).scalars().all() + by_release: dict[int, dict[str, dict[str, Any]]] = {} + for row in rows: + release_id = int(row.release_id) + bucket = by_release.setdefault(release_id, {}) + check_type = str(row.check_type or "") + if check_type not in bucket: + bucket[check_type] = self._check_payload(row) + return {release_id: list(checks.values()) for release_id, checks in by_release.items()} + + async def _latest_checks_for_release(self, *, tenant_id: int, release_id: int) -> list[dict[str, Any]]: + grouped = await self._latest_checks_by_release(tenant_id=tenant_id, release_ids=[int(release_id)]) + return grouped.get(int(release_id), []) + + async def _latest_replay_by_release(self, *, tenant_id: int, release_ids: list[int]) -> dict[int, dict[str, Any]]: + ids = [int(item) for item in release_ids if item is not None] + if not ids: + return {} + rows = list( + ( + await self.db.execute( + select(PromptReplayRun) + .where( + PromptReplayRun.tenant_id == int(tenant_id), + PromptReplayRun.release_id.in_(ids), + ) + .order_by(PromptReplayRun.release_id.asc(), PromptReplayRun.id.desc()) + ) + ).scalars().all() + ) + latest: dict[int, dict[str, Any]] = {} + for row in rows: + release_id = int(row.release_id) + if release_id not in latest: + latest[release_id] = self._replay_run_payload(row, include_results=False) + return latest + + async def _latest_replay_for_release(self, *, tenant_id: int, release_id: int) -> dict[str, Any] | None: + grouped = await self._latest_replay_by_release(tenant_id=tenant_id, release_ids=[int(release_id)]) + return grouped.get(int(release_id)) + + async def _pmflow_feedback_summary( + self, + *, + tenant_id: int, + prompt_key: str, + since: datetime, + release_id: int | None, + ) -> dict[str, Any]: + filters = [ + PMFlowAIFeedbackEvent.prompt_key == str(prompt_key or "").strip(), + PMFlowAIFeedbackEvent.created_at >= since, + ] + if tenant_id is not None: + filters.append(PMFlowAIFeedbackEvent.tenant_id == int(tenant_id)) + if release_id is not None: + filters.append(PMFlowAIFeedbackEvent.prompt_release_id == int(release_id)) + rows = list( + ( + await self.db.execute( + select(PMFlowAIFeedbackEvent) + .where(and_(*filters)) + .order_by(PMFlowAIFeedbackEvent.created_at.desc(), PMFlowAIFeedbackEvent.id.desc()) + .limit(200) + ) + ).scalars().all() + ) + total = len(rows) + positive = sum(1 for row in rows if row.rating == "positive") + negative = sum(1 for row in rows if row.rating == "negative") + reason_counts: dict[str, int] = {} + answer_kind_counts: dict[str, int] = {} + for row in rows: + answer_kind_counts[row.answer_kind] = answer_kind_counts.get(row.answer_kind, 0) + 1 + for tag in row.reason_tags or []: + label = str(tag or "").strip() + if label: + reason_counts[label] = reason_counts.get(label, 0) + 1 + return { + "total_feedback": total, + "positive_feedback": positive, + "negative_feedback": negative, + "negative_rate": round(negative / total, 4) if total else 0, + "reason_tag_counts": [ + {"tag": tag, "count": count} + for tag, count in sorted(reason_counts.items(), key=lambda item: (-item[1], item[0])) + ], + "answer_kind_counts": [ + {"answer_kind": answer_kind, "count": count} + for answer_kind, count in sorted(answer_kind_counts.items(), key=lambda item: (-item[1], item[0])) + ], + "recent_feedback": [self._pmflow_feedback_payload(row) for row in rows[:8]], + } + + async def _prompt_optimizer_candidates(self, *, tenant_id: int | None, prompt_key: str) -> list[dict[str, Any]]: + filters = [ + OptimizerCandidate.target_type == "prompt_key", + OptimizerCandidate.target_ref == str(prompt_key or "").strip(), + OptimizerCandidate.status == "draft", + ] + if tenant_id is not None: + filters.append(OptimizerCandidate.tenant_id == int(tenant_id)) + rows = list( + ( + await self.db.execute( + select(OptimizerCandidate) + .where(and_(*filters)) + .order_by(OptimizerCandidate.created_at.desc(), OptimizerCandidate.id.desc()) + .limit(8) + ) + ).scalars().all() + ) + return [ + { + "id": int(row.id), + "tenant_id": int(row.tenant_id), + "candidate_type": row.candidate_type, + "target_type": row.target_type, + "target_ref": row.target_ref, + "status": row.status, + "suggestion": row.suggestion or {}, + "evidence": row.evidence or {}, + "notes": row.notes, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + for row in rows + ] + + @staticmethod + def _pmflow_feedback_payload(row: PMFlowAIFeedbackEvent) -> dict[str, Any]: + metadata = row.feedback_metadata if isinstance(row.feedback_metadata, dict) else {} + prompt_optimization = metadata.get("promptOptimization") if isinstance(metadata.get("promptOptimization"), dict) else {} + return { + "id": int(row.id), + "tenant_id": int(row.tenant_id), + "project_id": int(row.project_id), + "target_type": row.target_type, + "target_id": row.target_id, + "answer_kind": row.answer_kind, + "rating": row.rating, + "reason_tags": list(row.reason_tags or []), + "comment": row.comment, + "scene_key": row.scene_key, + "prompt_release_id": int(row.prompt_release_id) if row.prompt_release_id is not None else None, + "runtime_profile_id": row.runtime_profile_id, + "optimizer_candidate_id": prompt_optimization.get("candidateId"), + "created_at": row.created_at.isoformat() if row.created_at else None, + } + + async def _active_golden_cases( + self, + *, + tenant_id: int, + prompt_key: str, + status: str | None, + limit: int, + ) -> list[PromptGoldenCase]: + stmt = ( + select(PromptGoldenCase) + .where( + PromptGoldenCase.tenant_id == int(tenant_id), + PromptGoldenCase.prompt_key == str(prompt_key or "").strip(), + ) + .order_by(PromptGoldenCase.id.asc()) + .limit(max(1, min(int(limit or 20), 100))) + ) + effective_status = str(status or "active").strip() + if effective_status and effective_status != "all": + stmt = stmt.where(PromptGoldenCase.status == effective_status) + return list((await self.db.execute(stmt)).scalars().all()) + + async def _active_release_for_prompt(self, *, tenant_id: int, prompt_key: str) -> PromptRelease | None: + return ( + await self.db.execute( + select(PromptRelease).where( + PromptRelease.tenant_id == int(tenant_id), + PromptRelease.prompt_key == str(prompt_key or "").strip(), + PromptRelease.status == "active", + ) + ) + ).scalar_one_or_none() + + def _build_release_check_specs( + self, + *, + tenant_id: int, + entry: dict[str, Any], + release: PromptRelease, + default_template: str, + reason: str | None, + active_case_count: int, + latest_replay: dict[str, Any] | None, + ) -> list[dict[str, Any]]: + release_body = str(release.body_snapshot or "") + specs: list[dict[str, Any]] = [] + + try: + default_vars = set(_template_variables_or_error(default_template)) + release_vars = set(_template_variables_or_error(release_body)) + unknown_vars = sorted(release_vars - default_vars) + if unknown_vars: + specs.append( + { + "check_type": "variable_contract", + "status": "failed", + "severity": "blocking", + "summary": f"模板引用了未登记变量:{', '.join(unknown_vars)}", + "details_json": {"default_variables": sorted(default_vars), "release_variables": sorted(release_vars), "unknown_variables": unknown_vars}, + } + ) + else: + specs.append( + { + "check_type": "variable_contract", + "status": "passed", + "severity": "info", + "summary": "变量契约通过,未引入默认模板之外的新变量。", + "details_json": {"default_variables": sorted(default_vars), "release_variables": sorted(release_vars), "reason": reason}, + } + ) + except ValueError as exc: + specs.append( + { + "check_type": "variable_contract", + "status": "failed", + "severity": "blocking", + "summary": f"模板语法错误:{exc}", + "details_json": {"reason": reason}, + } + ) + + contract_text = " ".join( + [ + str(entry.get("input_contract") or ""), + str(entry.get("output_contract") or ""), + str(entry.get("prompt_key") or ""), + ] + ).lower() + structured = "json" in contract_text or "结构化" in contract_text + if not structured: + specs.append( + { + "check_type": "schema_smoke", + "status": "skipped", + "severity": "info", + "summary": "该 prompt 未声明结构化 JSON 输出,schema smoke 本轮跳过。", + "details_json": {"reason": reason}, + } + ) + elif "json" in release_body.lower() or "json" in contract_text: + specs.append( + { + "check_type": "schema_smoke", + "status": "passed", + "severity": "info", + "summary": "结构化输出契约已声明 JSON/schema 约束。", + "details_json": {"output_contract": str(entry.get("output_contract") or ""), "reason": reason}, + } + ) + else: + specs.append( + { + "check_type": "schema_smoke", + "status": "failed", + "severity": "blocking", + "summary": "结构化 prompt 缺少 JSON/schema 输出约束。", + "details_json": {"output_contract": str(entry.get("output_contract") or ""), "reason": reason}, + } + ) + + _ = tenant_id + replay_status = str((latest_replay or {}).get("run_status") or "") + replay_failed_count = int((latest_replay or {}).get("failed_count") or 0) + replay_case_count = int((latest_replay or {}).get("case_count") or 0) + replay_risks = list((latest_replay or {}).get("risk_flags_json") or []) + replay_passed = replay_status == "completed" and replay_case_count > 0 and replay_failed_count == 0 + if active_case_count <= 0: + specs.append( + { + "check_type": "golden_replay", + "status": "skipped", + "severity": "blocking" if str(entry.get("editable_policy") or "") == "guarded_release" else "warning", + "summary": "尚未接入 active golden case,不能把 replay 结果伪造成通过。", + "details_json": {"case_count": 0, "reason": reason}, + } + ) + elif replay_passed: + specs.append( + { + "check_type": "golden_replay", + "status": "passed", + "severity": "info", + "summary": f"最近一次 replay 已通过:{replay_case_count} 个样本,{int((latest_replay or {}).get('warning_count') or 0)} 个需复核,无阻塞失败。", + "details_json": { + "case_count": active_case_count, + "run_id": (latest_replay or {}).get("id"), + "mode": (latest_replay or {}).get("run_mode") or "contract_smoke", + "risk_flags": replay_risks, + "reason": reason, + }, + } + ) + else: + if latest_replay: + summary = str(latest_replay.get("summary") or "最近一次 replay 未通过。") + details = { + "case_count": active_case_count, + "run_id": latest_replay.get("id"), + "run_status": latest_replay.get("run_status"), + "failed_count": replay_failed_count, + "risk_flags": replay_risks, + "reason": reason, + } + status = "failed" if replay_failed_count > 0 or replay_risks else "skipped" + else: + summary = "已有 active golden case,但尚未执行 release replay run。" + details = {"case_count": active_case_count, "reason": reason} + status = "skipped" + specs.append( + { + "check_type": "golden_replay", + "status": status, + "severity": "blocking" if str(entry.get("editable_policy") or "") == "guarded_release" else "warning", + "summary": summary, + "details_json": details, + } + ) + return specs + + async def _active_golden_case_count(self, *, tenant_id: int, prompt_key: str) -> int: + return int( + ( + await self.db.execute( + select(func.count(PromptGoldenCase.id)).where( + PromptGoldenCase.tenant_id == int(tenant_id), + PromptGoldenCase.prompt_key == str(prompt_key or "").strip(), + PromptGoldenCase.status == "active", + ) + ) + ).scalar() + or 0 + ) + + @staticmethod + def _check_payload(row: PromptReleaseCheck) -> dict[str, Any]: + return { + "id": int(row.id), + "prompt_key": str(row.prompt_key or ""), + "release_id": int(row.release_id), + "check_type": str(row.check_type or ""), + "status": str(row.status or ""), + "severity": str(row.severity or ""), + "summary": str(row.summary or ""), + "details_json": row.details_json or {}, + "created_by": int(row.created_by) if row.created_by is not None else None, + "created_at": row.created_at.isoformat() if getattr(row, "created_at", None) is not None else None, + "completed_at": row.completed_at, + } + + @staticmethod + def _golden_case_payload(row: PromptGoldenCase) -> dict[str, Any]: + return { + "id": int(row.id), + "prompt_key": str(row.prompt_key or ""), + "case_name": str(row.case_name or ""), + "status": str(row.status or ""), + "input_payload": row.input_payload or {}, + "expected_contract": row.expected_contract or {}, + "expected_contains": row.expected_contains, + "source": str(row.source or ""), + "source_ai_call_log_id": int(row.source_ai_call_log_id) if row.source_ai_call_log_id is not None else None, + "case_type": str(row.case_type or "regression"), + "priority": str(row.priority or "blocking"), + "scenario_key": row.scenario_key, + "tags": row.tags or [], + "last_run_status": row.last_run_status, + "last_failed_at": row.last_failed_at, + "created_by": int(row.created_by) if row.created_by is not None else None, + "notes": row.notes, + "created_at": row.created_at.isoformat() if getattr(row, "created_at", None) is not None else None, + } + + async def _evaluate_replay_case( + self, + *, + tenant_id: int, + release: PromptRelease, + baseline: PromptRelease | None, + golden_case: PromptGoldenCase, + entry: dict[str, Any], + include_llm: bool, + judge_enabled: bool, + run_id: int, + ) -> dict[str, Any]: + payload = golden_case.input_payload if isinstance(golden_case.input_payload, dict) else {} + candidate_template = str(release.body_snapshot or "") + baseline_template = str(baseline.body_snapshot or "") if baseline is not None else "" + candidate_output = "" + baseline_output = "" + failures: list[str] = [] + warnings: list[str] = [] + risks: list[str] = [] + try: + candidate_output = candidate_template.format(**payload).strip() + except Exception as exc: + failures.append(f"candidate_render_failed: {exc}") + risks.append("candidate_render_failed") + if baseline_template: + try: + baseline_output = baseline_template.format(**payload).strip() + except Exception as exc: + warnings.append(f"baseline_render_failed: {exc}") + risks.append("baseline_render_failed") + baseline_ai_call_log_id = None + candidate_ai_call_log_id = None + judge_score = None + judge_label = None + judge_reason = None + estimated_cost = 0.0 + if include_llm and not failures: + baseline_model_output = baseline_output + candidate_model_output = candidate_output + try: + if baseline_output: + baseline_model_output, baseline_ai_call_log_id = await self._run_llm_replay_prompt( + tenant_id=tenant_id, + prompt_key=str(release.prompt_key or ""), + release_id=int(baseline.id) if baseline is not None else None, + prompt=baseline_output, + function_name="prompt_replay_baseline", + run_id=run_id, + ) + candidate_model_output, candidate_ai_call_log_id = await self._run_llm_replay_prompt( + tenant_id=tenant_id, + prompt_key=str(release.prompt_key or ""), + release_id=int(release.id), + prompt=candidate_output, + function_name="prompt_replay_candidate", + run_id=run_id, + ) + baseline_output = baseline_model_output + candidate_output = candidate_model_output + if judge_enabled: + judge = await self._judge_replay_case( + tenant_id=tenant_id, + entry=entry, + golden_case=golden_case, + baseline_output=baseline_output, + candidate_output=candidate_output, + run_id=run_id, + ) + judge_score = judge.get("score") + judge_label = str(judge.get("label") or "manual_review") + judge_reason = str(judge.get("reason") or "") + risks.extend([str(item) for item in judge.get("risk_flags") or [] if str(item).strip()]) + decision = str(judge.get("decision") or "") + if decision == "block_release" or judge_label in {"worse", "risky", "invalid"}: + failures.append(judge_reason or "LLM judge blocked this replay case") + elif decision in {"needs_revision", "manual_review"} or judge_label == "same": + warnings.append(judge_reason or "LLM judge requires manual review") + except Exception as exc: + failures.append(f"llm_replay_failed: {exc}") + risks.append("llm_replay_failed") + contract_result = self._evaluate_expected_contract( + expected_contract=golden_case.expected_contract if isinstance(golden_case.expected_contract, dict) else {}, + expected_contains=golden_case.expected_contains, + input_payload=payload, + candidate_output=candidate_output, + entry=entry, + ) + failures.extend(contract_result["failures"]) + warnings.extend(contract_result["warnings"]) + risks.extend(contract_result["risk_flags"]) + status = "failed" if failures else "warning" if warnings else "passed" + return { + "status": status, + "baseline_output": baseline_output, + "candidate_output": candidate_output, + "contract_result_json": { + "case_name": golden_case.case_name, + "case_type": str(golden_case.case_type or "regression"), + "priority": str(golden_case.priority or "blocking"), + "failures": failures, + "warnings": warnings, + "mode": "llm_compare" if include_llm else "contract_smoke_no_llm", + "judge": { + "score": judge_score, + "label": judge_label, + "reason": judge_reason, + }, + }, + "diff_summary": self._diff_summary(baseline_output=baseline_output, candidate_output=candidate_output), + "risk_flags": sorted(set(risks)), + "baseline_ai_call_log_id": baseline_ai_call_log_id, + "candidate_ai_call_log_id": candidate_ai_call_log_id, + "judge_score": judge_score, + "judge_label": judge_label, + "judge_reason": judge_reason, + "estimated_cost": estimated_cost, + "error_summary": ";".join(failures or warnings)[:800] or None, + } + + @staticmethod + def _effective_replay_status(raw_status: str, *, priority: str) -> str: + status = str(raw_status or "").strip() or "warning" + case_priority = str(priority or "blocking").strip() + if status != "failed": + return status + if case_priority == "blocking": + return "failed" + return "warning" + + async def _run_llm_replay_prompt( + self, + *, + tenant_id: int, + prompt_key: str, + release_id: int | None, + prompt: str, + function_name: str, + run_id: int, + ) -> tuple[str, int | None]: + routing_meta = { + "prompt_key": prompt_key, + "prompt_release_id": release_id, + "prompt_source": "replay_candidate" if function_name.endswith("candidate") else "replay_baseline", + "tier_alias": "low", + "run_id": f"prompt-replay-{run_id}-{function_name}", + "scene_key": "prompt_replay", + } + output = await llm_service.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + function_name=function_name, + temperature=0.0, + max_tokens=1200, + routing_meta=routing_meta, + ) + call_id = await self._latest_ai_call_log_id( + tenant_id=tenant_id, + prompt_key=prompt_key, + release_id=release_id, + function_name=function_name, + ) + return str(output or "").strip(), call_id + + async def _judge_replay_case( + self, + *, + tenant_id: int, + entry: dict[str, Any], + golden_case: PromptGoldenCase, + baseline_output: str, + candidate_output: str, + run_id: int, + ) -> dict[str, Any]: + rubric = get_prompt_eval_rubric( + prompt_key=str(entry.get("prompt_key") or ""), + prompt_family=str(entry.get("prompt_family") or ""), + ) + judge_prompt = "\n".join( + [ + "你是 PMFlow 平台 Prompt 发布评估器。只输出 JSON,不输出解释性正文。", + "评估候选 Prompt 输出相对 baseline 是否适合发布。", + f"Prompt Key: {entry.get('prompt_key')}", + f"业务场景: {entry.get('business_scenario')}", + f"输入契约: {entry.get('input_contract')}", + f"输出契约: {entry.get('output_contract')}", + f"Rubric: {json.dumps(rubric, ensure_ascii=False)}", + f"Golden Case: {golden_case.case_name}", + f"Expected Contract: {json.dumps(golden_case.expected_contract or {}, ensure_ascii=False)}", + f"Baseline Output:\n{baseline_output or '无 baseline'}", + f"Candidate Output:\n{candidate_output}", + "输出 JSON schema: {\"score\":0.0,\"label\":\"better|same|worse|risky|invalid\",\"decision\":\"recommend_release|needs_revision|block_release|manual_review\",\"reason\":\"...\",\"risk_flags\":[],\"dimension_scores\":{\"contract_adherence\":0,\"business_correctness\":0,\"evidence_grounding\":0,\"safety_boundary\":0,\"operator_readability\":0}}", + ] + ) + try: + result = await llm_service.generate_structured_object( + judge_prompt, + db=self.db, + tenant_id=tenant_id, + function_name="prompt_replay_judge", + temperature=0.0, + max_tokens=900, + routing_meta={ + "prompt_key": str(entry.get("prompt_key") or ""), + "prompt_source": "replay_judge", + "tier_alias": "structured", + "run_id": f"prompt-replay-{run_id}-judge", + "scene_key": "prompt_replay", + }, + ) + payload = result.payload if isinstance(result.payload, dict) else {} + except Exception as exc: + return { + "score": None, + "label": "manual_review", + "decision": "manual_review", + "reason": f"Judge 结构化评估失败:{exc}", + "risk_flags": ["judge_invalid"], + } + return self._normalize_judge_payload(payload) + + @staticmethod + def _normalize_judge_payload(payload: dict[str, Any]) -> dict[str, Any]: + label = str(payload.get("label") or "manual_review").strip() + if label not in {"better", "same", "worse", "risky", "invalid"}: + label = "manual_review" + decision = str(payload.get("decision") or "manual_review").strip() + if decision not in {"recommend_release", "needs_revision", "block_release", "manual_review"}: + decision = "manual_review" + try: + score = float(payload.get("score")) if payload.get("score") is not None else None + except (TypeError, ValueError): + score = None + return { + "score": score, + "label": label, + "decision": decision, + "reason": str(payload.get("reason") or "").strip()[:1200], + "risk_flags": [str(item).strip() for item in list(payload.get("risk_flags") or []) if str(item).strip()], + "dimension_scores": payload.get("dimension_scores") if isinstance(payload.get("dimension_scores"), dict) else {}, + } + + async def _latest_ai_call_log_id( + self, + *, + tenant_id: int, + prompt_key: str, + release_id: int | None, + function_name: str, + ) -> int | None: + filters = [ + AICallLog.tenant_id == int(tenant_id), + AICallLog.prompt_key == str(prompt_key or "").strip(), + AICallLog.function_name == str(function_name or "").strip(), + ] + if release_id is not None: + filters.append(AICallLog.prompt_release_id == int(release_id)) + row = ( + await self.db.execute( + select(AICallLog) + .where(and_(*filters)) + .order_by(AICallLog.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + return int(row.id) if row is not None else None + + @staticmethod + def _evaluate_expected_contract( + *, + expected_contract: dict[str, Any], + expected_contains: str | None, + input_payload: dict[str, Any], + candidate_output: str, + entry: dict[str, Any], + ) -> dict[str, list[str]]: + failures: list[str] = [] + warnings: list[str] = [] + risk_flags: list[str] = [] + required_inputs = expected_contract.get("required_input_keys") or expected_contract.get("required_variables") or [] + if isinstance(required_inputs, str): + required_inputs = [required_inputs] + if isinstance(required_inputs, list): + missing = [str(item) for item in required_inputs if str(item) not in input_payload] + if missing: + failures.append(f"缺少输入变量:{', '.join(missing)}") + risk_flags.append("missing_input_variable") + required_contains = expected_contract.get("required_prompt_contains") or expected_contract.get("required_contains") or [] + if isinstance(required_contains, str): + required_contains = [required_contains] + if isinstance(required_contains, list): + missing_text = [str(item) for item in required_contains if str(item) and str(item) not in candidate_output] + if missing_text: + failures.append(f"候选渲染缺少期望片段:{', '.join(missing_text[:5])}") + risk_flags.append("missing_required_prompt_fragment") + if expected_contains and str(expected_contains).strip() not in candidate_output: + failures.append("候选渲染未包含 expected_contains。") + risk_flags.append("missing_expected_contains") + forbidden_terms = expected_contract.get("forbidden_terms") or [] + if isinstance(forbidden_terms, str): + forbidden_terms = [forbidden_terms] + if isinstance(forbidden_terms, list): + hits = [str(item) for item in forbidden_terms if str(item) and str(item) in candidate_output] + if hits: + failures.append(f"候选渲染包含禁止片段:{', '.join(hits[:5])}") + risk_flags.append("forbidden_prompt_fragment") + output_required = expected_contract.get("required") or expected_contract.get("required_keys") + if output_required: + warnings.append("该 golden case 声明了输出字段契约;contract smoke 不执行 LLM,只能记录为人工复核项。") + risk_flags.append("unsupported_output_contract") + if str(entry.get("output_contract") or "").lower().find("json") >= 0 and "json" not in candidate_output.lower(): + warnings.append("资产声明 JSON 输出,但候选 prompt 渲染未显式包含 JSON 约束。") + risk_flags.append("json_contract_weak") + return {"failures": failures, "warnings": warnings, "risk_flags": risk_flags} + + @staticmethod + def _diff_summary(*, baseline_output: str, candidate_output: str) -> str: + if not baseline_output: + return "无 baseline release,仅验证候选渲染。" + if baseline_output == candidate_output: + return "候选渲染与 baseline 一致。" + return f"候选渲染与 baseline 不同:baseline {len(baseline_output)} 字符,candidate {len(candidate_output)} 字符。" + + @staticmethod + def _replay_run_payload( + row: PromptReplayRun, + *, + include_results: bool, + results: list[PromptReplayCaseResult] | None = None, + ) -> dict[str, Any]: + payload = { + "id": int(row.id), + "prompt_key": str(row.prompt_key or ""), + "release_id": int(row.release_id), + "baseline_release_id": int(row.baseline_release_id) if row.baseline_release_id is not None else None, + "run_status": str(row.run_status or ""), + "run_mode": str(row.run_mode or ""), + "case_count": int(row.case_count or 0), + "passed_count": int(row.passed_count or 0), + "failed_count": int(row.failed_count or 0), + "warning_count": int(row.warning_count or 0), + "score_delta": float(row.score_delta or 0), + "summary": row.summary, + "risk_flags_json": row.risk_flags_json or [], + "created_by": int(row.created_by) if row.created_by is not None else None, + "started_at": row.started_at, + "completed_at": row.completed_at, + "created_at": row.created_at.isoformat() if getattr(row, "created_at", None) is not None else None, + } + if include_results: + payload["results"] = [ + { + "id": int(item.id), + "run_id": int(item.run_id), + "golden_case_id": int(item.golden_case_id), + "prompt_key": str(item.prompt_key or ""), + "release_id": int(item.release_id), + "baseline_output": item.baseline_output, + "candidate_output": item.candidate_output, + "status": str(item.status or ""), + "contract_result_json": item.contract_result_json or {}, + "diff_summary": item.diff_summary, + "risk_flags_json": item.risk_flags_json or [], + "baseline_ai_call_log_id": int(item.baseline_ai_call_log_id) if item.baseline_ai_call_log_id is not None else None, + "candidate_ai_call_log_id": int(item.candidate_ai_call_log_id) if item.candidate_ai_call_log_id is not None else None, + "judge_score": float(item.judge_score) if item.judge_score is not None else None, + "judge_label": item.judge_label, + "judge_reason": item.judge_reason, + "latency_ms": item.latency_ms, + "estimated_cost": float(item.estimated_cost or 0), + "error_summary": item.error_summary, + "created_at": item.created_at.isoformat() if getattr(item, "created_at", None) is not None else None, + } + for item in list(results or []) + ] + return payload + + @staticmethod + def _call_log_summary(row: AICallLog) -> dict[str, Any]: + return { + "id": int(row.id), + "trace_id": row.trace_id, + "status": row.status, + "function_name": row.function_name, + "prompt_release_id": int(row.prompt_release_id) if row.prompt_release_id is not None else None, + "prompt_source": row.prompt_source, + "latency_ms": row.latency_ms, + "estimated_cost": float(row.cost or 0), + "created_at": row.created_at.isoformat() if row.created_at else None, + "error_summary": _preview(row.response or "", limit=240), + } + + @staticmethod + def _is_failed_status(status: str) -> bool: + text = str(status or "").strip().lower() + return text not in {"success", "ok", "success_cache_hit", "success_failover"} and ( + "fail" in text or "error" in text or "invalid" in text or "empty" in text + ) + + @staticmethod + def _gate_summary_from_checks(items: list[dict[str, Any]]) -> dict[str, Any]: + latest: dict[str, dict[str, Any]] = {} + for item in items: + check_type = str(item.get("check_type") or "") + if check_type and check_type not in latest: + latest[check_type] = item + if not latest: + return { + "status": "not_run", + "summary": "尚未运行发布门禁检查。", + "blocking_reasons": ["尚未运行发布门禁检查。"], + "required_checks": list(RELEASE_CHECK_TYPES), + } + blocking_reasons: list[str] = [] + for check_type in RELEASE_CHECK_TYPES: + item = latest.get(check_type) + if item is None: + blocking_reasons.append(f"{check_type} 尚未运行。") + continue + if item.get("status") == "failed": + blocking_reasons.append(str(item.get("summary") or f"{check_type} 未通过。")) + elif item.get("status") == "skipped" and item.get("severity") == "blocking": + blocking_reasons.append(str(item.get("summary") or f"{check_type} 被跳过。")) + if blocking_reasons: + return { + "status": "blocked", + "summary": ";".join(blocking_reasons), + "blocking_reasons": blocking_reasons, + "required_checks": list(RELEASE_CHECK_TYPES), + } + return { + "status": "passed", + "summary": "发布门禁检查已通过。", + "blocking_reasons": [], + "required_checks": list(RELEASE_CHECK_TYPES), + } + + @staticmethod + def _validate_release_body(*, default_template: str, body_snapshot: str) -> None: + body = str(body_snapshot or "").strip() + if not body: + raise ValueError("body_snapshot is required") + try: + default_vars = set(_template_variables_or_error(default_template)) + release_vars = set(_template_variables_or_error(body)) + except ValueError as exc: + raise ValueError(f"invalid prompt template syntax: {exc}") from exc + unknown_vars = sorted(release_vars - default_vars) + if unknown_vars: + raise ValueError(f"release template references unknown variables: {', '.join(unknown_vars)}") + + @staticmethod + def _default_template(prompt_key: str) -> str: + try: + ai_prompts = import_module("app.config.ai_prompts") + except Exception: + return "" + value = getattr(ai_prompts, str(prompt_key or "").strip(), "") + return str(value or "").strip() if isinstance(value, str) else "" + + @staticmethod + def _migration_status(entry: dict[str, Any], default_template: str) -> str: + policy = str(entry.get("editable_policy") or "") + if policy == "needs_migration": + return "inline_prompt_needs_runtime_migration" + if not default_template: + return "definition_only" + if policy == "guarded_release": + return "guarded_runtime_configurable" + return "runtime_configurable" diff --git a/backend/app/services/prompt_release/service.py b/backend/app/services/prompt_release/service.py index d3b03f7e..fd12b611 100644 --- a/backend/app/services/prompt_release/service.py +++ b/backend/app/services/prompt_release/service.py @@ -6,10 +6,13 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from ...config.ai_prompt_registry import get_prompt_registry_entry -from ...models import PromptRelease +from ...models import PromptRelease, PromptReleaseCheck from ...utils.timezone import now_cn_naive +_RELEASE_CHECK_TYPES = ("variable_contract", "schema_smoke", "golden_replay") + + class PromptReleaseService: def __init__(self, db: AsyncSession): self.db = db @@ -74,17 +77,24 @@ class PromptReleaseService: tenant_id: int, release_id: int, actor_id: Optional[int], + prompt_key: Optional[str] = None, ) -> dict[str, Any]: + filters = [ + PromptRelease.tenant_id == int(tenant_id), + PromptRelease.id == int(release_id), + ] + if prompt_key: + filters.append(PromptRelease.prompt_key == str(prompt_key).strip()) row = ( await self.db.execute( - select(PromptRelease).where( - PromptRelease.tenant_id == int(tenant_id), - PromptRelease.id == int(release_id), - ) + select(PromptRelease).where(*filters) ) ).scalar_one_or_none() if row is None: raise ValueError("prompt release not found") + registry_entry = get_prompt_registry_entry(row.prompt_key) + if registry_entry and str(registry_entry.get("editable_policy") or "") == "guarded_release": + await self._assert_guarded_gate_passed(tenant_id=tenant_id, release_id=int(row.id)) previous = ( await self.db.execute( select(PromptRelease).where( @@ -103,6 +113,60 @@ class PromptReleaseService: await self.db.refresh(row) return self._snapshot(row) + async def _assert_guarded_gate_passed(self, *, tenant_id: int, release_id: int) -> None: + rows = ( + await self.db.execute( + select(PromptReleaseCheck) + .where( + PromptReleaseCheck.tenant_id == int(tenant_id), + PromptReleaseCheck.release_id == int(release_id), + ) + .order_by(PromptReleaseCheck.check_type.asc(), PromptReleaseCheck.id.desc()) + ) + ).scalars().all() + latest: dict[str, PromptReleaseCheck] = {} + for row in rows: + check_type = str(row.check_type or "") + if check_type and check_type not in latest: + latest[check_type] = row + blocking: list[str] = [] + for check_type in _RELEASE_CHECK_TYPES: + row = latest.get(check_type) + if row is None: + blocking.append(f"{check_type} not run") + elif str(row.status or "") != "passed": + blocking.append(f"{check_type}: {row.summary}") + if blocking: + raise ValueError(f"guarded prompt release is blocked by gate: {'; '.join(blocking)}") + + async def rollback_release( + self, + *, + tenant_id: int, + prompt_key: str, + release_id: int, + actor_id: Optional[int], + ) -> dict[str, Any]: + row = ( + await self.db.execute( + select(PromptRelease).where( + PromptRelease.tenant_id == int(tenant_id), + PromptRelease.prompt_key == str(prompt_key or "").strip(), + PromptRelease.id == int(release_id), + ) + ) + ).scalar_one_or_none() + if row is None: + raise ValueError("prompt release not found") + if row.status != "archived": + raise ValueError("only archived prompt releases can be rolled back") + return await self.activate_release( + tenant_id=tenant_id, + prompt_key=prompt_key, + release_id=release_id, + actor_id=actor_id, + ) + async def list_releases( self, *, @@ -125,16 +189,25 @@ class PromptReleaseService: tenant_id: int, prompt_key: str, ) -> Optional[str]: + row = await self.get_active_release(tenant_id=tenant_id, prompt_key=prompt_key) + return str(row.get("body_snapshot") or "").strip() if row else None + + async def get_active_release( + self, + *, + tenant_id: int, + prompt_key: str, + ) -> Optional[dict[str, Any]]: row = ( await self.db.execute( - select(PromptRelease.body_snapshot).where( + select(PromptRelease).where( PromptRelease.tenant_id == int(tenant_id), PromptRelease.prompt_key == str(prompt_key or "").strip(), PromptRelease.status == "active", ) ) ).scalar_one_or_none() - return str(row).strip() if row else None + return self._snapshot(row) if row else None async def render_prompt( self, diff --git a/backend/app/services/prompt_runtime_service.py b/backend/app/services/prompt_runtime_service.py new file mode 100644 index 00000000..148845c5 --- /dev/null +++ b/backend/app/services/prompt_runtime_service.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from .prompt_release import PromptReleaseService + + +@dataclass(frozen=True) +class PromptRuntimeRenderResult: + prompt: str + prompt_key: str + source: str + release_id: int | None = None + format_error: bool = False + + def routing_meta(self) -> dict[str, Any]: + return { + "prompt_key": self.prompt_key, + "prompt_release_id": self.release_id, + "prompt_source": self.source, + "prompt_format_error": self.format_error, + } + + +class PromptRuntimeService: + """Runtime access layer for tenant-managed prompt releases. + + The service keeps call sites away from direct prompt release storage details. + """ + + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def render( + self, + *, + tenant_id: int, + prompt_key: str, + fallback_template: str, + **variables: Any, + ) -> PromptRuntimeRenderResult: + active_release = await PromptReleaseService(self.db).get_active_release( + tenant_id=tenant_id, + prompt_key=prompt_key, + ) + active_template = str(active_release.get("body_snapshot") or "") if active_release else "" + release_id = int(active_release["id"]) if active_release and active_release.get("id") is not None else None + source = "active_release" if active_template else "default_fallback" + template = str(active_template or fallback_template or "") + try: + prompt = template.format(**variables).strip() + return PromptRuntimeRenderResult( + prompt=prompt, + prompt_key=prompt_key, + source=source, + release_id=release_id, + ) + except Exception: + prompt = str(fallback_template or "").format(**variables).strip() + return PromptRuntimeRenderResult( + prompt=prompt, + prompt_key=prompt_key, + source="default_fallback", + release_id=None, + format_error=True, + ) + + async def render_text( + self, + *, + tenant_id: int, + prompt_key: str, + fallback_template: str, + **variables: Any, + ) -> str: + result = await self.render( + tenant_id=tenant_id, + prompt_key=prompt_key, + fallback_template=fallback_template, + **variables, + ) + return result.prompt diff --git a/backend/app/services/space_saved_idea_service.py b/backend/app/services/space_saved_idea_service.py new file mode 100644 index 00000000..13e28662 --- /dev/null +++ b/backend/app/services/space_saved_idea_service.py @@ -0,0 +1,436 @@ +"""Saved idea service for the Personal Space pre-project layer.""" + +from __future__ import annotations + +import re +from typing import Any +from uuid import uuid4 + +from sqlalchemy import and_, desc, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..config.ai_prompts import PROMPT_SAVED_IDEA_ENRICHMENT +from ..models import SavedIdea +from ..schemas.pmflow_launch import PMFlowLaunchAttachment, PMFlowLaunchDraftRequest +from ..schemas.space import SavedIdeaCreateRequest, SavedIdeaRead, SavedIdeaUpdateRequest +from ..services.llm_service import llm_service +from ..services.operation_log_service import log_operation +from ..services.pmflow_launch_service import PMFlowLaunchService +from ..services.ai_routing_governance_service import PLATFORM_RUNTIME_TENANT_ID +from ..services.prompt_runtime_service import PromptRuntimeRenderResult, PromptRuntimeService +from ..utils.timezone import now_cn_naive + + +STATUS_LABELS = { + "saved": "未整理", + "enriched": "已整理", + "needs_more_info": "信息不足", + "draft_ready": "可生成草案", + "promoted": "已转为草案", + "archived": "已归档", +} + + +class SavedIdeaError(RuntimeError): + def __init__(self, code: str, message: str, *, status_code: int = 400): + super().__init__(message) + self.code = code + self.message = message + self.status_code = int(status_code) + + +class SpaceSavedIdeaService: + ENRICHMENT_PROMPT_KEY = "PROMPT_SAVED_IDEA_ENRICHMENT" + + def __init__(self, db: AsyncSession, *, tenant_id: int): + self.db = db + self.tenant_id = int(tenant_id) + + async def list_ideas( + self, + *, + user_id: int, + limit: int = 20, + include_archived: bool = False, + ) -> list[SavedIdeaRead]: + stmt = select(SavedIdea).where( + and_(SavedIdea.tenant_id == self.tenant_id, SavedIdea.user_id == int(user_id)) + ) + if not include_archived: + stmt = stmt.where(SavedIdea.status != "archived") + stmt = stmt.order_by(desc(SavedIdea.updated_at), desc(SavedIdea.id)).limit(max(1, min(int(limit), 100))) + rows = (await self.db.execute(stmt)).scalars().all() + return [self._to_read(row) for row in rows] + + async def create_idea(self, *, body: SavedIdeaCreateRequest, user_id: int) -> SavedIdeaRead: + raw_text = self._clean_text(body.raw_text) + if not raw_text: + raise SavedIdeaError("input_empty", "暂存想法内容不能为空", status_code=400) + title = self._clean_text(body.title or "") or self._derive_title(raw_text) + enrichment = await self._enrich_or_baseline(raw_text) + summary = self._clean_text(body.summary or "") or str(enrichment["summary"]) + row = SavedIdea( + tenant_id=self.tenant_id, + user_id=int(user_id), + idea_id=f"idea_{uuid4().hex[:16]}", + title=title[:255], + raw_text=raw_text, + summary=summary, + source_type=str(body.source_type or "project_launch")[:32], + source_ref=self._clean_text(body.source_ref or "")[:255] or None, + input_mode=str(body.input_mode or "idea")[:32], + attachments=[item.model_dump(mode="json") for item in body.attachments], + status=str(enrichment["status"]), + ai_summary=enrichment.get("ai_summary"), + missing_questions=enrichment["missing_questions"], + readiness_score=float(enrichment["readiness_score"]), + enrichment_status=str(enrichment["enrichment_status"]), + enrichment_payload=enrichment.get("enrichment_payload") or {}, + enrichment_error=enrichment.get("enrichment_error"), + enriched_at=enrichment.get("enriched_at"), + ) + self.db.add(row) + await log_operation( + self.db, + tenant_id=self.tenant_id, + user_id=int(user_id), + project_id=None, + entity_type="saved_idea", + entity_id=row.idea_id, + action="space.saved_idea.create", + before_data=None, + after_data={ + "title": row.title, + "status": row.status, + "source_type": row.source_type, + "readiness_score": row.readiness_score, + "enrichment_status": row.enrichment_status, + }, + interface_path="/space/saved-ideas", + caller="user", + ) + await self._commit() + await self.db.refresh(row) + return self._to_read(row) + + async def update_idea(self, *, idea_id: str, body: SavedIdeaUpdateRequest, user_id: int) -> SavedIdeaRead: + row = await self._load_owned(idea_id=idea_id, user_id=user_id) + before = self._snapshot(row) + if body.raw_text is not None: + row.raw_text = self._clean_text(body.raw_text) + if not row.raw_text: + raise SavedIdeaError("input_empty", "暂存想法内容不能为空", status_code=400) + enrichment = await self._enrich_or_baseline(row.raw_text) + row.summary = str(enrichment["summary"]) + row.ai_summary = enrichment.get("ai_summary") + row.missing_questions = enrichment["missing_questions"] + row.readiness_score = float(enrichment["readiness_score"]) + row.enrichment_status = str(enrichment["enrichment_status"]) + row.enrichment_payload = enrichment.get("enrichment_payload") or {} + row.enrichment_error = enrichment.get("enrichment_error") + row.enriched_at = enrichment.get("enriched_at") + if row.status in {"saved", "needs_more_info", "draft_ready", "enriched"}: + row.status = str(enrichment["status"]) + if body.title is not None: + row.title = self._clean_text(body.title)[:255] or row.title + if body.summary is not None: + row.summary = self._clean_text(body.summary) or row.summary + if body.status is not None: + row.status = str(body.status) + if row.status == "archived" and row.archived_at is None: + row.archived_at = now_cn_naive() + await log_operation( + self.db, + tenant_id=self.tenant_id, + user_id=int(user_id), + project_id=None, + entity_type="saved_idea", + entity_id=row.idea_id, + action="space.saved_idea.update", + before_data=before, + after_data=self._snapshot(row), + interface_path=f"/space/saved-ideas/{idea_id}", + caller="user", + ) + await self._commit() + await self.db.refresh(row) + return self._to_read(row) + + async def archive_idea(self, *, idea_id: str, user_id: int) -> SavedIdeaRead: + row = await self._load_owned(idea_id=idea_id, user_id=user_id) + before = self._snapshot(row) + row.status = "archived" + row.archived_at = now_cn_naive() + await log_operation( + self.db, + tenant_id=self.tenant_id, + user_id=int(user_id), + project_id=None, + entity_type="saved_idea", + entity_id=row.idea_id, + action="space.saved_idea.archive", + before_data=before, + after_data=self._snapshot(row), + interface_path=f"/space/saved-ideas/{idea_id}/archive", + caller="user", + ) + await self._commit() + await self.db.refresh(row) + return self._to_read(row) + + async def promote_to_launch_draft(self, *, idea_id: str, user_id: int) -> tuple[SavedIdeaRead, dict[str, Any]]: + row = await self._load_owned(idea_id=idea_id, user_id=user_id) + if row.status == "archived": + raise SavedIdeaError("idea_archived", "已归档想法不能生成项目草案", status_code=409) + if row.launch_draft_id: + row.status = "promoted" + await self._commit() + raise SavedIdeaError("draft_already_created", "该想法已经生成过项目草案", status_code=409) + + launch_service = PMFlowLaunchService(self.db, tenant_id=self.tenant_id) + draft = await launch_service.create_draft( + body=PMFlowLaunchDraftRequest( + input=row.raw_text, + input_type="template" if row.input_mode == "template" else "text", + attachments=[PMFlowLaunchAttachment(**item) for item in self._safe_list(row.attachments)], + source="saved_idea", + ), + user_id=int(user_id), + ) + row.status = "promoted" + row.launch_draft_id = draft.draft_id + row.promoted_at = now_cn_naive() + await log_operation( + self.db, + tenant_id=self.tenant_id, + user_id=int(user_id), + project_id=None, + entity_type="saved_idea", + entity_id=row.idea_id, + action="space.saved_idea.promote_to_launch_draft", + before_data=None, + after_data={"launch_draft_id": draft.draft_id, "title": draft.title}, + interface_path=f"/space/saved-ideas/{idea_id}/promote", + caller="user", + ) + await self._commit() + await self.db.refresh(row) + return self._to_read(row), draft.model_dump(mode="json") + + async def _load_owned(self, *, idea_id: str, user_id: int) -> SavedIdea: + stmt = select(SavedIdea).where( + and_( + SavedIdea.tenant_id == self.tenant_id, + SavedIdea.user_id == int(user_id), + SavedIdea.idea_id == str(idea_id), + ) + ) + row = (await self.db.execute(stmt)).scalar_one_or_none() + if row is None: + raise SavedIdeaError("idea_not_found", "暂存想法不存在或无权访问", status_code=404) + return row + + async def _commit(self) -> None: + try: + await self.db.commit() + except Exception: + await self.db.rollback() + raise + + def _to_read(self, row: SavedIdea) -> SavedIdeaRead: + return SavedIdeaRead( + id=row.idea_id, + title=row.title, + raw_text=row.raw_text, + summary=row.summary, + source_type=row.source_type, + source_ref=row.source_ref, + input_mode=row.input_mode, + attachments=self._safe_list(row.attachments), + status=row.status, + status_label=STATUS_LABELS.get(row.status, "未整理"), + ai_summary=row.ai_summary, + missing_questions=self._safe_str_list(row.missing_questions), + readiness_score=float(row.readiness_score or 0.0), + enrichment_status=str(row.enrichment_status or "baseline"), + enrichment_payload=self._safe_dict(row.enrichment_payload), + enrichment_error=row.enrichment_error, + enriched_at=row.enriched_at.isoformat() if row.enriched_at else None, + launch_draft_id=row.launch_draft_id, + promoted_project_id=int(row.promoted_project_id) if row.promoted_project_id is not None else None, + created_at=row.created_at.isoformat() if row.created_at else "", + updated_at=row.updated_at.isoformat() if row.updated_at else "", + href=f"/project-launch?ideaId={row.idea_id}", + ) + + def _snapshot(self, row: SavedIdea) -> dict[str, Any]: + return { + "idea_id": row.idea_id, + "title": row.title, + "status": row.status, + "readiness_score": row.readiness_score, + "enrichment_status": row.enrichment_status, + "launch_draft_id": row.launch_draft_id, + } + + @staticmethod + def _clean_text(value: str) -> str: + return re.sub(r"\s+", " ", str(value or "").strip()) + + def _derive_title(self, raw_text: str) -> str: + text = self._clean_text(raw_text) + separators = ["。", "!", "?", "\n", ".", "!", "?"] + end = min([idx for idx in (text.find(s) for s in separators) if idx >= 0] or [len(text)]) + title = text[:end].strip() or text + return title[:32] if len(title) > 32 else title + + def _derive_summary(self, raw_text: str) -> str: + text = self._clean_text(raw_text) + return text[:120] + ("..." if len(text) > 120 else "") + + async def _enrich_or_baseline(self, raw_text: str) -> dict[str, Any]: + baseline = self._baseline_enrichment(raw_text) + prompt_result = await self._render_enrichment_prompt(raw_text=raw_text) + try: + structured = await llm_service.generate_structured_object( + prompt_result.prompt, + db=self.db, + tenant_id=self.tenant_id, + function_name="space_saved_idea_enrichment_v1", + temperature=0.0, + max_tokens=900, + response_mode="fast", + routing_meta={ + "scene": "space_saved_idea_enrichment", + "tier_alias": "low", + "response_contract": "structured", + "prompt_runtime_tenant_id": PLATFORM_RUNTIME_TENANT_ID, + **prompt_result.routing_meta(), + }, + ) + except Exception as exc: + baseline["enrichment_status"] = "ai_failed" + baseline["enrichment_error"] = self._clean_text(str(exc))[:500] or "AI enrichment failed" + return baseline + + payload = structured.payload if structured is not None and isinstance(structured.payload, dict) else {} + return self._normalize_ai_enrichment(raw_text, payload, baseline) + + async def _render_enrichment_prompt(self, *, raw_text: str) -> PromptRuntimeRenderResult: + return await PromptRuntimeService(self.db).render( + tenant_id=PLATFORM_RUNTIME_TENANT_ID, + prompt_key=self.ENRICHMENT_PROMPT_KEY, + fallback_template=PROMPT_SAVED_IDEA_ENRICHMENT, + raw_text=raw_text[:4000], + ) + + def _baseline_enrichment(self, raw_text: str) -> dict[str, Any]: + summary = self._derive_summary(raw_text) + missing_questions = self._missing_questions(raw_text) + readiness_score = self._readiness_score(raw_text) + return { + "summary": summary, + "status": self._initial_status(raw_text), + "ai_summary": None, + "missing_questions": missing_questions, + "readiness_score": readiness_score, + "enrichment_status": "baseline", + "enrichment_payload": { + "source": "system_baseline", + "summary": summary, + "missing_questions": missing_questions, + "readiness_score": readiness_score, + }, + "enrichment_error": None, + "enriched_at": None, + } + + def _normalize_ai_enrichment(self, raw_text: str, payload: dict[str, Any], baseline: dict[str, Any]) -> dict[str, Any]: + summary = self._clean_text(str(payload.get("summary") or "")) or str(baseline["summary"]) + missing_questions = self._safe_str_list(payload.get("missing_questions"))[:3] or list(baseline["missing_questions"]) + score = self._clamp_score(payload.get("readiness_score"), fallback=float(baseline["readiness_score"])) + raw_status = str(payload.get("status") or "").strip() + status = raw_status if raw_status in {"needs_more_info", "enriched", "draft_ready"} else self._status_from_score(score) + confidence = self._clamp_score(payload.get("confidence"), fallback=0.0) + enrichment_payload = { + "source": "ai_structured", + "summary": summary, + "target_user": self._optional_text(payload.get("target_user")), + "problem": self._optional_text(payload.get("problem")), + "value_hypothesis": self._optional_text(payload.get("value_hypothesis")), + "validation_signal": self._optional_text(payload.get("validation_signal")), + "constraints": self._safe_str_list(payload.get("constraints"))[:5], + "missing_questions": missing_questions, + "readiness_score": score, + "status": status, + "confidence": confidence, + "input_excerpt": self._derive_summary(raw_text), + } + return { + "summary": summary, + "status": status, + "ai_summary": summary, + "missing_questions": missing_questions, + "readiness_score": score, + "enrichment_status": "ai_enriched", + "enrichment_payload": enrichment_payload, + "enrichment_error": None, + "enriched_at": now_cn_naive(), + } + + def _missing_questions(self, raw_text: str) -> list[str]: + text = raw_text.lower() + questions: list[str] = [] + if not any(keyword in text for keyword in ["面向", "用户", "客户", "人群", "who", "user"]): + questions.append("目标用户是谁?") + if not any(keyword in text for keyword in ["解决", "问题", "痛点", "提升", "减少", "problem"]): + questions.append("要解决的核心问题是什么?") + if not any(keyword in text for keyword in ["成功", "指标", "验证", "收入", "留存", "roi", "metric"]): + questions.append("什么信号代表这个想法值得推进?") + return questions[:3] + + def _readiness_score(self, raw_text: str) -> float: + length_score = min(len(raw_text) / 400, 0.45) + completeness = (3 - len(self._missing_questions(raw_text))) * 0.18 + return round(min(0.99, 0.15 + length_score + completeness), 2) + + def _initial_status(self, raw_text: str) -> str: + score = self._readiness_score(raw_text) + return self._status_from_score(score) + + def _status_from_score(self, score: float) -> str: + if score >= 0.78: + return "draft_ready" + if score >= 0.55: + return "enriched" + return "needs_more_info" + + @staticmethod + def _safe_list(value: Any) -> list[dict[str, Any]]: + return value if isinstance(value, list) else [] + + @staticmethod + def _safe_str_list(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item).strip()[:500] for item in value if str(item).strip()] + + @staticmethod + def _safe_dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + @staticmethod + def _optional_text(value: Any) -> str | None: + text = str(value or "").strip() + return text[:500] if text and text.lower() != "null" else None + + @staticmethod + def _clamp_score(value: Any, *, fallback: float) -> float: + try: + score = float(value) + except (TypeError, ValueError): + score = float(fallback) + return round(max(0.0, min(1.0, score)), 2) + + +__all__ = ["SavedIdeaError", "SpaceSavedIdeaService"] diff --git a/backend/app/services/team_execution_capability_service.py b/backend/app/services/team_execution_capability_service.py index 05fc7c5d..e954fc7c 100644 --- a/backend/app/services/team_execution_capability_service.py +++ b/backend/app/services/team_execution_capability_service.py @@ -183,7 +183,9 @@ class TeamExecutionCapabilityService: execution_summary: Any, workspace_map: dict[int, list[str]], ) -> PMFlowPersonalExecutionReadiness: - pending_confirmations = len([item for item in truth_state.changeItems if item.status == "pending_review"]) + pending_confirmations = len( + [item for item in truth_state.changeItems if self._read_field(item, "status") == "pending_review"] + ) pending_reviews = len( [ item @@ -414,6 +416,12 @@ class TeamExecutionCapabilityService: return ["ai_with_user_acceptance", "ai_direct"] return ["ai_with_user_acceptance"] + @staticmethod + def _read_field(item: Any, field: str) -> Any: + if isinstance(item, dict): + return item.get(field) + return getattr(item, field, None) + @staticmethod def _tool_label(editor_type: str) -> str: normalized = editor_type.lower() diff --git a/backend/app/services/workspace_service.py b/backend/app/services/workspace_service.py new file mode 100644 index 00000000..4ec5cbc9 --- /dev/null +++ b/backend/app/services/workspace_service.py @@ -0,0 +1,611 @@ +"""Workspace lifecycle and membership service. + +This module keeps the current single-tenant implementation compatible while +introducing the product model: every account owns a personal workspace and may +join team workspaces through memberships. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta +import hashlib +import secrets +from typing import Any + +from fastapi import HTTPException, status +from sqlalchemy import and_, func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import Project, Tenant, TenantInvitation, TenantMembership, User, UserProject, UserStatus +from ..services.auth import AuthService, has_any_role +from ..utils.timezone import now_cn_naive + +INVITATION_TOKEN_BYTES = 32 +INVITATION_EXPIRE_DAYS = 7 +ACTIVE_MEMBERSHIP_STATUS = "active" +PERSONAL_WORKSPACE_TYPE = "personal" +TEAM_WORKSPACE_TYPE = "team" +VALID_WORKSPACE_ROLES = {"OWNER", "REVIEWER", "BUILDER", "OPERATOR"} +TEAM_INVITABLE_ROLES = {"REVIEWER", "BUILDER", "OPERATOR"} + + +def hash_invitation_token(token: str) -> str: + return hashlib.sha256(str(token or "").encode("utf-8")).hexdigest() + + +def _normalize_email(email: str) -> str: + normalized = str(email or "").strip().lower() + if not normalized or "@" not in normalized: + raise HTTPException(status_code=400, detail="有效邮箱不能为空") + return normalized + + +def _phone_invitation_email(phone_e164: str) -> str: + safe = "".join(ch for ch in str(phone_e164 or "") if ch.isdigit()) + return f"{safe}@phone.pmflow.local" + + +def _normalize_role(role: str) -> str: + normalized = str(role or "").strip().upper() + if normalized not in VALID_WORKSPACE_ROLES: + raise HTTPException(status_code=400, detail=f"无效空间角色:{role}") + return normalized + + +def _workspace_payload(tenant: Tenant, membership: TenantMembership | None = None) -> dict[str, Any]: + return { + "id": int(tenant.id), + "name": tenant.name, + "domain": tenant.domain, + "workspace_type": tenant.workspace_type, + "slug": tenant.slug, + "owner_user_id": int(tenant.owner_user_id) if tenant.owner_user_id is not None else None, + "status": tenant.status, + "role": membership.role if membership else None, + "membership_status": membership.status if membership else None, + "is_personal_owner": bool(membership.is_personal_owner) if membership else False, + } + + +async def ensure_personal_workspace( + db: AsyncSession, + *, + user: User, + commit: bool = False, +) -> Tenant: + """Ensure a user has a personal workspace and membership.""" + row = await db.execute( + select(Tenant) + .join(TenantMembership, TenantMembership.tenant_id == Tenant.id) + .where( + and_( + TenantMembership.user_id == int(user.id), + TenantMembership.status == ACTIVE_MEMBERSHIP_STATUS, + TenantMembership.is_personal_owner.is_(True), + Tenant.workspace_type == PERSONAL_WORKSPACE_TYPE, + ) + ) + .order_by(Tenant.id.asc()) + ) + existing = row.scalars().first() + if existing: + return existing + + display_name = str(user.name or user.username or "我的").strip() + tenant = Tenant( + name=f"{display_name} 的个人空间", + domain=None, + workspace_type=PERSONAL_WORKSPACE_TYPE, + slug=f"personal-{int(user.id)}", + owner_user_id=int(user.id), + status="active", + ) + db.add(tenant) + await db.flush() + db.add( + TenantMembership( + tenant_id=int(tenant.id), + user_id=int(user.id), + role="OWNER", + status=ACTIVE_MEMBERSHIP_STATUS, + is_personal_owner=True, + ) + ) + if commit: + await db.commit() + await db.refresh(tenant) + return tenant + + +async def ensure_active_workspace_membership( + db: AsyncSession, + *, + user: User, + commit: bool = False, +) -> TenantMembership: + """Backfill and return membership for the user's current compatibility tenant.""" + tenant_id = int(getattr(user, "tenant_id", 0) or 0) + if tenant_id <= 0: + personal = await ensure_personal_workspace(db, user=user, commit=False) + user.tenant_id = int(personal.id) + user.role = "OWNER" + tenant_id = int(personal.id) + + row = await db.execute( + select(TenantMembership).where( + and_( + TenantMembership.tenant_id == tenant_id, + TenantMembership.user_id == int(user.id), + ) + ) + ) + membership = row.scalar_one_or_none() + if not membership: + membership = TenantMembership( + tenant_id=tenant_id, + user_id=int(user.id), + role=str(user.role or "BUILDER").upper(), + status=ACTIVE_MEMBERSHIP_STATUS, + is_personal_owner=False, + ) + db.add(membership) + await db.flush() + if commit: + await db.commit() + await db.refresh(membership) + return membership + + +async def register_user_with_personal_workspace( + db: AsyncSession, + *, + username: str, + password: str, + name: str, + email: str, +) -> User: + normalized_email = _normalize_email(email) + username_value = str(username or "").strip() + if not username_value: + raise HTTPException(status_code=400, detail="用户名不能为空") + + existing = ( + await db.execute( + select(User).where((func.lower(User.username) == username_value.lower()) | (func.lower(User.email) == normalized_email)) + ) + ).scalar_one_or_none() + if existing: + raise HTTPException(status_code=409, detail="用户名或邮箱已存在") + + tenant = Tenant( + name=f"{(name or username_value).strip()} 的个人空间", + workspace_type=PERSONAL_WORKSPACE_TYPE, + slug=None, + owner_user_id=None, + status="active", + ) + db.add(tenant) + await db.flush() + user = User( + tenant_id=int(tenant.id), + username=username_value, + password_hash=AuthService.hash_password(password), + name=(name or username_value).strip(), + email=normalized_email, + role="OWNER", + assigned_domains=[], + workload=0, + status=UserStatus.ACTIVE.value, + is_active=True, + ) + db.add(user) + await db.flush() + tenant.owner_user_id = int(user.id) + tenant.slug = f"personal-{int(user.id)}" + db.add( + TenantMembership( + tenant_id=int(tenant.id), + user_id=int(user.id), + role="OWNER", + status=ACTIVE_MEMBERSHIP_STATUS, + is_personal_owner=True, + ) + ) + await db.commit() + await db.refresh(user) + return user + + +async def list_user_workspaces(db: AsyncSession, *, user: User) -> dict[str, Any]: + await ensure_personal_workspace(db, user=user, commit=False) + await ensure_active_workspace_membership(db, user=user, commit=False) + await db.commit() + rows = ( + await db.execute( + select(Tenant, TenantMembership) + .join(TenantMembership, TenantMembership.tenant_id == Tenant.id) + .where( + and_( + TenantMembership.user_id == int(user.id), + TenantMembership.status == ACTIVE_MEMBERSHIP_STATUS, + Tenant.status == "active", + ) + ) + .order_by(Tenant.workspace_type.desc(), Tenant.id.asc()) + ) + ).all() + workspaces = [_workspace_payload(tenant, membership) for tenant, membership in rows] + active_workspace = next((item for item in workspaces if int(item["id"]) == int(user.tenant_id)), None) + return {"active_workspace": active_workspace, "workspaces": workspaces} + + +async def create_team_workspace(db: AsyncSession, *, current_user: User, name: str) -> dict[str, Any]: + team_name = str(name or "").strip() + if not team_name: + raise HTTPException(status_code=400, detail="团队空间名称不能为空") + tenant = Tenant( + name=team_name, + workspace_type=TEAM_WORKSPACE_TYPE, + owner_user_id=int(current_user.id), + status="active", + ) + db.add(tenant) + await db.flush() + membership = TenantMembership( + tenant_id=int(tenant.id), + user_id=int(current_user.id), + role="OWNER", + status=ACTIVE_MEMBERSHIP_STATUS, + is_personal_owner=False, + ) + db.add(membership) + current_user.tenant_id = int(tenant.id) + current_user.role = "OWNER" + await db.commit() + await db.refresh(tenant) + return _workspace_payload(tenant, membership) + + +async def switch_workspace(db: AsyncSession, *, current_user: User, tenant_id: int) -> dict[str, Any]: + row = ( + await db.execute( + select(Tenant, TenantMembership) + .join(TenantMembership, TenantMembership.tenant_id == Tenant.id) + .where( + and_( + Tenant.id == int(tenant_id), + Tenant.status == "active", + TenantMembership.user_id == int(current_user.id), + TenantMembership.status == ACTIVE_MEMBERSHIP_STATUS, + ) + ) + ) + ).one_or_none() + if not row: + raise HTTPException(status_code=403, detail="你不是该空间成员") + tenant, membership = row + current_user.tenant_id = int(tenant.id) + current_user.role = str(membership.role or current_user.role) + await db.commit() + await db.refresh(current_user) + return _workspace_payload(tenant, membership) + + +async def create_invitation( + db: AsyncSession, + *, + current_user: User, + email: str | None = None, + phone_country_code: str | None = None, + phone_number: str | None = None, + role: str, + project_ids: list[int] | None = None, +) -> dict[str, Any]: + workspace_role = str(current_user.role or "").upper() + invite_role = _normalize_role(role) + if invite_role not in TEAM_INVITABLE_ROLES: + raise HTTPException(status_code=400, detail="邀请成员角色只能是 REVIEWER、BUILDER 或 OPERATOR") + if not has_any_role(workspace_role, ["OWNER", "REVIEWER"]): + raise HTTPException(status_code=403, detail="只有空间负责人可以邀请成员") + if invite_role == "REVIEWER" and not has_any_role(workspace_role, ["OWNER"]): + raise HTTPException(status_code=403, detail="只有 OWNER 可以邀请 REVIEWER") + + from .phone_auth_service import mask_phone, normalize_phone + + normalized_phone = normalize_phone(phone_country_code, phone_number) + normalized_email = _normalize_email(email or _phone_invitation_email(normalized_phone["phone_e164"])) + tenant_id = int(current_user.tenant_id) + project_id_list = sorted({int(pid) for pid in (project_ids or []) if int(pid) > 0}) + if project_id_list: + existing_projects = ( + await db.execute( + select(Project.id).where(and_(Project.tenant_id == tenant_id, Project.id.in_(project_id_list))) + ) + ).scalars().all() + if len(set(existing_projects)) != len(project_id_list): + raise HTTPException(status_code=400, detail="项目不属于当前空间") + + existing_invitation = ( + await db.execute( + select(TenantInvitation).where( + and_( + TenantInvitation.tenant_id == tenant_id, + TenantInvitation.phone_e164 == normalized_phone["phone_e164"], + TenantInvitation.status == "pending", + ) + ) + ) + ).scalar_one_or_none() + + token = secrets.token_urlsafe(INVITATION_TOKEN_BYTES) + if existing_invitation: + invitation = existing_invitation + invitation.email = normalized_email + invitation.phone_country_code = normalized_phone["phone_country_code"] + invitation.phone_number = normalized_phone["phone_number"] + invitation.phone_e164 = normalized_phone["phone_e164"] + invitation.role = invite_role + invitation.project_ids = project_id_list + invitation.token_hash = hash_invitation_token(token) + invitation.expires_at = now_cn_naive() + timedelta(days=INVITATION_EXPIRE_DAYS) + invitation.invited_by_user_id = int(current_user.id) + else: + invitation = TenantInvitation( + tenant_id=tenant_id, + email=normalized_email, + phone_country_code=normalized_phone["phone_country_code"], + phone_number=normalized_phone["phone_number"], + phone_e164=normalized_phone["phone_e164"], + role=invite_role, + project_ids=project_id_list, + token_hash=hash_invitation_token(token), + status="pending", + expires_at=now_cn_naive() + timedelta(days=INVITATION_EXPIRE_DAYS), + invited_by_user_id=int(current_user.id), + ) + db.add(invitation) + try: + await db.commit() + await db.refresh(invitation) + except IntegrityError as exc: + await db.rollback() + raise HTTPException(status_code=409, detail="邀请链接生成冲突,请重试") from exc + return { + "id": int(invitation.id), + "email": invitation.email, + "phone_country_code": invitation.phone_country_code, + "phone_number": invitation.phone_number, + "phone_e164": invitation.phone_e164, + "masked_phone": mask_phone(invitation.phone_e164 or ""), + "role": invitation.role, + "project_ids": invitation.project_ids or [], + "status": invitation.status, + "expires_at": invitation.expires_at.isoformat(), + "token": token, + } + + +async def _apply_invitation_to_user( + db: AsyncSession, + *, + invitation: TenantInvitation, + user: User, + switch_to_workspace: bool, +) -> dict[str, Any]: + membership = ( + await db.execute( + select(TenantMembership).where( + and_( + TenantMembership.tenant_id == int(invitation.tenant_id), + TenantMembership.user_id == int(user.id), + ) + ) + ) + ).scalar_one_or_none() + if not membership: + membership = TenantMembership( + tenant_id=int(invitation.tenant_id), + user_id=int(user.id), + role=invitation.role, + status=ACTIVE_MEMBERSHIP_STATUS, + is_personal_owner=False, + ) + db.add(membership) + else: + membership.role = invitation.role + membership.status = ACTIVE_MEMBERSHIP_STATUS + + for project_id in sorted({int(pid) for pid in (invitation.project_ids or [])}): + exists = ( + await db.execute( + select(UserProject.id).where( + and_( + UserProject.tenant_id == int(invitation.tenant_id), + UserProject.user_id == int(user.id), + UserProject.project_id == project_id, + ) + ) + ) + ).scalar_one_or_none() + if exists is None: + db.add(UserProject(tenant_id=int(invitation.tenant_id), user_id=int(user.id), project_id=project_id)) + + invitation.status = "accepted" + invitation.accepted_by_user_id = int(user.id) + if switch_to_workspace: + user.tenant_id = int(invitation.tenant_id) + user.role = invitation.role + tenant = (await db.execute(select(Tenant).where(Tenant.id == int(invitation.tenant_id)))).scalar_one_or_none() + return { + "id": int(invitation.id), + "workspace_id": int(invitation.tenant_id), + "workspace_name": tenant.name if tenant else None, + "role": invitation.role, + "project_ids": invitation.project_ids or [], + "status": invitation.status, + } + + +async def accept_invitation_by_verified_phone( + db: AsyncSession, + *, + token: str, + user: User, + phone_e164: str, + switch_to_workspace: bool = True, +) -> dict[str, Any]: + invitation = ( + await db.execute( + select(TenantInvitation).where(TenantInvitation.token_hash == hash_invitation_token(token)) + ) + ).scalar_one_or_none() + if not invitation or invitation.status != "pending": + raise HTTPException(status_code=404, detail="邀请不存在或已失效") + if invitation.expires_at < now_cn_naive(): + invitation.status = "expired" + await db.flush() + raise HTTPException(status_code=410, detail="邀请已过期") + if str(invitation.phone_e164 or "") != str(phone_e164 or ""): + raise HTTPException(status_code=403, detail="当前验证手机号与邀请手机号不一致") + return await _apply_invitation_to_user(db, invitation=invitation, user=user, switch_to_workspace=switch_to_workspace) + + +async def accept_pending_invitations_for_phone( + db: AsyncSession, + *, + user: User, + phone_e164: str, + auto_accept: bool = False, +) -> list[dict[str, Any]]: + rows = ( + await db.execute( + select(TenantInvitation) + .where( + and_( + TenantInvitation.phone_e164 == phone_e164, + TenantInvitation.status == "pending", + TenantInvitation.expires_at >= now_cn_naive(), + ) + ) + .order_by(TenantInvitation.created_at.asc(), TenantInvitation.id.asc()) + ) + ).scalars().all() + if not auto_accept: + payload: list[dict[str, Any]] = [] + for invitation in rows: + tenant = (await db.execute(select(Tenant).where(Tenant.id == int(invitation.tenant_id)))).scalar_one_or_none() + payload.append( + { + "id": int(invitation.id), + "workspace_id": int(invitation.tenant_id), + "workspace_name": tenant.name if tenant else None, + "role": invitation.role, + "project_ids": invitation.project_ids or [], + "expires_at": invitation.expires_at.isoformat(), + } + ) + return payload + accepted: list[dict[str, Any]] = [] + for invitation in rows: + accepted.append(await _apply_invitation_to_user(db, invitation=invitation, user=user, switch_to_workspace=False)) + return accepted + + +async def accept_invitation( + db: AsyncSession, + *, + token: str, + current_user: User | None = None, + new_user: Any | None = None, +) -> dict[str, Any]: + invitation = ( + await db.execute( + select(TenantInvitation).where(TenantInvitation.token_hash == hash_invitation_token(token)) + ) + ).scalar_one_or_none() + if not invitation or invitation.status != "pending": + raise HTTPException(status_code=404, detail="邀请不存在或已失效") + if invitation.expires_at < now_cn_naive(): + invitation.status = "expired" + await db.commit() + raise HTTPException(status_code=410, detail="邀请已过期") + + user = current_user + if user is None: + if new_user is None: + raise HTTPException(status_code=401, detail="接受邀请前需要登录或创建账号") + if _normalize_email(new_user.email or "") != _normalize_email(invitation.email): + raise HTTPException(status_code=400, detail="注册邮箱必须与邀请邮箱一致") + user = await register_user_with_personal_workspace( + db, + username=new_user.username, + password=new_user.password, + name=new_user.name, + email=new_user.email or invitation.email, + ) + else: + if invitation.phone_e164: + if str(getattr(user, "phone_e164", "") or "") != str(invitation.phone_e164): + raise HTTPException(status_code=403, detail="当前账号手机号与邀请手机号不一致") + elif _normalize_email(user.email) != _normalize_email(invitation.email): + raise HTTPException(status_code=403, detail="当前账号邮箱与邀请邮箱不一致") + await ensure_personal_workspace(db, user=user, commit=False) + await _apply_invitation_to_user(db, invitation=invitation, user=user, switch_to_workspace=True) + await db.commit() + await db.refresh(user) + return {"user": user.to_dict(), "workspace_id": int(invitation.tenant_id), "role": invitation.role} + + +async def leave_workspace(db: AsyncSession, *, current_user: User, tenant_id: int) -> dict[str, Any]: + membership = ( + await db.execute( + select(TenantMembership).where( + and_( + TenantMembership.tenant_id == int(tenant_id), + TenantMembership.user_id == int(current_user.id), + TenantMembership.status == ACTIVE_MEMBERSHIP_STATUS, + ) + ) + ) + ).scalar_one_or_none() + if not membership: + raise HTTPException(status_code=404, detail="你不是该空间成员") + + tenant = (await db.execute(select(Tenant).where(Tenant.id == int(tenant_id)))).scalar_one_or_none() + if tenant and tenant.workspace_type == PERSONAL_WORKSPACE_TYPE: + raise HTTPException(status_code=400, detail="个人空间不能退出") + + if membership.role == "OWNER": + owner_count = int( + ( + await db.execute( + select(func.count(TenantMembership.id)).where( + and_( + TenantMembership.tenant_id == int(tenant_id), + TenantMembership.status == ACTIVE_MEMBERSHIP_STATUS, + TenantMembership.role == "OWNER", + ) + ) + ) + ).scalar() + or 0 + ) + if owner_count <= 1: + raise HTTPException(status_code=409, detail="退出前需要先转让 OWNER") + + membership.status = "removed" + await db.execute( + UserProject.__table__.delete().where( + and_( + UserProject.tenant_id == int(tenant_id), + UserProject.user_id == int(current_user.id), + ) + ) + ) + if int(current_user.tenant_id) == int(tenant_id): + personal = await ensure_personal_workspace(db, user=current_user, commit=False) + current_user.tenant_id = int(personal.id) + current_user.role = "OWNER" + await db.commit() + return {"left_workspace_id": int(tenant_id), "active_workspace_id": int(current_user.tenant_id)} diff --git a/backend/tests/test_adaptive_rag_mainline_contract.py b/backend/tests/test_adaptive_rag_mainline_contract.py index 13b1fb64..7d104e3a 100644 --- a/backend/tests/test_adaptive_rag_mainline_contract.py +++ b/backend/tests/test_adaptive_rag_mainline_contract.py @@ -1565,7 +1565,7 @@ async def test_execute_mainline_skips_snapshot_direct_for_project_existence_targ _ = kwargs return None - async def _try_glm_project_semantic_answer(self, **kwargs): + async def _try_fast_project_semantic_answer(self, **kwargs): _ = kwargs return None @@ -4261,7 +4261,7 @@ async def test_internal_model_config_fast_answer_reads_runtime_engine_bundle() - { "id": "low-1", "name": "Qwen 低阶引擎", - "provider": "glm", + "provider": "dashscope", "model": "qwen-plus", "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", }, @@ -4287,13 +4287,13 @@ async def test_internal_model_config_fast_answer_reads_runtime_engine_bundle() - if function_name == "adaptive_rag_answer_deep": return { "name": "Qwen 低阶引擎", - "provider": "glm", + "provider": "dashscope", "model": "qwen-plus", "_routing_meta": {"planned_tier": "low", "executed_tier": "low"}, } return { "name": "Qwen 低阶引擎", - "provider": "glm", + "provider": "dashscope", "model": "qwen-plus", "_routing_meta": {"planned_tier": "low", "executed_tier": "low"}, } @@ -6418,7 +6418,7 @@ async def test_execute_mainline_skips_followup_clarification_after_state_subject _ = kwargs return None - async def _try_glm_project_semantic_answer(self, **kwargs): + async def _try_fast_project_semantic_answer(self, **kwargs): _ = kwargs return None @@ -6717,8 +6717,8 @@ async def test_execute_mainline_blocks_internal_route_when_internal_context_pack async def _build_internal_model_config_fast_answer(self, **kwargs): # pragma: no cover - should not be called raise AssertionError(f"internal model config fast answer should be blocked: {kwargs}") - async def _try_glm_project_semantic_answer(self, **kwargs): # pragma: no cover - should not be called - raise AssertionError(f"glm semantic fast answer should be blocked: {kwargs}") + async def _try_fast_project_semantic_answer(self, **kwargs): # pragma: no cover - should not be called + raise AssertionError(f"provider semantic fast answer should be blocked: {kwargs}") async def _build_retrieval_response(self, **kwargs): # pragma: no cover - should not be called raise AssertionError(f"retrieval should be blocked: {kwargs}") @@ -6895,8 +6895,8 @@ async def test_execute_mainline_blocks_internal_route_when_query_plan_contract_m async def _build_internal_model_config_fast_answer(self, **kwargs): # pragma: no cover - should not be called raise AssertionError(f"internal model config fast answer should be blocked: {kwargs}") - async def _try_glm_project_semantic_answer(self, **kwargs): # pragma: no cover - should not be called - raise AssertionError(f"glm semantic fast answer should be blocked: {kwargs}") + async def _try_fast_project_semantic_answer(self, **kwargs): # pragma: no cover - should not be called + raise AssertionError(f"provider semantic fast answer should be blocked: {kwargs}") async def _build_retrieval_response(self, **kwargs): # pragma: no cover - should not be called raise AssertionError(f"retrieval should be blocked: {kwargs}") diff --git a/backend/tests/test_ai_call_traces_api.py b/backend/tests/test_ai_call_traces_api.py new file mode 100644 index 00000000..ababe8c5 --- /dev/null +++ b/backend/tests/test_ai_call_traces_api.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +from datetime import datetime +from types import SimpleNamespace + +import pytest +import pytest_asyncio +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy import JSON +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.api import ai_call_traces_api +from app.core.database import get_db +from app.dependencies.permissions import require_platform_ai_ops +from app.models.ai_call_log import AICallLog +from app.models.base import Base +from app.models.integration import Integration + + +def _use_sqlite_json_types(*tables) -> None: + for table in tables: + for column in table.columns: + if column.type.__class__.__name__ in {"JSONB", "ARRAY", "Vector"}: + column.type = JSON() + + +@pytest_asyncio.fixture +async def db_session(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + _use_sqlite_json_types(AICallLog.__table__, Integration.__table__) + async with engine.begin() as conn: + await conn.run_sync(lambda sync_conn: Base.metadata.create_all(sync_conn, tables=[AICallLog.__table__, Integration.__table__])) + maker = async_sessionmaker(engine, expire_on_commit=False) + async with maker() as session: + yield session + await engine.dispose() + + +@pytest.mark.asyncio +async def test_ai_call_trace_list_and_detail_redact_payload(db_session) -> None: + db_session.add( + Integration( + tenant_id=1, + type="llm_engine", + is_active=True, + config={ + "engines": [ + { + "id": "engine-qwen-plus", + "name": "阿里云-qwen-plus", + "provider": "dashscope", + "model": "qwen-plus", + } + ] + }, + ) + ) + db_session.add( + AICallLog( + tenant_id=1, + user_id=7, + function_name="pmflow_launch_draft_v1", + prompt="用户手机号 13800138000,key=sk-secretsecretsecretsecret,请生成项目草案", + response='{"ok":true,"routing_meta":{"scene_key":"project_launch","slot_key":"structured"}}', + tokens_used=128, + cost=0.001, + status="success", + trace_id="trace-1", + provider="dashscope", + model="qwen-plus", + latency_ms=345, + routing_meta={"scene_key": "project_launch", "slot_key": "structured", "route_source": "active_slot"}, + prompt_key="PROMPT_PM_FLOW_LAUNCH_DRAFT", + prompt_release_id=12, + prompt_source="active_release", + provider_request_payload={ + "model": "qwen-plus", + "messages": [ + {"role": "system", "content": "你是项目助手"}, + {"role": "user", "content": "手机号 13800138000,key=sk-secretsecretsecretsecret"}, + ], + "temperature": 0.0, + "max_tokens": 5600, + }, + provider_response_payload={ + "id": "cmpl-test", + "choices": [{"message": {"content": "{\"ok\":true}"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 90, "completion_tokens": 38, "total_tokens": 128}, + }, + provider_attempts=[ + { + "provider": "dashscope", + "model": "qwen-plus", + "request_payload": {"model": "qwen-plus", "messages": [{"role": "user", "content": "sk-secretsecretsecretsecret"}]}, + "response_payload": {"choices": [{"message": {"content": "{\"ok\":true}"}}]}, + "status": "success", + } + ], + planned_tier="structured", + executed_tier="structured", + routing_decision="slot", + route_source="active_slot", + route_rule_name="project_launch", + created_at=datetime(2026, 4, 29, 10, 0, 0), + ) + ) + await db_session.commit() + + app = FastAPI() + app.include_router(ai_call_traces_api.router) + + async def override_get_db(): + yield db_session + + app.dependency_overrides[get_db] = override_get_db + app.dependency_overrides[require_platform_ai_ops] = lambda: SimpleNamespace(id=1, role="OWNER") + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + list_response = await client.get("/ai-call-traces?provider=dashscope&scene_key=project_launch&prompt_key=PROMPT_PM_FLOW_LAUNCH_DRAFT") + detail_response = await client.get("/ai-call-traces/trace-1") + + assert list_response.status_code == 200 + payload = list_response.json()["data"] + assert payload["summary"]["total_calls"] == 1 + assert payload["items"][0]["provider"] == "dashscope" + assert payload["items"][0]["provider_code"] == "dashscope" + assert payload["items"][0]["provider_label"] == "Qwen / DashScope" + assert payload["items"][0]["runtime_label"] == "阿里云-qwen-plus" + assert payload["items"][0]["engine_name"] == "阿里云-qwen-plus" + assert payload["items"][0]["function_label"] == "新项目草案生成" + assert payload["items"][0]["scene_key"] == "project_launch" + assert payload["items"][0]["slot_key"] == "structured" + assert payload["items"][0]["prompt_key"] == "PROMPT_PM_FLOW_LAUNCH_DRAFT" + assert payload["items"][0]["prompt_release_id"] == 12 + assert payload["items"][0]["prompt_source"] == "active_release" + assert payload["items"][0]["prompt_tokens"] == 90 + assert payload["items"][0]["completion_tokens"] == 38 + assert payload["items"][0]["request_max_tokens"] == 5600 + assert payload["items"][0]["finish_reason"] == "stop" + assert "13800138000" not in payload["items"][0]["prompt_preview"] + assert "sk-secret" not in payload["items"][0]["prompt_preview"] + + assert detail_response.status_code == 200 + detail = detail_response.json()["data"] + assert detail["request"]["prompt_chars"] > 0 + assert detail["runtime_label"] == "阿里云-qwen-plus" + assert "[REDACTED]" in detail["request"]["prompt_redacted"] + assert detail["request"]["provider_request_payload_chars"] > 0 + assert "qwen-plus" in detail["request"]["provider_request_payload_redacted"] + assert "13800138000" not in detail["request"]["provider_request_payload_redacted"] + assert "sk-secret" not in detail["request"]["provider_request_payload_redacted"] + assert detail["response"]["provider_response_payload_chars"] > 0 + assert detail["response"]["provider_attempts_count"] == 1 + assert detail["routing"]["routing_meta"]["slot_key"] == "structured" + assert detail["audit"]["raw_payload_available"] is True + + +@pytest.mark.asyncio +async def test_ai_call_trace_list_shows_real_capability_probe_calls_by_default(db_session) -> None: + db_session.add_all( + [ + AICallLog( + tenant_id=1, + user_id=7, + function_name="probe_engine_capabilities", + prompt="basic_chat,structured_output", + response='{"basic_chat":{"status":"verified"}}', + tokens_used=100, + cost=0.001, + status="capability_probe_partial", + trace_id="trace-probe-summary", + provider="dashscope", + model="qwen-plus", + routing_meta={ + "scene_key": "platform_ai_ops", + "routing_decision": "direct_capability_probe", + "route_source": "provider_console", + "probe_run_id": "cap-1", + "probe_targets": ["basic_chat", "structured_output"], + }, + provider_attempts=[ + {"probe_target": "basic_chat", "status": "success"}, + {"probe_target": "structured_output", "status": "success"}, + ], + created_at=datetime(2026, 4, 29, 10, 1, 0), + ), + AICallLog( + tenant_id=1, + user_id=7, + function_name="probe_capability_basic_chat", + prompt="发送最小连通 prompt,要求模型只返回 OK。", + response="OK", + tokens_used=6, + cost=0.00001, + status="capability_verified", + trace_id="trace-probe-basic-chat", + provider="dashscope", + model="qwen-plus", + routing_meta={ + "scene_key": "platform_ai_ops", + "routing_decision": "direct_capability_probe", + "route_source": "provider_console", + "probe_run_id": "cap-1", + "probe_target": "basic_chat", + }, + created_at=datetime(2026, 4, 29, 10, 1, 1), + ), + ] + ) + await db_session.commit() + + app = FastAPI() + app.include_router(ai_call_traces_api.router) + + async def override_get_db(): + yield db_session + + app.dependency_overrides[get_db] = override_get_db + app.dependency_overrides[require_platform_ai_ops] = lambda: SimpleNamespace(id=1, role="OWNER") + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + default_response = await client.get("/ai-call-traces?provider=dashscope") + legacy_summary_response = await client.get("/ai-call-traces?provider=dashscope&function_name=probe_engine_capabilities") + + assert default_response.status_code == 200 + default_payload = default_response.json()["data"] + assert default_payload["total"] == 1 + assert default_payload["summary"]["total_calls"] == 1 + assert default_payload["items"][0]["function_name"] == "probe_capability_basic_chat" + assert default_payload["items"][0]["function_label"] == "能力探测:基础聊天" + + assert legacy_summary_response.status_code == 200 + legacy_summary_payload = legacy_summary_response.json()["data"] + assert legacy_summary_payload["total"] == 1 + assert legacy_summary_payload["items"][0]["function_name"] == "probe_engine_capabilities" + assert legacy_summary_payload["items"][0]["function_label"] == "供应商能力探测汇总(历史兼容)" + + +@pytest.mark.asyncio +async def test_ai_call_trace_structured_timeout_has_specific_diagnosis(db_session) -> None: + db_session.add( + AICallLog( + tenant_id=1, + user_id=7, + function_name="pmflow_launch_draft_v1", + prompt="请生成项目草案", + response="模型调用失败:function=pmflow_launch_draft_v1, reason=upstream_empty_or_invalid_response", + tokens_used=128, + cost=0.001, + status="structured_timeout", + trace_id="trace-timeout", + provider="dashscope", + model="qwen-plus", + routing_meta={ + "scene_key": "project_launch", + "slot_key": "structured", + "provider_timeout_seconds": 90, + "job_timeout_seconds": 180, + "input_bucket": "normal", + "scene_timeout_policy_id": "project_launch:pmflow_launch_draft_v1:normal", + "scene_timeout_policy_version": "scene-timeout-v1", + }, + provider_attempts=[ + {"status": "request_error", "error_type": "ReadTimeout", "error": ""}, + {"status": "request_error", "error_type": "ReadTimeout", "error": ""}, + ], + planned_tier="structured", + executed_tier="structured", + route_source="slot_binding", + created_at=datetime(2026, 5, 1, 19, 37, 42), + ) + ) + await db_session.commit() + + app = FastAPI() + app.include_router(ai_call_traces_api.router) + + async def override_get_db(): + yield db_session + + app.dependency_overrides[get_db] = override_get_db + app.dependency_overrides[require_platform_ai_ops] = lambda: SimpleNamespace(id=1, role="OWNER") + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + list_response = await client.get("/ai-call-traces?status=structured_timeout") + detail_response = await client.get("/ai-call-traces/trace-timeout") + + assert list_response.status_code == 200 + payload = list_response.json()["data"] + assert payload["summary"]["failed_calls"] == 1 + assert payload["items"][0]["status_label"] == "模型超时" + assert payload["items"][0]["error_diagnosis"]["code"] == "model_read_timeout" + assert payload["items"][0]["error_diagnosis"]["attempt_count"] == 2 + + assert detail_response.status_code == 200 + detail = detail_response.json()["data"] + assert detail["runtime_policy"]["provider_timeout_seconds"] == 90 + assert detail["runtime_policy"]["job_timeout_seconds"] == 180 + assert detail["runtime_policy"]["input_bucket"] == "normal" + assert detail["error_diagnosis"]["attempt_errors"][0]["error_type"] == "ReadTimeout" + assert "fallback runtime" in " ".join(detail["error_diagnosis"]["recommendation"]["recommended_actions"]) diff --git a/backend/tests/test_ai_pmo_ingress_mainline.py b/backend/tests/test_ai_pmo_ingress_mainline.py index d2ceb542..93783e5f 100644 --- a/backend/tests/test_ai_pmo_ingress_mainline.py +++ b/backend/tests/test_ai_pmo_ingress_mainline.py @@ -15929,7 +15929,7 @@ async def test_low_tier_rag_failure_retries_high_tier(monkeypatch) -> None: low_engine = { "id": "low-1", - "provider": "glm", + "provider": "dashscope", "model": "qwen-plus", "base_url": "https://open.bigmodel.cn/api/paas/v4", "api_key": "low-key", @@ -16067,7 +16067,7 @@ async def test_low_tier_intent_failure_retries_high_tier(monkeypatch) -> None: low_engine = { "id": "low-1", - "provider": "glm", + "provider": "dashscope", "model": "qwen-plus", "base_url": "https://open.bigmodel.cn/api/paas/v4", "api_key": "low-key", @@ -16098,7 +16098,7 @@ async def test_low_tier_intent_failure_retries_high_tier(monkeypatch) -> None: "enabled": True, "priority": 310, "tier": "low", - "provider": "glm", + "provider": "dashscope", "model": "qwen-plus", "api_env": "DASHSCOPE_API_KEY", "function_exact": "pmflow_semantic_context_decision", @@ -16218,7 +16218,7 @@ async def test_low_tier_structured_failure_retries_high_tier(monkeypatch) -> Non low_engine = { "id": "low-1", - "provider": "glm", + "provider": "dashscope", "model": "qwen-plus", "base_url": "https://open.bigmodel.cn/api/paas/v4", "api_key": "low-key", @@ -16249,7 +16249,7 @@ async def test_low_tier_structured_failure_retries_high_tier(monkeypatch) -> Non "enabled": True, "priority": 310, "tier": "low", - "provider": "glm", + "provider": "dashscope", "model": "qwen-plus", "api_env": "DASHSCOPE_API_KEY", "function_exact": "pmflow_semantic_context_decision", diff --git a/backend/tests/test_ai_prompt_registry_contract.py b/backend/tests/test_ai_prompt_registry_contract.py index 436efa3f..38a451fd 100644 --- a/backend/tests/test_ai_prompt_registry_contract.py +++ b/backend/tests/test_ai_prompt_registry_contract.py @@ -9,6 +9,7 @@ from app.config.ai_prompts import ( PROMPT_DECOMPOSE_TASKS, PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION, PROMPT_FEISHU_REQUIREMENT_ANALYSIS, + PROMPT_SAVED_IDEA_ENRICHMENT, PROMPT_TASK_BREAKDOWN, ) from app.config.ai_prompts_catalog import __all__ as PROMPT_CATALOG_EXPORTS @@ -18,8 +19,8 @@ def test_prompt_registry_covers_all_catalog_exports() -> None: catalog_keys = set(PROMPT_CATALOG_EXPORTS) registry_keys = set(iter_prompt_registry_keys()) - assert registry_keys == catalog_keys - assert set(PROMPT_REGISTRY_MAP) == catalog_keys + assert catalog_keys.issubset(registry_keys) + assert set(PROMPT_REGISTRY_MAP) == registry_keys for key in catalog_keys: entry = get_prompt_registry_entry(key) @@ -29,6 +30,9 @@ def test_prompt_registry_covers_all_catalog_exports() -> None: assert entry["responsibility_scope"] assert entry["priority"] in {"high", "medium", "low"} assert entry["audience"] + assert entry["editable_policy"] + assert entry["runtime_source"] + assert entry["risk_level"] in {"critical", "high", "medium", "low"} def test_prompt_registry_returns_detached_copies() -> None: @@ -83,3 +87,33 @@ def test_deprecated_prompt_aliases_point_to_canonical_prompts() -> None: assert "PROMPT_TASK_BREAKDOWN" not in registry_keys assert PROMPT_FEISHU_REQUIREMENT_ANALYSIS == PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION assert PROMPT_TASK_BREAKDOWN == PROMPT_DECOMPOSE_TASKS + + +def test_prompt_registry_includes_runtime_guardrails_and_inline_inventory() -> None: + expected = { + "PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT": "guarded_release", + "PROMPT_RAG_QUERY_SEMANTICS": "guarded_release", + "PROMPT_INTERNAL_TARGET_RESOLUTION": "guarded_release", + "PROMPT_EXTERNAL_QUERY_PLAN": "guarded_release", + "PROMPT_SEMANTIC_FACT_EXTRACT_PROJECT": "guarded_release", + "PROMPT_SIGNAL_WEBPAGE_VALUE_ANALYSIS": "needs_migration", + "PROMPT_AUDIT_FUNCTIONALITY_REVIEW": "needs_migration", + "PROMPT_LLM_STRUCTURED_JSON_REPAIR": "needs_migration", + } + + for prompt_key, editable_policy in expected.items(): + entry = get_prompt_registry_entry(prompt_key) + assert entry is not None + assert entry["editable_policy"] == editable_policy + assert entry["call_sites"] + + +def test_saved_idea_enrichment_prompt_covers_non_idea_greeting_input() -> None: + entry = get_prompt_registry_entry("PROMPT_SAVED_IDEA_ENRICHMENT") + + assert entry is not None + assert any("space_saved_idea_service.py" in site for site in entry["call_sites"]) + assert "问候" in entry["input_contract"] + assert "问候" in PROMPT_SAVED_IDEA_ENRICHMENT + assert "readiness_score 输出 0.0" in PROMPT_SAVED_IDEA_ENRICHMENT + assert "这个想法具体想解决什么问题?" in PROMPT_SAVED_IDEA_ENRICHMENT diff --git a/backend/tests/test_ai_readiness_api.py b/backend/tests/test_ai_readiness_api.py new file mode 100644 index 00000000..3362605b --- /dev/null +++ b/backend/tests/test_ai_readiness_api.py @@ -0,0 +1,333 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +import pytest_asyncio +from fastapi import FastAPI, HTTPException +from httpx import ASGITransport, AsyncClient +from sqlalchemy import JSON +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.api import ai_readiness_api +from app.core.database import get_db +from app.dependencies.auth import get_current_user +from app.dependencies.permissions import require_platform_ai_ops +from app.models.ai_call_log import AICallLog +from app.models.base import Base +from app.models.integration import Integration +from app.services.ai_readiness_service import AIReadinessService + + +def _use_sqlite_json_types(*tables) -> None: + for table in tables: + for column in table.columns: + if column.type.__class__.__name__ in {"JSONB", "ARRAY", "Vector"}: + column.type = JSON() + + +@pytest_asyncio.fixture +async def db_session(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + tables = [Integration.__table__, AICallLog.__table__] + _use_sqlite_json_types(*tables) + async with engine.begin() as conn: + await conn.run_sync(lambda sync_conn: Base.metadata.create_all(sync_conn, tables=tables)) + maker = async_sessionmaker(engine, expire_on_commit=False) + async with maker() as session: + yield session + await engine.dispose() + + +def _engine_bundle() -> dict: + return { + "active_engine_id": "high-1", + "engines": [ + { + "id": "low-1", + "name": "Qwen 低阶引擎", + "provider": "dashscope", + "model": "qwen-plus", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "api_key": "low-key", + "enabled": True, + "connection_status": "available", + "last_test_status": "success", + "capabilities": { + "structured_output": True, + "json_schema": True, + "capability_probe_status": "passed", + "capability_evidence": { + "structured_output": {"status": "verified", "source": "probe"}, + "json_schema": {"status": "verified", "source": "probe"}, + }, + }, + }, + { + "id": "high-1", + "name": "OpenAI 高阶引擎", + "provider": "openai", + "model": "GLM-5", + "base_url": "https://api.edgefn.net/v1", + "api_key": "high-key", + "enabled": True, + "connection_status": "available", + "last_test_status": "success", + }, + ], + } + + +def _slot_bundle(*, include_structured: bool) -> dict: + slots = { + "low": {"primary_engine_id": "low-1", "failure_policy": "upgrade_to_high"}, + "high": {"primary_engine_id": "high-1", "failure_policy": "explicit_fail"}, + } + if include_structured: + slots["structured"] = {"primary_engine_id": "low-1", "failure_policy": "try_fallback_then_fail"} + return {"active": {"slots": slots}, "active_version": "slot-v-test"} + + +async def _seed_platform_config(session, *, include_structured: bool) -> None: + session.add(Integration(tenant_id=1, type="llm_engine", config=_engine_bundle(), is_active=True)) + session.add(Integration(tenant_id=1, type="ai_capability_slots", config=_slot_bundle(include_structured=include_structured), is_active=True)) + await session.commit() + + +@pytest.mark.asyncio +async def test_project_launch_readiness_blocks_when_structured_slot_unpublished(db_session) -> None: + await _seed_platform_config(db_session, include_structured=False) + + payload = await AIReadinessService(db_session, tenant_id=12).get_project_launch_readiness() + + assert payload["status"] == "blocked" + assert payload["can_generate_project_draft"] is False + assert payload["platform"]["checks"]["active_slot"] == "failed" + assert any(item["code"] == "structured_slot_unpublished" for item in payload["platform"]["blockers"]) + assert payload["title"] == "AI 项目草案暂不可用" + + +@pytest.mark.asyncio +async def test_project_launch_readiness_ready_after_structured_slot_and_success(db_session) -> None: + await _seed_platform_config(db_session, include_structured=True) + db_session.add( + AICallLog( + tenant_id=12, + function_name="pmflow_launch_draft_v1", + prompt="prompt", + response="{}", + tokens_used=1, + cost=0, + status="success", + planned_tier="structured", + executed_tier="structured", + routing_decision="capability_slot", + route_source="slot_binding", + route_rule_name="structured", + ) + ) + await db_session.commit() + + payload = await AIReadinessService(db_session, tenant_id=12).get_project_launch_readiness() + + assert payload["status"] == "ready" + assert payload["can_generate_project_draft"] is True + assert payload["platform"]["checks"]["capability_probe"] == "passed" + assert payload["platform"]["checks"]["recent_smoke"] == "passed" + + +@pytest.mark.asyncio +async def test_project_launch_readiness_uses_platform_smoke_when_newer_than_tenant_failure(db_session) -> None: + await _seed_platform_config(db_session, include_structured=True) + db_session.add_all( + [ + AICallLog( + tenant_id=12, + function_name="pmflow_launch_draft_v1", + prompt="prompt", + response="old failure", + tokens_used=1, + cost=0, + status="structured_http_error", + planned_tier="low", + executed_tier="low", + routing_decision="rule_match", + route_source="default", + route_rule_name="engine_low_fallback", + ), + AICallLog( + tenant_id=1, + function_name="pmflow_launch_draft_v1", + prompt="prompt", + response="{}", + tokens_used=1, + cost=0, + status="success", + planned_tier="structured", + executed_tier="structured", + routing_decision="capability_slot", + route_source="slot_binding", + route_rule_name="structured", + ), + ] + ) + await db_session.commit() + + payload = await AIReadinessService(db_session, tenant_id=12).get_project_launch_readiness() + + assert payload["status"] == "ready" + assert payload["can_generate_project_draft"] is True + assert payload["platform"]["checks"]["recent_smoke"] == "passed" + + +@pytest.mark.asyncio +async def test_project_launch_readiness_keeps_ready_after_single_recent_failure_with_recent_success(db_session) -> None: + await _seed_platform_config(db_session, include_structured=True) + db_session.add_all( + [ + AICallLog( + tenant_id=12, + function_name="pmflow_launch_draft_v1", + prompt="prompt", + response="{}", + tokens_used=1, + cost=0, + status="success", + planned_tier="structured", + executed_tier="structured", + routing_decision="capability_slot", + route_source="slot_binding", + route_rule_name="structured", + ), + AICallLog( + tenant_id=12, + function_name="pmflow_launch_draft_v1", + prompt="prompt", + response="模型调用失败:structured_empty", + tokens_used=1, + cost=0, + status="structured_empty", + planned_tier="structured", + executed_tier="structured", + routing_decision="capability_slot", + route_source="slot_binding", + route_rule_name="structured", + ), + ] + ) + await db_session.commit() + + payload = await AIReadinessService(db_session, tenant_id=12).get_project_launch_readiness() + + assert payload["status"] == "ready" + assert payload["can_generate_project_draft"] is True + assert payload["platform"]["checks"]["recent_smoke"] == "passed" + assert payload["platform"]["checks"]["recent_failures"] == "warning" + assert any(item["code"] == "recent_generation_failed" for item in payload["platform"]["blockers"]) + + +@pytest.mark.asyncio +async def test_project_launch_readiness_recovers_after_three_consecutive_recent_failures(db_session) -> None: + await _seed_platform_config(db_session, include_structured=True) + db_session.add_all( + [ + AICallLog( + tenant_id=12, + function_name="pmflow_launch_draft_v1", + prompt="prompt", + response="{}", + tokens_used=1, + cost=0, + status="success", + planned_tier="structured", + executed_tier="structured", + routing_decision="capability_slot", + route_source="slot_binding", + route_rule_name="structured", + ), + AICallLog( + tenant_id=12, + function_name="pmflow_launch_draft_v1", + prompt="prompt", + response="structured_empty", + tokens_used=1, + cost=0, + status="structured_empty", + planned_tier="structured", + executed_tier="structured", + routing_decision="capability_slot", + route_source="slot_binding", + route_rule_name="structured", + ), + AICallLog( + tenant_id=12, + function_name="pmflow_launch_draft_v1", + prompt="prompt", + response="structured_empty", + tokens_used=1, + cost=0, + status="structured_empty", + planned_tier="structured", + executed_tier="structured", + routing_decision="capability_slot", + route_source="slot_binding", + route_rule_name="structured", + ), + AICallLog( + tenant_id=12, + function_name="pmflow_launch_draft_v1", + prompt="prompt", + response="structured_empty", + tokens_used=1, + cost=0, + status="structured_empty", + planned_tier="structured", + executed_tier="structured", + routing_decision="capability_slot", + route_source="slot_binding", + route_rule_name="structured", + ), + ] + ) + await db_session.commit() + + payload = await AIReadinessService(db_session, tenant_id=12).get_project_launch_readiness() + + assert payload["status"] == "recovering" + assert payload["can_generate_project_draft"] is False + assert payload["platform"]["checks"]["recent_smoke"] == "failed" + assert payload["platform"]["checks"]["recent_failures"] == "failed" + + +def _build_app(session, *, role: str = "OWNER") -> FastAPI: + app = FastAPI() + app.include_router(ai_readiness_api.router) + fake_user = SimpleNamespace(id=101, tenant_id=12, username="tester", role=role) + + async def _fake_get_db(): + yield session + + async def _fake_platform_ai_ops(): + if role != "OWNER": + raise HTTPException(status_code=403, detail="Platform capability required: platform.ai_routing.manage") + return fake_user + + app.dependency_overrides[get_db] = _fake_get_db + app.dependency_overrides[get_current_user] = lambda: fake_user + app.dependency_overrides[require_platform_ai_ops] = _fake_platform_ai_ops + return app + + +@pytest.mark.asyncio +async def test_ai_readiness_api_returns_enveloped_payload(db_session) -> None: + await _seed_platform_config(db_session, include_structured=False) + app = _build_app(db_session) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get("/ai-readiness/project-launch") + + assert response.status_code == 200 + body = response.json() + assert body["code"] == 0 + assert body["data"]["scene"] == "project_launch" + assert body["data"]["status"] == "blocked" diff --git a/backend/tests/test_ai_routing_governance_service.py b/backend/tests/test_ai_routing_governance_service.py index 757221f3..8cbd089b 100644 --- a/backend/tests/test_ai_routing_governance_service.py +++ b/backend/tests/test_ai_routing_governance_service.py @@ -1,24 +1,31 @@ from __future__ import annotations import ast +import json from pathlib import Path from types import SimpleNamespace import pytest import pytest_asyncio -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException from httpx import ASGITransport, AsyncClient -from sqlalchemy import JSON +from sqlalchemy import JSON, select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.api import ai_routing from app.core.database import get_db from app.dependencies.auth import get_current_user +from app.dependencies.permissions import require_platform_ai_ops from app.dependencies.tenant import get_tenant_id from app.models.ai_call_log import AICallLog from app.models.base import Base from app.models.integration import Integration from app.services.ai_routing_governance_service import AIRoutingGovernanceService +from app.services.ai_routing_governance_service import _summarize_capability_probe_status as summarize_governance_probe_status +from app.services.ai_function_runtime_profile_service import AIFunctionRuntimeProfileService +from app.services.llm_service import ChatCompletionResult, llm_service +from app.services.llm_service.mixin_engine_admin import _summarize_capability_probe_status as summarize_engine_probe_status +from app.services.model.model_routing import ModelRoutingService def _use_sqlite_json_types(*tables) -> None: @@ -49,10 +56,13 @@ def _engine_bundle() -> dict: { "id": "low-1", "name": "Qwen 低阶引擎", - "provider": "glm", + "provider": "dashscope", "model": "qwen-plus", "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", "api_key": "db-low-key", + "enabled": True, + "connection_status": "available", + "last_test_status": "success", }, { "id": "high-1", @@ -61,13 +71,42 @@ def _engine_bundle() -> dict: "model": "GLM-5", "base_url": "https://api.edgefn.net/v1", "api_key": "db-high-key", + "enabled": True, + "connection_status": "untested", }, ], } +def test_declared_unverified_candidate_capabilities_keep_probe_status_partial() -> None: + declared = { + "structured_output": True, + "json_schema": True, + "tool_calling": True, + "vision": True, + "audio": True, + "private_deployment": True, + "external_search_ready": True, + } + verified = { + "status": "verified", + "ok": True, + "evidence_strength": "live_probe", + } + probe = { + "evidence": { + "structured_output": dict(verified), + "json_schema": dict(verified), + "tool_calling": dict(verified), + } + } + + assert summarize_engine_probe_status(declared=declared, probe=probe) == "partial" + assert summarize_governance_probe_status(declared=declared, probe=probe) == "partial" + + @pytest.mark.asyncio -async def test_overview_masks_keys_and_reports_env_override(db_session, monkeypatch) -> None: +async def test_overview_masks_keys_and_ignores_env_placeholders(db_session, monkeypatch) -> None: monkeypatch.setenv("DASHSCOPE_API_KEY", "local-placeholder") db_session.add(Integration(tenant_id=1, type="llm_engine", config=_engine_bundle(), is_active=True)) await db_session.commit() @@ -77,11 +116,293 @@ async def test_overview_masks_keys_and_reports_env_override(db_session, monkeypa assert data["config_version"] assert data["slots"][0]["label"] == "快速槽" assert data["slots"][0]["api_key_masked"] == "db-l...-key" - assert any(item["code"] == "env_key_override" for item in data["diagnostics"]) + assert not any(item["code"] == "env_key_override" for item in data["diagnostics"]) + assert data["slots"][0]["config_source"] == "供应商配置" assert "api_env" not in str(data) assert "local-placeholder" not in str(data) assert "db-low-key" not in str(data) + console = await AIRoutingGovernanceService(db_session, tenant_id=1).get_admin_console() + assert console["providers"][0]["status_label"] == "可用" + assert console["providers"][0]["is_default_fallback"] is True + assert console["providers"][0]["capabilities"]["structured_output"] is True + assert console["providers"][1]["status_label"] == "未测试" + assert console["capability_slots"][0]["primary_engine_id"] == "low-1" + assert console["capability_slots"][0]["requirements"]["summary"] + assert console["scene_slot_matrix"][0]["scene_key"] == "project_launch" + assert console["scene_slot_matrix"][0]["scene_label"] == "项目启动草案" + assert console["scene_slot_matrix"][0]["current_slot"] == "structured" + assert console["scene_slot_matrix"][0]["required_slot"] == "structured" + assert console["scene_slot_matrix"][0]["active_runtime"]["slot_key"] == "structured" + assert console["scene_slot_matrix"][0]["active_slot_runtime"]["slot_key"] == "structured" + assert console["scene_slot_matrix"][0]["draft_slot_runtime"]["slot_key"] == "structured" + assert console["scene_slot_matrix"][0]["scene_contract"]["output_contract"] == "结构化草案 + 人工确认" + launch_profile = console["scene_slot_matrix"][0]["function_runtime_profiles"][0] + assert launch_profile["function_name"] == "pmflow_launch_draft_v1" + assert launch_profile["active"]["max_tokens"] == 7200 + assert launch_profile["active"]["provider_timeout_seconds"] == 90 + assert launch_profile["active"]["job_timeout_seconds"] == 180 + assert launch_profile["draft"]["repair_max_tokens"] == 3600 + assert launch_profile["draft"]["large_input_policy"] == "preprocess_then_generate" + change_review_row = next(item for item in console["scene_slot_matrix"] if item["scene_key"] == "change_review") + assert change_review_row["scene_contract"]["lifecycle"] == "active" + assert change_review_row["scene_contract"]["protected"] is True + change_review_profile = change_review_row["function_runtime_profiles"][0] + assert change_review_profile["function_name"] == "pmflow_change_review_extraction_v1" + assert change_review_profile["prompt_key"] == "PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION" + assert change_review_profile["active"]["max_tokens"] == 5600 + assert console["scene_slot_matrix"][0]["scene_contract"]["lifecycle"] == "active" + assert console["scene_slot_matrix"][0]["scene_health"]["status"] in {"blocked", "warning", "unknown", "healthy"} + assert console["scene_slot_matrix"][0]["release_gate"]["suggested_action"] in {"configure_slot", "probe_capability", "add_provider", "inspect_failures", "none"} + assert "draft_dirty" in console["scene_slot_matrix"][0] + assert isinstance(console["scene_slot_matrix"][0]["capability_gap"], dict) + assert isinstance(console["scene_slot_matrix"][0]["required_capabilities"], list) + assert any(item["slot_key"] == "structured" for item in console["scene_slot_matrix"][0]["slots"]) + assert console["scene_slot_matrix"][0]["runtime_version"] + candidate = next(item for item in console["scene_slot_matrix"] if item["scene_key"] == "release_review") + assert candidate["scene_contract"]["lifecycle"] == "candidate" + assert candidate["scene_health"]["status"] == "unknown" + assert candidate["release_gate"]["publish_blocked"] is False + + +@pytest.mark.asyncio +async def test_function_runtime_profile_draft_smoke_and_publish_gate(db_session) -> None: + service = AIFunctionRuntimeProfileService(db_session) + + await service.update_profile( + function_name="pmflow_launch_draft_v1", + payload={ + "max_tokens": 7600, + "repair_max_tokens": 3600, + "temperature": 0, + "foreground_wait_seconds": 12, + "provider_timeout_seconds": 120, + "job_timeout_seconds": 240, + "max_retries": 2, + "large_input_policy": "preprocess_then_generate", + }, + updated_by=101, + ) + blocked = await service.get_profiles() + assert blocked["dirty"] is True + assert any(item["code"] == "smoke_required" for item in blocked["validation"]["items"]) + + await service.record_smoke( + function_name="pmflow_launch_draft_v1", + status="success", + summary="unit smoke", + checked_by=101, + ) + await service.record_smoke( + function_name="pmflow_change_review_extraction_v1", + status="success", + summary="unit smoke", + checked_by=101, + ) + published = await service.publish_profiles(published_by=101, summary="publish profile") + + assert published["dirty"] is False + assert published["active_profiles"]["pmflow_launch_draft_v1"]["max_tokens"] == 7600 + assert published["active_profiles"]["pmflow_launch_draft_v1"]["provider_timeout_seconds"] == 120 + resolved = await service.resolve_profile( + function_name="pmflow_launch_draft_v1", + scene_key="project_launch", + prompt_key="PROMPT_PMFLOW_LAUNCH_DRAFT", + ) + assert resolved.max_tokens == 7600 + assert resolved.provider_timeout_seconds == 120 + assert resolved.job_timeout_seconds == 240 + assert resolved.profile_source == "active" + + +@pytest.mark.asyncio +async def test_platform_ai_ops_reads_provider_bundle_from_platform_runtime_scope(db_session) -> None: + db_session.add(Integration(tenant_id=1, type="llm_engine", config=_engine_bundle(), is_active=True)) + db_session.add( + Integration( + tenant_id=9, + type="llm_engine", + config={"active_engine_id": "wrong", "engines": []}, + is_active=True, + ) + ) + await db_session.commit() + + data = await AIRoutingGovernanceService(db_session, tenant_id=9).get_admin_console() + + assert data["providers"][0]["name"] == "Qwen 低阶引擎" + assert data["providers"][0]["enabled"] is True + + +@pytest.mark.asyncio +async def test_provider_actions_follow_engine_state(db_session) -> None: + bundle = _engine_bundle() + bundle["default_fallback_engine_id"] = "low-1" + bundle["engines"].append( + { + "id": "disabled-1", + "name": "停用备用引擎", + "provider": "dashscope", + "model": "qwen-plus", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "api_key": "db-disabled-key", + "enabled": False, + "connection_status": "available", + } + ) + db_session.add(Integration(tenant_id=1, type="llm_engine", config=bundle, is_active=True)) + await db_session.commit() + + data = await AIRoutingGovernanceService(db_session, tenant_id=99).get_admin_console() + providers = {item["id"]: item for item in data["providers"]} + + assert providers["low-1"]["available_actions"] == { + "enable": False, + "disable": True, + "edit": True, + "test": True, + "capability_probe": True, + "set_default_fallback": False, + "delete": False, + } + assert providers["high-1"]["available_actions"]["test"] is True + assert providers["high-1"]["available_actions"]["capability_probe"] is False + assert providers["high-1"]["available_actions"]["set_default_fallback"] is False + assert providers["high-1"]["available_actions"]["delete"] is False + assert providers["disabled-1"]["available_actions"] == { + "enable": True, + "disable": False, + "edit": True, + "test": False, + "capability_probe": False, + "set_default_fallback": False, + "delete": True, + } + + +@pytest.mark.asyncio +async def test_llm_engine_state_guards_block_invalid_provider_operations(db_session) -> None: + bundle = _engine_bundle() + bundle["default_fallback_engine_id"] = "low-1" + bundle["engines"].append( + { + "id": "disabled-1", + "name": "停用备用引擎", + "provider": "dashscope", + "model": "qwen-plus", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "api_key": "db-disabled-key", + "enabled": False, + "connection_status": "available", + } + ) + db_session.add(Integration(tenant_id=1, type="llm_engine", config=bundle, is_active=True)) + await db_session.commit() + + with pytest.raises(ValueError, match="未通过连接测试"): + await llm_service.set_default_fallback_engine(db_session, tenant_id=99, engine_id="high-1") + + with pytest.raises(ValueError, match="测试未通过"): + await llm_service.probe_engine_capabilities(db_session, tenant_id=99, engine_id="high-1") + + with pytest.raises(ValueError, match="兜底默认"): + await llm_service.delete_engine(db_session, tenant_id=99, engine_id="low-1") + + with pytest.raises(ValueError, match="启用中"): + await llm_service.delete_engine(db_session, tenant_id=99, engine_id="high-1") + + with pytest.raises(ValueError, match="已停用"): + await llm_service.test_engine_connection(db_session, tenant_id=99, engine_id="disabled-1") + + +@pytest.mark.asyncio +async def test_admin_console_reports_slot_observability_from_ai_call_logs(db_session) -> None: + db_session.add(Integration(tenant_id=1, type="llm_engine", config=_engine_bundle(), is_active=True)) + success = AICallLog( + tenant_id=1, + function_name="pmflow_launch_draft_v1", + prompt="prompt", + response="{}", + tokens_used=12, + cost=0.003, + status="success", + planned_tier="structured", + executed_tier="structured", + routing_decision="active_slot", + route_source="active", + route_rule_name="structured", + routing_config_version="slot-v-test", + routing_config_updated_at="2026-04-29T00:00:00+08:00", + ) + success.routing_meta = {"slot_key": "structured", "slot_runtime_source": "active_slot", "slot_fallback_index": 1} + success.latency_ms = 120 + second_success = AICallLog( + tenant_id=1, + function_name="generate_task_breakdown", + prompt="prompt", + response="{}", + tokens_used=4, + cost=0.001, + status="success", + planned_tier="structured", + executed_tier="structured", + routing_decision="active_slot", + route_source="active", + route_rule_name="structured", + routing_config_version="slot-v-test", + routing_config_updated_at="2026-04-29T00:00:00+08:00", + ) + second_success.routing_meta = {"slot_key": "structured", "slot_runtime_source": "active_slot", "slot_fallback_index": 0} + degraded = AICallLog( + tenant_id=1, + function_name="pmflow_requirement_plan_draft", + prompt="prompt", + response="", + tokens_used=8, + cost=0.002, + status="structured_invalid", + planned_tier="structured", + executed_tier="structured", + routing_decision="structured_slot_degraded", + route_source="active", + route_rule_name="structured", + routing_config_version="slot-v-test", + routing_config_updated_at="2026-04-29T00:00:00+08:00", + ) + degraded.routing_meta = {"slot_key": "structured", "slot_runtime_source": "active_slot_degraded", "slot_degraded": True} + degraded.latency_ms = 180 + db_session.add_all([success, second_success, degraded]) + await db_session.commit() + + data = await AIRoutingGovernanceService(db_session, tenant_id=1).get_admin_console() + by_slot = {item["slot_key"]: item for item in data["slot_observability"]} + structured = by_slot["structured"]["windows"]["last_24h"] + high = by_slot["high"]["windows"]["last_24h"] + + assert structured["status"] == "sampled" + assert structured["total_calls"] == 3 + assert structured["failed_calls"] == 1 + assert structured["fallback_hits"] == 1 + assert structured["degraded_calls"] == 1 + assert structured["active_slot_calls"] == 2 + assert structured["active_slot_degraded_calls"] == 1 + assert structured["average_latency_ms"] == 150 + assert structured["estimated_cost"] == 0.006 + assert structured["recent_failure_samples"][0]["slot_runtime_source"] == "active_slot_degraded" + assert high["status"] == "empty" + assert high["sample_state_label"] == "暂无运行样本" + recommendations = data["slot_recommendations"] + codes = {item["code"] for item in recommendations} + assert "structured_high_failure_rate" in codes + assert "structured_fallback_primary_candidate" in codes + assert "structured_active_slot_degraded" in codes + degraded_recommendation = next(item for item in recommendations if item["code"] == "structured_active_slot_degraded") + assert degraded_recommendation["severity"] == "critical" + assert "7d degraded 调用 1 次" in degraded_recommendation["evidence_summary"] + scene_row = next(item for item in data["scene_slot_matrix"] if item["scene_key"] == "project_launch") + assert scene_row["scene_health"]["blockers"] + assert scene_row["release_gate"]["affected_by_draft"] in {True, False} + @pytest.mark.asyncio async def test_admin_console_reports_protected_downgrade_and_structured_failures(db_session) -> None: @@ -90,7 +411,12 @@ async def test_admin_console_reports_protected_downgrade_and_structured_failures Integration( tenant_id=1, type="model_routing", - config={"mappings": {"adaptive_rag_answer_deep": {"tier_alias": "low", "enabled": True}}}, + config={ + "mappings": { + "adaptive_rag_answer_deep": {"tier_alias": "low", "enabled": True}, + "pmflow_requirement_plan_draft": {"tier_alias": "low", "enabled": True}, + } + }, is_active=True, ) ) @@ -122,6 +448,26 @@ async def test_admin_console_reports_protected_downgrade_and_structured_failures assert "structured_failure_rate" in codes assert data["recent_failures"][0]["status_label"] == "模型返回为空" assert data["scene_routes"][0]["label"] == "项目启动草案" + launch_row = next(item for item in data["scene_slot_matrix"] if item["scene_key"] == "project_launch") + assert launch_row["recent_failure_rate"] == 1.0 + assert launch_row["recent_failure_count"] == 3 + assert launch_row["recent_failed_calls"] == 3 + assert "项目启动草案" in launch_row["impact_summary"] + requirement_row = next(item for item in data["scene_slot_matrix"] if item["scene_key"] == "requirement_structuring") + assert requirement_row["risk_level"] == "critical" + assert any(slot["risk_level"] == "critical" for slot in requirement_row["slots"]) + + +@pytest.mark.asyncio +async def test_model_routing_blocks_protected_scene_downgrade_to_low_slot(db_session) -> None: + with pytest.raises(ValueError, match="保护场景不能降级"): + await ModelRoutingService.upsert_override( + db_session, + tenant_id=9, + function_name="pmflow_requirement_plan_draft", + tier_alias="low", + updated_by=101, + ) @pytest.mark.asyncio @@ -142,9 +488,756 @@ async def test_team_policy_persists_integration_without_runtime_route_changes(db assert loaded["automation_boundary"] == "所有写操作进入待确认。" +@pytest.mark.asyncio +async def test_capability_slot_binding_persists_in_platform_runtime_scope(db_session) -> None: + db_session.add(Integration(tenant_id=1, type="llm_engine", config=_engine_bundle(), is_active=True)) + await db_session.commit() + + service = AIRoutingGovernanceService(db_session, tenant_id=9) + saved = await service.update_capability_slot( + slot_key="high", + payload={ + "primary_engine_id": "high-1", + "fallback_engine_ids": ["low-1"], + "failure_policy": "try_fallback_then_fail", + "allow_scene_downgrade": False, + }, + updated_by=101, + ) + await db_session.commit() + + high_slot = next(item for item in saved["slots"] if item["slot_key"] == "high") + assert high_slot["primary_engine_id"] == "high-1" + assert high_slot["fallback_engine_ids"] == ["low-1"] + row = ( + await db_session.execute( + select(Integration).where( + Integration.tenant_id == 1, + Integration.type == "ai_capability_slots", + ) + ) + ).scalar_one() + assert row.config["draft"]["slots"]["high"]["primary_engine_id"] == "high-1" + assert row.config["active"]["slots"].get("high") is None + + +@pytest.mark.asyncio +async def test_runtime_activation_slots_are_configurable_and_candidates_explain_gaps(db_session) -> None: + bundle = _engine_bundle() + for engine in bundle["engines"]: + if engine["id"] == "high-1": + engine["capabilities"] = { + "structured_output": True, + "json_schema": True, + "tool_calling": False, + "long_context": True, + "max_context_tokens": 8192, + "capability_source": "manual", + "capability_probe_status": "untested", + } + db_session.add(Integration(tenant_id=1, type="llm_engine", config=bundle, is_active=True)) + await db_session.commit() + + service = AIRoutingGovernanceService(db_session, tenant_id=9) + saved = await service.update_capability_slot( + slot_key="structured", + payload={ + "primary_engine_id": "high-1", + "fallback_engine_ids": [], + "failure_policy": "explicit_fail", + "allow_scene_downgrade": False, + }, + updated_by=101, + ) + + structured_slot = next(item for item in saved["draft_slots"] if item["slot_key"] == "structured") + tool_slot = next(item for item in saved["draft_slots"] if item["slot_key"] == "tool") + long_slot = next(item for item in saved["draft_slots"] if item["slot_key"] == "long_context") + external_slot = next(item for item in saved["draft_slots"] if item["slot_key"] == "external_query") + assert structured_slot["configurable"] is True + assert structured_slot["primary_engine_id"] == "high-1" + assert tool_slot["configurable"] is True + assert long_slot["configurable"] is True + assert external_slot["configurable"] is True + assert external_slot["external_provider_registry"]["mcp_adapter_available"] is True + assert tool_slot["blocked_candidate_engine_count"] >= 1 + assert any("缺能力" in ";".join(item.get("gap_reasons") or []) for item in tool_slot["blocked_candidate_engines"]) + assert long_slot["blocked_candidate_engine_count"] >= 1 + + saved = await service.update_capability_slot( + slot_key="external_query", + payload={ + "primary_engine_id": "live_search", + "fallback_engine_ids": ["mcp"], + "failure_policy": "explicit_fail", + "allow_scene_downgrade": False, + }, + updated_by=101, + ) + external_slot = next(item for item in saved["draft_slots"] if item["slot_key"] == "external_query") + assert external_slot["primary_provider_id"] == "live_search" + assert external_slot["fallback_provider_ids"] == ["mcp"] + assert external_slot["verifier_required"] is True + assert external_slot["source_filtering_required"] is True + + +@pytest.mark.asyncio +async def test_capability_slot_publish_promotes_draft_to_active_and_keeps_history(db_session) -> None: + db_session.add(Integration(tenant_id=1, type="llm_engine", config=_engine_bundle(), is_active=True)) + await db_session.commit() + + service = AIRoutingGovernanceService(db_session, tenant_id=9) + await service.update_capability_slot( + slot_key="low", + payload={ + "primary_engine_id": "low-1", + "fallback_engine_ids": [], + "failure_policy": "upgrade_to_high", + "allow_scene_downgrade": False, + }, + updated_by=101, + ) + await service.update_capability_slot( + slot_key="high", + payload={ + "primary_engine_id": "high-1", + "fallback_engine_ids": ["low-1"], + "failure_policy": "try_fallback_then_fail", + "allow_scene_downgrade": False, + }, + updated_by=101, + ) + draft = await service.get_capability_slots() + assert draft["dirty"] is True + assert draft["validation"]["can_publish"] is True + assert any(item["code"] == "low_primary_capability_evidence_untested" and item["severity"] == "warning" for item in draft["validation"]["items"]) + + published = await service.publish_capability_slots(published_by=101, summary="unit test publish") + await db_session.commit() + + high_active = next(item for item in published["active_slots"] if item["slot_key"] == "high") + assert high_active["primary_engine_id"] == "high-1" + assert published["dirty"] is False + assert published["history"][0]["summary"] == "unit test publish" + + +@pytest.mark.asyncio +async def test_external_query_slot_publish_requires_provider_verifier_and_source_filtering(db_session, monkeypatch) -> None: + db_session.add(Integration(tenant_id=1, type="llm_engine", config=_engine_bundle(), is_active=True)) + await db_session.commit() + + service = AIRoutingGovernanceService(db_session, tenant_id=9) + await service.update_capability_slot( + slot_key="low", + payload={ + "primary_engine_id": "low-1", + "fallback_engine_ids": [], + "failure_policy": "upgrade_to_high", + "allow_scene_downgrade": False, + }, + updated_by=101, + ) + await service.update_capability_slot( + slot_key="high", + payload={ + "primary_engine_id": "high-1", + "fallback_engine_ids": [], + "failure_policy": "explicit_fail", + "allow_scene_downgrade": False, + }, + updated_by=101, + ) + await service.update_capability_slot( + slot_key="external_query", + payload={ + "primary_engine_id": "live_search", + "fallback_engine_ids": ["mcp"], + "failure_policy": "explicit_fail", + "allow_scene_downgrade": False, + }, + updated_by=101, + ) + + validation = await service.validate_capability_slots() + assert validation["can_publish"] is True + + monkeypatch.setenv("AI_EXTERNAL_QUERY_VERIFIER_ENABLED", "0") + blocked = await service.validate_capability_slots() + assert blocked["can_publish"] is False + assert any(item["code"] == "external_query_verifier_unavailable" for item in blocked["items"]) + + monkeypatch.setenv("AI_EXTERNAL_QUERY_VERIFIER_ENABLED", "1") + monkeypatch.setenv("AI_EXTERNAL_QUERY_SOURCE_FILTERING_FAIL_CLOSED", "0") + blocked = await service.validate_capability_slots() + assert blocked["can_publish"] is False + assert any(item["code"] == "external_query_source_filtering_open" for item in blocked["items"]) + + +@pytest.mark.asyncio +async def test_capability_slot_validation_blocks_runtime_that_misses_capability_matrix(db_session) -> None: + bundle = _engine_bundle() + for engine in bundle["engines"]: + if engine["id"] == "high-1": + engine["capabilities"] = {"local_only": True, "structured_output": True, "json_schema": True} + db_session.add(Integration(tenant_id=1, type="llm_engine", config=bundle, is_active=True)) + await db_session.commit() + + service = AIRoutingGovernanceService(db_session, tenant_id=9) + await service.update_capability_slot( + slot_key="low", + payload={ + "primary_engine_id": "low-1", + "fallback_engine_ids": [], + "failure_policy": "upgrade_to_high", + "allow_scene_downgrade": False, + }, + updated_by=101, + ) + await service.update_capability_slot( + slot_key="high", + payload={ + "primary_engine_id": "high-1", + "fallback_engine_ids": [], + "failure_policy": "explicit_fail", + "allow_scene_downgrade": False, + }, + updated_by=101, + ) + + validation = await service.validate_capability_slots() + + assert validation["can_publish"] is False + assert any(item["code"] == "high_主运行时_capability_missing" for item in validation["items"]) + + +@pytest.mark.asyncio +async def test_reserved_slot_validation_applies_requirements_when_enabled(db_session) -> None: + bundle = _engine_bundle() + for engine in bundle["engines"]: + if engine["id"] == "low-1": + engine["capabilities"] = {"structured_output": True, "json_schema": False} + db_session.add(Integration(tenant_id=1, type="llm_engine", config=bundle, is_active=True)) + db_session.add( + Integration( + tenant_id=1, + type="ai_capability_slots", + config={ + "active_version": "unpublished", + "draft": { + "slots": { + "low": {"primary_engine_id": "low-1", "fallback_engine_ids": []}, + "high": {"primary_engine_id": "high-1", "fallback_engine_ids": []}, + "structured": {"primary_engine_id": "low-1", "fallback_engine_ids": []}, + } + }, + }, + is_active=True, + ) + ) + await db_session.commit() + + validation = await AIRoutingGovernanceService(db_session, tenant_id=9).validate_capability_slots() + + assert validation["can_publish"] is False + assert any(item["code"] == "structured_主运行时_capability_missing" for item in validation["items"]) + + +@pytest.mark.asyncio +async def test_reserved_slot_validation_distinguishes_missing_probe_evidence(db_session) -> None: + bundle = _engine_bundle() + for engine in bundle["engines"]: + if engine["id"] == "low-1": + engine["capabilities"] = { + "structured_output": True, + "json_schema": True, + "capability_source": "manual", + "capability_probe_status": "untested", + } + db_session.add(Integration(tenant_id=1, type="llm_engine", config=bundle, is_active=True)) + db_session.add( + Integration( + tenant_id=1, + type="ai_capability_slots", + config={ + "active_version": "unpublished", + "draft": { + "slots": { + "low": {"primary_engine_id": "low-1", "fallback_engine_ids": []}, + "high": {"primary_engine_id": "high-1", "fallback_engine_ids": []}, + "structured": {"primary_engine_id": "low-1", "fallback_engine_ids": []}, + } + }, + }, + is_active=True, + ) + ) + await db_session.commit() + + validation = await AIRoutingGovernanceService(db_session, tenant_id=9).validate_capability_slots() + + assert validation["can_publish"] is False + assert any(item["code"] == "structured_主运行时_capability_evidence_missing" for item in validation["items"]) + assert not any(item["code"] == "structured_主运行时_capability_missing" for item in validation["items"]) + structured_slot = next(item for item in (await AIRoutingGovernanceService(db_session, tenant_id=9).get_capability_slots())["draft_slots"] if item["slot_key"] == "structured") + assert structured_slot["capability_coverage"]["capabilities"]["capability_probe"]["source"] == "manual" + assert structured_slot["capability_coverage"]["capabilities"]["capability_probe"]["status"] == "untested" + with pytest.raises(ValueError, match="草稿存在阻塞项"): + await AIRoutingGovernanceService(db_session, tenant_id=9).publish_capability_slots( + published_by=101, + summary="should block without probe evidence", + ) + + +@pytest.mark.asyncio +async def test_tool_and_long_context_slots_block_without_probe_evidence_when_enabled(db_session) -> None: + bundle = _engine_bundle() + for engine in bundle["engines"]: + if engine["id"] == "high-1": + engine["capabilities"] = { + "tool_calling": True, + "long_context": True, + "max_context_tokens": 128000, + "capability_source": "manual", + "capability_probe_status": "untested", + } + db_session.add(Integration(tenant_id=1, type="llm_engine", config=bundle, is_active=True)) + db_session.add( + Integration( + tenant_id=1, + type="ai_capability_slots", + config={ + "active_version": "unpublished", + "draft": { + "slots": { + "tool": {"primary_engine_id": "high-1", "fallback_engine_ids": []}, + "long_context": {"primary_engine_id": "high-1", "fallback_engine_ids": []}, + } + }, + }, + is_active=True, + ) + ) + await db_session.commit() + + service = AIRoutingGovernanceService(db_session, tenant_id=9) + validation = await service.validate_capability_slots() + + assert validation["can_publish"] is False + assert any(item["code"] == "tool_主运行时_capability_evidence_missing" and item["severity"] == "critical" for item in validation["items"]) + assert any(item["code"] == "long_context_主运行时_capability_evidence_missing" and item["severity"] == "critical" for item in validation["items"]) + with pytest.raises(ValueError, match="草稿存在阻塞项"): + await service.publish_capability_slots(published_by=101, summary="should block tool long-context evidence gaps") + + +@pytest.mark.asyncio +async def test_operator_attestation_marks_long_context_as_verified_slot_evidence(db_session) -> None: + bundle = _engine_bundle() + for engine in bundle["engines"]: + if engine["id"] == "high-1": + engine["connection_status"] = "available" + engine["capabilities"] = { + "long_context": True, + "max_context_tokens": 128000, + "capability_source": "manual", + "capability_probe_status": "partial", + "capability_probe": { + "status": "partial", + "last_verified_at": "2026-04-30T12:00:00+08:00", + "source": "probed", + "verified_capabilities": {}, + "evidence": { + "model_self_report": {"ok": True, "status": "declared_only", "evidence_strength": "model_self_report"}, + "long_context_128k": { + "ok": False, + "status": "declared_only", + "evidence_strength": "declared_only", + "tested_tokens": 128000, + }, + }, + }, + } + db_session.add(Integration(tenant_id=1, type="llm_engine", config=bundle, is_active=True)) + db_session.add( + Integration( + tenant_id=1, + type="ai_capability_slots", + config={ + "active_version": "unpublished", + "draft": { + "slots": { + "long_context": {"primary_engine_id": "high-1", "fallback_engine_ids": []}, + } + }, + }, + is_active=True, + ) + ) + await db_session.commit() + + result = await llm_service.attest_engine_capability( + db_session, + tenant_id=9, + engine_id="high-1", + capability_key="long_context", + tested_tokens=1_048_576, + source_type="official_doc", + source_url="https://example.com/qwen-plus", + source_note="官方规格显示 1M 上下文", + user_id=101, + ) + + evidence = result["capabilities"]["capability_probe"]["evidence"]["long_context"] + assert evidence["ok"] is True + assert evidence["evidence_strength"] == "operator_verified" + assert evidence["method"] == "operator_attestation" + assert evidence["tested_tokens"] == 1_048_576 + assert result["capabilities"]["effective_capabilities"]["long_context"] is True + assert result["capabilities"]["effective_capabilities"]["long_context_128k"] is True + assert result["capabilities"]["capability_probe"]["status"] == "passed" + assert "long_context_128k" not in result["capabilities"]["capability_probe"]["evidence"] + + validation = await AIRoutingGovernanceService(db_session, tenant_id=9).validate_capability_slots() + assert not any(item["code"] == "long_context_主运行时_capability_evidence_missing" for item in validation["items"]) + assert not any(item["code"] == "long_context_主运行时_capability_evidence_stale" for item in validation["items"]) + + +@pytest.mark.asyncio +async def test_reserved_slot_validation_blocks_stale_probe_evidence(db_session) -> None: + bundle = _engine_bundle() + for engine in bundle["engines"]: + if engine["id"] == "low-1": + engine["capabilities"] = { + "structured_output": True, + "json_schema": True, + "capability_probe": { + "status": "passed", + "source": "probed", + "last_verified_at": "2025-01-01T00:00:00", + "verified_capabilities": { + "structured_output": True, + "json_schema": True, + "tool_calling": False, + "long_context": False, + }, + "evidence": { + "structured_output": {"ok": True, "checked_at": "2025-01-01T00:00:00", "summary": "ok"}, + "json_schema": {"ok": True, "checked_at": "2025-01-01T00:00:00", "summary": "ok"}, + }, + }, + } + db_session.add(Integration(tenant_id=1, type="llm_engine", config=bundle, is_active=True)) + db_session.add( + Integration( + tenant_id=1, + type="ai_capability_slots", + config={ + "active_version": "unpublished", + "draft": { + "slots": { + "structured": {"primary_engine_id": "low-1", "fallback_engine_ids": []}, + } + }, + }, + is_active=True, + ) + ) + await db_session.commit() + + validation = await AIRoutingGovernanceService(db_session, tenant_id=9).validate_capability_slots() + + assert validation["can_publish"] is False + assert any(item["code"] == "structured_主运行时_capability_evidence_stale" for item in validation["items"]) + + +@pytest.mark.asyncio +async def test_engine_capability_probe_writes_evidence_into_engine_config(db_session, monkeypatch) -> None: + db_session.add(Integration(tenant_id=1, type="llm_engine", config=_engine_bundle(), is_active=True)) + await db_session.commit() + + async def fake_structured_probe(**kwargs): + _ = kwargs + return ChatCompletionResult(text='{"ok": true, "label": "pmflow"}', raw_response={}) + + async def fake_tool_probe(*, engine): + _ = engine + return {"status": "verified", "message": "tool ok", "latency_ms": 11, "evidence_strength": "live_probe"} + + async def fake_long_context_probe(*, engine, min_context_tokens=32768): + _ = engine + return { + "status": "verified", + "message": "long context ok", + "latency_ms": 12, + "max_context_tokens": min_context_tokens, + "evidence_strength": "live_probe", + } + + async def fake_json_schema_probe(*, engine): + _ = engine + return {"status": "verified", "message": "json schema ok", "latency_ms": 7, "evidence_strength": "live_probe"} + + monkeypatch.setattr(llm_service, "_chat_completion_raw", fake_structured_probe) + monkeypatch.setattr(llm_service, "_probe_json_schema_capability", fake_json_schema_probe) + monkeypatch.setattr(llm_service, "_probe_tool_calling_capability", fake_tool_probe) + monkeypatch.setattr(llm_service, "_probe_long_context_capability", fake_long_context_probe) + + result = await llm_service.probe_engine_capabilities( + db_session, + tenant_id=9, + engine_id="low-1", + targets=["structured", "tool", "long_context"], + ) + + assert result["status"] == "passed" + assert result["capabilities"]["capability_source"] == "probe" + assert result["capabilities"]["capability_probe"]["source"] == "probed" + assert result["capabilities"]["capability_probe"]["status"] == "passed" + assert result["capabilities"]["capability_probe"]["verified_capabilities"]["tool_calling"] is True + assert result["capabilities"]["capability_probe"]["evidence"]["tool_calling"]["ok"] is True + assert result["capabilities"]["capability_evidence"]["tool_calling"]["status"] == "verified" + row = ( + await db_session.execute( + select(Integration).where( + Integration.tenant_id == 1, + Integration.type == "llm_engine", + ) + ) + ).scalar_one() + saved = next(item for item in row.config["engines"] if item["id"] == "low-1") + assert saved["capabilities"]["capability_probe_status"] == "passed" + assert saved["capabilities"]["capability_probe"]["status"] == "passed" + assert saved["capabilities"]["capability_evidence"]["long_context"]["status"] == "verified" + assert saved["capabilities"]["capability_probe"]["evidence"]["long_context"]["tested_tokens"] == 32768 + + +@pytest.mark.asyncio +async def test_long_context_probe_uses_declared_window_not_32k_default(db_session) -> None: + bundle = _engine_bundle() + for engine in bundle["engines"]: + if engine["id"] == "low-1": + engine["capabilities"] = { + "long_context": True, + "max_context_tokens": 16384, + "capability_source": "manual", + "capability_probe_status": "untested", + } + db_session.add(Integration(tenant_id=1, type="llm_engine", config=bundle, is_active=True)) + await db_session.commit() + + result = await llm_service.probe_engine_capabilities( + db_session, + tenant_id=9, + engine_id="low-1", + targets=["long_context"], + ) + + evidence = result["capabilities"]["capability_probe"]["evidence"]["long_context"] + assert result["status"] == "passed" + assert evidence["status"] == "verified" + assert evidence["evidence_strength"] == "provider_contract" + assert evidence["tested_tokens"] == 16384 + assert "32768" not in evidence["error_summary"] + assert result["capabilities"]["effective_capabilities"]["long_context"] is True + assert result["capabilities"]["effective_capabilities"]["long_context_32k"] is False + + +@pytest.mark.asyncio +async def test_model_self_report_probe_is_auxiliary_not_verified_slot_evidence(db_session, monkeypatch) -> None: + db_session.add(Integration(tenant_id=1, type="llm_engine", config=_engine_bundle(), is_active=True)) + await db_session.commit() + + async def fake_self_report_completion(**kwargs): + assert kwargs["function_name"] == "probe_capability_model_self_report" + payload = { + "model_name": "qwen-plus", + "context_window_tokens": 1000000, + "max_input_tokens": 997952, + "max_output_tokens": 32768, + "context_tier": "1m", + "supports_32k": True, + "supports_128k": True, + "supports_1m": True, + "confidence": "medium", + "note": "self report only", + } + return ChatCompletionResult( + text=json.dumps(payload), + raw_request={"model": kwargs["model"], "messages": [{"role": "user", "content": kwargs["prompt"]}]}, + raw_response={"choices": [{"message": {"content": json.dumps(payload)}}]}, + provider_attempts=[{"status": "success", "provider": kwargs["provider"], "model": kwargs["model"]}], + ) + + monkeypatch.setattr(llm_service, "_chat_completion_raw", fake_self_report_completion) + + result = await llm_service.probe_engine_capabilities( + db_session, + tenant_id=9, + engine_id="low-1", + targets=["self_report"], + ) + + evidence = result["capabilities"]["capability_probe"]["evidence"]["model_self_report"] + assert result["status"] == "partial" + assert result["targets"] == ["model_self_report"] + assert evidence["status"] == "declared_only" + assert evidence["evidence_strength"] == "model_self_report" + assert evidence["ok"] is False + assert evidence["model_self_report"]["context_window_tokens"] == 1000000 + assert result["capabilities"]["capability_probe"]["verified_capabilities"]["model_self_report"] is False + assert result["capabilities"]["effective_capabilities"]["long_context"] is False + +def test_default_full_probe_targets_include_model_self_report_reference() -> None: + targets = llm_service._normalize_capability_probe_targets( + None, + mode="full", + capabilities={"structured_output": True, "json_schema": True, "tool_calling": True, "long_context": False}, + ) + + assert targets[:2] == ["basic_chat", "model_self_report"] + + +@pytest.mark.asyncio +async def test_model_self_report_does_not_downgrade_verified_probe_status(db_session, monkeypatch) -> None: + db_session.add(Integration(tenant_id=1, type="llm_engine", config=_engine_bundle(), is_active=True)) + await db_session.commit() + + async def fake_self_report_completion(**kwargs): + payload = {"model_name": kwargs["model"], "context_window_tokens": 1000000, "confidence": "medium"} + return ChatCompletionResult(text=json.dumps(payload), raw_request={"model": kwargs["model"]}, raw_response={"content": payload}) + + async def fake_tool_probe(*, engine): + _ = engine + return {"status": "verified", "message": "tool ok", "latency_ms": 11, "evidence_strength": "live_probe"} + + async def fake_long_context_probe(*, engine, min_context_tokens=None): + _ = engine, min_context_tokens + return { + "status": "verified", + "message": "long ok", + "latency_ms": None, + "max_context_tokens": 1000000, + "declared_ok": True, + "evidence_strength": "provider_contract", + } + + monkeypatch.setattr(llm_service, "_chat_completion_raw", fake_self_report_completion) + monkeypatch.setattr(llm_service, "_probe_tool_calling_capability", fake_tool_probe) + monkeypatch.setattr(llm_service, "_probe_long_context_capability", fake_long_context_probe) + + result = await llm_service.probe_engine_capabilities( + db_session, + tenant_id=9, + engine_id="low-1", + targets=["self_report", "tool", "long_context"], + ) + + assert result["status"] == "passed" + assert result["capabilities"]["capability_probe"]["evidence"]["model_self_report"]["evidence_strength"] == "model_self_report" + assert result["capabilities"]["capability_probe"]["verified_capabilities"]["tool_calling"] is True + assert result["capabilities"]["capability_probe"]["verified_capabilities"]["model_self_report"] is False + + +@pytest.mark.asyncio +async def test_engine_update_resets_probe_evidence_when_declared_capabilities_change(db_session) -> None: + bundle = _engine_bundle() + for engine in bundle["engines"]: + if engine["id"] == "low-1": + engine["capabilities"] = { + "structured_output": True, + "json_schema": True, + "tool_calling": True, + "long_context": True, + "max_context_tokens": 32768, + "capability_source": "probe", + "capability_probe_status": "passed", + "capability_last_verified_at": "2026-04-30T12:00:00+08:00", + "capability_probe": { + "status": "passed", + "source": "probed", + "last_verified_at": "2026-04-30T12:00:00+08:00", + "verified_capabilities": {"structured_output": True, "json_schema": True, "tool_calling": True, "long_context": True}, + "evidence": { + "structured_output": { + "ok": True, + "status": "verified", + "evidence_strength": "live_probe", + "checked_at": "2026-04-30T12:00:00+08:00", + "summary": "structured ok", + }, + "json_schema": { + "ok": True, + "status": "verified", + "evidence_strength": "live_probe", + "checked_at": "2026-04-30T12:00:00+08:00", + "summary": "json ok", + }, + "tool_calling": { + "ok": True, + "status": "verified", + "evidence_strength": "live_probe", + "checked_at": "2026-04-30T12:00:00+08:00", + "summary": "tool ok", + }, + "long_context": { + "ok": True, + "status": "verified", + "evidence_strength": "live_probe", + "checked_at": "2026-04-30T12:00:00+08:00", + "tested_tokens": 32768, + "summary": "long ok", + }, + }, + }, + "capability_evidence": { + "structured_output": {"status": "verified", "source": "probe", "verified_at": "2026-04-30T12:00:00+08:00", "message": "structured ok", "evidence_strength": "live_probe"}, + "json_schema": {"status": "verified", "source": "probe", "verified_at": "2026-04-30T12:00:00+08:00", "message": "json ok", "evidence_strength": "live_probe"}, + "tool_calling": {"status": "verified", "source": "probe", "verified_at": "2026-04-30T12:00:00+08:00", "message": "tool ok", "evidence_strength": "live_probe"}, + "long_context": {"status": "verified", "source": "probe", "verified_at": "2026-04-30T12:00:00+08:00", "message": "long ok", "evidence_strength": "live_probe", "max_context_tokens": 32768}, + }, + } + db_session.add(Integration(tenant_id=1, type="llm_engine", config=bundle, is_active=True)) + await db_session.commit() + + renamed = await llm_service.update_engine( + db_session, + tenant_id=9, + engine_id="low-1", + name="Qwen renamed", + ) + assert renamed["capabilities"]["capability_probe_status"] == "passed" + assert renamed["capabilities"]["capability_probe"]["evidence"]["tool_calling"]["ok"] is True + + changed = await llm_service.update_engine( + db_session, + tenant_id=9, + engine_id="low-1", + capabilities={ + "structured_output": True, + "json_schema": True, + "tool_calling": True, + "long_context": True, + "max_context_tokens": 16384, + "cost_tier": "low", + "reliability_tier": "standard", + }, + ) + assert changed["capabilities"]["capability_probe_status"] == "untested" + assert changed["capabilities"]["capability_last_verified_at"] is None + assert changed["capabilities"]["capability_probe"]["evidence"] == {} + assert changed["capabilities"]["capability_evidence"] == {} + assert changed["capabilities"]["effective_capabilities"]["tool_calling"] is False + assert changed["capabilities"]["max_context_tokens"] == 16384 + + def _build_app(*, role: str = "REVIEWER") -> FastAPI: app = FastAPI() app.include_router(ai_routing.router) + fake_user = SimpleNamespace( + id=101, + tenant_id=9, + username="tester", + role=role, + ) class FakeDb: async def commit(self) -> None: @@ -153,14 +1246,15 @@ def _build_app(*, role: str = "REVIEWER") -> FastAPI: async def _fake_get_db(): yield FakeDb() + async def _fake_platform_ai_ops(): + if role not in {"OWNER", "REVIEWER"}: + raise HTTPException(status_code=403, detail="Platform capability required: platform.ai_routing.manage") + return fake_user + app.dependency_overrides[get_db] = _fake_get_db app.dependency_overrides[get_tenant_id] = lambda: 9 - app.dependency_overrides[get_current_user] = lambda: SimpleNamespace( - id=101, - tenant_id=9, - username="tester", - role=role, - ) + app.dependency_overrides[get_current_user] = lambda: fake_user + app.dependency_overrides[require_platform_ai_ops] = _fake_platform_ai_ops return app @@ -177,12 +1271,19 @@ def test_api_router_exposes_ai_routing_routes() -> None: assert "ai_routing" in route_assignments["CORE_ROUTE_MODULES"] assert "model_routing" in route_assignments["CORE_ROUTE_MODULES"] + assert "llm_engines" in route_assignments["CORE_ROUTE_MODULES"] assert "ai_routing" not in route_assignments["OPTIONAL_ROUTE_GROUPS"]["ai_control"] assert "model_routing" not in route_assignments["OPTIONAL_ROUTE_GROUPS"]["ai_control"] + assert "llm_engines" not in route_assignments["OPTIONAL_ROUTE_GROUPS"]["ai_control"] route_paths = {str(getattr(route, "path", "")).strip() for route in ai_routing.router.routes} assert "/ai-routing/overview" in route_paths assert "/ai-routing/team-policy" in route_paths assert "/ai-routing/admin-console" in route_paths + assert "/ai-routing/capability-slots" in route_paths + assert "/ai-routing/capability-slots/{slot_key}/draft" in route_paths + assert "/ai-routing/capability-slots/validate" in route_paths + assert "/ai-routing/capability-slots/publish" in route_paths + assert "/ai-routing/capability-slots/rollback" in route_paths @pytest.mark.asyncio @@ -192,7 +1293,8 @@ async def test_ai_routing_overview_requires_governance_role() -> None: response = await client.get("/ai-routing/overview") assert response.status_code == 403 - assert response.json()["code"] == 403 + payload = response.json() + assert payload.get("code") == 403 or "platform.ai_routing.manage" in str(payload.get("detail") or "") @pytest.mark.asyncio @@ -215,6 +1317,66 @@ async def test_ai_routing_api_returns_enveloped_payloads(monkeypatch) -> None: async def get_admin_console(self): return {"providers": [], "scene_routes": []} + async def get_capability_slots(self): + return { + "version": "draft", + "active_version": "unpublished", + "draft_version": "draft", + "dirty": False, + "validation": {"can_publish": True, "summary": {"ok": 1}, "items": []}, + "history": [], + "slots": [], + "active_slots": [], + "draft_slots": [], + } + + async def update_capability_slot(self, *, slot_key, payload, updated_by: int): + assert slot_key == "high" + assert updated_by == 101 + return { + "version": "draft", + "active_version": "unpublished", + "draft_version": "draft", + "dirty": True, + "validation": {"can_publish": True, "summary": {"ok": 1}, "items": []}, + "history": [], + "slots": [{**payload, "slot_key": slot_key}], + "active_slots": [], + "draft_slots": [{**payload, "slot_key": slot_key}], + } + + async def validate_capability_slots(self): + return {"can_publish": True, "summary": {"ok": 1}, "items": []} + + async def publish_capability_slots(self, *, published_by: int, summary=None): + assert published_by == 101 + return { + "version": "slot-v1", + "active_version": "slot-v1", + "draft_version": "draft", + "dirty": False, + "validation": {"can_publish": True, "summary": {"ok": 1}, "items": []}, + "history": [{"version": "slot-v1"}], + "slots": [], + "active_slots": [], + "draft_slots": [], + } + + async def rollback_capability_slots(self, *, target_version, rolled_back_by: int): + assert target_version is None + assert rolled_back_by == 101 + return { + "version": "slot-v2", + "active_version": "slot-v2", + "draft_version": "draft", + "dirty": False, + "validation": {"can_publish": True, "summary": {"ok": 1}, "items": []}, + "history": [{"version": "slot-v2"}], + "slots": [], + "active_slots": [], + "draft_slots": [], + } + async def run_diagnostics(self): return {"items": [], "summary": {"ok": 1}} @@ -230,12 +1392,22 @@ async def test_ai_routing_api_returns_enveloped_payloads(monkeypatch) -> None: policy = await client.get("/ai-routing/team-policy") saved = await client.put("/ai-routing/team-policy", json={"strategy": "quality_first", "scenes": []}) console = await client.get("/ai-routing/admin-console") + slots = await client.put("/ai-routing/capability-slots/high", json={"primary_engine_id": "high-1", "fallback_engine_ids": []}) + slot_draft = await client.put("/ai-routing/capability-slots/high/draft", json={"primary_engine_id": "high-1", "fallback_engine_ids": []}) + slot_validation = await client.post("/ai-routing/capability-slots/validate") + slot_publish = await client.post("/ai-routing/capability-slots/publish", json={"summary": "publish"}) + slot_rollback = await client.post("/ai-routing/capability-slots/rollback", json={}) diagnostics = await client.post("/ai-routing/diagnostics/run") assert overview.json()["code"] == 0 assert policy.json()["data"]["strategy"] == "balanced" assert saved.json()["data"]["strategy"] == "quality_first" assert console.json()["data"]["providers"] == [] + assert slots.json()["data"]["slots"][0]["slot_key"] == "high" + assert slot_draft.json()["data"]["dirty"] is True + assert slot_validation.json()["data"]["can_publish"] is True + assert slot_publish.json()["data"]["active_version"] == "slot-v1" + assert slot_rollback.json()["data"]["active_version"] == "slot-v2" assert diagnostics.json()["data"]["summary"]["ok"] == 1 diff --git a/backend/tests/test_ai_service_core_runtime.py b/backend/tests/test_ai_service_core_runtime.py index cc14736f..2ffcbf96 100644 --- a/backend/tests/test_ai_service_core_runtime.py +++ b/backend/tests/test_ai_service_core_runtime.py @@ -10,7 +10,8 @@ from app.core.startup import model_probe from app.services.llm_service import mixin_chat_openai from app.services.ai_service_core import AIService from app.services.llm_service import mixin_cache_circuit -from app.services.llm_service import llm_service +from app.services.llm_service import ChatCompletionResult, llm_service +from app.services.ai_scene_runtime_policy import AISceneRuntimePolicyService from app.services.llm_service.mixin_control_plane_routing import LLMControlPlaneRoutingMixin from app.services.model.model_config import load_model_config, resolve_model_config_path from app.services.model.model_context_bundle import ModelContextBundleService @@ -74,6 +75,11 @@ def test_structured_extraction_prefers_richer_message_content_over_sparse_tool_c assert result.payload["mvp_items"] == ["接入 TikTok Shop", "对接 Shopify"] +def test_structured_exception_classifier_preserves_json_invalid_reason() -> None: + assert llm_service._classify_structured_exception(ValueError("structured_json_invalid")) == "structured_json_invalid" + assert llm_service._classify_structured_exception(ValueError("structured_schema_invalid")) == "structured_schema_invalid" + + @pytest.mark.asyncio async def test_invoke_with_retry_uses_llm_service(monkeypatch) -> None: service = AIService() @@ -144,7 +150,7 @@ def test_model_config_bundle_centralizes_provider_and_tier_defaults() -> None: provider_defaults = bundle.get("provider_defaults") tier_defaults = bundle.get("tier_defaults") - assert provider_defaults["glm"]["model"] == "qwen-plus" + assert provider_defaults["dashscope"]["model"] == "qwen-plus" assert provider_defaults["openai"]["model"] == "GLM-5" assert tier_defaults["low"]["model"] == "qwen-plus" assert tier_defaults["high"]["model"] == "GLM-5" @@ -202,6 +208,54 @@ def test_llm_service_prefers_explicit_routing_contract_over_function_name_hints( assert timeout_seconds <= llm_service.request_timeout +def test_scene_runtime_policy_sets_project_launch_timeout_by_input_scale() -> None: + small_policy, small_tokens = AISceneRuntimePolicyService.resolve( + scene_key="project_launch", + function_name="pmflow_launch_draft_v1", + input_text="做一个 AI 会议纪要助手", + ) + normal_policy, _ = AISceneRuntimePolicyService.resolve( + scene_key="project_launch", + function_name="pmflow_launch_draft_v1", + input_text="x" * 8000, + ) + large_policy, _ = AISceneRuntimePolicyService.resolve( + scene_key="project_launch", + function_name="pmflow_launch_draft_v1", + input_text="x" * 40000, + ) + + assert small_tokens > 0 + assert small_policy.input_bucket == "small" + assert small_policy.provider_timeout_seconds == 60 + assert normal_policy.input_bucket == "normal" + assert normal_policy.provider_timeout_seconds == 90 + assert large_policy.input_bucket == "large" + assert large_policy.provider_timeout_seconds == 120 + assert large_policy.large_input_policy == "preprocess_then_generate" + + +def test_llm_request_timeout_uses_explicit_scene_policy_over_global_default() -> None: + timeout_seconds = llm_service._request_timeout_for_function( + "pmflow_launch_draft_v1", + response_mode="fast", + routing_meta={ + "response_contract": "structured", + "provider_timeout_seconds": 90, + "scene_timeout_policy_id": "project_launch:pmflow_launch_draft_v1:normal", + }, + ) + + assert timeout_seconds == 90 + + +def test_llm_retry_budget_uses_explicit_runtime_profile_policy() -> None: + assert llm_service._max_retries_from_routing_meta({"max_retries": 2}) == 2 + assert llm_service._max_retries_from_routing_meta({"max_retries": 9}) == 3 + assert llm_service._max_retries_from_routing_meta({"max_retries": -1}) == 0 + assert llm_service._max_retries_from_routing_meta({}) is None + + def test_intent_http_status_retry_budget_defaults_to_three(monkeypatch) -> None: monkeypatch.delenv("LLM_INTENT_HTTP_RETRY_BUDGET", raising=False) @@ -226,7 +280,7 @@ def test_semantic_context_decision_no_longer_receives_model_context_bundle() -> def test_control_plane_routing_can_respect_explicit_low_tier_for_adaptive_rag_deep() -> None: low_engine = { "id": "low-1", - "provider": "glm", + "provider": "dashscope", "model": "qwen-plus", "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", "api_key": "low-key", @@ -256,7 +310,7 @@ def test_control_plane_routing_can_respect_explicit_low_tier_for_adaptive_rag_de "enabled": True, "priority": 50, "tier": "low", - "provider": "glm", + "provider": "dashscope", "model": "qwen-plus", "match_all": True, }, @@ -270,7 +324,7 @@ def test_control_plane_routing_can_respect_explicit_low_tier_for_adaptive_rag_de def _load_control_plane_routing_bundle(self) -> dict[str, object]: return dict(control_plane_bundle) - def _resolve_engine_from_rule(self, *, rule, engines, active_engine): + def _resolve_engine_from_rule(self, *, rule, engines, active_engine, slot_bundle=None): _ = active_engine for item in engines: if ( @@ -292,7 +346,7 @@ def test_control_plane_routing_can_respect_explicit_low_tier_for_adaptive_rag_de ) assert isinstance(routed, dict) - assert routed["provider"] == "glm" + assert routed["provider"] == "dashscope" assert routed["model"] == "qwen-plus" assert routed["_routing_meta"]["planned_tier"] == "low" @@ -306,8 +360,8 @@ def test_provider_payload_reads_canonical_model_config(monkeypatch) -> None: assert payload["low"] == "qwen-plus" assert payload["high"] == "GLM-5" assert payload["base_url"] == "https://api.edgefn.net/v1" - assert payload["providers"]["glm"]["model"] == "qwen-plus" - assert payload["providers"]["glm"]["base_url"] == "https://dashscope.aliyuncs.com/compatible-mode/v1" + assert payload["providers"]["dashscope"]["model"] == "qwen-plus" + assert payload["providers"]["dashscope"]["base_url"] == "https://dashscope.aliyuncs.com/compatible-mode/v1" assert payload["providers"]["openai"]["model"] == "GLM-5" @@ -316,7 +370,7 @@ async def test_ai_service_runtime_prefers_canonical_config_over_env(monkeypatch) service = AIService() monkeypatch.setenv("CONTROL_PLANE_ROUTING_ENABLED", "false") monkeypatch.setenv("LLM_DIRECT_EMERGENCY_ENABLED", "true") - monkeypatch.setenv("LLM_PROVIDER", "glm") + monkeypatch.setenv("LLM_PROVIDER", "dashscope") monkeypatch.setenv("DASHSCOPE_MODEL", "wrong-value") monkeypatch.setenv("DASHSCOPE_BASE_URL", "https://example.invalid") @@ -326,13 +380,13 @@ async def test_ai_service_runtime_prefers_canonical_config_over_env(monkeypatch) assert runtime["base_url"] == "https://dashscope.aliyuncs.com/compatible-mode/v1" -def test_ai_service_runtime_ignores_legacy_glm_api_key(monkeypatch) -> None: +def test_ai_service_runtime_ignores_removed_glm_api_key(monkeypatch) -> None: service = AIService() monkeypatch.setenv("CONTROL_PLANE_ROUTING_ENABLED", "false") monkeypatch.setenv("LLM_DIRECT_EMERGENCY_ENABLED", "true") - monkeypatch.setenv("LLM_PROVIDER", "glm") + monkeypatch.setenv("LLM_PROVIDER", "dashscope") monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False) - monkeypatch.setenv("GLM_API_KEY", "legacy-glm-key") + monkeypatch.setenv("REMOVED_PROVIDER_API_KEY", "removed-dashscope-key") runtime = service._resolve_llm_runtime(api_key=None, model=None, base_url=None) @@ -344,7 +398,7 @@ def test_control_plane_rule_keeps_existing_api_key_when_env_missing(monkeypatch) rule = { "tier": "low", - "provider": "glm", + "provider": "dashscope", "model": "qwen-plus", "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", "api_env": "DASHSCOPE_API_KEY", @@ -353,7 +407,7 @@ def test_control_plane_rule_keeps_existing_api_key_when_env_missing(monkeypatch) { "id": "low-1", "name": "Qwen 低阶引擎", - "provider": "glm", + "provider": "dashscope", "model": "qwen-turbo", "api_key": "db-key", "base_url": "https://api.edgefn.net/v1", @@ -375,18 +429,18 @@ def test_control_plane_rule_keeps_existing_api_key_when_env_missing(monkeypatch) ) assert routed is not None - assert routed["provider"] == "glm" + assert routed["provider"] == "dashscope" assert routed["model"] == "qwen-plus" assert routed["base_url"] == "https://dashscope.aliyuncs.com/compatible-mode/v1" assert routed["api_key"] == "db-key" -def test_control_plane_rule_prefers_env_api_key_over_db_key(monkeypatch) -> None: +def test_control_plane_rule_ignores_env_api_key_and_uses_saved_key(monkeypatch) -> None: monkeypatch.setenv("DASHSCOPE_API_KEY", "env-dashscope-key") rule = { "tier": "low", - "provider": "glm", + "provider": "dashscope", "model": "qwen-plus", "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", "api_env": "DASHSCOPE_API_KEY", @@ -395,7 +449,7 @@ def test_control_plane_rule_prefers_env_api_key_over_db_key(monkeypatch) -> None { "id": "low-1", "name": "Qwen 低阶引擎", - "provider": "glm", + "provider": "dashscope", "model": "qwen-turbo", "api_key": "db-key", "base_url": "https://api.edgefn.net/v1", @@ -417,12 +471,970 @@ def test_control_plane_rule_prefers_env_api_key_over_db_key(monkeypatch) -> None ) assert routed is not None - assert routed["provider"] == "glm" - assert routed["api_key"] == "env-dashscope-key" + assert routed["provider"] == "dashscope" + assert routed["api_key"] == "db-key" + + +def test_engine_runtime_keeps_saved_key_when_env_placeholder_exists(monkeypatch) -> None: + monkeypatch.setenv("DASHSCOPE_API_KEY", "local-placeholder") + engine = { + "id": "low-1", + "name": "Qwen 低阶引擎", + "provider": "dashscope", + "model": "qwen-plus", + "api_key": "db-dashscope-key", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + } + + runtime = llm_service._normalize_engine_runtime(engine) + + assert runtime["api_key"] == "db-dashscope-key" + + +def test_default_fallback_resolution_skips_disabled_engine() -> None: + bundle = { + "default_fallback_engine_id": "low-disabled", + "active_engine_id": "low-disabled", + "engines": [ + { + "id": "low-disabled", + "name": "Qwen 停用引擎", + "provider": "dashscope", + "model": "qwen-plus", + "api_key": "low-key", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "enabled": False, + }, + { + "id": "high-enabled", + "name": "高阶可用引擎", + "provider": "openai", + "model": "GLM-5", + "api_key": "high-key", + "base_url": "https://api.edgefn.net/v1", + "enabled": True, + }, + ], + } + + runtime = llm_service._resolve_active_engine_from_bundle(bundle) + + assert runtime["id"] == "high-enabled" + assert runtime["provider"] == "openai" + + +def test_capability_slot_runtime_consumes_active_slots_only() -> None: + engines = [ + { + "id": "low-active", + "provider": "dashscope", + "model": "qwen-plus", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "api_key": "low-key", + "enabled": True, + }, + { + "id": "low-draft", + "provider": "openai", + "model": "GLM-5", + "base_url": "https://api.edgefn.net/v1", + "api_key": "high-key", + "enabled": True, + }, + ] + slot_bundle = { + "active": {"slots": {"low": {"primary_engine_id": "low-active"}}}, + "draft": {"slots": {"low": {"primary_engine_id": "low-draft"}}}, + } + + routed = llm_service._resolve_engine_from_slot_binding( + tier="low", + engines=engines, + slot_bundle=slot_bundle, + ) + + assert routed is not None + assert routed["id"] == "low-active" + assert routed["provider"] == "dashscope" + + +@pytest.mark.asyncio +async def test_structured_object_runtime_uses_active_structured_slot(monkeypatch) -> None: + bundle = { + "active_engine_id": "low-default", + "engines": [ + { + "id": "low-default", + "provider": "dashscope", + "model": "qwen-plus", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "api_key": "low-key", + "enabled": True, + }, + { + "id": "structured-primary", + "provider": "openai", + "model": "GLM-5", + "base_url": "https://api.edgefn.net/v1", + "api_key": "structured-key", + "enabled": True, + }, + { + "id": "structured-draft", + "provider": "deepseek", + "model": "deepseek-chat", + "base_url": "https://api.deepseek.com/v1", + "api_key": "draft-key", + "enabled": True, + }, + ], + } + slot_bundle = { + "active_version": "slot-v-test-active", + "active": {"slots": {"structured": {"primary_engine_id": "structured-primary"}}}, + "draft": {"slots": {"structured": {"primary_engine_id": "structured-draft"}}}, + } + + async def fake_get_or_init_bundle(db, *, tenant_id: int): + _ = (db, tenant_id) + return object(), bundle + + async def fake_load_capability_slot_bundle(db): + _ = db + return slot_bundle + + monkeypatch.setattr(llm_service, "_get_or_init_bundle", fake_get_or_init_bundle) + monkeypatch.setattr(llm_service, "_load_capability_slot_bundle", fake_load_capability_slot_bundle) + + routed = await llm_service._resolve_engine_for_call( + object(), + tenant_id=9, + function_name="pmflow_launch_draft_v1", + routing_meta={"response_contract": "structured"}, + ) + + assert routed["id"] == "structured-primary" + assert routed["provider"] == "openai" + assert routed["_routing_meta"]["planned_tier"] == "structured" + assert routed["_routing_meta"]["executed_tier"] == "structured" + assert routed["_routing_meta"]["route_source"] == "slot_binding" + assert routed["_routing_meta"]["routing_decision"] == "capability_slot" + assert routed["_routing_meta"]["slot_key"] == "structured" + assert routed["_routing_meta"]["slot_version"] == "slot-v-test-active" + assert routed["_routing_meta"]["slot_runtime_source"] == "active_slot" + assert routed["_routing_meta"]["slot_fallback_index"] == 0 + assert routed["_routing_meta"]["slot_degraded"] is False + + +@pytest.mark.asyncio +async def test_structured_object_runtime_uses_structured_slot_fallback_chain(monkeypatch) -> None: + bundle = { + "active_engine_id": "low-default", + "engines": [ + { + "id": "low-default", + "provider": "dashscope", + "model": "qwen-plus", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "api_key": "low-key", + "enabled": True, + }, + { + "id": "structured-primary", + "provider": "openai", + "model": "GLM-5", + "base_url": "https://api.edgefn.net/v1", + "api_key": "primary-key", + "enabled": True, + }, + { + "id": "structured-fallback", + "provider": "deepseek", + "model": "deepseek-chat", + "base_url": "https://api.deepseek.com/v1", + "api_key": "fallback-key", + "enabled": True, + }, + ], + "settings": {"enable_local_kb_qa": False, "local_kb_qa_scope": "kb_qa_only"}, + } + slot_bundle = { + "active_version": "slot-v-fallback", + "active": { + "slots": { + "structured": { + "primary_engine_id": "structured-primary", + "fallback_engine_ids": ["structured-fallback"], + } + } + }, + "draft": {"slots": {}}, + } + attempted_keys: list[str] = [] + + async def fake_get_or_init_bundle(db, *, tenant_id: int): + _ = (db, tenant_id) + return object(), bundle + + async def fake_load_capability_slot_bundle(db): + _ = db + return slot_bundle + + async def fake_chat_completion_raw(**kwargs): + attempted_keys.append(str(kwargs.get("api_key") or "")) + if kwargs.get("api_key") == "primary-key": + raise RuntimeError("primary structured runtime failed") + return ChatCompletionResult(text='{"ok": true}', raw_response={}) + + async def fake_log_ai_call(**kwargs): + llm_service._last_ai_call_summary = { + "planned_tier": (kwargs.get("routing_meta") or {}).get("planned_tier"), + "executed_tier": (kwargs.get("routing_meta") or {}).get("executed_tier"), + "slot_key": (kwargs.get("routing_meta") or {}).get("slot_key"), + "slot_version": (kwargs.get("routing_meta") or {}).get("slot_version"), + "slot_runtime_source": (kwargs.get("routing_meta") or {}).get("slot_runtime_source"), + "slot_fallback_index": (kwargs.get("routing_meta") or {}).get("slot_fallback_index"), + "slot_degraded": (kwargs.get("routing_meta") or {}).get("slot_degraded"), + } + + monkeypatch.setattr(llm_service, "_get_or_init_bundle", fake_get_or_init_bundle) + monkeypatch.setattr(llm_service, "_load_capability_slot_bundle", fake_load_capability_slot_bundle) + monkeypatch.setattr(llm_service, "_chat_completion_raw", fake_chat_completion_raw) + monkeypatch.setattr(llm_service, "_log_ai_call", fake_log_ai_call) + + result = await llm_service.generate_structured_object( + "return strict json", + db=object(), + tenant_id=9, + function_name="llm_generate", + use_prompt_profile=False, + ) + + assert result.payload == {"ok": True} + assert result.provider == "deepseek" + assert attempted_keys == ["primary-key", "fallback-key"] + assert llm_service._last_ai_call_summary["planned_tier"] == "structured" + assert llm_service._last_ai_call_summary["executed_tier"] == "structured" + assert llm_service._last_ai_call_summary["slot_key"] == "structured" + assert llm_service._last_ai_call_summary["slot_version"] == "slot-v-fallback" + assert llm_service._last_ai_call_summary["slot_runtime_source"] == "active_slot" + assert llm_service._last_ai_call_summary["slot_fallback_index"] == 1 + assert llm_service._last_ai_call_summary["slot_degraded"] is False + + +@pytest.mark.asyncio +async def test_structured_object_runtime_fails_explicitly_after_structured_slot_chain_exhausted(monkeypatch) -> None: + bundle = { + "active_engine_id": "low-default", + "engines": [ + { + "id": "low-default", + "provider": "dashscope", + "model": "qwen-plus", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "api_key": "low-key", + "enabled": True, + }, + { + "id": "structured-primary", + "provider": "openai", + "model": "GLM-5", + "base_url": "https://api.edgefn.net/v1", + "api_key": "primary-key", + "enabled": True, + }, + { + "id": "structured-fallback", + "provider": "deepseek", + "model": "deepseek-chat", + "base_url": "https://api.deepseek.com/v1", + "api_key": "fallback-key", + "enabled": True, + }, + ], + "settings": {"enable_local_kb_qa": False, "local_kb_qa_scope": "kb_qa_only"}, + } + slot_bundle = { + "active": { + "slots": { + "structured": { + "primary_engine_id": "structured-primary", + "fallback_engine_ids": ["structured-fallback"], + } + } + }, + "draft": {"slots": {}}, + } + attempted_keys: list[str] = [] + + async def fake_get_or_init_bundle(db, *, tenant_id: int): + _ = (db, tenant_id) + return object(), bundle + + async def fake_load_capability_slot_bundle(db): + _ = db + return slot_bundle + + async def fake_chat_completion_raw(**kwargs): + attempted_keys.append(str(kwargs.get("api_key") or "")) + raise RuntimeError(f"{kwargs.get('api_key')} failed") + + async def fake_log_ai_call(**kwargs): + _ = kwargs + + monkeypatch.setattr(llm_service, "_get_or_init_bundle", fake_get_or_init_bundle) + monkeypatch.setattr(llm_service, "_load_capability_slot_bundle", fake_load_capability_slot_bundle) + monkeypatch.setattr(llm_service, "_chat_completion_raw", fake_chat_completion_raw) + monkeypatch.setattr(llm_service, "_log_ai_call", fake_log_ai_call) + + with pytest.raises(RuntimeError): + await llm_service.generate_structured_object( + "return strict json", + db=object(), + tenant_id=9, + function_name="llm_generate", + use_prompt_profile=False, + ) + + assert attempted_keys == ["primary-key", "fallback-key"] + + +@pytest.mark.asyncio +async def test_structured_object_runtime_ignores_unpublished_structured_draft(monkeypatch) -> None: + bundle = { + "active_engine_id": "low-default", + "engines": [ + { + "id": "low-default", + "provider": "dashscope", + "model": "qwen-plus", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "api_key": "low-key", + "enabled": True, + }, + { + "id": "structured-draft", + "provider": "openai", + "model": "GLM-5", + "base_url": "https://api.edgefn.net/v1", + "api_key": "draft-key", + "enabled": True, + }, + ], + } + slot_bundle = { + "active": {"slots": {}}, + "draft": {"slots": {"structured": {"primary_engine_id": "structured-draft"}}}, + } + + async def fake_get_or_init_bundle(db, *, tenant_id: int): + _ = (db, tenant_id) + return object(), bundle + + async def fake_load_capability_slot_bundle(db): + _ = db + return slot_bundle + + monkeypatch.setenv("CONTROL_PLANE_ROUTING_ENABLED", "0") + monkeypatch.setenv("LLM_MIXED_ROUTING_ENABLED", "0") + monkeypatch.setattr(llm_service, "_get_or_init_bundle", fake_get_or_init_bundle) + monkeypatch.setattr(llm_service, "_load_capability_slot_bundle", fake_load_capability_slot_bundle) + + routed = await llm_service._resolve_engine_for_call( + object(), + tenant_id=9, + function_name="pmflow_launch_draft_v1", + routing_meta={"response_contract": "structured"}, + ) + + assert routed["id"] == "low-default" + assert routed["id"] != "structured-draft" + assert routed["_routing_meta"]["route_source"] == "active_engine" + + +@pytest.mark.asyncio +async def test_structured_slot_runtime_skips_disabled_primary_and_uses_slot_fallback(monkeypatch) -> None: + bundle = { + "active_engine_id": "low-default", + "engines": [ + { + "id": "low-default", + "provider": "dashscope", + "model": "qwen-plus", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "api_key": "low-key", + "enabled": True, + }, + { + "id": "structured-primary-disabled", + "provider": "openai", + "model": "GLM-5", + "base_url": "https://api.edgefn.net/v1", + "api_key": "primary-key", + "enabled": False, + }, + { + "id": "structured-fallback", + "provider": "deepseek", + "model": "deepseek-chat", + "base_url": "https://api.deepseek.com/v1", + "api_key": "fallback-key", + "enabled": True, + }, + ], + } + slot_bundle = { + "active_version": "slot-v-disabled-primary", + "active": { + "slots": { + "structured": { + "primary_engine_id": "structured-primary-disabled", + "fallback_engine_ids": ["structured-fallback"], + } + } + }, + "draft": {"slots": {}}, + } + + async def fake_get_or_init_bundle(db, *, tenant_id: int): + _ = (db, tenant_id) + return object(), bundle + + async def fake_load_capability_slot_bundle(db): + _ = db + return slot_bundle + + monkeypatch.setattr(llm_service, "_get_or_init_bundle", fake_get_or_init_bundle) + monkeypatch.setattr(llm_service, "_load_capability_slot_bundle", fake_load_capability_slot_bundle) + + routed = await llm_service._resolve_engine_for_call( + object(), + tenant_id=9, + function_name="pmflow_launch_draft_v1", + routing_meta={"response_contract": "structured"}, + ) + + assert routed["id"] == "structured-fallback" + assert routed["_routing_meta"]["slot_key"] == "structured" + assert routed["_routing_meta"]["slot_version"] == "slot-v-disabled-primary" + assert routed["_routing_meta"]["slot_fallback_index"] == 1 + assert routed["_routing_meta"]["slot_degraded"] is False + + +@pytest.mark.asyncio +async def test_structured_slot_runtime_skips_runtime_without_required_capabilities(monkeypatch) -> None: + bundle = { + "active_engine_id": "low-default", + "engines": [ + { + "id": "low-default", + "provider": "dashscope", + "model": "qwen-plus", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "api_key": "low-key", + "enabled": True, + }, + { + "id": "structured-primary-unsupported", + "provider": "openai", + "model": "GLM-5", + "base_url": "https://api.edgefn.net/v1", + "api_key": "primary-key", + "enabled": True, + "capabilities": {"structured_output": True, "json_schema": False}, + }, + { + "id": "structured-fallback", + "provider": "deepseek", + "model": "deepseek-chat", + "base_url": "https://api.deepseek.com/v1", + "api_key": "fallback-key", + "enabled": True, + "capabilities": {"structured_output": True, "json_schema": True}, + }, + ], + } + slot_bundle = { + "active_version": "slot-v-capability-skip", + "active": { + "slots": { + "structured": { + "primary_engine_id": "structured-primary-unsupported", + "fallback_engine_ids": ["structured-fallback"], + } + } + }, + "draft": {"slots": {}}, + } + + async def fake_get_or_init_bundle(db, *, tenant_id: int): + _ = (db, tenant_id) + return object(), bundle + + async def fake_load_capability_slot_bundle(db): + _ = db + return slot_bundle + + monkeypatch.setattr(llm_service, "_get_or_init_bundle", fake_get_or_init_bundle) + monkeypatch.setattr(llm_service, "_load_capability_slot_bundle", fake_load_capability_slot_bundle) + + routed = await llm_service._resolve_engine_for_call( + object(), + tenant_id=9, + function_name="pmflow_launch_draft_v1", + routing_meta={"response_contract": "structured"}, + ) + + assert routed["id"] == "structured-fallback" + assert routed["_routing_meta"]["slot_fallback_index"] == 1 + + +@pytest.mark.asyncio +async def test_protected_structured_call_fails_when_active_structured_slot_has_no_usable_runtime(monkeypatch) -> None: + bundle = { + "active_engine_id": "low-default", + "engines": [ + { + "id": "low-default", + "provider": "dashscope", + "model": "qwen-plus", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "api_key": "low-key", + "enabled": True, + } + ], + } + slot_bundle = { + "active_version": "slot-v-missing-runtime", + "active": {"slots": {"structured": {"primary_engine_id": "deleted-runtime"}}}, + "draft": {"slots": {}}, + } + + async def fake_get_or_init_bundle(db, *, tenant_id: int): + _ = (db, tenant_id) + return object(), bundle + + async def fake_load_capability_slot_bundle(db): + _ = db + return slot_bundle + + monkeypatch.setattr(llm_service, "_get_or_init_bundle", fake_get_or_init_bundle) + monkeypatch.setattr(llm_service, "_load_capability_slot_bundle", fake_load_capability_slot_bundle) + + with pytest.raises(RuntimeError, match="structured_slot_runtime_unavailable"): + await llm_service._resolve_engine_for_call( + object(), + tenant_id=9, + function_name="pmflow_launch_draft_v1", + routing_meta={"response_contract": "structured"}, + ) + + +@pytest.mark.asyncio +async def test_nonprotected_structured_call_degrades_to_existing_path_when_active_slot_unusable(monkeypatch) -> None: + bundle = { + "active_engine_id": "low-default", + "engines": [ + { + "id": "low-default", + "provider": "dashscope", + "model": "qwen-plus", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "api_key": "low-key", + "enabled": True, + } + ], + } + slot_bundle = { + "active_version": "slot-v-degraded", + "active": {"slots": {"structured": {"primary_engine_id": "deleted-runtime"}}}, + "draft": {"slots": {}}, + } + + async def fake_get_or_init_bundle(db, *, tenant_id: int): + _ = (db, tenant_id) + return object(), bundle + + async def fake_load_capability_slot_bundle(db): + _ = db + return slot_bundle + + monkeypatch.setenv("CONTROL_PLANE_ROUTING_ENABLED", "0") + monkeypatch.setenv("LLM_MIXED_ROUTING_ENABLED", "0") + monkeypatch.setattr(llm_service, "_get_or_init_bundle", fake_get_or_init_bundle) + monkeypatch.setattr(llm_service, "_load_capability_slot_bundle", fake_load_capability_slot_bundle) + + routed = await llm_service._resolve_engine_for_call( + object(), + tenant_id=9, + function_name="llm_generate", + routing_meta={"response_contract": "structured"}, + ) + + assert routed["id"] == "low-default" + assert routed["_routing_meta"]["route_source"] == "active_engine" + assert routed["_routing_meta"]["slot_key"] == "structured" + assert routed["_routing_meta"]["slot_version"] == "slot-v-degraded" + assert routed["_routing_meta"]["slot_runtime_source"] == "active_slot_degraded" + assert routed["_routing_meta"]["slot_degraded"] is True + + +@pytest.mark.asyncio +async def test_long_context_runtime_uses_active_slot_only_for_explicit_long_context_call(monkeypatch) -> None: + bundle = { + "active_engine_id": "low-default", + "engines": [ + { + "id": "low-default", + "provider": "dashscope", + "model": "qwen-plus", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "api_key": "low-key", + "enabled": True, + }, + { + "id": "long-primary", + "provider": "openai", + "model": "gpt-4.1", + "base_url": "https://api.openai.com/v1", + "api_key": "long-key", + "enabled": True, + "capabilities": {"long_context": True, "max_context_tokens": 128000}, + }, + ], + } + slot_bundle = { + "active_version": "slot-v-long-active", + "active": {"slots": {"long_context": {"primary_engine_id": "long-primary"}}}, + "draft": {"slots": {}}, + } + + async def fake_get_or_init_bundle(db, *, tenant_id: int): + _ = (db, tenant_id) + return object(), bundle + + async def fake_load_capability_slot_bundle(db): + _ = db + return slot_bundle + + monkeypatch.setattr(llm_service, "_get_or_init_bundle", fake_get_or_init_bundle) + monkeypatch.setattr(llm_service, "_load_capability_slot_bundle", fake_load_capability_slot_bundle) + + routed = await llm_service._resolve_engine_for_call( + object(), + tenant_id=9, + function_name="long_document_context_synthesis", + routing_meta={"planned_tier": "long_context", "slot_key": "long_context"}, + ) + + assert routed["id"] == "long-primary" + assert routed["_routing_meta"]["planned_tier"] == "long_context" + assert routed["_routing_meta"]["slot_key"] == "long_context" + assert routed["_routing_meta"]["slot_version"] == "slot-v-long-active" + assert routed["_routing_meta"]["slot_runtime_source"] == "active_slot" + + +@pytest.mark.asyncio +async def test_tool_runtime_uses_active_slot_for_explicit_mcp_execution_planning(monkeypatch) -> None: + bundle = { + "active_engine_id": "low-default", + "engines": [ + { + "id": "low-default", + "provider": "dashscope", + "model": "qwen-plus", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "api_key": "low-key", + "enabled": True, + }, + { + "id": "tool-primary", + "provider": "openai", + "model": "gpt-4.1", + "base_url": "https://api.openai.com/v1", + "api_key": "tool-key", + "enabled": True, + "capabilities": {"tool_calling": True}, + }, + ], + } + slot_bundle = { + "active_version": "slot-v-tool-active", + "active": {"slots": {"tool": {"primary_engine_id": "tool-primary"}}}, + "draft": {"slots": {}}, + } + + async def fake_get_or_init_bundle(db, *, tenant_id: int): + _ = (db, tenant_id) + return object(), bundle + + async def fake_load_capability_slot_bundle(db): + _ = db + return slot_bundle + + monkeypatch.setattr(llm_service, "_get_or_init_bundle", fake_get_or_init_bundle) + monkeypatch.setattr(llm_service, "_load_capability_slot_bundle", fake_load_capability_slot_bundle) + + routed = await llm_service._resolve_engine_for_call( + object(), + tenant_id=9, + function_name="mcp_execution_plan", + routing_meta={"execution_mode": "local_planner"}, + ) + + assert routed["id"] == "tool-primary" + assert routed["_routing_meta"]["planned_tier"] == "tool" + assert routed["_routing_meta"]["slot_key"] == "tool" + assert routed["_routing_meta"]["slot_version"] == "slot-v-tool-active" + assert routed["_routing_meta"]["slot_runtime_source"] == "active_slot" + + +@pytest.mark.asyncio +async def test_long_context_and_tool_slots_skip_runtimes_without_required_capabilities(monkeypatch) -> None: + bundle = { + "active_engine_id": "low-default", + "engines": [ + { + "id": "low-default", + "provider": "dashscope", + "model": "qwen-plus", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "api_key": "low-key", + "enabled": True, + }, + { + "id": "long-short", + "provider": "openai", + "model": "short-context", + "base_url": "https://api.openai.com/v1", + "api_key": "short-key", + "enabled": True, + "capabilities": {"long_context": True, "max_context_tokens": 8192}, + }, + { + "id": "long-fallback", + "provider": "openai", + "model": "long-context", + "base_url": "https://api.openai.com/v1", + "api_key": "long-key", + "enabled": True, + "capabilities": {"long_context": True, "max_context_tokens": 128000}, + }, + { + "id": "tool-unsupported", + "provider": "openai", + "model": "no-tools", + "base_url": "https://api.openai.com/v1", + "api_key": "no-tools-key", + "enabled": True, + "capabilities": {"tool_calling": False}, + }, + { + "id": "tool-fallback", + "provider": "openai", + "model": "tools", + "base_url": "https://api.openai.com/v1", + "api_key": "tools-key", + "enabled": True, + "capabilities": {"tool_calling": True}, + }, + ], + } + slot_bundle = { + "active_version": "slot-v-capability-filter", + "active": { + "slots": { + "long_context": { + "primary_engine_id": "long-short", + "fallback_engine_ids": ["long-fallback"], + }, + "tool": { + "primary_engine_id": "tool-unsupported", + "fallback_engine_ids": ["tool-fallback"], + }, + } + }, + "draft": {"slots": {}}, + } + + async def fake_get_or_init_bundle(db, *, tenant_id: int): + _ = (db, tenant_id) + return object(), bundle + + async def fake_load_capability_slot_bundle(db): + _ = db + return slot_bundle + + monkeypatch.setattr(llm_service, "_get_or_init_bundle", fake_get_or_init_bundle) + monkeypatch.setattr(llm_service, "_load_capability_slot_bundle", fake_load_capability_slot_bundle) + + long_routed = await llm_service._resolve_engine_for_call( + object(), + tenant_id=9, + function_name="long_document_context_synthesis", + routing_meta={"slot_key": "long_context"}, + ) + tool_routed = await llm_service._resolve_engine_for_call( + object(), + tenant_id=9, + function_name="mcp_execution_plan", + routing_meta={"slot_key": "tool"}, + ) + + assert long_routed["id"] == "long-fallback" + assert long_routed["_routing_meta"]["slot_fallback_index"] == 1 + assert tool_routed["id"] == "tool-fallback" + assert tool_routed["_routing_meta"]["slot_fallback_index"] == 1 + + +@pytest.mark.asyncio +async def test_long_context_and_tool_runtime_ignore_unpublished_drafts(monkeypatch) -> None: + bundle = { + "active_engine_id": "low-default", + "engines": [ + { + "id": "low-default", + "provider": "dashscope", + "model": "qwen-plus", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "api_key": "low-key", + "enabled": True, + }, + { + "id": "long-draft", + "provider": "openai", + "model": "gpt-4.1", + "base_url": "https://api.openai.com/v1", + "api_key": "long-draft-key", + "enabled": True, + "capabilities": {"long_context": True, "max_context_tokens": 128000}, + }, + { + "id": "tool-draft", + "provider": "openai", + "model": "gpt-4.1", + "base_url": "https://api.openai.com/v1", + "api_key": "tool-draft-key", + "enabled": True, + "capabilities": {"tool_calling": True}, + }, + ], + } + slot_bundle = { + "active": {"slots": {}}, + "draft": { + "slots": { + "long_context": {"primary_engine_id": "long-draft"}, + "tool": {"primary_engine_id": "tool-draft"}, + } + }, + } + + async def fake_get_or_init_bundle(db, *, tenant_id: int): + _ = (db, tenant_id) + return object(), bundle + + async def fake_load_capability_slot_bundle(db): + _ = db + return slot_bundle + + monkeypatch.setenv("CONTROL_PLANE_ROUTING_ENABLED", "0") + monkeypatch.setenv("LLM_MIXED_ROUTING_ENABLED", "0") + monkeypatch.setattr(llm_service, "_get_or_init_bundle", fake_get_or_init_bundle) + monkeypatch.setattr(llm_service, "_load_capability_slot_bundle", fake_load_capability_slot_bundle) + + long_routed = await llm_service._resolve_engine_for_call( + object(), + tenant_id=9, + function_name="long_document_context_synthesis", + routing_meta={"slot_key": "long_context"}, + ) + tool_routed = await llm_service._resolve_engine_for_call( + object(), + tenant_id=9, + function_name="mcp_execution_plan", + routing_meta={"execution_mode": "local_planner"}, + ) + + assert long_routed["id"] == "low-default" + assert long_routed["id"] != "long-draft" + assert tool_routed["id"] == "low-default" + assert tool_routed["id"] != "tool-draft" + + +@pytest.mark.asyncio +async def test_external_query_slot_remains_candidate_and_does_not_route_runtime(monkeypatch) -> None: + bundle = { + "active_engine_id": "low-default", + "engines": [ + { + "id": "low-default", + "provider": "dashscope", + "model": "qwen-plus", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "api_key": "low-key", + "enabled": True, + }, + { + "id": "external-candidate", + "provider": "openai", + "model": "gpt-4.1", + "base_url": "https://api.openai.com/v1", + "api_key": "external-key", + "enabled": True, + }, + ], + } + slot_bundle = { + "active_version": "slot-v-external-candidate", + "active": {"slots": {"external_query": {"primary_engine_id": "external-candidate"}}}, + "draft": {"slots": {}}, + } + + async def fake_get_or_init_bundle(db, *, tenant_id: int): + _ = (db, tenant_id) + return object(), bundle + + async def fake_load_capability_slot_bundle(db): + _ = db + return slot_bundle + + monkeypatch.setenv("CONTROL_PLANE_ROUTING_ENABLED", "0") + monkeypatch.setenv("LLM_MIXED_ROUTING_ENABLED", "0") + monkeypatch.setattr(llm_service, "_get_or_init_bundle", fake_get_or_init_bundle) + monkeypatch.setattr(llm_service, "_load_capability_slot_bundle", fake_load_capability_slot_bundle) + + routed = await llm_service._resolve_engine_for_call( + object(), + tenant_id=9, + function_name="external_query_answer", + routing_meta={"slot_key": "external_query"}, + ) + + assert routed["id"] == "low-default" + assert routed["id"] != "external-candidate" + assert routed["_routing_meta"]["slot_key"] == "" + + +def test_engine_capabilities_are_normalized_for_legacy_runtime() -> None: + qwen_caps = llm_service._normalize_engine_capabilities(None, provider="dashscope", model="qwen-plus") + ollama_caps = llm_service._normalize_engine_capabilities({}, provider="ollama", model="llama3") + + assert qwen_caps["structured_output"] is True + assert qwen_caps["json_schema"] is True + assert qwen_caps["max_context_tokens"] >= 32768 + assert qwen_caps["capability_source"] == "default" + assert qwen_caps["capability_probe_status"] == "untested" + assert qwen_caps["capability_evidence"] == {} + assert qwen_caps["capability_probe"]["status"] == "untested" + assert qwen_caps["capability_probe"]["source"] == "inferred" + assert qwen_caps["capability_probe"]["verified_capabilities"]["structured_output"] is False + assert ollama_caps["local_only"] is True + assert ollama_caps["private_deployment"] is True def test_provider_circuit_state_clears_when_model_config_changes(monkeypatch, tmp_path) -> None: - circuit_key = "glm|https://dashscope.aliyuncs.com/compatible-mode/v1|qwen-plus" + circuit_key = "dashscope|https://dashscope.aliyuncs.com/compatible-mode/v1|qwen-plus" original_state = dict(llm_service._provider_circuit_state) original_signature = llm_service._provider_circuit_config_signature try: @@ -467,7 +1479,7 @@ async def test_startup_model_probe_does_not_poison_provider_circuit(monkeypatch) original_signature = llm_service._provider_circuit_config_signature seen_function_names: list[str] = [] try: - llm_service._provider_circuit_state["glm|https://dashscope.aliyuncs.com/compatible-mode/v1|qwen-plus"] = { + llm_service._provider_circuit_state["dashscope|https://dashscope.aliyuncs.com/compatible-mode/v1|qwen-plus"] = { "consecutive_failures": 2, "open_until": time.monotonic() + 300.0, "last_error": "stale", @@ -479,7 +1491,7 @@ async def test_startup_model_probe_does_not_poison_provider_circuit(monkeypatch) seen_function_names.append(function_name) if function_name == "pmflow_semantic_context_decision": return { - "provider": "glm", + "provider": "dashscope", "model": "qwen-plus", "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", "api_key": "db-key", @@ -502,7 +1514,7 @@ async def test_startup_model_probe_does_not_poison_provider_circuit(monkeypatch) assert "pmflow_semantic_context_decision" in seen_function_names assert "classify_intent_ai_pmo_kernel" not in seen_function_names - assert "glm|https://dashscope.aliyuncs.com/compatible-mode/v1|qwen-plus" in llm_service._provider_circuit_state + assert "dashscope|https://dashscope.aliyuncs.com/compatible-mode/v1|qwen-plus" in llm_service._provider_circuit_state finally: llm_service._provider_circuit_state.clear() llm_service._provider_circuit_state.update(original_state) @@ -560,7 +1572,7 @@ async def test_openai_compatible_chat_retries_once_on_connect_error(monkeypatch) request_timeout=1.0, structured_output=False, function_label="unit_test_connect_retry", - provider_name="glm", + provider_name="dashscope", is_control_plane_gateway_request=False, control_plane_agents_timeout_seconds=120, request_started_at_perf=time.perf_counter(), @@ -625,7 +1637,7 @@ async def test_openai_compatible_chat_accepts_structured_json_from_reasoning_con request_timeout=1.0, structured_output=True, function_label="draft_generator_v1", - provider_name="glm", + provider_name="dashscope", is_control_plane_gateway_request=False, control_plane_agents_timeout_seconds=120, request_started_at_perf=time.perf_counter(), @@ -758,7 +1770,7 @@ async def test_openai_compatible_chat_ignores_invalid_structured_candidate_from_ @pytest.mark.asyncio async def test_chat_completion_raw_opens_provider_circuit_immediately_on_billing_block(monkeypatch) -> None: - circuit_key = "glm|https://dashscope.aliyuncs.com/compatible-mode/v1|qwen-plus" + circuit_key = "dashscope|https://dashscope.aliyuncs.com/compatible-mode/v1|qwen-plus" original_state = dict(llm_service._provider_circuit_state) original_signature = llm_service._provider_circuit_config_signature @@ -800,12 +1812,12 @@ async def test_chat_completion_raw_opens_provider_circuit_immediately_on_billing monkeypatch.setattr(mixin_chat_openai.httpx, "AsyncClient", FakeClient) try: - with pytest.raises(ValueError, match="provider_billing_blocked:glm"): + with pytest.raises(ValueError, match="provider_billing_blocked:dashscope") as exc_info: await llm_service._chat_completion_raw( - provider="glm", + provider="dashscope", prompt="输出 JSON", system_prompt="你是 JSON 生成器", - api_key="glm-key", + api_key="dashscope-key", base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", model="qwen-plus", temperature=0.0, @@ -816,6 +1828,9 @@ async def test_chat_completion_raw_opens_provider_circuit_immediately_on_billing ) assert llm_service._is_provider_circuit_open(circuit_key) is True + assert getattr(exc_info.value, "provider_request_payload")["model"] == "qwen-plus" + assert getattr(exc_info.value, "provider_response_payload")["error"]["type"] == "Arrearage" + assert getattr(exc_info.value, "provider_attempts")[0]["status"] == "http_error" finally: llm_service._provider_circuit_state.clear() llm_service._provider_circuit_state.update(original_state) @@ -887,7 +1902,7 @@ async def test_openai_compatible_chat_retries_empty_reasoning_response_with_bigg request_timeout=1.0, structured_output=False, function_label="adaptive_rag_answer_normal", - provider_name="glm", + provider_name="dashscope", is_control_plane_gateway_request=False, control_plane_agents_timeout_seconds=120, request_started_at_perf=time.perf_counter(), @@ -896,6 +1911,10 @@ async def test_openai_compatible_chat_retries_empty_reasoning_response_with_bigg ) assert result.text.startswith("这是挂载 runtime") + assert result.raw_request["model"] == "GLM-5" + assert result.raw_response["choices"][0]["message"]["content"].startswith("这是挂载 runtime") + assert len(result.provider_attempts or []) == 3 + assert (result.provider_attempts or [])[-1]["status"] == "success" assert len(calls) == 3 assert calls[0]["max_tokens"] == 512 assert calls[1]["max_tokens"] == 512 diff --git a/backend/tests/test_draft_generator_service.py b/backend/tests/test_draft_generator_service.py index ccb834dc..81b5a82a 100644 --- a/backend/tests/test_draft_generator_service.py +++ b/backend/tests/test_draft_generator_service.py @@ -218,7 +218,7 @@ async def test_generate_and_persist_recovers_rich_logged_payload_when_structured "followup_plan": [], }, source_field="tool_calls.function.arguments", - provider="glm", + provider="dashscope", model="qwen-plus", raw_text='{"title":"","summary":"占位摘要","mvp_items":[],"followup_plan":[]}', validation="ok", @@ -481,7 +481,7 @@ async def test_generate_charter_seed_uses_project_context(monkeypatch) -> None: return StructuredExtractionResult( payload=payload, source_field="message.content", - provider="glm", + provider="dashscope", model="qwen-plus", raw_text=json.dumps(payload, ensure_ascii=False), validation="ok", @@ -529,7 +529,7 @@ async def test_generate_charter_from_signal_includes_project_context(monkeypatch return StructuredExtractionResult( payload=payload, source_field="message.content", - provider="glm", + provider="dashscope", model="qwen-plus", raw_text=json.dumps(payload, ensure_ascii=False), validation="ok", diff --git a/backend/tests/test_internal_query_context_packet.py b/backend/tests/test_internal_query_context_packet.py index 5dd54b09..272bf9b3 100644 --- a/backend/tests/test_internal_query_context_packet.py +++ b/backend/tests/test_internal_query_context_packet.py @@ -373,7 +373,7 @@ async def test_internal_model_config_fast_answer_prefers_internal_context_packet { "id": "low-1", "name": "Qwen 低阶引擎", - "provider": "glm", + "provider": "dashscope", "model": "qwen-plus", } ], @@ -390,7 +390,7 @@ async def test_internal_model_config_fast_answer_prefers_internal_context_packet _ = (db, tenant_id, function_name, routing_meta) return { "name": "Qwen 低阶引擎", - "provider": "glm", + "provider": "dashscope", "model": "qwen-plus", "_routing_meta": {"planned_tier": "low", "executed_tier": "low"}, } diff --git a/backend/tests/test_mcp_external_query_bridge.py b/backend/tests/test_mcp_external_query_bridge.py index d60ab1b3..47d3c858 100644 --- a/backend/tests/test_mcp_external_query_bridge.py +++ b/backend/tests/test_mcp_external_query_bridge.py @@ -238,7 +238,85 @@ async def test_external_query_gateway_returns_external_context_packet_in_meta() assert packet["plan"]["answer_contract"] == "summary_with_sources" assert packet["verified_evidence"] assert packet["adapter_attempts"] - assert packet["adapter_attempts"][-1]["adapter"] == "live_search" + + +@pytest.mark.asyncio +async def test_external_query_gateway_consumes_active_external_query_slot_only_for_external_route(db_session) -> None: + db_session.add( + Integration( + tenant_id=1, + type="ai_capability_slots", + config={ + "active_version": "slot-v-external", + "active": { + "slots": { + "external_query": { + "primary_provider_id": "live_search", + "fallback_provider_ids": ["mcp"], + "verifier_required": True, + "source_filtering_required": True, + "failure_policy": "explicit_fail", + } + } + }, + "draft": {"slots": {}}, + }, + is_active=True, + ) + ) + await db_session.commit() + + class _FakeService(AdaptiveRAGAnswerRetrievalMixin, AdaptiveRAGDeepGuardRulesMixin): + def __init__(self, db): + self.db = db + self.tenant_id = 1 + self.llm = None + + def _build_live_external_search_queries(self, question: str) -> list[str]: + return [question] + + async def _search_live_external_results(self, *, query: str, max_results: int = 5): # noqa: ANN001 + _ = (query, max_results) + return [{"url": "https://news.example/openai-release", "title": "OpenAI latest release"}] + + async def _fetch_url_text(self, url: str) -> str: + _ = url + return "OpenAI latest release introduces public API updates." + + def _chunk_text(self, text: str, *, chunk_size: int = 900, overlap: int = 120) -> list[str]: + _ = (chunk_size, overlap) + return [text] + + def _score_live_external_search_result(self, *, question: str, title: str, url: str) -> float: + _ = (question, title, url) + return 0.92 + + def _score_live_external_chunk(self, *, question: str, title: str, content: str, url: str) -> float: + _ = (question, title, url) + return 0.94 if "OpenAI" in content else 0.1 + + service = _FakeService(db_session) + + external_rows, external_meta = await service._execute_external_query_gateway( + question="OpenAI latest release", + query_semantics={"route_mode": "external"}, + result_limit=3, + max_pages=1, + ) + assert len(external_rows) == 1 + assert external_meta["slot_key"] == "external_query" + assert external_meta["slot_version"] == "slot-v-external" + assert external_meta["slot_runtime_source"] == "active_slot" + assert external_meta["adapter"] == "live_search" + + internal_rows, internal_meta = await service._execute_external_query_gateway( + question="OpenAI latest release", + query_semantics={"route_mode": "internal"}, + result_limit=3, + max_pages=1, + ) + assert len(internal_rows) == 1 + assert "slot_key" not in internal_meta @pytest.mark.asyncio diff --git a/backend/tests/test_model_routing_service.py b/backend/tests/test_model_routing_service.py index 246c77e8..6cf601a0 100644 --- a/backend/tests/test_model_routing_service.py +++ b/backend/tests/test_model_routing_service.py @@ -57,9 +57,9 @@ def test_load_model_config_rejects_safety_tier_configuration(tmp_path) -> None: "version": "v-test", "updated_at": "2026-03-26T22:00:00+08:00", "tier_defaults": { - "low": {"provider": "glm", "model": "qwen-plus", "api_env": "DASHSCOPE_API_KEY"}, + "low": {"provider": "dashscope", "model": "qwen-plus", "api_env": "DASHSCOPE_API_KEY"}, "high": {"provider": "openai", "model": "GLM-5", "api_env": "OPENAI_API_KEY"}, - "safety": {"provider": "glm", "model": "qwen-plus", "api_env": "DASHSCOPE_API_KEY"}, + "safety": {"provider": "dashscope", "model": "qwen-plus", "api_env": "DASHSCOPE_API_KEY"}, }, "rules": [ { @@ -167,41 +167,17 @@ async def test_model_routing_override_is_tier_only_and_reads_last_execution(db_e @pytest.mark.asyncio -async def test_runtime_low_override_does_not_downgrade_protected_adaptive_rag_functions(db_env) -> None: +async def test_model_routing_rejects_low_override_for_protected_adaptive_rag_functions(db_env) -> None: db_session, _maker = db_env - await ModelRoutingService.upsert_override( - db_session, - tenant_id=1, - function_name="adaptive_rag_answer_deep", - tier_alias="low", - updated_by=9, - ) - - low_engine = { - "id": "low-1", - "provider": "glm", - "model": "qwen-plus", - "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", - "api_key": "low-key", - } - high_engine = { - "id": "high-1", - "provider": "openai", - "model": "GLM-5", - "base_url": "https://api.openai.com/v1", - "api_key": "high-key", - } - - routed = await llm_service._resolve_function_override_engine( - db_session, - tenant_id=1, - function_name="adaptive_rag_answer_deep", - engines=[low_engine, high_engine], - active_engine=low_engine, - ) - - assert routed is None + with pytest.raises(ValueError, match="保护场景不能降级"): + await ModelRoutingService.upsert_override( + db_session, + tenant_id=1, + function_name="adaptive_rag_answer_deep", + tier_alias="low", + updated_by=9, + ) @pytest.mark.asyncio @@ -218,7 +194,7 @@ async def test_runtime_low_override_allows_adaptive_rag_answer_normal_to_use_low low_engine = { "id": "low-1", - "provider": "glm", + "provider": "dashscope", "model": "qwen-plus", "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", "api_key": "low-key", @@ -240,7 +216,7 @@ async def test_runtime_low_override_allows_adaptive_rag_answer_normal_to_use_low ) assert routed is not None - assert routed["provider"] == "glm" + assert routed["provider"] == "dashscope" assert routed["model"] == "qwen-plus" @@ -261,7 +237,7 @@ async def test_model_routing_list_includes_current_business_entry_points(db_env) @pytest.mark.asyncio -async def test_log_ai_call_persists_routing_metadata_without_model_columns(db_env, monkeypatch) -> None: +async def test_log_ai_call_persists_routing_and_model_metadata(db_env, monkeypatch) -> None: db_session, maker = db_env bundle = load_model_config(refresh=True) monkeypatch.setattr(mixin_prompt_profile_runtime, "AsyncSessionLocal", maker) @@ -274,6 +250,9 @@ async def test_log_ai_call_persists_routing_metadata_without_model_columns(db_en status="success_failover", provider="openai", model="GLM-5", + provider_request_payload={"model": "GLM-5", "messages": [{"role": "user", "content": "planner prompt"}]}, + provider_response_payload={"choices": [{"message": {"content": "planner response"}}]}, + provider_attempts=[{"status": "success", "request_payload": {"model": "GLM-5"}}], routing_meta={ "planned_tier": "low", "executed_tier": "high", @@ -298,7 +277,11 @@ async def test_log_ai_call_persists_routing_metadata_without_model_columns(db_en assert row.route_rule_name == "engine_high_reasoning_glm5" assert row.routing_config_version == bundle["version"] assert row.routing_config_updated_at == bundle["updated_at"] - assert not hasattr(row, "model") + assert row.provider == "openai" + assert row.model == "GLM-5" + assert row.provider_request_payload["model"] == "GLM-5" + assert row.provider_response_payload["choices"][0]["message"]["content"] == "planner response" + assert row.provider_attempts[0]["status"] == "success" @pytest.mark.asyncio @@ -400,10 +383,10 @@ async def test_get_embedding_async_falls_back_to_local_ollama_embed_when_active_ async def _fake_resolve_active_engine(db, *, tenant_id: int): _ = (db, tenant_id) return { - "provider": "glm", + "provider": "dashscope", "model": "qwen-plus", "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", - "api_key": "glm-key", + "api_key": "dashscope-key", } monkeypatch.setattr(llm_service, "_resolve_active_engine", _fake_resolve_active_engine) @@ -448,10 +431,10 @@ async def test_get_embedding_async_returns_empty_when_all_real_backends_fail_ins async def _fake_resolve_active_engine(db, *, tenant_id: int): _ = (db, tenant_id) return { - "provider": "glm", + "provider": "dashscope", "model": "qwen-plus", "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", - "api_key": "glm-key", + "api_key": "dashscope-key", } monkeypatch.setattr(llm_service, "_resolve_active_engine", _fake_resolve_active_engine) diff --git a/backend/tests/test_phone_auth_api.py b/backend/tests/test_phone_auth_api.py new file mode 100644 index 00000000..dd4f47a6 --- /dev/null +++ b/backend/tests/test_phone_auth_api.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +from datetime import timedelta + +import pytest +import pytest_asyncio +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy import JSON +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.api import auth +from app.core.database import get_db +from app.models import PhoneVerificationCode, Tenant, TenantInvitation, TenantMembership, User +from app.models.base import Base +from app.services.workspace_service import hash_invitation_token +from app.utils.timezone import now_cn_naive + + +TABLES = [ + Tenant.__table__, + User.__table__, + TenantMembership.__table__, + TenantInvitation.__table__, + PhoneVerificationCode.__table__, +] + + +def _use_sqlite_json_types() -> None: + for table in TABLES: + for column in table.columns: + if column.type.__class__.__name__.upper() in {"JSONB", "ARRAY"}: + column.type = JSON() + + +@pytest_asyncio.fixture +async def phone_auth_session(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + _use_sqlite_json_types() + async with engine.begin() as conn: + await conn.run_sync(lambda sync_conn: Base.metadata.create_all(sync_conn, tables=TABLES)) + maker = async_sessionmaker(engine, expire_on_commit=False) + async with maker() as session: + yield session + await engine.dispose() + + +def _build_app(session) -> FastAPI: + app = FastAPI() + app.include_router(auth.router) + + async def _fake_get_db(): + yield session + + app.dependency_overrides[get_db] = _fake_get_db + return app + + +@pytest.mark.asyncio +async def test_phone_code_verify_creates_user_and_personal_workspace(phone_auth_session, monkeypatch) -> None: + monkeypatch.setenv("PMFLOW_PHONE_OTP_DEV_CODE", "123456") + app = _build_app(phone_auth_session) + transport = ASGITransport(app=app) + + async with AsyncClient(transport=transport, base_url="http://test") as client: + sent = await client.post( + "/auth/phone/send-code", + json={"phone_country_code": "+86", "phone_number": "13900001001", "purpose": "login"}, + ) + assert sent.status_code == 200 + assert sent.json()["dev_code"] == "123456" + + verified = await client.post( + "/auth/phone/verify", + json={ + "phone_country_code": "+86", + "phone_number": "13900001001", + "code": "123456", + "purpose": "login", + "name": "手机号用户", + }, + ) + assert verified.status_code == 200 + body = verified.json() + assert body["access_token"] + assert body["user"]["phone_e164"] == "+8613900001001" + assert body["user"]["workspace_context"]["active_workspace"]["workspace_type"] == "personal" + + +@pytest.mark.asyncio +async def test_phone_verify_accepts_bound_invitation_and_keeps_personal_workspace(phone_auth_session, monkeypatch) -> None: + monkeypatch.setenv("PMFLOW_PHONE_OTP_DEV_CODE", "654321") + session = phone_auth_session + team = Tenant(name="手机号团队", workspace_type="team", status="active") + owner = User( + tenant_id=1, + username="owner", + email="owner@example.com", + password_hash="hashed", + name="Owner", + role="OWNER", + assigned_domains=[], + workload=0, + status="active", + is_active=True, + ) + session.add_all([team, owner]) + await session.flush() + team.owner_user_id = int(owner.id) + owner.tenant_id = int(team.id) + session.add(TenantMembership(tenant_id=int(team.id), user_id=int(owner.id), role="OWNER", status="active")) + token = "phone-invite-token" + session.add( + TenantInvitation( + tenant_id=int(team.id), + email="8613900001002@phone.pmflow.local", + phone_country_code="+86", + phone_number="13900001002", + phone_e164="+8613900001002", + role="BUILDER", + project_ids=[], + token_hash=hash_invitation_token(token), + status="pending", + expires_at=now_cn_naive() + timedelta(days=1), + invited_by_user_id=int(owner.id), + ) + ) + await session.commit() + + app = _build_app(session) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + sent = await client.post( + "/auth/phone/send-code", + json={"phone_country_code": "+86", "phone_number": "13900001002", "purpose": "login"}, + ) + assert sent.status_code == 200 + verified = await client.post( + "/auth/phone/verify", + json={ + "phone_country_code": "+86", + "phone_number": "13900001002", + "code": "654321", + "purpose": "login", + "name": "被邀请用户", + "invite_token": token, + }, + ) + assert verified.status_code == 200 + context = verified.json()["user"]["workspace_context"] + assert context["active_workspace"]["id"] == int(team.id) + workspace_types = [item["workspace_type"] for item in context["workspaces"]] + assert workspace_types.count("personal") == 1 + assert workspace_types.count("team") == 1 diff --git a/backend/tests/test_pmflow_change_review_api.py b/backend/tests/test_pmflow_change_review_api.py index 40964bf3..1b788699 100644 --- a/backend/tests/test_pmflow_change_review_api.py +++ b/backend/tests/test_pmflow_change_review_api.py @@ -16,25 +16,31 @@ from app.api import pmflow_os_api # noqa: E402 from app.core.database import get_db # noqa: E402 from app.dependencies.auth import get_current_user # noqa: E402 from app.models import ( # noqa: E402 + AICallLog, DevWorkspaceBinding, FeedbackIngest, FeishuItem, GitHubWebhookDelivery, IterationRecommendation, + JudgeRun, ModelCatalogItem, ModelEvalRun, ModelRecommendationSnapshot, + ComplianceCheckRun, PMFlowApproval, + PMFlowAIFeedbackEvent, PMFlowChangeItem, PMFlowChangeSet, PMFlowEvidenceLink, PMFlowLaunchDraft, PMFlowThread, PMFlowThreadMessage, + OptimizerCandidate, ProgressEvent, Project, ProjectModelBinding, PromptPackage, + PromptRelease, ReleaseValidationRun, RepoBinding, RevenueEvent, @@ -48,6 +54,7 @@ from app.models import ( # noqa: E402 UserProject, ) from app.models.base import Base # noqa: E402 +from app.services.prompt_assets_service import PromptAssetService # noqa: E402 TABLES = [ @@ -63,6 +70,8 @@ TABLES = [ PMFlowChangeItem.__table__, PMFlowApproval.__table__, PMFlowEvidenceLink.__table__, + PMFlowAIFeedbackEvent.__table__, + AICallLog.__table__, TruthNode.__table__, Task.__table__, SemanticFact.__table__, @@ -70,12 +79,16 @@ TABLES = [ ProjectModelBinding.__table__, ModelRecommendationSnapshot.__table__, ModelEvalRun.__table__, + PromptRelease.__table__, + JudgeRun.__table__, + OptimizerCandidate.__table__, PromptPackage.__table__, DevWorkspaceBinding.__table__, RepoBinding.__table__, TaskCodeLink.__table__, ProgressEvent.__table__, ReleaseValidationRun.__table__, + ComplianceCheckRun.__table__, FeedbackIngest.__table__, IterationRecommendation.__table__, RevenueEvent.__table__, @@ -299,6 +312,100 @@ async def test_change_review_message_extraction_detail_and_projection(db_env) -> assert len(truth_payload["evidence"]) == 1 +@pytest.mark.asyncio +async def test_thread_message_can_trigger_real_ai_extraction_without_mock_proposed_items(db_env, monkeypatch) -> None: + session = db_env + user, project = await _seed_user_project(session) + app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER")) + calls: list[dict[str, object]] = [] + + async def _fake_generate_structured_object(prompt: str, **kwargs): + calls.append({"prompt": prompt, **kwargs}) + return SimpleNamespace( + payload={ + "title": "会议助手", + "requirements": [ + {"title": "自动记录会议", "summary": "支持会议实时转写和发言人区分。", "acceptance_criteria": ["能看到逐字稿"]} + ], + "tasks": [ + {"title": "实现转写接入", "summary": "接入语音转写并回写会议纪要。", "priority": "high", "acceptance_criteria": ["产生纪要"]} + ], + "risks": [ + {"title": "隐私合规", "summary": "会议内容涉及敏感信息。", "level": "high", "mitigation": "上线前完成权限与脱敏设计。"} + ], + "decisions": [ + {"title": "先接飞书", "reason": "目标团队主要使用飞书。", "owner": "产品负责人"} + ], + "confidence": 0.86, + "warnings": [], + } + ) + + monkeypatch.setattr("app.services.pmflow_change_review_service.llm_service.generate_structured_object", _fake_generate_structured_object) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + message = await client.post( + f"/pmflow-os/projects/{project.id}/threads/thread-project-{project.id}/messages", + json={"content": "做一个会议助手,自动记录、总结、跟踪待办,先接飞书。", "source": "web", "attachments": []}, + ) + msg = message.json()["data"] + extraction = await client.post( + f"/pmflow-os/projects/{project.id}/extractions", + json={"thread_id": msg["threadId"], "message_id": msg["id"], "scope": "latest_message"}, + ) + truth = await client.get(f"/pmflow-os/projects/{project.id}/truth-state") + + assert extraction.status_code == 200 + payload = extraction.json()["data"] + assert calls + assert calls[0]["function_name"] == "pmflow_change_review_extraction_v1" + assert calls[0]["max_tokens"] == 5600 + assert calls[0]["routing_meta"]["scene"] == "pmflow_os_thread_extraction" + assert calls[0]["routing_meta"]["scene_key"] == "change_review" + assert calls[0]["routing_meta"]["prompt_key"] == "PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION" + assert calls[0]["routing_meta"]["function_runtime_max_tokens"] == 5600 + assert payload["changeSet"]["status"] == "open" + assert payload["changeSet"]["routingMeta"]["aiExtraction"]["confidence"] == 0.86 + assert payload["changeSet"]["routingMeta"]["aiExtraction"]["maxTokens"] == 5600 + assert len(payload["items"]) == 4 + assert {item["objectType"] for item in payload["items"]} == {"Requirement", "Task", "Risk", "Decision"} + assert all(item["status"] == "pending_review" for item in payload["items"]) + assert truth.json()["data"]["activeChangeSetId"] == payload["changeSet"]["id"] + + +@pytest.mark.asyncio +async def test_ai_extraction_failure_does_not_create_fake_changeset(db_env, monkeypatch) -> None: + session = db_env + user, project = await _seed_user_project(session) + app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER")) + + async def _fake_generate_structured_object(prompt: str, **kwargs): + return SimpleNamespace(payload={"title": "空结果", "requirements": [], "tasks": [], "risks": [], "decisions": [], "confidence": 0.2}) + + monkeypatch.setattr("app.services.pmflow_change_review_service.llm_service.generate_structured_object", _fake_generate_structured_object) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + message = await client.post( + f"/pmflow-os/projects/{project.id}/threads/thread-project-{project.id}/messages", + json={"content": "先看看这个方向。", "source": "web", "attachments": []}, + ) + msg = message.json()["data"] + extraction = await client.post( + f"/pmflow-os/projects/{project.id}/extractions", + json={"thread_id": msg["threadId"], "message_id": msg["id"], "scope": "latest_message"}, + ) + truth = await client.get(f"/pmflow-os/projects/{project.id}/truth-state") + + assert extraction.status_code == 409 + assert "证据不足" in extraction.json()["message"] + assert await session.scalar(select(func.count(PMFlowChangeSet.id))) == 0 + assert await session.scalar(select(func.count(PMFlowChangeItem.id))) == 0 + assert await session.scalar(select(func.count(PMFlowThreadMessage.id))) == 2 + truth_messages = truth.json()["data"]["messages"] + assert truth_messages[-1]["role"] == "assistant" + assert "没有形成可审查的需求、任务、风险或决策" in truth_messages[-1]["content"] + + @pytest.mark.asyncio async def test_change_item_accept_edit_defer_write_approvals_and_do_not_write_memory(db_env) -> None: session = db_env @@ -447,7 +554,11 @@ async def test_change_set_commit_writes_truth_nodes_tasks_and_semantic_facts(db_ assert committed.status_code == 200 payload = committed.json()["data"] assert payload["changeSet"]["status"] == "committed" + assert payload["changeSet"]["committedBy"] == "Owner" + assert payload["changeSet"]["committedById"] == str(user.id) + assert payload["changeSet"]["writebackAt"] assert len(payload["committedItems"]) == 3 + assert {item["committedBy"] for item in payload["committedItems"]} == {"Owner"} assert payload["failedItems"] == [] assert await session.scalar(select(func.count(TruthNode.id))) == 3 assert await session.scalar(select(func.count(Task.id))) == 1 @@ -460,6 +571,8 @@ async def test_change_set_commit_writes_truth_nodes_tasks_and_semantic_facts(db_ truth_payload = truth.json()["data"] assert truth_payload["activeChangeSetId"] is None + assert truth_payload["lastWritebackBy"] == "Owner" + assert truth_payload["lastWritebackById"] == str(user.id) assert truth_payload["projectMemory"]["sourceSummary"]["truth_nodes"] == 3 assert truth_payload["projectMemory"]["sourceSummary"]["semantic_facts"] == 4 @@ -494,7 +607,7 @@ async def test_change_set_commit_blocks_high_risk_without_approval_and_rolls_bac @pytest.mark.asyncio -async def test_change_item_review_conflict_and_closed_changeset_guard(db_env) -> None: +async def test_change_item_can_be_reworked_before_commit_and_closed_changeset_guard(db_env) -> None: session = db_env user, project = await _seed_user_project(session) app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER")) @@ -503,11 +616,20 @@ async def test_change_item_review_conflict_and_closed_changeset_guard(db_env) -> _, detail = await _create_message_and_extraction(client, project.id) item_id = detail["items"][0]["id"] first = await client.post(f"/pmflow-os/change-items/{item_id}/accept") - second = await client.post(f"/pmflow-os/change-items/{item_id}/accept") + second = await client.post( + f"/pmflow-os/change-items/{item_id}/edit", + json={"summary": "写入前继续修订", "diff": "写入前继续修订 diff", "payload_patch": {"summary": "写入前继续修订"}, "reason": "入库前仍可修改"}, + ) + third = await client.post(f"/pmflow-os/change-items/{item_id}/defer", json={"reason": "先暂缓"}) + restored = await client.post(f"/pmflow-os/change-items/{item_id}/accept") assert first.status_code == 200 - assert second.status_code == 409 - assert "item_already_reviewed" in second.json()["message"] + assert second.status_code == 200 + assert third.status_code == 200 + assert restored.status_code == 200 + item = (await session.execute(select(PMFlowChangeItem).where(PMFlowChangeItem.id == int(item_id)))).scalar_one() + assert item.status == "accepted" + assert item.summary == "写入前继续修订" pending = (await session.execute(select(PMFlowChangeItem).where(PMFlowChangeItem.status == "pending_review"))).scalars().first() change_set = (await session.execute(select(PMFlowChangeSet).where(PMFlowChangeSet.id == pending.change_set_id))).scalar_one() @@ -544,3 +666,100 @@ async def test_change_review_project_permission_boundary(db_env) -> None: missing = await client.get(f"/pmflow-os/projects/{project.id}/change-sets/{detail['changeSet']['id']}") assert missing.status_code == 404 + + +@pytest.mark.asyncio +async def test_ai_feedback_records_prompt_quality_signal_and_returns_in_truth_state(db_env) -> None: + session = db_env + user, project = await _seed_user_project(session) + app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER")) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + _, detail = await _create_message_and_extraction(client, project.id) + change_set_id = detail["changeSet"]["id"] + response = await client.post( + f"/pmflow-os/projects/{project.id}/ai-feedback", + json={ + "target_type": "change_set", + "target_id": change_set_id, + "answer_kind": "structured_extraction", + "rating": "negative", + "reason_tags": ["漏掉重点", "不够可执行"], + "thread_id": detail["changeSet"]["threadId"], + "change_set_id": change_set_id, + "scene_key": "pmflow_os_thread_extraction", + "prompt_key": "PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION", + }, + ) + truth_state = await client.get(f"/pmflow-os/projects/{project.id}/truth-state") + + assert response.status_code == 200 + payload = response.json()["data"] + assert payload["targetType"] == "change_set" + assert payload["rating"] == "negative" + assert payload["reasonTags"] == ["漏掉重点", "不够可执行"] + assert payload["promptKey"] == "PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION" + assert payload["metadata"]["promptOptimization"]["status"] == "draft_optimizer_candidate" + candidate_id = int(payload["metadata"]["promptOptimization"]["candidateId"]) + judge_run_id = int(payload["metadata"]["promptOptimization"]["judgeRunId"]) + + assert truth_state.status_code == 200 + feedback = truth_state.json()["data"]["aiFeedback"] + assert len(feedback) == 1 + assert feedback[0]["targetId"] == change_set_id + assert feedback[0]["sceneKey"] == "pmflow_os_thread_extraction" + assert feedback[0]["metadata"]["promptOptimizationUse"] == "quality_signal_for_scene_prompt_iteration" + assert feedback[0]["metadata"]["promptOptimization"]["candidateId"] == candidate_id + + judge = await session.scalar(select(JudgeRun).where(JudgeRun.id == judge_run_id)) + assert judge is not None + assert judge.prompt_key == "PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION" + assert judge.judge_status == "feedback_review" + assert len(judge.judge_status) <= 16 + assert judge.findings[0]["code"] == "missed_key_point" + + candidate = await session.scalar(select(OptimizerCandidate).where(OptimizerCandidate.id == candidate_id)) + assert candidate is not None + assert candidate.status == "draft" + assert candidate.target_type == "prompt_key" + assert candidate.target_ref == "PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION" + assert "关键对象覆盖检查" in candidate.suggestion["recommended_actions"][0] + + quality = await PromptAssetService(session).get_quality_summary( + tenant_id=1, + prompt_key="PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION", + window="7d", + ) + assert quality["pmflow_feedback"]["negative_feedback"] == 1 + assert quality["pmflow_feedback"]["reason_tag_counts"][0] == {"tag": "不够可执行", "count": 1} + assert quality["optimizer_candidates"][0]["id"] == candidate_id + + platform_quality = await PromptAssetService(session).get_quality_summary( + tenant_id=999, + prompt_key="PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION", + window="7d", + include_all_pmflow_feedback=True, + ) + assert platform_quality["pmflow_feedback"]["negative_feedback"] == 1 + assert platform_quality["pmflow_feedback"]["recent_feedback"][0]["tenant_id"] == 1 + assert platform_quality["optimizer_candidates"][0]["tenant_id"] == 1 + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + positive_response = await client.post( + f"/pmflow-os/projects/{project.id}/ai-feedback", + json={ + "target_type": "change_set", + "target_id": change_set_id, + "answer_kind": "structured_extraction", + "rating": "positive", + "thread_id": detail["changeSet"]["threadId"], + "change_set_id": change_set_id, + "scene_key": "pmflow_os_thread_extraction", + "prompt_key": "PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION", + }, + ) + + assert positive_response.status_code == 200 + assert positive_response.json()["data"]["metadata"]["promptOptimization"]["status"] == "superseded_by_positive_feedback" + await session.refresh(candidate) + assert candidate.status == "superseded" diff --git a/backend/tests/test_pmflow_launch_api.py b/backend/tests/test_pmflow_launch_api.py index b693ab75..df7668fc 100644 --- a/backend/tests/test_pmflow_launch_api.py +++ b/backend/tests/test_pmflow_launch_api.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import os from datetime import timedelta from types import SimpleNamespace @@ -13,7 +14,7 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://tester:tester@localhost:5432/pmflow_test") -from app.api import pmflow_launch_api, pmflow_os_api # noqa: E402 +from app.api import pmflow_launch_api, pmflow_os_api, projects_api # noqa: E402 from app.core.database import get_db # noqa: E402 from app.dependencies.auth import get_current_user # noqa: E402 from app.models import ( # noqa: E402 @@ -31,11 +32,15 @@ from app.models import ( # noqa: E402 PMFlowEvidenceLink, OperationLog, PMFlowLaunchDraft, + PMFlowLaunchDraftJob, PMFlowThread, PMFlowThreadMessage, + ComplianceCheckRun, ProgressEvent, Project, ProjectModelBinding, + PromptRelease, + ProjectSignalLink, PromptPackage, ReleaseValidationRun, RepoBinding, @@ -49,6 +54,7 @@ from app.models import ( # noqa: E402 User, UserProject, ) +from app.schemas.pmflow_launch import PMFlowLaunchDraftJobRequest # noqa: E402 from app.models.base import Base # noqa: E402 from app.services import pmflow_launch_service # noqa: E402 from app.utils.timezone import now_cn_naive # noqa: E402 @@ -59,8 +65,10 @@ TABLES = [ FeishuItem.__table__, Signal.__table__, Project.__table__, + ProjectSignalLink.__table__, UserProject.__table__, PMFlowLaunchDraft.__table__, + PMFlowLaunchDraftJob.__table__, PMFlowThread.__table__, PMFlowThreadMessage.__table__, PMFlowChangeSet.__table__, @@ -74,12 +82,14 @@ TABLES = [ ProjectModelBinding.__table__, ModelRecommendationSnapshot.__table__, ModelEvalRun.__table__, + PromptRelease.__table__, PromptPackage.__table__, DevWorkspaceBinding.__table__, RepoBinding.__table__, TaskCodeLink.__table__, ProgressEvent.__table__, ReleaseValidationRun.__table__, + ComplianceCheckRun.__table__, FeedbackIngest.__table__, IterationRecommendation.__table__, RevenueEvent.__table__, @@ -131,6 +141,7 @@ def _build_app(session, current_user) -> FastAPI: app = FastAPI() app.include_router(pmflow_launch_api.router) app.include_router(pmflow_os_api.router) + app.include_router(projects_api.router) async def _fake_get_db(): yield session @@ -179,6 +190,30 @@ def _patch_llm(monkeypatch, payload): monkeypatch.setattr(pmflow_launch_service.llm_service, "generate_structured_object", _fake_generate_structured_object) +def _runtime_profile(**overrides): + data = { + "function_name": "pmflow_launch_draft_v1", + "scene_key": "project_launch", + "prompt_key": "PROMPT_PMFLOW_LAUNCH_DRAFT", + "required_slot": "structured", + "response_contract": "structured", + "max_tokens": 7200, + "repair_max_tokens": 3600, + "temperature": 0.0, + "foreground_wait_seconds": 10, + "provider_timeout_seconds": 90, + "job_timeout_seconds": 180, + "max_retries": 1, + "large_input_policy": "preprocess_then_generate", + "response_mode": "fast", + "response_format": "json_object", + "profile_version": "test", + "profile_source": "active", + } + data.update(overrides) + return pmflow_launch_service.AIFunctionRuntimeProfile(**data) + + @pytest.mark.asyncio async def test_create_launch_draft_success_persists_draft(db_env, monkeypatch) -> None: session = db_env @@ -198,6 +233,156 @@ async def test_create_launch_draft_success_persists_draft(db_env, monkeypatch) - assert rows[0].draft_payload["title"] == "AI 会议纪要助手" +@pytest.mark.asyncio +async def test_create_launch_draft_uses_platform_prompt_release(db_env, monkeypatch) -> None: + session = db_env + user = await _seed_user(session, tenant_id=12) + release = PromptRelease( + tenant_id=1, + prompt_key="PROMPT_PMFLOW_LAUNCH_DRAFT", + version=1, + body_snapshot=( + "ACTIVE_RELEASE_MARKER\n" + "用户输入:{input_text}\n" + "附件摘要:{attachments_json}\n" + "输出 JSON:{{\"title\":\"string\",\"one_liner\":\"string\",\"target_user\":\"string\"," + "\"problem\":\"string\",\"solution\":\"string\",\"requirements\":[],\"tasks\":[]," + "\"risks\":[],\"decisions\":[],\"evidence\":[],\"confidence\":0,\"warnings\":[]}}" + ), + status="active", + created_by=1, + activated_by=1, + activated_at=now_cn_naive().isoformat(), + ) + session.add(release) + await session.commit() + await session.refresh(release) + captured: dict[str, object] = {} + + async def _fake_generate_structured_object(prompt, *args, **kwargs): + captured["prompt"] = prompt + captured["routing_meta"] = kwargs.get("routing_meta") + return SimpleNamespace(payload=_draft_payload(), raw_text="{}", provider="test", model="test") + + monkeypatch.setattr(pmflow_launch_service.llm_service, "generate_structured_object", _fake_generate_structured_object) + app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=user.tenant_id, role="MEMBER")) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.post("/pmflow-launch/drafts", json={"input": "我想做一个 AI 会议纪要工具"}) + + assert response.status_code == 200 + assert "ACTIVE_RELEASE_MARKER" in str(captured["prompt"]) + routing_meta = captured["routing_meta"] + assert isinstance(routing_meta, dict) + assert routing_meta["prompt_key"] == "PROMPT_PMFLOW_LAUNCH_DRAFT" + assert routing_meta["prompt_source"] == "active_release" + assert routing_meta["prompt_release_id"] == release.id + assert routing_meta["prompt_runtime_tenant_id"] == 1 + assert routing_meta["provider_timeout_seconds"] == 90 + assert routing_meta["job_timeout_seconds"] == 180 + assert routing_meta["large_input_policy"] == "preprocess_then_generate" + + +@pytest.mark.asyncio +async def test_draft_job_response_exposes_runtime_policy(db_env, monkeypatch) -> None: + session = db_env + user = await _seed_user(session) + + async def _fake_resolve(self, *, stage: str = "active"): + return _runtime_profile( + foreground_wait_seconds=12, + provider_timeout_seconds=120, + job_timeout_seconds=240, + max_retries=2, + large_input_policy="direct_generate", + ) + + monkeypatch.setattr(pmflow_launch_service.PMFlowLaunchService, "_resolve_draft_runtime_profile", _fake_resolve) + service = pmflow_launch_service.PMFlowLaunchService(session, tenant_id=1) + job = await service.create_draft_job( + body=PMFlowLaunchDraftJobRequest(input="我想做一个 AI 会议纪要工具,面向小团队整理行动项。"), + user_id=int(user.id), + ) + + assert job.runtime_policy is not None + assert job.runtime_policy.foreground_wait_seconds == 12 + assert job.runtime_policy.provider_timeout_seconds == 120 + assert job.runtime_policy.job_timeout_seconds == 240 + assert job.runtime_policy.max_retries == 2 + assert job.runtime_policy.large_input_policy == "direct_generate" + + +@pytest.mark.asyncio +async def test_draft_job_marks_failed_when_job_timeout_expires(db_env, monkeypatch) -> None: + session = db_env + user = await _seed_user(session) + + async def _fake_resolve(self, *, stage: str = "active"): + return _runtime_profile(job_timeout_seconds=10) + + async def _slow_generate_structured_object(*args, **kwargs): + await asyncio.sleep(2) + return SimpleNamespace(payload=_draft_payload(), raw_text="{}", provider="test", model="test") + + monkeypatch.setattr(pmflow_launch_service.PMFlowLaunchService, "_resolve_draft_runtime_profile", _fake_resolve) + monkeypatch.setattr(pmflow_launch_service.PMFlowLaunchService, "_bounded_seconds", staticmethod(lambda *args, **kwargs: 1)) + monkeypatch.setattr(pmflow_launch_service.llm_service, "generate_structured_object", _slow_generate_structured_object) + + service = pmflow_launch_service.PMFlowLaunchService(session, tenant_id=1) + job = await service.create_draft_job( + body=PMFlowLaunchDraftJobRequest(input="我想做一个 AI 会议纪要工具,面向小团队整理行动项。"), + user_id=int(user.id), + ) + await service.run_draft_job(job_id=job.job_id) + + row = ( + await session.execute(select(PMFlowLaunchDraftJob).where(PMFlowLaunchDraftJob.job_id == job.job_id)) + ).scalar_one() + assert row.status == "failed" + assert row.error_code == "draft_job_timeout" + + +@pytest.mark.asyncio +async def test_large_input_policy_compacts_material_before_prompt(db_env) -> None: + service = pmflow_launch_service.PMFlowLaunchService(db_env, tenant_id=1) + body = PMFlowLaunchDraftJobRequest( + input="产品目标:" + ("很长的项目材料 " * 300), + attachments=[{"type": "text", "name": "research.md", "summary": "用户访谈:" + ("访谈内容 " * 1000)}], + ) + next_body, meta = service._apply_large_input_policy( + body=body, + routing_meta={ + "input_bucket": "large", + "large_input_policy": "preprocess_then_generate", + }, + ) + + assert meta["large_input_preprocessed"] is True + assert len(next_body.input) <= 3900 + assert next_body.attachments[0].summary is not None + assert len(next_body.attachments[0].summary or "") <= 560 + + +@pytest.mark.asyncio +async def test_create_launch_draft_uses_product_sized_token_budget(db_env, monkeypatch) -> None: + session = db_env + user = await _seed_user(session, tenant_id=12) + captured: dict[str, object] = {} + + async def _fake_generate_structured_object(*args, **kwargs): + captured["max_tokens"] = kwargs.get("max_tokens") + return SimpleNamespace(payload=_draft_payload(), raw_text="{}", provider="test", model="test") + + monkeypatch.setattr(pmflow_launch_service.llm_service, "generate_structured_object", _fake_generate_structured_object) + app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=user.tenant_id, role="MEMBER")) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.post("/pmflow-launch/drafts", json={"input": "我想做一个 AI 会议纪要工具,帮助团队提取行动项"}) + + assert response.status_code == 200 + assert captured["max_tokens"] == pmflow_launch_service.PMFlowLaunchService.DRAFT_GENERATION_MAX_TOKENS + + @pytest.mark.asyncio async def test_create_launch_draft_missing_core_fields_returns_422(db_env, monkeypatch) -> None: session = db_env @@ -206,7 +391,7 @@ async def test_create_launch_draft_missing_core_fields_returns_422(db_env, monke app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER")) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - response = await client.post("/pmflow-launch/drafts", json={"input": "做一个工具"}) + response = await client.post("/pmflow-launch/drafts", json={"input": "我想做一个 AI 会议纪要工具,帮助团队提取行动项"}) assert response.status_code == 422 assert response.json()["code"] == 422 @@ -214,6 +399,29 @@ async def test_create_launch_draft_missing_core_fields_returns_422(db_env, monke assert response.json()["data"] is None +@pytest.mark.asyncio +async def test_create_launch_draft_rejects_low_information_input_before_llm(db_env, monkeypatch) -> None: + session = db_env + user = await _seed_user(session) + called = False + + async def _fake_generate_structured_object(*args, **kwargs): + nonlocal called + called = True + return SimpleNamespace(payload=_draft_payload(), raw_text="{}", provider="test", model="test") + + monkeypatch.setattr(pmflow_launch_service.llm_service, "generate_structured_object", _fake_generate_structured_object) + app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER")) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.post("/pmflow-launch/drafts", json={"input": "你好"}) + + assert response.status_code == 422 + assert "input_needs_more_info" in response.json()["message"] + assert response.json()["data"] is None + assert called is False + + @pytest.mark.asyncio async def test_create_launch_draft_count_warnings_are_explicit(db_env, monkeypatch) -> None: session = db_env @@ -222,12 +430,64 @@ async def test_create_launch_draft_count_warnings_are_explicit(db_env, monkeypat app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER")) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - response = await client.post("/pmflow-launch/drafts", json={"input": "做一个工具"}) + response = await client.post("/pmflow-launch/drafts", json={"input": "我想做一个 AI 会议纪要工具,帮助团队提取行动项"}) assert response.status_code == 200 assert "risks_count_out_of_range" in " ".join(response.json()["data"]["warnings"]) +@pytest.mark.asyncio +async def test_launch_draft_job_runs_and_persists_completed_draft(db_env, monkeypatch) -> None: + session = db_env + user = await _seed_user(session) + _patch_llm(monkeypatch, _draft_payload()) + service = pmflow_launch_service.PMFlowLaunchService(session, tenant_id=1) + + job = await service.create_draft_job( + body=PMFlowLaunchDraftJobRequest(input="我想做一个 AI 会议纪要工具,帮助团队提取行动项", source="web"), + user_id=int(user.id), + ) + + assert job.status == "queued" + await service.run_draft_job(job_id=job.job_id) + + completed = await service.get_draft_job(job_id=job.job_id, user_id=int(user.id)) + assert completed.status == "completed" + assert completed.draft_id is not None + assert completed.draft is not None + assert completed.draft.title == "AI 会议纪要助手" + assert completed.changed_fields + assert (await session.scalar(select(func.count(PMFlowLaunchDraftJob.id)))) == 1 + assert (await session.scalar(select(func.count(PMFlowLaunchDraft.id)))) == 1 + + +@pytest.mark.asyncio +async def test_launch_draft_job_supersedes_active_job_when_user_supplements_input(db_env, monkeypatch) -> None: + session = db_env + user = await _seed_user(session) + _patch_llm(monkeypatch, _draft_payload()) + service = pmflow_launch_service.PMFlowLaunchService(session, tenant_id=1) + + first = await service.create_draft_job( + body=PMFlowLaunchDraftJobRequest(input="我想做一个 AI 会议纪要工具,帮助团队提取行动项", source="web"), + user_id=int(user.id), + ) + second = await service.create_draft_job( + body=PMFlowLaunchDraftJobRequest( + input="我想做一个 AI 会议纪要工具,帮助团队提取行动项。补充:首版优先飞书接入。", + source="web", + mode="merge_supplement", + revision_note="首版优先飞书接入", + ), + user_id=int(user.id), + ) + + superseded = await service.get_draft_job(job_id=first.job_id, user_id=int(user.id)) + assert superseded.status == "superseded" + assert superseded.superseded_by_job_id == second.job_id + assert second.status == "queued" + + async def _create_draft_via_api(session, user, monkeypatch): _patch_llm(monkeypatch, _draft_payload()) app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=user.tenant_id, role="MEMBER")) @@ -334,3 +594,89 @@ async def test_confirm_launch_draft_rolls_back_on_critical_write_failure(db_env, assert (await session.scalar(select(func.count(Project.id)))) == 0 assert (await session.scalar(select(func.count(TruthNode.id)))) == 0 + + +@pytest.mark.asyncio +async def test_project_create_duplicate_title_defaults_to_conflict(db_env) -> None: + session = db_env + user = await _seed_user(session) + app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="OWNER")) + + payload = {"title": "同名项目", "description": "普通创建默认不吞掉重复标题。", "status": "active"} + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + first = await client.post("/projects/", json=payload) + second = await client.post("/projects/", json=payload) + + assert first.status_code == 200 + assert second.status_code == 409 + assert second.json()["detail"] == "project title already exists" + assert (await session.scalar(select(func.count(Project.id)))) == 1 + + +@pytest.mark.asyncio +async def test_projects_list_and_detail_are_scoped_by_role(db_env) -> None: + session = db_env + manager = await _seed_user(session, username="manager") + builder = await _seed_user(session, username="builder") + outsider = await _seed_user(session, username="outsider") + + owned_project = Project( + tenant_id=1, + name="Builder Owned", + title="Builder Owned", + description="Owned by builder", + owner_id=builder.id, + status="active", + assigned_role="BUILDER", + signal_count=0, + ) + member_project = Project( + tenant_id=1, + name="Builder Member", + title="Builder Member", + description="Builder is assigned", + owner_id=outsider.id, + status="active", + assigned_role="BUILDER", + signal_count=0, + ) + hidden_project = Project( + tenant_id=1, + name="Hidden Project", + title="Hidden Project", + description="No builder access", + owner_id=outsider.id, + status="active", + assigned_role="BUILDER", + signal_count=0, + ) + session.add_all([owned_project, member_project, hidden_project]) + await session.flush() + session.add(UserProject(tenant_id=1, user_id=builder.id, project_id=member_project.id)) + await session.commit() + + manager_app = _build_app(session, SimpleNamespace(id=manager.id, tenant_id=1, role="OWNER")) + builder_app = _build_app(session, SimpleNamespace(id=builder.id, tenant_id=1, role="BUILDER")) + + async with AsyncClient(transport=ASGITransport(app=manager_app), base_url="http://test") as client: + manager_response = await client.get("/projects/?summary=1") + assert manager_response.status_code == 200 + assert {item["title"] for item in manager_response.json()["projects"]} == { + "Builder Owned", + "Builder Member", + "Hidden Project", + } + + async with AsyncClient(transport=ASGITransport(app=builder_app), base_url="http://test") as client: + builder_response = await client.get("/projects/?summary=1") + allowed_detail = await client.get(f"/projects/{member_project.id}") + hidden_detail = await client.get(f"/projects/{hidden_project.id}") + + assert builder_response.status_code == 200 + assert {item["title"] for item in builder_response.json()["projects"]} == { + "Builder Owned", + "Builder Member", + } + assert allowed_detail.status_code == 200 + assert allowed_detail.json()["title"] == "Builder Member" + assert hidden_detail.status_code == 404 diff --git a/backend/tests/test_pmflow_os_projection_api.py b/backend/tests/test_pmflow_os_projection_api.py index fdcb0ae6..c88747af 100644 --- a/backend/tests/test_pmflow_os_projection_api.py +++ b/backend/tests/test_pmflow_os_projection_api.py @@ -17,6 +17,7 @@ from app.api import pmflow_os_api # noqa: E402 from app.core.database import get_db # noqa: E402 from app.dependencies.auth import get_current_user # noqa: E402 from app.models import ( # noqa: E402 + ComplianceCheckRun, DevWorkspaceBinding, FeedbackIngest, FeishuItem, @@ -27,6 +28,7 @@ from app.models import ( # noqa: E402 ModelEvalRun, ModelRecommendationSnapshot, PMFlowApproval, + PMFlowAIFeedbackEvent, PMFlowChangeItem, PMFlowChangeSet, PMFlowEvidenceLink, @@ -71,6 +73,7 @@ TABLES = [ PMFlowChangeSet.__table__, PMFlowChangeItem.__table__, PMFlowApproval.__table__, + PMFlowAIFeedbackEvent.__table__, PMFlowEvidenceLink.__table__, TruthNode.__table__, Task.__table__, @@ -89,6 +92,7 @@ TABLES = [ IterationRecommendation.__table__, RevenueEvent.__table__, ROISnapshot.__table__, + ComplianceCheckRun.__table__, GitHubWebhookDelivery.__table__, ] @@ -293,6 +297,162 @@ async def test_truth_state_projects_memory_from_truth_nodes_and_semantic_facts(d assert payload["projectMemory"]["semanticFacts"][0]["source"] == "semantic_facts" +@pytest.mark.asyncio +async def test_project_memory_read_models_expose_items_assets_context_and_writebacks(db_env) -> None: + session = db_env + user, project = await _seed_user_project(session) + requirement = TruthNode( + tenant_id=1, + node_type="requirement", + project_id=project.id, + created_by=user.id, + title="支持批量导入文件", + content={"summary": "支持批量导入 PDF、Word 和 Markdown。", "stage": "planning", "tags": ["import"]}, + version=1, + status="aligned", + ) + decision = TruthNode( + tenant_id=1, + node_type="decision", + project_id=project.id, + created_by=user.id, + title="首版限制导入数量", + content={"summary": "首版单次最多导入 50 个文件。", "owner": "产品负责人"}, + version=1, + status="confirmed", + ) + session.add_all([requirement, decision]) + await session.flush() + task = Task( + tenant_id=1, + truth_node_id=requirement.id, + project_id=project.id, + assignee_id=user.id, + ai_recommended_assignees={}, + ai_breakdown_payload={}, + manual_overrides={}, + title="实现批量导入入口", + description="把已确认需求拆成开发任务。", + required_skill_tag_ids=[], + status="todo", + priority="high", + ) + thread = PMFlowThread( + tenant_id=1, + project_id=project.id, + launch_draft_id=None, + title="项目线程", + objective="沉淀项目记忆", + status="active", + source="project_memory_test", + ) + session.add_all([task, thread]) + await session.flush() + message = PMFlowThreadMessage( + tenant_id=1, + thread_id=thread.id, + project_id=project.id, + role="user", + content="需要支持批量导入文件。", + source="web", + attachments=[], + message_metadata={}, + status="sent", + ) + session.add(message) + await session.flush() + change_set = PMFlowChangeSet( + tenant_id=1, + project_id=project.id, + thread_id=thread.id, + source_message_id=message.id, + status="committed", + summary="批量导入写入项目记忆", + headline="批量导入写入", + extraction_scope="latest_message", + routing_meta={"scene": "project_memory_test"}, + created_by=user.id, + committed_at=datetime.utcnow(), + ) + session.add(change_set) + await session.flush() + change_item = PMFlowChangeItem( + tenant_id=1, + change_set_id=change_set.id, + project_id=project.id, + group="new_object", + object_type="Requirement", + object_id=str(requirement.id), + title="支持批量导入文件", + summary="支持批量导入 PDF、Word 和 Markdown。", + diff="新增需求", + payload={}, + confidence=0.94, + risk_level="medium", + status="committed", + ) + session.add(change_item) + await session.flush() + session.add_all( + [ + PMFlowEvidenceLink( + tenant_id=1, + project_id=project.id, + change_set_id=change_set.id, + change_item_id=change_item.id, + source_type="conversation", + source_id=str(message.id), + title="对话证据", + excerpt="需要支持批量导入文件。", + display_label="对话", + evidence_metadata={}, + ), + SemanticFact( + tenant_id=1, + entity_type="truth_node", + entity_id=requirement.id, + fact_namespace="project_memory", + fact_key="import_scope", + fact_value_text="PDF、Word、Markdown", + fact_value_norm="pdf_word_markdown", + value_type="keyword", + confidence=0.91, + evidence_source_type="conversation", + evidence_source_id=message.id, + evidence_excerpt="支持批量导入文件。", + is_active=True, + ), + ] + ) + await session.commit() + app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER")) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + overview = await client.get(f"/pmflow-os/projects/{project.id}/project-memory/overview") + items = await client.get(f"/pmflow-os/projects/{project.id}/project-memory/items?category=requirement") + filtered_items = await client.get( + f"/pmflow-os/projects/{project.id}/project-memory/items?category=requirement&stage=planning&source_type=conversation&status=confirmed" + ) + assets = await client.get(f"/pmflow-os/projects/{project.id}/project-memory/assets") + filtered_assets = await client.get(f"/pmflow-os/projects/{project.id}/project-memory/assets?source_type=conversation&status=indexed") + context = await client.get(f"/pmflow-os/projects/{project.id}/project-memory/context-packs") + writebacks = await client.get(f"/pmflow-os/projects/{project.id}/project-memory/writebacks") + item_detail = await client.get(f"/pmflow-os/projects/{project.id}/project-memory/items/truth-node-{requirement.id}") + + assert overview.status_code == 200 + overview_payload = overview.json()["data"] + assert overview_payload["metrics"][1]["key"] == "requirements" + assert overview_payload["requirements"][0]["title"] == "支持批量导入文件" + assert items.json()["data"]["items"][0]["category"] == "requirement" + assert filtered_items.json()["data"]["items"][0]["evidenceRefs"][0]["sourceType"] == "conversation" + assert assets.json()["data"]["assets"][0]["assetType"] == "conversation" + assert filtered_assets.json()["data"]["assets"][0]["status"] == "indexed" + assert context.json()["data"]["contextPacks"][0]["status"] == "ready" + assert writebacks.json()["data"]["writebacks"][0]["itemCounts"]["committed"] == 1 + assert item_detail.status_code == 200 + assert item_detail.json()["data"]["item"]["id"] == f"truth-node-{requirement.id}" + + @pytest.mark.asyncio async def test_truth_state_task_projection_comes_from_tasks_table(db_env) -> None: session = db_env @@ -362,6 +522,7 @@ async def test_execution_summary_empty_state_is_pending_with_warnings(db_env) -> async def test_execution_capabilities_returns_read_only_snapshot_with_warnings(db_env) -> None: session = db_env user, project = await _seed_user_project(session) + await _seed_open_change_set(session, user=user, project=project, risk_level="medium", item_count=1) skill = SkillTag( tenant_id=1, name="产品判断", @@ -418,7 +579,7 @@ async def test_execution_capabilities_returns_read_only_snapshot_with_warnings(d "needs_human_judgement", "blocked", } - assert payload["personalReadiness"]["waitingForUser"]["pendingConfirmations"] == 0 + assert payload["personalReadiness"]["waitingForUser"]["pendingConfirmations"] == 1 assert payload["teamSignals"][0]["displayName"] == "Owner" assert payload["teamSignals"][0]["toolStatus"]["connectedTools"] == ["Cursor"] assert payload["teamSignals"][0]["referenceSkills"] == ["产品判断"] @@ -1311,6 +1472,71 @@ async def test_release_feedback_ingest_creates_signal_and_iteration_recommendati assert len(recommendation_rows) == 1 +@pytest.mark.asyncio +async def test_release_signals_aggregate_revenue_roi_and_compliance_risks(db_env) -> None: + session = db_env + user, project = await _seed_user_project(session) + now = datetime.utcnow() + session.add( + RevenueEvent( + tenant_id=1, + project_id=project.id, + source_type="manual", + category="subscription", + currency="USD", + amount_usd=1200.0, + external_ref="rev_001", + event_payload={"plan": "team"}, + occurred_at=now, + ) + ) + session.add( + ROISnapshot( + tenant_id=1, + project_id=project.id, + range_key="monthly", + window_start=now - timedelta(days=30), + window_end=now, + total_revenue_usd=1200.0, + total_cost_usd=1800.0, + roi_ratio=-0.3333, + snapshot_payload={"reason": "paid acquisition cost spike"}, + ) + ) + session.add( + ComplianceCheckRun( + tenant_id=1, + project_id=project.id, + status="blocked", + score=58.0, + checklist=[{"key": "privacy", "status": "failed"}], + issues=[{"title": "缺少隐私授权记录", "severity": "high"}], + summary={"owner": "legal"}, + ) + ) + await session.commit() + app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER")) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get(f"/pmflow-os/projects/{project.id}/execution-summary") + + assert response.status_code == 200 + release_signals = response.json()["data"]["releaseSignals"] + assert release_signals["status"] == "active" + assert release_signals["summary"]["revenueEventCount"] == 1 + assert release_signals["summary"]["roiSnapshotCount"] == 1 + assert release_signals["summary"]["complianceCheckCount"] == 1 + assert release_signals["summary"]["riskSignalCount"] == 2 + assert release_signals["revenueEvents"][0]["amountUsd"] == 1200.0 + assert release_signals["roiSnapshots"][0]["riskLevel"] == "high" + assert release_signals["complianceChecks"][0]["riskLevel"] == "high" + assert "revenue" in release_signals["relationship"]["businessSignalTypes"] + assert "roi" in release_signals["relationship"]["businessSignalTypes"] + assert "compliance" in release_signals["relationship"]["businessSignalTypes"] + assert any("release_roi_risk" in item for item in release_signals["warnings"]) + assert any("release_compliance_risk" in item for item in release_signals["warnings"]) + + @pytest.mark.asyncio async def test_iteration_recommendation_creates_reusable_changeset_draft(db_env) -> None: session = db_env diff --git a/backend/tests/test_prompt_assets_api.py b/backend/tests/test_prompt_assets_api.py new file mode 100644 index 00000000..50ac958f --- /dev/null +++ b/backend/tests/test_prompt_assets_api.py @@ -0,0 +1,388 @@ +from __future__ import annotations + +import os +from types import SimpleNamespace + +import pytest +import pytest_asyncio +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy import text +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +os.environ["DATABASE_URL"] = "postgresql+asyncpg://pmflow:pmflow_pass@127.0.0.1:5435/pmflow" + +from app.api import prompt_assets_api +from app.core.database import get_db +from app.dependencies.permissions import require_platform_ai_ops +from app.dependencies.tenant import get_tenant_id +from app.models.base import Base +from app.models.ai_call_log import AICallLog +from app.models.reply_iteration import PromptRelease, PromptReleaseCheck +from app.services.prompt_release.service import PromptReleaseService + + +@pytest_asyncio.fixture +async def db_session(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync( + lambda sync_conn: Base.metadata.create_all( + sync_conn, + tables=[PromptRelease.__table__], + ) + ) + await conn.run_sync( + lambda sync_conn: Base.metadata.create_all( + sync_conn, + tables=[AICallLog.__table__], + ) + ) + await conn.execute( + text( + """ + CREATE TABLE prompt_release_checks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id BIGINT NOT NULL, + prompt_key VARCHAR(128) NOT NULL, + release_id INTEGER NOT NULL, + check_type VARCHAR(48) NOT NULL, + status VARCHAR(24) NOT NULL, + severity VARCHAR(16) NOT NULL DEFAULT 'info', + summary TEXT NOT NULL, + details_json JSON, + created_by BIGINT, + completed_at VARCHAR(64), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL + ) + """ + ) + ) + await conn.execute( + text( + """ + CREATE TABLE prompt_golden_cases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id BIGINT NOT NULL, + prompt_key VARCHAR(128) NOT NULL, + case_name VARCHAR(255) NOT NULL, + status VARCHAR(24) NOT NULL DEFAULT 'active', + input_payload JSON, + expected_contract JSON, + expected_contains TEXT, + source VARCHAR(48) NOT NULL DEFAULT 'manual', + source_ai_call_log_id INTEGER, + case_type VARCHAR(32) NOT NULL DEFAULT 'regression', + priority VARCHAR(24) NOT NULL DEFAULT 'blocking', + scenario_key VARCHAR(128), + tags JSON, + last_run_status VARCHAR(24), + last_failed_at VARCHAR(64), + created_by BIGINT, + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL + ) + """ + ) + ) + await conn.execute( + text( + """ + CREATE TABLE prompt_replay_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id BIGINT NOT NULL, + prompt_key VARCHAR(128) NOT NULL, + release_id INTEGER NOT NULL, + baseline_release_id INTEGER, + run_status VARCHAR(24) NOT NULL DEFAULT 'pending', + run_mode VARCHAR(32) NOT NULL DEFAULT 'contract_smoke', + case_count INTEGER NOT NULL DEFAULT 0, + passed_count INTEGER NOT NULL DEFAULT 0, + failed_count INTEGER NOT NULL DEFAULT 0, + warning_count INTEGER NOT NULL DEFAULT 0, + score_delta FLOAT, + summary TEXT, + risk_flags_json JSON, + created_by BIGINT, + started_at VARCHAR(64), + completed_at VARCHAR(64), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL + ) + """ + ) + ) + await conn.execute( + text( + """ + CREATE TABLE prompt_replay_case_results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id BIGINT NOT NULL, + run_id INTEGER NOT NULL, + golden_case_id INTEGER NOT NULL, + prompt_key VARCHAR(128) NOT NULL, + release_id INTEGER NOT NULL, + baseline_output TEXT, + candidate_output TEXT, + status VARCHAR(24) NOT NULL, + contract_result_json JSON, + diff_summary TEXT, + risk_flags_json JSON, + baseline_ai_call_log_id INTEGER, + candidate_ai_call_log_id INTEGER, + judge_score FLOAT, + judge_label VARCHAR(32), + judge_reason TEXT, + latency_ms INTEGER, + estimated_cost FLOAT, + error_summary TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL + ) + """ + ) + ) + maker = async_sessionmaker(engine, expire_on_commit=False) + async with maker() as session: + yield session + await engine.dispose() + + +def _build_app(db_session) -> FastAPI: + app = FastAPI() + app.include_router(prompt_assets_api.router) + + async def override_get_db(): + yield db_session + + app.dependency_overrides[get_db] = override_get_db + app.dependency_overrides[get_tenant_id] = lambda: 1 + app.dependency_overrides[require_platform_ai_ops] = lambda: SimpleNamespace(id=1001, tenant_id=1, role="AI_OPS") + return app + + +@pytest.mark.asyncio +async def test_prompt_assets_api_lists_and_reads_detail(db_session) -> None: + release_service = PromptReleaseService(db_session) + release = await release_service.create_release( + tenant_id=1, + prompt_key="PROMPT_FEISHU_DIRECT_QA", + body_snapshot="运营定制:{question}", + created_by=1001, + ) + await release_service.activate_release(tenant_id=1, release_id=release["id"], actor_id=1001) + + app = _build_app(db_session) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + list_response = await client.get("/prompt-assets?keyword=PROMPT_FEISHU_DIRECT_QA") + detail_response = await client.get("/prompt-assets/PROMPT_FEISHU_DIRECT_QA") + + assert list_response.status_code == 200 + payload = list_response.json()["data"] + assert payload["total"] == 1 + assert payload["items"][0]["active_release"]["version"] == release["version"] + + assert detail_response.status_code == 200 + detail = detail_response.json()["data"] + assert detail["prompt_key"] == "PROMPT_FEISHU_DIRECT_QA" + assert detail["default_template_available"] is True + assert "default_template_body" in detail + assert detail["releases"][0]["body_snapshot"] == "运营定制:{question}" + + +@pytest.mark.asyncio +async def test_prompt_assets_api_manages_release_lifecycle(db_session) -> None: + app = _build_app(db_session) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + first_response = await client.post( + "/prompt-assets/PROMPT_FEISHU_DIRECT_QA/releases", + json={"body_snapshot": "第一版:{question}", "notes": "initial"}, + ) + second_response = await client.post( + "/prompt-assets/PROMPT_FEISHU_DIRECT_QA/releases", + json={"body_snapshot": "第二版:{question}", "notes": "improve tone"}, + ) + + assert first_response.status_code == 200 + assert second_response.status_code == 200 + first = first_response.json()["data"] + second = second_response.json()["data"] + assert first["version"] == 1 + assert second["version"] == 2 + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + activate_first = await client.post(f"/prompt-assets/PROMPT_FEISHU_DIRECT_QA/releases/{first['id']}/activate") + activate_second = await client.post(f"/prompt-assets/PROMPT_FEISHU_DIRECT_QA/releases/{second['id']}/activate") + rollback_first = await client.post(f"/prompt-assets/PROMPT_FEISHU_DIRECT_QA/releases/{first['id']}/rollback") + detail_response = await client.get("/prompt-assets/PROMPT_FEISHU_DIRECT_QA") + + assert activate_first.status_code == 200 + assert activate_second.status_code == 200 + assert activate_second.json()["data"]["status"] == "active" + assert rollback_first.status_code == 200 + assert rollback_first.json()["data"]["id"] == first["id"] + detail = detail_response.json()["data"] + assert detail["active_release"]["id"] == first["id"] + statuses = {item["id"]: item["status"] for item in detail["releases"]} + assert statuses[first["id"]] == "active" + assert statuses[second["id"]] == "archived" + + +@pytest.mark.asyncio +async def test_prompt_assets_api_runs_guarded_release_gate_and_blocks_activation(db_session) -> None: + app = _build_app(db_session) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + create_response = await client.post( + "/prompt-assets/PROMPT_RAG_QUERY_SEMANTICS/releases", + json={"body_snapshot": "门禁候选:{message}\\n{workflow_bundle}\\n{planner_message_intent}\\n请输出 JSON。", "notes": "guarded candidate"}, + ) + + assert create_response.status_code == 200 + release = create_response.json()["data"] + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + activate_before_check = await client.post(f"/prompt-assets/PROMPT_RAG_QUERY_SEMANTICS/releases/{release['id']}/activate") + checks_response = await client.post(f"/prompt-assets/PROMPT_RAG_QUERY_SEMANTICS/releases/{release['id']}/checks/run") + list_checks_response = await client.get(f"/prompt-assets/PROMPT_RAG_QUERY_SEMANTICS/releases/{release['id']}/checks") + activate_after_check = await client.post(f"/prompt-assets/PROMPT_RAG_QUERY_SEMANTICS/releases/{release['id']}/activate") + + assert activate_before_check.status_code == 400 + assert "blocked by gate" in activate_before_check.json()["message"] + assert checks_response.status_code == 200 + checks = checks_response.json()["data"] + assert checks["gate"]["status"] == "blocked" + assert {item["check_type"] for item in checks["items"]} == {"variable_contract", "schema_smoke", "golden_replay"} + assert any(item["check_type"] == "golden_replay" and item["status"] == "skipped" for item in checks["items"]) + assert list_checks_response.status_code == 200 + assert list_checks_response.json()["data"]["gate"]["status"] == "blocked" + assert activate_after_check.status_code == 400 + + +@pytest.mark.asyncio +async def test_prompt_assets_api_creates_golden_case_from_call_and_quality_summary(db_session) -> None: + release_service = PromptReleaseService(db_session) + release = await release_service.create_release( + tenant_id=1, + prompt_key="PROMPT_FEISHU_DIRECT_QA", + body_snapshot="运营定制:{question}", + created_by=1001, + ) + db_session.add( + AICallLog( + tenant_id=1, + function_name="adaptive_rag_answer_normal", + prompt="运营定制:这周项目风险是什么?", + response="[error] provider timeout", + status="failed", + prompt_key="PROMPT_FEISHU_DIRECT_QA", + prompt_release_id=release["id"], + prompt_source="active_release", + latency_ms=300, + cost=0.004, + ) + ) + await db_session.commit() + app = _build_app(db_session) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + quality_response = await client.get("/prompt-assets/PROMPT_FEISHU_DIRECT_QA/quality-summary?window=7d") + case_response = await client.post( + "/prompt-assets/PROMPT_FEISHU_DIRECT_QA/golden-cases/from-call/1", + json={"case_name": "失败调用回归样本"}, + ) + list_response = await client.get("/prompt-assets/PROMPT_FEISHU_DIRECT_QA/golden-cases") + watch_response = await client.get(f"/prompt-assets/PROMPT_FEISHU_DIRECT_QA/releases/{release['id']}/watch-summary") + + assert quality_response.status_code == 200 + quality = quality_response.json()["data"] + assert quality["total_calls"] == 1 + assert quality["failed_calls"] == 1 + assert quality["recent_failures"][0]["id"] == 1 + assert case_response.status_code == 200 + assert case_response.json()["data"]["source"] == "ai_call_log" + assert case_response.json()["data"]["priority"] == "important" + assert list_response.status_code == 200 + assert list_response.json()["data"]["active_count"] == 1 + assert watch_response.status_code == 200 + assert watch_response.json()["data"]["windows"]["24h"]["total_calls"] == 1 + + +@pytest.mark.asyncio +async def test_prompt_assets_api_runs_replay_and_guarded_gate_allows_activation(db_session) -> None: + app = _build_app(db_session) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + create_response = await client.post( + "/prompt-assets/PROMPT_RAG_QUERY_SEMANTICS/releases", + json={"body_snapshot": "门禁候选:{message}\\n{workflow_bundle}\\n{planner_message_intent}\\n请输出 JSON。", "notes": "guarded candidate"}, + ) + + assert create_response.status_code == 200 + release = create_response.json()["data"] + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + case_response = await client.post( + "/prompt-assets/PROMPT_RAG_QUERY_SEMANTICS/golden-cases", + json={ + "case_name": "基础语义路由样本", + "input_payload": { + "message": "查一下项目风险", + "workflow_bundle": "项目上下文", + "planner_message_intent": "query", + }, + "expected_contract": { + "required_input_keys": ["message"], + "required_prompt_contains": ["查一下项目风险"], + }, + "priority": "blocking", + "case_type": "regression", + "tags": ["smoke"], + }, + ) + replay_response = await client.post(f"/prompt-assets/PROMPT_RAG_QUERY_SEMANTICS/releases/{release['id']}/replay-runs", json={"max_cases": 5}) + runs_response = await client.get(f"/prompt-assets/PROMPT_RAG_QUERY_SEMANTICS/releases/{release['id']}/replay-runs") + run_id = replay_response.json()["data"]["id"] + run_detail_response = await client.get(f"/prompt-assets/PROMPT_RAG_QUERY_SEMANTICS/replay-runs/{run_id}") + checks_response = await client.post(f"/prompt-assets/PROMPT_RAG_QUERY_SEMANTICS/releases/{release['id']}/checks/run") + activate_response = await client.post(f"/prompt-assets/PROMPT_RAG_QUERY_SEMANTICS/releases/{release['id']}/activate") + + assert case_response.status_code == 200 + assert case_response.json()["data"]["priority"] == "blocking" + assert replay_response.status_code == 200 + replay = replay_response.json()["data"] + assert replay["run_status"] == "completed" + assert replay["failed_count"] == 0 + assert replay["results"][0]["status"] == "passed" + assert runs_response.status_code == 200 + assert runs_response.json()["data"]["total"] == 1 + assert run_detail_response.status_code == 200 + assert run_detail_response.json()["data"]["id"] == run_id + assert checks_response.status_code == 200 + assert checks_response.json()["data"]["gate"]["status"] == "passed" + assert activate_response.status_code == 200 + assert activate_response.json()["data"]["status"] == "active" + + +@pytest.mark.asyncio +async def test_prompt_assets_api_rejects_migration_needed_and_unknown_variables(db_session) -> None: + app = _build_app(db_session) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + migration_response = await client.post( + "/prompt-assets/PROMPT_SIGNAL_WEBPAGE_VALUE_ANALYSIS/releases", + json={"body_snapshot": "不能直接运营"}, + ) + unknown_var_response = await client.post( + "/prompt-assets/PROMPT_FEISHU_DIRECT_QA/releases", + json={"body_snapshot": "未知变量:{unknown_signal}"}, + ) + + assert migration_response.status_code == 400 + assert "must be migrated" in migration_response.json()["message"] + assert unknown_var_response.status_code == 400 + assert "unknown variables" in unknown_var_response.json()["message"] diff --git a/backend/tests/test_prompt_assets_service.py b/backend/tests/test_prompt_assets_service.py new file mode 100644 index 00000000..6863aca5 --- /dev/null +++ b/backend/tests/test_prompt_assets_service.py @@ -0,0 +1,436 @@ +from __future__ import annotations + +import os + +import pytest_asyncio +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +import pytest + +os.environ["DATABASE_URL"] = "postgresql+asyncpg://pmflow:pmflow_pass@127.0.0.1:5435/pmflow" + +from sqlalchemy import JSON +from app.models.base import Base +from app.models.ai_call_log import AICallLog +from app.models.reply_iteration import PromptGoldenCase, PromptRelease, PromptReleaseCheck, PromptReplayCaseResult, PromptReplayRun +from app.services.prompt_assets_service import PromptAssetService +from app.services.prompt_release.service import PromptReleaseService + + +def _use_sqlite_json_types(*tables) -> None: + for table in tables: + for column in table.columns: + if column.type.__class__.__name__ in {"JSONB", "ARRAY", "Vector"}: + column.type = JSON() + + +@pytest_asyncio.fixture +async def db_session(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + _use_sqlite_json_types( + PromptRelease.__table__, + PromptReleaseCheck.__table__, + PromptGoldenCase.__table__, + PromptReplayRun.__table__, + PromptReplayCaseResult.__table__, + AICallLog.__table__, + ) + async with engine.begin() as conn: + await conn.run_sync( + lambda sync_conn: Base.metadata.create_all( + sync_conn, + tables=[PromptRelease.__table__, PromptReleaseCheck.__table__], + ) + ) + await conn.run_sync( + lambda sync_conn: Base.metadata.create_all( + sync_conn, + tables=[PromptGoldenCase.__table__, PromptReplayRun.__table__, PromptReplayCaseResult.__table__, AICallLog.__table__], + ) + ) + maker = async_sessionmaker(engine, expire_on_commit=False) + async with maker() as session: + yield session + await engine.dispose() + + +@pytest.mark.asyncio +async def test_prompt_asset_service_lists_registry_with_release_state(db_session) -> None: + release_service = PromptReleaseService(db_session) + release = await release_service.create_release( + tenant_id=1, + prompt_key="PROMPT_FEISHU_DIRECT_QA", + body_snapshot="定制问答:{question}", + created_by=1001, + ) + await release_service.activate_release(tenant_id=1, release_id=release["id"], actor_id=1001) + + payload = await PromptAssetService(db_session).list_assets(tenant_id=1, keyword="PROMPT_FEISHU_DIRECT_QA") + + assert payload["total"] == 1 + item = payload["items"][0] + assert item["prompt_key"] == "PROMPT_FEISHU_DIRECT_QA" + assert item["default_template_available"] is True + assert item["active_release"]["version"] == release["version"] + assert "question" in item["template_variables"] + + +@pytest.mark.asyncio +async def test_prompt_asset_service_marks_inline_prompts_as_migration_needed(db_session) -> None: + item = await PromptAssetService(db_session).get_asset( + tenant_id=1, + prompt_key="PROMPT_SIGNAL_WEBPAGE_VALUE_ANALYSIS", + ) + + assert item["editable_policy"] == "needs_migration" + assert item["runtime_source"] == "inline_discovered" + assert item["default_template_available"] is False + assert item["migration_status"] == "inline_prompt_needs_runtime_migration" + + +@pytest.mark.asyncio +async def test_prompt_asset_service_creates_activates_and_rolls_back_managed_release(db_session) -> None: + service = PromptAssetService(db_session) + + first = await service.create_release( + tenant_id=1, + prompt_key="PROMPT_FEISHU_DIRECT_QA", + body_snapshot="第一版:{question}", + created_by=1001, + ) + second = await service.create_release( + tenant_id=1, + prompt_key="PROMPT_FEISHU_DIRECT_QA", + body_snapshot="第二版:{question}", + created_by=1001, + ) + await service.activate_release( + tenant_id=1, + prompt_key="PROMPT_FEISHU_DIRECT_QA", + release_id=first["id"], + actor_id=1001, + ) + active_second = await service.activate_release( + tenant_id=1, + prompt_key="PROMPT_FEISHU_DIRECT_QA", + release_id=second["id"], + actor_id=1001, + ) + rolled_back = await service.rollback_release( + tenant_id=1, + prompt_key="PROMPT_FEISHU_DIRECT_QA", + release_id=first["id"], + actor_id=1001, + ) + + assert first["version"] == 1 + assert second["version"] == 2 + assert active_second["status"] == "active" + assert rolled_back["id"] == first["id"] + detail = await service.get_asset(tenant_id=1, prompt_key="PROMPT_FEISHU_DIRECT_QA") + assert detail["active_release"]["id"] == first["id"] + assert detail["active_release"]["body_snapshot"] == "第一版:{question}" + + +@pytest.mark.asyncio +async def test_prompt_asset_service_runs_guarded_release_gate_and_blocks_activation(db_session) -> None: + service = PromptAssetService(db_session) + + release = await service.create_release( + tenant_id=1, + prompt_key="PROMPT_RAG_QUERY_SEMANTICS", + body_snapshot="候选版本:{message}\n{workflow_bundle}\n{planner_message_intent}\n请输出 JSON。", + created_by=1001, + ) + + with pytest.raises(ValueError, match="blocked by gate"): + await service.activate_release( + tenant_id=1, + prompt_key="PROMPT_RAG_QUERY_SEMANTICS", + release_id=release["id"], + actor_id=1001, + ) + + checks = await service.run_release_checks( + tenant_id=1, + prompt_key="PROMPT_RAG_QUERY_SEMANTICS", + release_id=release["id"], + actor_id=1001, + ) + + assert checks["gate"]["status"] == "blocked" + assert any(item["check_type"] == "variable_contract" and item["status"] == "passed" for item in checks["items"]) + assert any(item["check_type"] == "golden_replay" and item["status"] == "skipped" for item in checks["items"]) + + with pytest.raises(ValueError, match="golden"): + await service.activate_release( + tenant_id=1, + prompt_key="PROMPT_RAG_QUERY_SEMANTICS", + release_id=release["id"], + actor_id=1001, + ) + + +@pytest.mark.asyncio +async def test_prompt_asset_service_replay_unblocks_guarded_gate_and_quality_summary(db_session) -> None: + service = PromptAssetService(db_session) + release = await service.create_release( + tenant_id=1, + prompt_key="PROMPT_RAG_QUERY_SEMANTICS", + body_snapshot="候选版本:{message}\n{workflow_bundle}\n{planner_message_intent}\n请输出 JSON。", + created_by=1001, + ) + golden_case = await service.create_golden_case( + tenant_id=1, + prompt_key="PROMPT_RAG_QUERY_SEMANTICS", + case_name="基础语义路由样本", + input_payload={ + "message": "查一下项目风险", + "workflow_bundle": "项目上下文", + "planner_message_intent": "query", + }, + expected_contract={"required_input_keys": ["message"], "required_prompt_contains": ["查一下项目风险"]}, + created_by=1001, + ) + db_session.add( + AICallLog( + tenant_id=1, + function_name="rag_query_semantics", + prompt="语义判断", + response="[error] schema invalid", + status="structured_invalid", + prompt_key="PROMPT_RAG_QUERY_SEMANTICS", + prompt_release_id=release["id"], + prompt_source="active_release", + routing_meta={ + "prompt_key": "PROMPT_RAG_QUERY_SEMANTICS", + "prompt_release_id": release["id"], + "prompt_source": "active_release", + }, + latency_ms=120, + cost=0.002, + ) + ) + await db_session.commit() + + checks_before_replay = await service.run_release_checks( + tenant_id=1, + prompt_key="PROMPT_RAG_QUERY_SEMANTICS", + release_id=release["id"], + actor_id=1001, + ) + replay = await service.create_replay_run( + tenant_id=1, + prompt_key="PROMPT_RAG_QUERY_SEMANTICS", + release_id=release["id"], + created_by=1001, + ) + checks = await service.run_release_checks( + tenant_id=1, + prompt_key="PROMPT_RAG_QUERY_SEMANTICS", + release_id=release["id"], + actor_id=1001, + ) + quality = await service.get_quality_summary( + tenant_id=1, + prompt_key="PROMPT_RAG_QUERY_SEMANTICS", + window="7d", + ) + cases = await service.list_golden_cases(tenant_id=1, prompt_key="PROMPT_RAG_QUERY_SEMANTICS") + + assert golden_case["case_name"] == "基础语义路由样本" + assert checks_before_replay["gate"]["status"] == "blocked" + assert replay["run_status"] == "completed" + assert replay["failed_count"] == 0 + assert replay["results"][0]["status"] == "passed" + assert checks["gate"]["status"] == "passed" + assert any(item["check_type"] == "golden_replay" and item["status"] == "passed" for item in checks["items"]) + assert quality["total_calls"] == 1 + assert quality["failed_calls"] == 1 + assert quality["release_breakdown"][0]["prompt_release_id"] == release["id"] + assert cases["active_count"] == 1 + + +@pytest.mark.asyncio +async def test_prompt_asset_service_blocks_guarded_activation_when_replay_failed(db_session) -> None: + service = PromptAssetService(db_session) + release = await service.create_release( + tenant_id=1, + prompt_key="PROMPT_RAG_QUERY_SEMANTICS", + body_snapshot="候选版本:{message}\n{workflow_bundle}\n{planner_message_intent}", + created_by=1001, + ) + await service.create_golden_case( + tenant_id=1, + prompt_key="PROMPT_RAG_QUERY_SEMANTICS", + case_name="缺失输入样本", + input_payload={"message": "查一下项目风险"}, + expected_contract={"required_input_keys": ["workflow_bundle"]}, + created_by=1001, + ) + + replay = await service.create_replay_run( + tenant_id=1, + prompt_key="PROMPT_RAG_QUERY_SEMANTICS", + release_id=release["id"], + created_by=1001, + ) + checks = await service.run_release_checks( + tenant_id=1, + prompt_key="PROMPT_RAG_QUERY_SEMANTICS", + release_id=release["id"], + actor_id=1001, + ) + + assert replay["failed_count"] == 1 + assert replay["results"][0]["status"] == "failed" + assert checks["gate"]["status"] == "blocked" + assert any(item["check_type"] == "golden_replay" and item["status"] == "failed" for item in checks["items"]) + with pytest.raises(ValueError, match="blocked by gate"): + await service.activate_release( + tenant_id=1, + prompt_key="PROMPT_RAG_QUERY_SEMANTICS", + release_id=release["id"], + actor_id=1001, + ) + + +@pytest.mark.asyncio +async def test_prompt_asset_service_llm_replay_records_judge_and_call_links(db_session, monkeypatch) -> None: + async def fake_llm_replay(self, *, prompt: str, function_name: str, **kwargs): + return f"{function_name} 输出:{prompt}", 9001 if function_name.endswith("candidate") else 9000 + + async def fake_judge(self, **kwargs): + return { + "score": 0.91, + "label": "better", + "decision": "recommend_release", + "reason": "候选输出更清晰且未违反边界。", + "risk_flags": [], + } + + monkeypatch.setattr(PromptAssetService, "_run_llm_replay_prompt", fake_llm_replay) + monkeypatch.setattr(PromptAssetService, "_judge_replay_case", fake_judge) + service = PromptAssetService(db_session) + release = await service.create_release( + tenant_id=1, + prompt_key="PROMPT_RAG_QUERY_SEMANTICS", + body_snapshot="候选版本:{message}\n{workflow_bundle}\n{planner_message_intent}\n请输出 JSON。", + created_by=1001, + ) + await service.create_golden_case( + tenant_id=1, + prompt_key="PROMPT_RAG_QUERY_SEMANTICS", + case_name="LLM 回放样本", + input_payload={ + "message": "查一下项目风险", + "workflow_bundle": "项目上下文", + "planner_message_intent": "query", + }, + expected_contract={"required_input_keys": ["message"]}, + priority="blocking", + created_by=1001, + ) + + replay = await service.create_replay_run( + tenant_id=1, + prompt_key="PROMPT_RAG_QUERY_SEMANTICS", + release_id=release["id"], + created_by=1001, + include_llm=True, + max_cases=5, + ) + + assert replay["run_mode"] == "llm_compare" + assert replay["failed_count"] == 0 + assert replay["results"][0]["status"] == "passed" + assert replay["results"][0]["candidate_ai_call_log_id"] == 9001 + assert replay["results"][0]["judge_score"] == 0.91 + assert replay["results"][0]["judge_label"] == "better" + + +@pytest.mark.asyncio +async def test_prompt_asset_service_non_blocking_case_failure_is_warning(db_session) -> None: + service = PromptAssetService(db_session) + release = await service.create_release( + tenant_id=1, + prompt_key="PROMPT_RAG_QUERY_SEMANTICS", + body_snapshot="候选版本:{message}\n{workflow_bundle}\n{planner_message_intent}", + created_by=1001, + ) + await service.create_golden_case( + tenant_id=1, + prompt_key="PROMPT_RAG_QUERY_SEMANTICS", + case_name="观察型失败样本", + input_payload={"message": "查一下项目风险"}, + expected_contract={"required_input_keys": ["workflow_bundle"]}, + priority="important", + created_by=1001, + ) + + replay = await service.create_replay_run( + tenant_id=1, + prompt_key="PROMPT_RAG_QUERY_SEMANTICS", + release_id=release["id"], + created_by=1001, + ) + + assert replay["failed_count"] == 0 + assert replay["warning_count"] == 1 + assert replay["results"][0]["status"] == "warning" + + +@pytest.mark.asyncio +async def test_prompt_asset_service_release_watch_summary(db_session) -> None: + service = PromptAssetService(db_session) + release = await service.create_release( + tenant_id=1, + prompt_key="PROMPT_FEISHU_DIRECT_QA", + body_snapshot="运营定制:{question}", + created_by=1001, + ) + db_session.add( + AICallLog( + tenant_id=1, + function_name="adaptive_rag_answer_normal", + prompt="运营定制:这周项目风险是什么?", + response="[error] provider timeout", + status="failed", + prompt_key="PROMPT_FEISHU_DIRECT_QA", + prompt_release_id=release["id"], + prompt_source="active_release", + latency_ms=300, + cost=0.004, + ) + ) + await db_session.commit() + + watch = await service.get_release_watch_summary( + tenant_id=1, + prompt_key="PROMPT_FEISHU_DIRECT_QA", + release_id=release["id"], + ) + + assert watch["recommendation"] in {"needs_review", "rollback_suggested"} + assert watch["windows"]["24h"]["total_calls"] == 1 + + +@pytest.mark.asyncio +async def test_prompt_asset_service_rejects_migration_needed_or_invalid_release(db_session) -> None: + service = PromptAssetService(db_session) + + with pytest.raises(ValueError, match="migrated"): + await service.create_release( + tenant_id=1, + prompt_key="PROMPT_SIGNAL_WEBPAGE_VALUE_ANALYSIS", + body_snapshot="不能直接发布", + created_by=1001, + ) + + with pytest.raises(ValueError, match="unknown variables"): + await service.create_release( + tenant_id=1, + prompt_key="PROMPT_FEISHU_DIRECT_QA", + body_snapshot="未知变量:{unknown_signal}", + created_by=1001, + ) diff --git a/backend/tests/test_prompt_runtime_service.py b/backend/tests/test_prompt_runtime_service.py new file mode 100644 index 00000000..506157ac --- /dev/null +++ b/backend/tests/test_prompt_runtime_service.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import pytest +import pytest_asyncio +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.models.base import Base +from app.models.reply_iteration import PromptRelease +from app.services.prompt_release.service import PromptReleaseService +from app.services.prompt_runtime_service import PromptRuntimeService + + +@pytest_asyncio.fixture +async def db_session(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync( + lambda sync_conn: Base.metadata.create_all( + sync_conn, + tables=[PromptRelease.__table__], + ) + ) + maker = async_sessionmaker(engine, expire_on_commit=False) + async with maker() as session: + yield session + await engine.dispose() + + +@pytest.mark.asyncio +async def test_prompt_runtime_service_prefers_active_release(db_session) -> None: + release = await PromptReleaseService(db_session).create_release( + tenant_id=1, + prompt_key="PROMPT_FEISHU_DIRECT_QA", + body_snapshot="active: {question}", + created_by=1001, + ) + await PromptReleaseService(db_session).activate_release( + tenant_id=1, + release_id=release["id"], + actor_id=1001, + ) + + result = await PromptRuntimeService(db_session).render( + tenant_id=1, + prompt_key="PROMPT_FEISHU_DIRECT_QA", + fallback_template="fallback: {question}", + question="hello", + ) + + assert result.prompt == "active: hello" + assert result.source == "active_release" + assert result.release_id == release["id"] + assert result.routing_meta()["prompt_key"] == "PROMPT_FEISHU_DIRECT_QA" + assert result.routing_meta()["prompt_release_id"] == release["id"] + assert result.format_error is False + + +@pytest.mark.asyncio +async def test_prompt_runtime_service_falls_back_on_active_release_format_error(db_session) -> None: + release = await PromptReleaseService(db_session).create_release( + tenant_id=1, + prompt_key="PROMPT_FEISHU_DIRECT_QA", + body_snapshot="broken: {missing}", + created_by=1001, + ) + await PromptReleaseService(db_session).activate_release( + tenant_id=1, + release_id=release["id"], + actor_id=1001, + ) + + result = await PromptRuntimeService(db_session).render( + tenant_id=1, + prompt_key="PROMPT_FEISHU_DIRECT_QA", + fallback_template="fallback: {question}", + question="hello", + ) + + assert result.prompt == "fallback: hello" + assert result.source == "default_fallback" + assert result.release_id is None + assert result.routing_meta()["prompt_format_error"] is True + assert result.format_error is True diff --git a/backend/tests/test_reply_iteration_api.py b/backend/tests/test_reply_iteration_api.py index c7c5eaac..b4cebfe6 100644 --- a/backend/tests/test_reply_iteration_api.py +++ b/backend/tests/test_reply_iteration_api.py @@ -264,7 +264,7 @@ async def test_reply_iteration_and_training_admin_routes(db_session) -> None: json={ "dataset_id": dataset_id, "job_type": "sft", - "base_model_family": "glm", + "base_model_family": "dashscope", "target_slot": "high", "provider_backend": "control-plane-training", }, diff --git a/backend/tests/test_semantic_fact_service.py b/backend/tests/test_semantic_fact_service.py index a4b723f7..b4669850 100644 --- a/backend/tests/test_semantic_fact_service.py +++ b/backend/tests/test_semantic_fact_service.py @@ -462,7 +462,7 @@ async def test_glm_project_semantic_answer_passes_owner_fields_into_prompt(db_se self.llm = _FakeLLM() service = _FakeService() - result = await service._try_glm_project_semantic_answer( + result = await service._try_fast_project_semantic_answer( clean_q="系统里有没有采购经理每日实时库存上报服务?", project_id=None, query_semantics={ @@ -556,7 +556,7 @@ async def test_glm_project_semantic_answer_appends_deterministic_attention_point self.llm = _FakeLLM() service = _FakeService() - result = await service._try_glm_project_semantic_answer( + result = await service._try_fast_project_semantic_answer( clean_q="系统里有没有采购经理库存预警服务?", project_id=None, query_semantics={ @@ -671,7 +671,7 @@ async def test_glm_project_semantic_answer_prefers_internal_context_packet_contr } ) - result = await service._try_glm_project_semantic_answer( + result = await service._try_fast_project_semantic_answer( clean_q="系统里有没有采购经理库存预警服务?", project_id=None, query_semantics={ diff --git a/backend/tests/test_signal_planner_contract.py b/backend/tests/test_signal_planner_contract.py index fde9c08a..e1c85bfe 100644 --- a/backend/tests/test_signal_planner_contract.py +++ b/backend/tests/test_signal_planner_contract.py @@ -169,7 +169,7 @@ async def test_signal_planner_analyze_degrades_without_charter(monkeypatch) -> N async def _fake_get_effective_routing(db, *, tenant_id: int, function_name: str): _ = (db, tenant_id, function_name) - return {"source": "override", "provider": "glm", "model": "qwen-plus"} + return {"source": "override", "provider": "dashscope", "model": "qwen-plus"} async def _fake_merge_and_save(db, *, signal_id: int, planner_result: dict, tenant_id: int): _ = (db, signal_id, tenant_id) diff --git a/backend/tests/test_space_saved_ideas_api.py b/backend/tests/test_space_saved_ideas_api.py new file mode 100644 index 00000000..31f42576 --- /dev/null +++ b/backend/tests/test_space_saved_ideas_api.py @@ -0,0 +1,310 @@ +from __future__ import annotations + +import os +from types import SimpleNamespace + +import pytest +import pytest_asyncio +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy import JSON, select +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://tester:tester@localhost:5432/pmflow_test") + +from app.api import space_api # noqa: E402 +from app.core.database import get_db # noqa: E402 +from app.dependencies.auth import get_current_user # noqa: E402 +from app.models import OperationLog, PMFlowLaunchDraft, Project, SavedIdea, User # noqa: E402 +from app.models.base import Base # noqa: E402 +from app.models.reply_iteration import PromptRelease # noqa: E402 +from app.services import pmflow_launch_service # noqa: E402 +from app.services import space_saved_idea_service # noqa: E402 +from app.services.ai_routing_governance_service import PLATFORM_RUNTIME_TENANT_ID # noqa: E402 +from app.services.prompt_release.service import PromptReleaseService # noqa: E402 + + +TABLES = [ + User.__table__, + Project.__table__, + SavedIdea.__table__, + PMFlowLaunchDraft.__table__, + OperationLog.__table__, + PromptRelease.__table__, +] + + +def _use_sqlite_json_types() -> None: + for table in TABLES: + for column in table.columns: + type_name = column.type.__class__.__name__.upper() + if type_name in {"JSONB", "ARRAY"}: + column.type = JSON() + + +@pytest_asyncio.fixture +async def db_env(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + _use_sqlite_json_types() + async with engine.begin() as conn: + await conn.run_sync(lambda sync_conn: Base.metadata.create_all(sync_conn, tables=TABLES)) + maker = async_sessionmaker(engine, expire_on_commit=False) + async with maker() as session: + yield session + await engine.dispose() + + +async def _seed_user(session, *, tenant_id: int = 1): + user = User( + tenant_id=tenant_id, + username="owner", + email="owner@example.com", + password_hash="hashed", + name="Owner", + role="MEMBER", + assigned_domains=[], + workload=0, + status="active", + is_active=True, + ) + session.add(user) + await session.commit() + return user + + +@pytest.mark.asyncio +async def test_space_ai_concierge_sessions_facade_uses_core_space_route(db_env, monkeypatch): + user = await _seed_user(db_env) + captured: dict[str, object] = {} + + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + captured["tenant_id"] = tenant_id + + async def list_qa_sessions(self, **kwargs): + captured["list_kwargs"] = kwargs + return {"items": [{"id": 7, "title": "空间问答", "message_count": 1}], "page": 1, "total_pages": 1} + + monkeypatch.setattr(space_api, "AdaptiveRAGService", FakeAdaptiveRAGService) + app = _build_app(db_env, user) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get("/space/ai-concierge/sessions?page=1&page_size=20") + + assert response.status_code == 200 + payload = response.json()["data"] + assert payload["items"][0]["title"] == "空间问答" + assert captured["tenant_id"] == int(user.tenant_id) + assert captured["list_kwargs"]["user_id"] == int(user.id) + + +@pytest.mark.asyncio +async def test_space_ai_concierge_ask_facade_uses_kernel_ingress(db_env, monkeypatch): + user = await _seed_user(db_env) + captured: dict[str, object] = {} + + class FakeAIPMOKernelService: + def __init__(self, db, tenant_id: int): + captured["tenant_id"] = tenant_id + + async def process_ingress(self, **kwargs): + captured["ingress_kwargs"] = kwargs + return { + "handled": True, + "direct_reply_text": "先处理待确认事项。", + "rag_result": { + "answer": "先处理待确认事项。", + "reply_text": "先处理待确认事项。", + "sources": [{"source": "space", "label": "个人空间", "score": 1}], + }, + } + + monkeypatch.setattr(space_api, "AIPMOKernelService", FakeAIPMOKernelService) + app = _build_app(db_env, user) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.post( + "/space/ai-concierge/ask", + json={ + "question": "我今天应该先处理什么?", + "asked_via": "pmflow", + "thinking_mode": "deep", + "new_session": True, + "action_mode": "auto", + "sync_requirement": False, + }, + ) + + assert response.status_code == 200 + payload = response.json()["data"] + assert payload["reply_text"] == "先处理待确认事项。" + assert captured["tenant_id"] == int(user.tenant_id) + assert captured["ingress_kwargs"]["message_text"] == "我今天应该先处理什么?" + + +def _build_app(session, current_user) -> FastAPI: + app = FastAPI() + app.include_router(space_api.router) + + async def _fake_get_db(): + yield session + + app.dependency_overrides[get_db] = _fake_get_db + app.dependency_overrides[get_current_user] = lambda: current_user + return app + + +def _draft_payload(): + return { + "title": "AI 会议纪要工具", + "one_liner": "自动整理会议结论和待办", + "target_user": "产品经理、研发负责人", + "problem": "会议后行动项分散", + "solution": "从会议内容中提取结论、风险和任务", + "requirements": [ + {"title": "上传会议内容", "summary": "支持录音或文本输入", "acceptance_criteria": ["能提交文本"]}, + {"title": "提取行动项", "summary": "输出责任人和待办", "acceptance_criteria": ["能生成任务"]}, + {"title": "同步项目上下文", "summary": "把结论带入项目", "acceptance_criteria": ["能进入项目页"]}, + ], + "risks": [{"title": "隐私风险", "summary": "会议内容敏感", "level": "high", "mitigation": "确认权限"}], + "decisions": [{"title": "先做文本输入", "reason": "降低复杂度", "owner": "用户确认"}], + "tasks": [ + {"title": "设计输入表单", "summary": "完成输入", "group": "product", "priority": "medium", "requirement_index": 0} + ], + "evidence": [{"source": "user_input", "excerpt": "AI 会议纪要工具"}], + "confidence": 0.8, + "warnings": [], + } + + +@pytest.mark.asyncio +async def test_saved_idea_create_list_archive(db_env, monkeypatch): + user = await _seed_user(db_env) + + async def _fake_enrich_fail(*args, **kwargs): + raise RuntimeError("ai unavailable") + + monkeypatch.setattr(space_saved_idea_service.llm_service, "generate_structured_object", _fake_enrich_fail) + app = _build_app(db_env, user) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + create_response = await client.post( + "/space/saved-ideas", + json={ + "raw_text": "我想做一个面向产品经理的 AI 会议纪要工具,解决会后行动项分散的问题。", + "source_type": "project_launch", + "input_mode": "idea", + }, + ) + assert create_response.status_code == 200 + created = create_response.json()["data"] + assert created["id"].startswith("idea_") + assert created["raw_text"].startswith("我想做一个") + assert created["status"] in {"enriched", "draft_ready", "needs_more_info"} + assert created["enrichment_status"] == "ai_failed" + assert created["enrichment_payload"]["source"] == "system_baseline" + + list_response = await client.get("/space/saved-ideas") + assert list_response.status_code == 200 + assert list_response.json()["data"]["items"][0]["id"] == created["id"] + + archive_response = await client.post(f"/space/saved-ideas/{created['id']}/archive") + assert archive_response.status_code == 200 + assert archive_response.json()["data"]["status"] == "archived" + + active_list_response = await client.get("/space/saved-ideas") + assert active_list_response.json()["data"]["items"] == [] + + +@pytest.mark.asyncio +async def test_saved_idea_promotes_to_project_launch_draft(db_env, monkeypatch): + user = await _seed_user(db_env) + + async def _fake_generate_structured_object(*args, **kwargs): + if kwargs.get("function_name") == "space_saved_idea_enrichment_v1": + raise RuntimeError("ai unavailable") + return SimpleNamespace(payload=_draft_payload(), raw_text="{}", provider="test", model="test") + + monkeypatch.setattr(pmflow_launch_service.llm_service, "generate_structured_object", _fake_generate_structured_object) + app = _build_app(db_env, user) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + create_response = await client.post( + "/space/saved-ideas", + json={"raw_text": "我想做一个面向产品经理的 AI 会议纪要工具,解决会后行动项分散的问题。"}, + ) + idea_id = create_response.json()["data"]["id"] + + promote_response = await client.post(f"/space/saved-ideas/{idea_id}/promote") + assert promote_response.status_code == 200 + payload = promote_response.json()["data"] + assert payload["idea"]["status"] == "promoted" + assert payload["draft"]["draft_id"].startswith("draft_") + assert payload["redirect_url"] == "/project-launch?state=human_review" + + row = (await db_env.execute(select(SavedIdea).where(SavedIdea.idea_id == idea_id))).scalar_one() + assert row.launch_draft_id + draft = (await db_env.execute(select(PMFlowLaunchDraft).where(PMFlowLaunchDraft.draft_id == row.launch_draft_id))).scalar_one() + assert draft.input_text.startswith("我想做一个") + + +@pytest.mark.asyncio +async def test_saved_idea_uses_ai_enrichment_when_available(db_env, monkeypatch): + user = await _seed_user(db_env) + release = await PromptReleaseService(db_env).create_release( + tenant_id=PLATFORM_RUNTIME_TENANT_ID, + prompt_key=space_saved_idea_service.SpaceSavedIdeaService.ENRICHMENT_PROMPT_KEY, + body_snapshot="ACTIVE SAVED IDEA PROMPT: {raw_text}", + created_by=int(user.id), + ) + await PromptReleaseService(db_env).activate_release( + tenant_id=PLATFORM_RUNTIME_TENANT_ID, + release_id=release["id"], + actor_id=int(user.id), + ) + captured: dict[str, object] = {} + + async def _fake_ai_enrich(*args, **kwargs): + captured["prompt"] = args[0] + assert kwargs["function_name"] == "space_saved_idea_enrichment_v1" + assert kwargs["routing_meta"]["response_contract"] == "structured" + assert kwargs["routing_meta"]["prompt_key"] == space_saved_idea_service.SpaceSavedIdeaService.ENRICHMENT_PROMPT_KEY + assert kwargs["routing_meta"]["prompt_release_id"] == release["id"] + assert kwargs["routing_meta"]["prompt_source"] == "active_release" + return SimpleNamespace( + payload={ + "summary": "面向产品经理的会议纪要工具", + "target_user": "产品经理", + "problem": "会后行动项分散", + "value_hypothesis": "减少会后整理成本", + "validation_signal": "行动项整理时间下降", + "constraints": ["先支持文本输入"], + "missing_questions": ["目标会议来源是什么?"], + "readiness_score": 0.82, + "status": "draft_ready", + "confidence": 0.76, + }, + raw_text="{}", + provider="test", + model="test", + ) + + monkeypatch.setattr(space_saved_idea_service.llm_service, "generate_structured_object", _fake_ai_enrich) + app = _build_app(db_env, user) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.post( + "/space/saved-ideas", + json={"raw_text": "我想做一个面向产品经理的 AI 会议纪要工具,解决会后行动项分散的问题。"}, + ) + assert response.status_code == 200 + created = response.json()["data"] + assert created["status"] == "draft_ready" + assert created["status_label"] == "可生成草案" + assert created["summary"] == "面向产品经理的会议纪要工具" + assert created["ai_summary"] == "面向产品经理的会议纪要工具" + assert created["missing_questions"] == ["目标会议来源是什么?"] + assert created["readiness_score"] == 0.82 + assert created["enrichment_status"] == "ai_enriched" + assert created["enrichment_payload"]["target_user"] == "产品经理" + assert captured["prompt"] == "ACTIVE SAVED IDEA PROMPT: 我想做一个面向产品经理的 AI 会议纪要工具,解决会后行动项分散的问题。" diff --git a/backend/tests/test_sstc_tasks_ai_breakdown_contract.py b/backend/tests/test_sstc_tasks_ai_breakdown_contract.py index ca3a0c96..dcbffb6e 100644 --- a/backend/tests/test_sstc_tasks_ai_breakdown_contract.py +++ b/backend/tests/test_sstc_tasks_ai_breakdown_contract.py @@ -236,7 +236,7 @@ async def test_general_task_breakdown_route_uses_project_and_charter_context(mon return StructuredExtractionResult( payload=payload, source_field="message.content", - provider="glm", + provider="dashscope", model="qwen-plus", raw_text=json.dumps(payload, ensure_ascii=False), validation="ok", @@ -349,7 +349,7 @@ async def test_sstc_task_breakdown_route_persists_business_object_and_payload(mo return StructuredExtractionResult( payload=payload, source_field="message.content", - provider="glm", + provider="dashscope", model="qwen-plus", raw_text=json.dumps(payload, ensure_ascii=False), validation="ok", diff --git a/backend/tests/test_training_pipeline_service.py b/backend/tests/test_training_pipeline_service.py index 21d06245..c3f4b877 100644 --- a/backend/tests/test_training_pipeline_service.py +++ b/backend/tests/test_training_pipeline_service.py @@ -166,7 +166,7 @@ async def test_training_dataset_rules_and_job_bootstrap(db_session) -> None: actor_id=9001, dataset_id=sft_dataset["id"], job_type="sft", - base_model_family="glm", + base_model_family="dashscope", target_slot="high", provider_backend="control-plane-training", ) diff --git a/backend/tests/test_workspace_membership_api.py b/backend/tests/test_workspace_membership_api.py new file mode 100644 index 00000000..def4b950 --- /dev/null +++ b/backend/tests/test_workspace_membership_api.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from datetime import timedelta + +import pytest +import pytest_asyncio +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy import JSON +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.api import workspaces_api +from app.core.database import get_db +from app.dependencies.auth import get_current_user, get_optional_current_user +from app.models import Project, Tenant, TenantInvitation, TenantMembership, User, UserProject +from app.models.base import Base +from app.services.workspace_service import hash_invitation_token +from app.utils.timezone import now_cn_naive + + +TABLES = [ + Tenant.__table__, + User.__table__, + Project.__table__, + UserProject.__table__, + TenantMembership.__table__, + TenantInvitation.__table__, +] + + +def _use_sqlite_json_types() -> None: + for table in TABLES: + for column in table.columns: + if column.type.__class__.__name__.upper() in {"JSONB", "ARRAY"}: + column.type = JSON() + + +@pytest_asyncio.fixture +async def workspace_db_env(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + _use_sqlite_json_types() + async with engine.begin() as conn: + await conn.run_sync(lambda sync_conn: Base.metadata.create_all(sync_conn, tables=TABLES)) + maker = async_sessionmaker(engine, expire_on_commit=False) + async with maker() as session: + yield session + await engine.dispose() + + +def _build_app(session, current_user: User | None = None) -> FastAPI: + app = FastAPI() + app.include_router(workspaces_api.router) + + async def _fake_get_db(): + yield session + + app.dependency_overrides[get_db] = _fake_get_db + if current_user is not None: + app.dependency_overrides[get_current_user] = lambda: current_user + app.dependency_overrides[get_optional_current_user] = lambda: current_user + return app + + +@pytest.mark.asyncio +async def test_invited_user_keeps_personal_workspace_after_leaving_team(workspace_db_env) -> None: + session = workspace_db_env + team = Tenant(name="Alpha Team", workspace_type="team", status="active") + personal = Tenant(name="杨宇 的个人空间", workspace_type="personal", status="active") + owner = User( + tenant_id=1, + username="owner", + email="owner@example.com", + password_hash="hashed", + name="Owner", + role="OWNER", + assigned_domains=[], + workload=0, + status="active", + is_active=True, + ) + builder = User( + tenant_id=2, + username="yangyu", + email="yangyu@example.com", + password_hash="hashed", + name="杨宇", + role="OWNER", + assigned_domains=[], + workload=0, + status="active", + is_active=True, + ) + session.add_all([team, personal, owner, builder]) + await session.flush() + team.owner_user_id = int(owner.id) + personal.owner_user_id = int(builder.id) + owner.tenant_id = int(team.id) + builder.tenant_id = int(personal.id) + session.add_all( + [ + TenantMembership(tenant_id=int(team.id), user_id=int(owner.id), role="OWNER", status="active"), + TenantMembership( + tenant_id=int(personal.id), + user_id=int(builder.id), + role="OWNER", + status="active", + is_personal_owner=True, + ), + ] + ) + project = Project( + tenant_id=int(team.id), + name="Team Project", + title="Team Project", + description="", + owner_id=int(owner.id), + status="active", + ) + session.add(project) + await session.flush() + token = "invite-token" + session.add( + TenantInvitation( + tenant_id=int(team.id), + email="yangyu@example.com", + role="BUILDER", + project_ids=[int(project.id)], + token_hash=hash_invitation_token(token), + status="pending", + expires_at=now_cn_naive() + timedelta(days=1), + invited_by_user_id=int(owner.id), + ) + ) + await session.commit() + + app = _build_app(session, builder) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + accepted = await client.post("/workspaces/invitations/accept", json={"token": token}) + assert accepted.status_code == 200 + assert accepted.json()["data"]["workspace_id"] == int(team.id) + + spaces = await client.get("/workspaces/me") + assert spaces.status_code == 200 + payload = spaces.json()["data"] + assert {item["workspace_type"] for item in payload["workspaces"]} == {"personal", "team"} + + left = await client.post(f"/workspaces/{team.id}/leave") + assert left.status_code == 200 + assert left.json()["data"]["active_workspace_id"] == int(personal.id) + + spaces_after = await client.get("/workspaces/me") + assert spaces_after.status_code == 200 + workspaces = spaces_after.json()["data"]["workspaces"] + assert [item["workspace_type"] for item in workspaces] == ["personal"] diff --git a/docs/FEISHU_AI_FLOW_CURRENT.md b/docs/FEISHU_AI_FLOW_CURRENT.md index f12c5459..e4e2ee51 100644 --- a/docs/FEISHU_AI_FLOW_CURRENT.md +++ b/docs/FEISHU_AI_FLOW_CURRENT.md @@ -211,7 +211,7 @@ sequenceDiagram 说明:上面第 3 组名称仍在评测/历史链路中使用,但实时主入口不直接走这条链路。 -### 8.2 `glm` / `minimax` 在哪一步、做什么 +### 8.2 `dashscope` / `minimax` 在哪一步、做什么 > 依据:`backend/.control_plane/pmflow-routing-rules.json`(当前默认 `CONTROL_PLANE_ROUTING_ENABLED=true`,`LLM_MIXED_ROUTING_ENABLED=false`)。 @@ -232,7 +232,7 @@ sequenceDiagram | PMFlow 本地路由层 指挥计划 | `control_plane_command_dispatch` | **active 引擎**(未在规则中显式绑定) | 把自然语言转 typed operations | | 子 worker 执行 | `signal_source_ai_worker_execution` | **active 引擎**(未在规则中显式绑定) | 各角色生成 deliverables/risks/next_actions | -说明:这 3 个函数名不在当前 PMFlow 本地路由层 规则文件中,因此在默认配置下走 active 引擎;若 active 仍是默认 PMFlow 本地路由层 low 模型,则通常表现为 `glm`。 +说明:这 3 个函数名不在当前 PMFlow 本地路由层 规则文件中,因此在默认配置下走 active 引擎;若 active 仍是默认 PMFlow 本地路由层 low 模型,则通常表现为 `dashscope`。 #### C. 历史/评测链路(`FeishuSignalPipelineService`) diff --git a/docs/GLM5_SIGNAL_PLANNER_IMPLEMENTATION_PLAN_V4.md b/docs/GLM5_SIGNAL_PLANNER_IMPLEMENTATION_PLAN_V4.md index f6fae8c3..1589a034 100644 --- a/docs/GLM5_SIGNAL_PLANNER_IMPLEMENTATION_PLAN_V4.md +++ b/docs/GLM5_SIGNAL_PLANNER_IMPLEMENTATION_PLAN_V4.md @@ -243,7 +243,7 @@ - 功能验收样本: - `signal_id=273`:`elapsed_seconds=30`,`planner_status=completed`,`ai_planner_state=NOT_NULL` - 重复消息幂等:`source_message_id=dedupe-signal-1773313023` 在 `ingest_events/work_items` 计数均为 `1` - - 模型路由覆盖生效日志:`函数覆盖路由命中:function=signal_planner_analyze, provider=glm, model=qwen-plus` + - 模型路由覆盖生效日志:`函数覆盖路由命中:function=signal_planner_analyze, provider=dashscope, model=qwen-plus` - 截图产物: - `docs/task_briefs/sidebar_new_entries_20260312.png` - `docs/task_briefs/model_routing_override_effective_20260312.png` diff --git a/docs/PMFLOW_PRODUCT_BLUEPRINT.md b/docs/PMFLOW_PRODUCT_BLUEPRINT.md index de5c5d57..d8aa6bc1 100644 --- a/docs/PMFLOW_PRODUCT_BLUEPRINT.md +++ b/docs/PMFLOW_PRODUCT_BLUEPRINT.md @@ -176,10 +176,36 @@ PMFlow 的核心不是“双模式切换”,而是一个统一 `Project Thread 这层解决“AI 到底理解成什么”的即时信任问题。 +本轮结构化提取不是独立工作流终点,而是结构化读取检查层: + +- 从指挥台进入时,主任务是核对 AI 本轮理解,并继续补充或进入待确认。 +- 从待确认中心进入时,主任务是核对候选对象的完整读取、证据与字段来源,处理完成后应返回待确认队列继续拍板。 +- 已写入项目记忆后的结构化结果只作为来源快照,修改必须通过新的变更确认链路发起。 + ### 5.5 Change Review / 待确认中心 这是 PMFlow 区别于聊天工具的关键页面。AI 的结构化写入先形成业务 diff,再由用户接受、修改、暂缓或拒绝。 +确认写入项目记忆前,候选对象始终可继续接受、修改、暂缓或恢复处理;“接受”只是进入待写入集合,不等于写入项目真相层。真正锁定边界是用户明确执行“确认写入项目记忆”之后。 + +结构化提取后的默认顺序固定为: + +```text +Structured Extraction +-> ChangeSet 生成 +-> Change Review 初审 / 分流 +-> 必要时 Team Layer 角色审查 +-> Change Review 形成 accepted / edited / deferred / rejected 审查结果 +-> 确认写入 Project Memory +-> Work Graph 生成正式任务与执行候选 +-> Execution Capability Graph 判断执行模式 +-> Execution Bridge 发起执行交接 +``` + +因此,团队层审查不是 Project Memory 写入后的复盘,也不是另一个待确认中心。团队层只负责把高风险、跨角色、缺证据或需要专业判断的 ChangeSet 路由给合适 reviewer,并展示审查状态、阻塞原因和跳转入口;正式接受、修改、暂缓、拒绝和写入项目记忆仍回到 Change Review 完成。 + +低风险、证据充分、单人项目内的普通变更可以由负责人在 Change Review 直接处理并写入记忆;高风险或团队项目中的关键变更必须先满足角色审查,再允许确认写入项目记忆。禁止先写入 Project Memory 再要求团队补审。 + 待确认中心聚合: - AI 提议但未确认的对象。 @@ -219,6 +245,18 @@ PMFlow 的核心不是“双模式切换”,而是一个统一 `Project Thread - 同步副驾:开发者在 IDE 里实时协作。 - 异步 agent:后台接任务、执行、开 PR、回写结果。 +进入开发执行的边界必须晚于 Project Memory commit。AI 提取出的 Task 或执行建议在 ChangeSet 阶段只是候选对象,不能直接派给人或 agent 执行。只有当相关需求、决策、约束、风险和任务通过 Change Review 写入 Project Memory,并在 Work Graph 中成为正式任务后,才进入执行准备态。 + +执行准备态先由 Execution Capability Graph 判断执行模式,再在 Execution Bridge 完成具体交接: + +- `ai_direct`:低风险、边界清晰、测试与回写路径明确,可由 AI / agent 执行,但仍必须生成可审计执行包。 +- `ai_with_user_acceptance`:AI 执行,用户负责验收结果。 +- `ai_with_technical_review`:AI 执行但必须绑定技术 reviewer,适合权限、数据、安全、迁移、发布门禁等高风险改动。 +- `needs_human_judgement`:目标、范围、验收标准或商业取舍不清,先回到 Change Review / 指挥台补判断。 +- `blocked`:缺 repo、插件、测试命令、执行包、reviewer、权限或回写证据时显式阻塞。 + +人员或 agent 跟进分两层:审查责任在 Change Review / Team Layer 阶段确定,决定谁对变更质量拍板;执行责任在 Project Memory commit 后、Execution Bridge handoff 前确定,决定谁或哪个 agent 领取正式任务。执行建议可以提前展示,但不得在候选变更尚未写入项目记忆时生效。 + ### 5.8 Release & Growth / 发布与增长 发布不是终点,而是进入增长和变现验证的入口。 diff --git a/docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md b/docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md index 07a37e32..aa0c32b0 100644 --- a/docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md +++ b/docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md @@ -48,7 +48,7 @@ - 这是产品主链的高保真原型和本地行为闭环。 - 还不是正式后端数据闭环。 -- 不接真实 AI 提取,不写真实数据库,不消费真实项目对象。 +- 显式 demo 模式仍是本地高保真原型;API 模式下 PMFlow OS 已接真实项目对象、真实 thread message、真实 AI extraction、ChangeSet/ChangeItem 审查与 commit 主链。 #### 后端控制面 @@ -71,7 +71,7 @@ - 后端已经有 V1 产品化需要的部分基础对象和接口。 - 但它们还没有和 `frontend/src/features/pmflow-os/` 的本地 truth store 正式打通。 -- `ChangeSet / Approval / Evidence / Project Memory` 这条主产品链仍主要停留在前端本地态和旧 SSTC/semantic facts 体系之间,缺少统一的 PMFlow OS 产品级 API 合同。 +- `ChangeSet / Approval / Evidence / Project Memory` 这条主产品链已形成 PMFlow OS 产品级 API 合同:AI extraction 生成 open ChangeSet + pending_review ChangeItems,ChangeReview 负责 accept/edit/defer,commit 后才写入 Project Memory。 ### 1.2 当前缺口 @@ -81,17 +81,17 @@ 关键缺口按优先级排序: -1. `Project Thread / ChangeSet / Evidence / Approval / Project Memory` 的前后端合同没有完全收口。 +1. `Project Thread / ChangeSet / Evidence / Approval / Project Memory` 的前后端合同已形成主链,后续重点是质量、覆盖面和 UI 真实态 polish。 2. `project-launch` 还没有接真实 AI 结构化、真实项目创建和真实草案确认。 -3. `pmflow-os` 本地 store 还没有接后端读写,当前仍是 mock truth store。 +3. `pmflow-os` store 已接后端读写;只有显式 demo 参数才使用 mock truth store。当前缺口是更多页面去静态文案、真实状态细节和失败态打磨。 4. `ChangeReview` 的接受、修改、暂缓还没有写入正式对象、审批记录和证据链。 5. `ExecutionBridge` 已接入真实 execution-summary/dev-sync、execution handoff、运行端领取和 progress writeback;当前缺口不再是执行桥读写合同,而是执行质量、审查责任和个人队列的产品化。 -6. `ReleaseGrowth` 已接入 execution evidence gate、release validation run、feedback ingest 和 recommendation -> ChangeSet draft 回流;revenue、ROI、compliance 还没有串成完整发布后反馈循环。 -7. `TeamLayer` 已接入 reviewer routing、review decision 状态展示与 Team Feed 只读动态流;Role Inbox、Decision Desk 仍缺完整真实数据模型。 +6. `ReleaseGrowth` 已接入 execution evidence gate、release validation run、feedback ingest、revenue event、ROI snapshot、compliance check 和 recommendation -> ChangeSet draft 回流;发布后业务信号已进入统一 `releaseSignals` 读模型,但仍不做完整财务系统、真实支付或真实合规平台。 +7. `TeamLayer` 已接入 reviewer routing、review decision 状态展示、Team Feed 只读动态流、执行能力图谱和容量信号;当前页面已收敛为“团队动态 / 决策台 / 角色收件箱 / 智能体工位 / AI 副驾”的协作态势首屏。团队层只做责任路由、阻塞提示和跳转,不作为第二个 Change Review,也不直接写 Project Memory。 5. `ExecutionBridge` 页面还没有消费 dev-sync / GitHub / PromptPackage 真实接口。 -6. `ReleaseGrowth` 已接入 execution evidence gate、release validation run 与 feedback ingest;revenue、ROI、compliance 还没有串成完整发布后反馈循环。 -7. `TeamLayer` 已接入 reviewer routing / review decision 读模型与 Team Feed 只读动态流,能展示待审 ChangeSet、建议审查人、审查原因、风险类型、缺角色 warning、审查状态、审查意见,以及 review / execution / release gate 聚合动态;Role Inbox、Decision Desk 仍缺完整真实数据模型。 -8. `Execution Capability Graph` 已确认为团队空间前置模型:旧 `用户管理 / 能力管理 / 团队负载` 不再作为一级产品功能,降级为高级设置/明细路由;个人空间展示执行准备度,TeamLayer 展示执行能力与容量信号;后续团队空间只能扩展 `AI + 工具 + 人类判断责任 + 项目上下文 + 容量信号`,不得回到传统用户/技能/工时报表三件套。 +6. `ReleaseGrowth` 已接入 execution evidence gate、release validation run、feedback ingest、revenue event、ROI snapshot 与 compliance check;这些信号已统一进入 `releaseSignals`,可辅助生成下一轮 ChangeSet draft,但不自动 commit。 +7. `TeamLayer` 已接入 reviewer routing / review decision 读模型与 Team Feed 只读动态流,能展示待审变更集、建议审查人、审查原因、风险类型、缺角色 warning、审查状态、审查意见,以及 review / execution / release gate 聚合动态;首屏已按团队协作驾驶舱组织为团队动态、决策台、角色收件箱、智能体工位和上下文 AI 副驾。Role Inbox、Decision Desk 当前仍是基于现有 truth state 的摘要表达,完整真实收件箱与决策台模型仍是后续 Team lane 工作。 +8. `Execution Capability Graph` 已确认为团队空间前置模型:旧 `用户管理 / 能力管理 / 团队负载` 不再作为一级产品功能;成员能力进入 `/space/settings/members`,容量进入 `/team-layer?view=capacity`,个人空间展示执行准备度,TeamLayer 展示执行能力与容量信号;后续团队空间只能扩展 `AI + 工具 + 人类判断责任 + 项目上下文 + 容量信号`,不得回到传统用户/技能/工时报表三件套。 9. `Adaptive Human Capacity` 已确认为旧工时负载的替代模型:每日 `suggestedBaseline` 必须由当天真实任务池动态生成,所有人工触点共享同一个容量池;总时间判断硬上限,类型占比只判断疲劳和质量风险。负责人 override 必须先 simulate,再 apply,并记录原因、审计、过期时间;团队空间后续不得回到固定工作类型槽、静态技能标签或旧 workload ratio 直接派活。 ## 2. 下一步推进方向 @@ -176,6 +176,50 @@ - 发布与增长 - 团队协作 +### 3.1 审查、写入记忆与开发执行边界 + +当前产品方案确认以下顺序为 Main Flow 主链合同,后续原型和实现不得绕开: + +```text +对话 / 材料输入 +-> Structured Extraction +-> ChangeSet / ChangeItem / EvidenceLink +-> Change Review 初审与分流 +-> 必要时 Team Layer 角色审查 +-> accepted / edited / deferred / rejected 审查结果 +-> 确认写入 Project Memory +-> Work Graph 形成正式 Task / Decision / Risk / Requirement +-> Execution Capability Graph 判断执行模式 +-> Execution Bridge 发起执行交接 +-> Codex / Local Bridge / GitHub agent / IDE 执行与回写 +``` + +关键判断: + +- 不允许先写入项目记忆再要求团队补审;高风险、跨角色、缺证据或需要专业判断的变更必须先完成角色审查。 +- 团队层不是第二套待确认中心,只做责任路由、角色收件箱、团队动态、审查状态、阻塞解释和跳转。 +- `accept / edit / defer / reject / review decision` 只改变审查与 approval 状态,不写 Project Memory。 +- `确认写入项目记忆` 是唯一把 accepted / edited 项提交到 `truth_nodes / tasks / semantic_facts` 等正式对象的动作。 +- 开发执行不能从 AI 候选 Task 直接开始;必须先由 Project Memory commit 形成正式 Task,再进入执行准备与 handoff。 +- 人员或 agent 分配分两层:审查责任在 Change Review / Team Layer 阶段确定;执行责任在正式 Task 形成后、Execution Bridge handoff 前通过 Execution Capability Graph 与容量信号确定。 +- `发起执行交接 / 推送到 IDE / 分派给 agent` 只能在正式任务、上下文包、repo/runtime、测试与回写证据满足最小条件后成为可执行主按钮;否则显示 blocked/warning 和下一步。 + +按钮状态限制: + +| 动作 | 允许使用 | 禁止或限制 | +| --- | --- | --- | +| 接受 | 候选项正确、证据充分,可进入待写入集合 | 不写 Project Memory;高风险缺 reviewer gate 时不可执行 | +| 修改 | 候选项方向正确但字段或范围需修订 | 必须保留原始 proposal、before/after 与 evidence | +| 暂不落库 | 当前不写入但保留后续处理空间 | 不等于拒绝;不得进入 commit eligible 集合 | +| 拒绝 | AI 误读、重复、证据错误或方向错误 | terminal discard;不得用 defer 冒充 | +| 批量接受 | 低风险、同类、证据充分且无需角色审查 | 不得混入高风险、缺证据、unsupported 或 required review 项 | +| 稍后处理 | 队列提醒 / 排序,不改变业务状态 | 不得写 approval、defer 或 reject | +| 请求审查 | 需要产品、技术、QA、增长等角色异步判断 | reviewer 缺失时只显示阻塞,不虚构分派 | +| 通过审查 | reviewer 认可变更质量 | 不写 Project Memory,不触发执行 | +| 要求修改 | reviewer 要求补字段、证据、范围或验收标准 | 不终止 item,回到 owner 修改 | +| 确认写入项目记忆 | accepted / edited 项通过 gate 后 atomic commit | 存在 pending required review、无 eligible item、缺 evidence 或 unsupported object type 时不可执行 | +| 发起执行交接 | 正式 Task 已存在且执行准备度满足最低条件 | 不得针对 pending ChangeItem 或 draft Task 执行 | + ### Phase A:产品合同收口 目标: @@ -228,7 +272,7 @@ 当前拆分: -- C1:真实 Change Review 数据底座与审查状态机,已实现 `ChangeSet / ChangeItem / Approval / EvidenceLink` 表和 `accept / edit / defer`,但不 commit。 +- C1:真实 Change Review 数据底座与审查状态机,已实现项目线程消息、真实 AI extraction、显式 `proposed_items` draft、`ChangeSet / ChangeItem / Approval / EvidenceLink` 表和 `accept / edit / defer`;AI extraction 失败必须显式失败,不生成模板假结果。 - C2:提交入库,把已接受/已编辑 ChangeItem 写入 `truth_nodes / tasks / semantic_facts` 并完成高风险 approval gate。 范围: @@ -275,11 +319,10 @@ 范围: -- `ReleaseGrowthPage` 已消费 execution evidence gate、release validation run 与 feedback ingest。 -- 继续扩展 feedback / revenue / compliance 到统一发布后信号读模型。 -- 支持 revenue event 或基础 ROI snapshot。 -- 支持 compliance check 的轻量门禁。 -- 反馈能生成 IterationRecommendation 或下一轮 ChangeSet。 +- `ReleaseGrowthPage` 已消费 execution evidence gate、release validation run、feedback ingest 与统一 `releaseSignals`。 +- `releaseSignals` 已聚合 feedback / revenue / ROI / compliance / recommendation,缺数据时返回 explicit empty/warning,不用静态 mock 指标补位。 +- revenue event、基础 ROI snapshot 与 compliance check 只作为轻量发布后经营/风险信号,不扩成完整财务系统、真实支付或真实合规平台。 +- 真实 recommendation 能生成下一轮 ChangeSet draft。 验收: @@ -305,7 +348,8 @@ 验收: - 不同角色看到不同待处理项。 -- 高风险 ChangeSet 能先展示建议 reviewer/owner 与审查原因;自动指定或权限化分派不属于当前最小闭环。 +- 高风险变更集能先展示建议审查人/负责人、审查原因与证据数量;自动指定或权限化分派不属于当前最小闭环。 +- 团队层是协作态势与责任路由页,不能成为第二个待确认中心;正式接受、修改、暂缓、拒绝和写入 Project Memory 仍回到 `ChangeReviewPage`。 - Team Feed 展示 AI 提取、人工确认、执行回写、测试/发布变化。 ## 4. 单次任务范围标准 @@ -828,6 +872,211 @@ P8 可以三条 lane 并行,但共享高冲突文件必须由 Main Flow 或 Sh ## 8. 进度台账 +### 2026-05-05 / main-flow-d3-execution-bridge-productization / implemented + +完成内容: + +- 将 `/execution-bridge` 从字段调试式页面重组为产品化开发执行工作面:顶部执行链路、左侧开发上下文包、中间执行回写视图、右侧 AI 副驾审查、底部联动流水线。 +- 将用户可见的执行状态、事件类型、运行端、仓库状态、上下文包状态、warning code 统一中文化;`repo_bindings_empty / prompt_package_empty / progress_events_empty` 等工程码不再直接暴露给客户。 +- 保留既有真实 API 合同:仍消费 `execution-summary`、`execution-handoffs`、`consume` 与 `execution-progress-events`,不改后端 schema,不绕过 Project Memory commit 后才能 handoff 的门禁。 +- 手动回写表单改为中文选项与产品语义;PR、测试、提交、终端输出和回写日志按证据 tab 展示,没有证据时保持明确空态。 +- AI 副驾审查区按需求覆盖、代码质量、测试影响、回写完整度生成审查重点和下一步建议,作为执行桥的审查辅助,不执行真实写操作。 +- `ExecutionBridgeCards` 补充 warning 展示映射,并将新区域圆角收敛到项目 UI 规范内。 + +涉及文件: + +- `frontend/src/features/pmflow-os/pages/ExecutionBridgePage.tsx` +- `frontend/src/features/pmflow-os/components/ExecutionBridgeCards.tsx` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- `cd frontend && npm run typecheck` 通过。 +- `cd frontend && npm run test -- src/features/pmflow-os/store.api.test.tsx` 通过:32 passed;仍有既有 React Router future flag warning。 +- `cd frontend && npm run build` 通过。 +- Playwright 打开 `http://localhost:5176/execution-bridge?demo=1` 做桌面与移动截图检查;页面本身无 React key warning,demo 登录壳仍有既有 `GET /api/ai-readiness/project-launch` 401,因为本地未接真实后端认证,不影响本页 UI 结论。 +- 本轮只修改前端 UI 与进度文档,未修改后端代码、Schema 或 migration,因此未执行后端编译、数据库迁移或热重启。 + +未完成或风险: + +- 本轮未接 Deep Agents runtime;Deep Agents 仍作为后续运行端适配器/架构参考,不进入当前 UI 主链。 +- 当前工作区存在大量既有未提交改动,本 Packet 未提交,避免把非本次范围的改动混入 commit。 + +下一步建议: + +- 进入 `main-flow-d4-execution-runtime-capability`:把 Execution Capability Graph 更深接入开发执行页,形成 `AI 直接执行 / AI+用户验收 / AI+技术审查 / 需人工判断 / 阻塞` 的可视化执行模式与 reviewer gate。 + +### 2026-05-05 / structured-extraction-selection-count-fix / implemented + +完成内容: + +- 修复 `/structured-extraction` 结构化写入快照中候选对象 checkbox 只展示选中态、未回写页面选择集合的问题。 +- `ObjectChangeCard` 增加显式 `onSelectedChange` 回调与对象级 aria label;未接入选择回调的复用场景中 checkbox 会禁用,避免出现可点但无效的控件。 +- `StructuredExtractionPage` 增加 `selectedItemIds` 状态,候选项变化时自动清理失效选择;底部固定决策栏的 `已选择 N 项` 改为真实计数,勾选和取消都会同步更新。 +- 选择行为只影响当前页面 UI 选择态,不改变 ChangeItem 审查状态、不写 Project Memory,也不触发 defer/accept 等业务动作。 + +涉及文件: + +- `frontend/src/features/pmflow-os/pages/StructuredExtractionPage.tsx` +- `frontend/src/features/pmflow-os/components/ProjectStructuredLoop.tsx` +- `frontend/src/features/pmflow-os/store.api.test.tsx` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- `cd frontend && npm run test -- src/features/pmflow-os/store.api.test.tsx -t "structured extraction selected count|updates the structured extraction selected count"` 通过:1 passed,31 skipped。 +- `cd frontend && npx tsc --noEmit --pretty false --incremental false` 通过。 +- `cd frontend && npm run test -- src/features/pmflow-os` 通过:33 passed。 +- `cd frontend && npm run build` 通过。 +- 本轮只修改前端 UI 与文档,未修改后端代码、Schema 或 migration,因此未执行后端编译、数据库迁移或热重启。 + +### 2026-05-05 / review-memory-execution-product-gates-implementation / implemented + +完成内容: + +- `ChangeReviewPage` 按产品门禁限制按钮状态:高风险候选在缺 reviewer approval 前不能直接接受;缺证据候选不能直接接受;`确认写入项目记忆` 只有在目标 ChangeSet 存在 accepted / edited 项、无 pending_review、证据齐全、高风险 approval 满足且对象类型受支持时才可执行。 +- 批量操作收敛语义:`接受可处理项` 只处理低风险、高置信、有证据且无需角色审查的候选;`批量修改` 仍允许对 reviewable item 产生人工修订;`稍后处理` 改为队列级提醒/清空选择,不再调用 `defer` 或改变 ChangeItem 审查状态。 +- 审查决策按钮增加状态限制:缺明确 reviewer 时不能虚构 `请求审查`;无待审上下文时不能通过、要求修改或拒绝。 +- `ExecutionBridgePage` 与 PMFlow OS store 双层加执行交接守门:只有已写入项目记忆后形成的正式 task 状态才允许创建 handoff;draft/proposed/pending/candidate 等候选任务只显示阻塞说明,不调用真实 handoff API。 +- `ChangeReviewAiCopilotPanel` 主按钮支持 disabled reason,避免右侧 AI 副驾绕过主按钮门禁。 +- 测试 wrapper 补齐 `ToastProvider`,并修正一个未 await 的 `promoteAcceptedToGraph` 测试调用,避免异步 act 泄漏污染后续用例。 + +涉及文件: + +- `frontend/src/features/pmflow-os/pages/ChangeReviewPage.tsx` +- `frontend/src/features/pmflow-os/pages/ExecutionBridgePage.tsx` +- `frontend/src/features/pmflow-os/components/ProjectStructuredLoop.tsx` +- `frontend/src/features/pmflow-os/store.tsx` +- `frontend/src/features/pmflow-os/store.api.test.tsx` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- `cd frontend && npx tsc --noEmit --pretty false --incremental false` 通过。 +- `cd frontend && npm run test -- src/features/pmflow-os/store.api.test.tsx -t "blocks high-risk|batch snooze|prevents direct execution"` 通过:3 passed,28 skipped。 +- `cd frontend && npm run test -- src/features/pmflow-os` 通过:32 passed。 +- `cd frontend && npm run build` 通过。 +- 本轮未修改后端代码、Schema 或 migration,因此未执行后端编译、数据库迁移或热重启。 + +### 2026-05-05 / review-memory-execution-product-gates / documented + +完成内容: + +- 明确结构化提取后的主顺序:`Structured Extraction -> ChangeSet -> Change Review -> 必要时 Team Layer 角色审查 -> 确认写入 Project Memory -> Work Graph -> Execution Capability Graph -> Execution Bridge`。 +- 确认团队层只做责任路由、角色收件箱、团队动态、审查状态、阻塞解释和跳转,不作为第二套待确认中心,不直接写 Project Memory。 +- 明确 `accept / edit / defer / reject / review decision` 只处理审查状态;`确认写入项目记忆` 才是正式写入边界;开发执行必须晚于 Project Memory commit。 +- 补齐决策与待确认按钮的使用场景和限制,包括接受、修改、暂不落库、拒绝、批量接受、稍后处理、请求审查、通过审查、要求修改、确认写入项目记忆、发起执行交接。 +- 明确人员或 agent 跟进分两层:审查责任在 Change Review / Team Layer 阶段确定;执行责任在正式 Task 形成后、Execution Bridge handoff 前通过 Execution Capability Graph 和容量信号确定。 + +涉及文件: + +- `docs/PMFLOW_PRODUCT_BLUEPRINT.md` +- `docs/PMFLOW_UI_CONTRACT.md` +- `docs/architecture/PMFLOW_OS_PRODUCT_CONTRACT.md` +- `docs/architecture/RUNTIME_GUARDRAILS.md` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` +- `AGENTS.md` + +验证: + +- 本轮只更新产品与架构文档,未修改代码、API、Schema 或 migration;未运行编译、单测、构建或热重启。 +- `rg` 已核对新增合同关键词覆盖审查、写入记忆、按钮限制、执行交接和责任分配边界。 + +### 2026-05-05 / team-layer-routing-card-density / implemented + +完成内容: + +- 补齐团队层当前契约确认:团队层只做只读协作态势、责任路由、阻塞解释和跳转;正式接受、修改、暂缓、拒绝和写入项目记忆仍回到 `ChangeReviewPage`。 +- 优化 `/team-layer` 决策台与待审变更责任路由卡片密度:操作按钮从正文右侧移到顶部操作区,标题、摘要、审查原因、证据和建议审查人使用完整卡片宽度;当只有 1-2 条路由时不再强行占 3 列,避免出现卡片内部右侧大块空白。 +- 继续保持用户可见文案中文化,不暴露 `Team Layer / ChangeSet / REVIEWER / OWNER` 等工程或后端标签。 + +涉及文件: + +- `frontend/src/features/pmflow-os/pages/TeamLayerPage.tsx` +- `frontend/src/features/pmflow-os/store.api.test.tsx` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/ARCHITECTURE_INVENTORY.md` + +验证: + +- `cd frontend && npm run test -- src/features/pmflow-os/store.api.test.tsx -t "renders reviewer routing on team layer"` 通过。 +- `cd frontend && npm run typecheck` 通过。 + +### 2026-05-04 / team-layer-collaboration-dashboard / implemented + +完成内容: + +- 将 `/team-layer` 从纵向“审查路由 + 能力/容量模块”重组为团队协作驾驶舱:首页按 `团队动态 / 决策台 / 角色收件箱 / 智能体工位 / AI 副驾` 展示项目真相层最近变化、待拍板事项、角色责任、执行单元阻塞和协同建议。 +- 明确产品边界:团队层只做责任路由、阻塞解释、执行态势和跳转;正式接受、修改、暂缓、拒绝和写入项目记忆仍回到 `/change-review?projectId=&changeSetId=`,避免出现第二套 Change Review。 +- 用户可见文案中文化:团队层展示层将 `ChangeSet / REVIEWER / OWNER / high risk` 等后端/英文标签转换为 `变更集 / 审查人 / 负责人 / 高风险`,按钮按状态改为 `进入待确认 / 查看审查进展 / 回到变更处理 / 查看决策`。 +- 容量调度下沉到执行态势之后;首屏先展示团队动态、决策台、角色收件箱和智能体工位,减少超宽屏无意义留白。 + +涉及文件: + +- `frontend/src/features/pmflow-os/pages/TeamLayerPage.tsx` +- `frontend/src/features/pmflow-os/store.api.test.tsx` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/ARCHITECTURE_INVENTORY.md` + +验证: + +- `cd frontend && npm run typecheck` 通过。 +- `cd frontend && npm run test -- src/features/pmflow-os/store.api.test.tsx -t "renders reviewer routing on team layer"` 通过。 +- `cd frontend && npm run build` 通过。 +- 整文件 `store.api.test.tsx` 当前仍受既有 `ChangeReviewPage` 测试包裹缺 `ToastProvider` 问题影响,非本次团队层改动引入。 + +### 2026-05-03 / structured-extraction-review-detail-ui / implemented + +完成内容: + +- 将 `/structured-extraction` 红框内候选对象从轻列表升级为可展开审查卡片:折叠态保留对象类型、标题、功能描述、置信度、风险、优先级、证据数量和审查完整度;展开态补齐功能描述、证据来源、写入项目记忆后字段预览、验收标准、优先级与风险说明、补充一句入口。 +- 页面层接入现有 `acceptChange / editChange / deferChange`,支持默认展开高风险待确认项、单项展开/收起、按组“全部展开/全部收起”、修改保存和补充一句生成修订,不新增后端合同。 +- 顶部统计从单纯对象类型数量调整为审查决策指标:候选对象、待确认、证据充分、写入风险、缺失信息;右侧写入前检查新增审查完整度,继续只基于真实候选和 projection warning 派生。 +- 修复 demo `createdAt=10:22` 被格式化成 `Invalid Date` 的显示问题;移动端操作按钮改为窄屏两列并收敛横向溢出。 + +涉及文件: + +- `frontend/src/features/pmflow-os/pages/StructuredExtractionPage.tsx` +- `frontend/src/features/pmflow-os/components/ProjectStructuredLoop.tsx` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- `cd frontend && npm run typecheck` 通过。 +- `cd frontend && npm run build` 通过。 +- 本地 Vite `http://127.0.0.1:5174/structured-extraction?demo=1` + Playwright smoke:桌面 1728px 下功能描述、证据来源、写入项目记忆后、验收标准、优先级与风险、补充一句均渲染;移动端 390px 下无横向溢出。 + +### 2026-05-01 / space-inbox-secondary-pages / implemented + +完成内容: + +- 将空间首页红框内的 `暂存想法 / 草案箱 / 最近项目` 收敛为工作雷达预览:首页只展示少量高优先级条目,数量多时通过明确的“查看全部”进入二级处理面。 +- 新增 `frontend/src/features/personal-space/SpaceCollectionPages.tsx`,承接 `/space/ideas` 暂存想法收集箱和 `/space/drafts` 草案箱;暂存想法支持状态筛选、搜索、补充、归档和转 Project Launch 草案,草案箱支持按待确认、信息不足、冲突、已确认、挂起筛选并跳回主流程确认。 +- `SpaceSidebar / Layout / router` 增加空间二级入口,保持空间壳,不把这些对象写入项目真相层;Space 仍只允许 saved idea 作为 pre-project seed 写入,草案确认和项目执行回到主流程。 +- `ProjectsPage` 增加“全部 / 最近 / 待处理 / AI 进行中 / 已归档”筛选,首页最近项目通过 `/space/projects?view=recent` 复用所有项目页承接,不新增单独最近项目页。 + +涉及文件: + +- `frontend/src/features/personal-space/SpaceCollectionPages.tsx` +- `frontend/src/features/personal-space/PersonalSpacePage.tsx` +- `frontend/src/features/personal-space/components/SpaceChrome.tsx` +- `frontend/src/components/layout/Layout.tsx` +- `frontend/src/router/index.tsx` +- `frontend/src/pages/projects/ProjectsPage.tsx` +- `frontend/src/api/space.ts` +- `CLAUDE.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/ARCHITECTURE_INVENTORY.md` + +验证: + +- `cd frontend && npm run typecheck` 通过。 +- `cd frontend && npm run build` 通过。 +- `cd frontend && npm run test -- src/features/personal-space/PersonalSpacePage.test.tsx` 通过,7 passed;仍有既有 React Router v7 future flag warning。 +- 本地 Vite `http://127.0.0.1:5174` + Playwright smoke:`/space`、`/space/ideas`、`/space/drafts`、`/space/projects?view=recent` 均能渲染空间壳;移动端 390px 下 `/space/ideas` 可渲染。 + ### 2026-04-27 / integration-current-worktree-cleanup / done 完成内容: @@ -846,15 +1095,130 @@ P8 可以三条 lane 并行,但共享高冲突文件必须由 Main Flow 或 Sh - Alembic:`PYTHONPATH=. /opt/homebrew/bin/python3.11 -m alembic heads` 在 `backend/` 下返回 `20260427_add_human_capacity_overrides (head)`。 - 前端:`npm run typecheck` 通过。 - 前端:`npm run build` 通过。 + +### 2026-04-27 / Main Flow AI Extraction Production Loop / done + +完成内容: + +- 打通 API 模式下的项目线程输入主链:`sendMessage -> createPMFlowThreadMessage -> createPMFlowExtraction -> open ChangeSet -> pending_review ChangeItems -> ChangeReview`。 +- `POST /pmflow-os/projects/{project_id}/extractions` 支持两种受控路径:显式 `proposed_items` draft 继续保留;未传候选项时由后端调用真实 `llm_service.generate_structured_object`,通过 PMFlow prompt/config/routing 层生成待审对象。 +- AI extraction 成功时只生成 open ChangeSet、pending_review ChangeItems 和 thread message evidence;失败时返回 `ai_extraction_failed / extraction_evidence_insufficient`,不创建模板假结果,不绕过 ChangeReview 写 Project Memory。 +- `StructuredExtractionPage` 与 `CommandCenterPage` 在真实模式显示结构化失败/待补充状态,并按真实 ChangeItems 动态显示数量,不再用固定 27/6/21 文案冒充真实结果。 + +涉及文件: + +- `backend/app/services/pmflow_change_review_service.py` +- `backend/app/api/pmflow_os_api/` +- `backend/app/schemas/pmflow_os.py` +- `backend/tests/test_pmflow_change_review_api.py` +- `frontend/src/api/pmflowOs.ts` +- `frontend/src/features/pmflow-os/store.tsx` +- `frontend/src/features/pmflow-os/pages/CommandCenterPage.tsx` +- `frontend/src/features/pmflow-os/pages/StructuredExtractionPage.tsx` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/API_CONTRACTS.md` +- `docs/architecture/ARCHITECTURE_INVENTORY.md` + +验证: + +- 后端编译:`python3 -m py_compile backend/app/services/pmflow_change_review_service.py backend/app/schemas/pmflow_os.py backend/app/api/pmflow_os_api/__init__.py backend/tests/test_pmflow_change_review_api.py` 通过。 +- 后端测试:`PYTHONPATH=backend /opt/homebrew/bin/python3.11 -m pytest backend/tests/test_pmflow_change_review_api.py backend/tests/test_pmflow_os_projection_api.py -q` 通过,39 passed。 +- 前端测试:`npm run test -- src/features/pmflow-os` 通过,13 passed。 +- 前端:`npm run typecheck` 通过。 +- 前端:`npm run build` 通过。 - Diff hygiene:`git diff --check` 通过;未发现 Git 冲突标记。 +### 2026-04-27 / release-business-signal-loop-p1 / done + +完成内容: + +- `GET /pmflow-os/projects/{project_id}/execution-summary` 的 `releaseSignals` 从单一 feedback/recommendation 读模型扩展为统一发布后业务信号:真实聚合 `FeedbackIngest / RevenueEvent / ROISnapshot / ComplianceCheckRun / IterationRecommendation`。 +- `releaseSignals.summary` 新增 `roiSnapshotCount / complianceCheckCount / riskSignalCount`,`relationship.businessSignalTypes` 显示当前可用信号类型;缺 feedback、revenue、ROI、compliance 或 recommendation 时返回明确 warning,不用静态 mock 指标补成功态。 +- ROI 为负或合规 blocked/failed/open issues 时进入风险态,ReleaseGrowthPage 真实模式展示 feedback、revenue、ROI、compliance、recommendation/group 的空态、数据态和风险态。 +- recommendation 单条/同组仍只生成下一轮 open ChangeSet draft,继续由 ChangeReview accept/edit/defer/commit;本 Packet 不接真实支付、不做完整财务系统、不接真实合规平台、不自动 commit。 + +涉及文件: + +- `backend/app/services/pmflow_os_projection_service.py` +- `backend/tests/test_pmflow_os_projection_api.py` +- `frontend/src/api/pmflowOs.ts` +- `frontend/src/features/pmflow-os/pages/ReleaseGrowthPage.tsx` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/ARCHITECTURE_INVENTORY.md` + +验证: + +- 后端编译:`python3 -m py_compile backend/app/services/pmflow_os_projection_service.py backend/app/schemas/pmflow_os.py backend/app/api/pmflow_os_api/*.py` 通过。 +- 后端测试:`PYTHONPATH=backend /opt/homebrew/bin/python3.11 -m pytest backend/tests/test_pmflow_os_projection_api.py -q` 通过,30 passed。 +- 前端测试:`npm run test -- src/features/pmflow-os` 通过,13 passed。 +- 前端:`npm run typecheck` 通过。 +- 前端:`npm run build` 通过。 + +### 2026-04-27 / space-consume-mainflow-next-signals / done + +完成内容: + +- `/space` 的 `nextActionQueue` 只读消费 Main Flow 新信号:AI extraction 成功后的 pending ChangeSet / ChangeItem、review attention、execution failed / blocked / delivery failed、release blocked、releaseSignals 中的 feedback / revenue / ROI / compliance,以及 recommendation 下一轮 draft。 +- `feedbackAttention` 优先消费 `execution-summary.releaseSignals.feedback / revenueEvents / roiSnapshots / complianceChecks` 结构化字段,再兼容旧 `metricSignals`,不再只靠文本猜测发布后业务信号。 +- 新增执行结果阻塞类 next action:`recentProgressEvents` 的 `failed / blocked / test_failed` 与 `recentDeliveries.status=failed` 只跳 `/execution-bridge?projectId=`。 +- 优先级收口为待确认、待审查、执行阻塞、发布阻塞、发布后反馈、下一轮草案;所有动作只跳主流程页面,不写 ChangeSet、PMFlowApproval、ProgressEvent、ReleaseValidationRun、FeedbackIngest 或任何 release business 表。 + +涉及文件: + +- `frontend/src/api/space.ts` +- `frontend/src/features/personal-space/PersonalSpacePage.test.tsx` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/ARCHITECTURE_INVENTORY.md` + +验证: + +- `cd frontend && npm run test -- src/features/personal-space/PersonalSpacePage.test.tsx` 通过,4 passed。 +- `cd frontend && npm run typecheck` 通过。 +- `cd frontend && npm run build` 通过。 + +### 2026-04-28 / navigation-shell-migration-p1 / implemented + +完成内容: + +- 将旧 `/projects` 一级入口迁移为 `/space/projects`,继续复用项目可见性真源,但用户语义改为“空间内所有项目”,主动作回到 PMFlow OS 主链;项目删除收进高级操作。 +- 将旧 `/users`、`/users/:id/profile`、`/users/:id/skills`、`/users/:id/skill-tasks/:skillId` 迁移为 `/space/settings/members` 与 `/space/settings/members/:id`,用户语义收敛为“成员与权限 / 成员能力与责任”,不再暴露后台用户管理入口。 +- 将旧 `/workloads`、`/workloads/:id` 迁移为 `/team-layer?view=capacity`,Team Layer 会滚动到团队今日容量调度台;旧工时负载不再作为一级产品入口。 +- 将 `/dashboard` 兼容跳转 `/space`;`SpaceSidebar` 新增稳定目的地“所有项目”和管理员“成员与权限”,仍不把最近项目、暂存想法、草案箱、待确认或 AI 正在推进拆成一级导航。 +- `Layout` 增加空间配置壳覆盖 `/space/projects` 与 `/space/settings/*`,避免这些页面继续落入旧 `Sidebar + TopBar`。 + +涉及文件: + +- `frontend/src/router/index.tsx` +- `frontend/src/components/layout/Layout.tsx` +- `frontend/src/components/layout/TopBar.tsx` +- `frontend/src/features/personal-space/components/SpaceChrome.tsx` +- `frontend/src/features/personal-space/PersonalSpacePage.tsx` +- `frontend/src/features/pmflow-os/pages/TeamLayerPage.tsx` +- `frontend/src/pages/projects/ProjectsPage.tsx` +- `frontend/src/pages/users/UsersPage.tsx` +- `frontend/src/pages/user-profile/UserProfilePage.tsx` +- `frontend/src/components/SkillRadarChart.tsx` +- `CLAUDE.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/ARCHITECTURE_INVENTORY.md` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- 前端:`npm run typecheck` 通过。 +- 前端:`vitest run src/router/legacyAIStewardRedirect.test.ts src/features/personal-space/PersonalSpacePage.test.tsx src/components/layout/Sidebar.test.tsx` 通过。 +- 前端:`npm run build` 通过。 + ### 2026-04-27 / adaptive-human-capacity-p1 / implemented 完成内容: - 将旧“工时负载”升级为 `Adaptive Human Capacity`:后端新增只读每日容量计划、simulate-before-apply、override 审计记录,计划由当前 truth-state / execution-summary / reviewer routing / workspace binding 实时聚合。 - 新增 `human_capacity_overrides` 持久化表,只存负责人手工调整与审计,不存每日计划快照;过期 override 不参与今日计划。 -- `execution-capabilities` 补充 `adaptiveCapacitySummary`;个人空间展示“我的今日容量”;Team Layer 增加团队今日容量调度台、模拟结果、负责人调整入口和调整记录;`/workloads` 降级为高级容量明细并把传统工时标为成本参考。 +- `execution-capabilities` 补充 `adaptiveCapacitySummary`;个人空间展示“我的今日容量”;Team Layer 增加团队今日容量调度台、模拟结果、负责人调整入口和调整记录;旧 `/workloads*` 前端入口兼容跳转到 `/team-layer?view=capacity`,传统工时只保留为底层成本参考信号。 - 产品边界写入蓝图和 guardrails:每日建议基线不得固定,工作类型共享容量池,负责人 override 必须有原因、审计、过期时间,后续团队空间不得回到固定工时槽、静态技能标签或旧 workload ratio 派活。 涉及文件: @@ -871,7 +1235,12 @@ P8 可以三条 lane 并行,但共享高冲突文件必须由 Main Flow 或 Sh - `frontend/src/features/pmflow-os/types.ts` - `frontend/src/features/pmflow-os/pages/TeamLayerPage.tsx` - `frontend/src/features/personal-space/PersonalSpacePage.tsx` -- `frontend/src/pages/workloads/WorkloadsPage.tsx` +- `frontend/src/router/index.tsx` +- `frontend/src/components/layout/Layout.tsx` +- `frontend/src/features/personal-space/components/SpaceChrome.tsx` +- `frontend/src/pages/projects/ProjectsPage.tsx` +- `frontend/src/pages/users/UsersPage.tsx` +- `frontend/src/pages/user-profile/UserProfilePage.tsx` - `docs/PMFLOW_PRODUCT_BLUEPRINT.md` - `docs/architecture/API_CONTRACTS.md` - `docs/architecture/CODE_REFERENCE_INDEX.md` @@ -884,7 +1253,7 @@ P8 可以三条 lane 并行,但共享高冲突文件必须由 Main Flow 或 Sh - 后端:`python -m py_compile backend/app` 通过;`pytest tests/test_pmflow_os_projection_api.py -q` 通过。 - 数据库:`alembic upgrade head` 成功升级到 `20260427_add_human_capacity_overrides`。 - 前端:`npm run typecheck`、`npm run build`、`vitest run src/features/personal-space/PersonalSpacePage.test.tsx src/features/pmflow-os/store.api.test.tsx src/components/layout/Sidebar.test.tsx` 通过。 -- 浏览器:检查 `/space`、`/team-layer?projectId=1`、`/workloads?projectId=1`,容量区块/空态/不可用 warning 可见,未使用 mock 或旧负载比例冒充。 +- 浏览器:检查 `/space`、`/team-layer?projectId=1`、`/team-layer?projectId=1&view=capacity`,容量区块/空态/不可用 warning 可见,未使用 mock 或旧负载比例冒充;旧 `/workloads?projectId=1` 仅作为兼容入口跳转。 - 运行态:`docker compose restart backend` 后 `pmflow-backend` health 为 `healthy`。 ### 2026-04-26 / team-feed-p1-review-activity-stream / implemented @@ -893,8 +1262,8 @@ P8 可以三条 lane 并行,但共享高冲突文件必须由 Main Flow 或 Sh - `ProjectTruthState.feed` 从静态/局部 feed 扩展为 Team Feed 只读动态流,聚合 review request、review decision、ChangeSet 状态、execution progress 和 release gate 事件。 - 后端只读消费既有 `PMFlowChangeSet / PMFlowChangeItem / PMFlowApproval / PMFlowEvidenceLink / ProgressEvent / releaseGate` 投影,不新增表、不新增 route、不写审批、不写 ChangeSet、不写通知。 -- `TeamLayerPage` 复用 `TeamFeedPanel` 展示真实 `state.feed`;feed 为空时明确显示无真实动态,不使用 mock 动态冒充团队事件。 -- `TeamFeedPanel` 增加真实时间格式化和空态,保留角色收件箱/决策台为摘要展示,不塞入业务操作。 +- `TeamLayerPage` 直接展示真实 `state.feed` 聚合动态;feed 为空时明确显示无真实动态,不使用 mock 动态冒充团队事件。 +- 团队动态区增加真实时间格式化和空态,保留角色收件箱/决策台为摘要展示,不塞入业务操作。 涉及文件: @@ -1164,7 +1533,7 @@ P8 可以三条 lane 并行,但共享高冲突文件必须由 Main Flow 或 Sh 完成内容: -- 在 `frontend/src/api/space.ts` 新增 `nextActionQueue` 只读聚合模型,把高风险 ChangeSet 待确认、release blocked / failed evidence、handoff consumed 但无后续 progress、iteration draft 待确认、feedback/revenue/compliance 信号待处理收敛为统一“我的下一步”队列。 +- 在 `frontend/src/api/space.ts` 新增 `nextActionQueue` 只读聚合模型,把 AI extraction 待确认、review attention、execution failed/blocked/delivery failed、release blocked / failed evidence、handoff consumed 后缺少 progress、feedback/revenue/ROI/compliance 信号和 iteration draft 待确认收敛为统一“我的下一步”队列。 - `/space` 桌面端和移动端新增首屏“我的下一步”队列,按优先级和更新时间排序;每个 item 只跳转 `/change-review`、`/execution-bridge` 或 `/release-growth`。 - 保留原有继续推进、待确认、执行中、发布关注、发布后反馈、下一轮迭代建议等明细区块作为二级上下文;空态明确,不虚构项目、反馈、recommendation 或 ChangeSet。 - 本轮不写 ChangeSet、不写 ProgressEvent、不写 ReleaseValidationRun、不写 FeedbackIngest,不改 PMFlow OS 页面、Account Settings 或 router。 @@ -1235,7 +1604,7 @@ P8 可以三条 lane 并行,但共享高冲突文件必须由 Main Flow 或 Sh - 收口 Iteration Loop P1、Space P5 与 Account P6 的并行成果,当前集成分支已包含 `ea8f9d0 feat(pmflow-os): draft changesets from iteration recommendations`、`5db6f71 feat(space): surface iteration draft attention`、`8cc0cd9 feat(account): map iteration review reminders`。 - 确认本轮没有修改 `frontend/src/router/index.tsx`、`frontend/src/features/pmflow-os/components/MobileBottomNav.tsx` 或新增 route/nav;现有 `/release-growth`、`/change-review`、`/space`、`/me` 入口继续承接本批成果。 - 共享文档边界已同步:Main Flow 负责 recommendation -> ChangeSet draft 写入口与 ReleaseGrowth 触发动作;Space 只读消费 recommendation draft 状态并跳转主流程;Account 只把 `iteration_recommendation / next_change_review` 映射到待确认提醒偏好,不接真实邮件、桌面推送或系统通知中心。 -- P6 后,发布后反馈闭环已从“feedback -> recommendation 展示”推进到“recommendation -> 下一轮 ChangeSet draft -> Change Review accept/edit/defer”,仍保持不自动 commit、不写 Project Memory、不创建正式任务的监督式边界。 +- P6 后,Space 现在只读消费 Main Flow 的 AI extraction、execution result 与 release business signals;发布后反馈闭环已从“feedback -> recommendation 展示”推进到“recommendation -> 下一轮 ChangeSet draft -> Change Review accept/edit/defer”,仍保持不自动 commit、不写 Project Memory、不创建正式任务的监督式边界。 涉及文件: @@ -1314,7 +1683,7 @@ P8 可以三条 lane 并行,但共享高冲突文件必须由 Main Flow 或 Sh - 收口 Release Feedback P1、Space P4 与 Account P5 的并行成果,当前集成分支已包含 `1d213ba feat(pmflow-os): ingest release feedback signals`、`7025459 feat(space): surface post-release feedback attention`、`66ec4c7 feat(account): define post-release notification preferences`。 - 确认本轮没有修改 `frontend/src/router/index.tsx`、`frontend/src/features/pmflow-os/components/MobileBottomNav.tsx` 或新增 route/nav;现有 `/release-growth`、`/space`、`/me` 入口继续承接本批成果。 -- 共享文档边界已同步:Main Flow 负责 `release-feedback-ingests`、`execution-summary.releaseSignals` 与 ReleaseGrowth 真实信号展示;Space 只读消费 `releaseSignals` 派生“发布后反馈关注”并跳转主流程;Account 只提供 post-release 通知偏好 helper,不接真实邮件、桌面推送或系统通知中心。 +- 共享文档边界已同步:Main Flow 负责 `release-feedback-ingests`、`execution-summary.releaseSignals` 与 ReleaseGrowth 真实信号展示;Space 只读消费 `releaseSignals`、execution result 与 AI extraction 待确认状态,派生“我的下一步”和“发布后反馈关注”并跳转主流程;Account 只提供 post-release 通知偏好 helper,不接真实邮件、桌面推送或系统通知中心。 - P5 后,Release/Growth 已从“发布前门禁 + 验证记录”推进到“发布后 feedback / revenue / iteration recommendation 信号读模型雏形”;下一步应优先把 iteration recommendation 进入 ChangeSet draft,而不是扩新页面。 涉及文件: @@ -1397,7 +1766,7 @@ P8 可以三条 lane 并行,但共享高冲突文件必须由 Main Flow 或 Sh 完成内容: - 在 PMFlow OS 后端新增轻量写入口 `POST /pmflow-os/projects/{project_id}/release-feedback-ingests`,复用现有 `AIProjectControlService.ingest_feedback` 与 `FeedbackIngest / IterationRecommendation` 模型,不新增 migration。 -- `GET /pmflow-os/projects/{project_id}/execution-summary` 新增 `releaseSignals` 投影,聚合真实发布后 `feedback / revenueEvents / recommendations`,并显式返回 `releaseGateStatus / latestValidationRunId / changeSetCreated=false` 关系信息。 +- `GET /pmflow-os/projects/{project_id}/execution-summary` 的 `releaseSignals` 投影聚合真实发布后 `feedback / revenueEvents / roiSnapshots / complianceChecks / recommendations`,并显式返回 `releaseGateStatus / latestValidationRunId / changeSetCreated / businessSignalTypes` 关系信息。 - `ReleaseGrowthPage` 真实模式新增“发布后反馈信号”区域和“摄取发布后反馈”表单;无信号时展示明确 empty/warning,不用 mock 成功数据补位。 - 前端 `PMFlowOsProvider` 新增 `createReleaseFeedbackIngest` 动作,真实模式调用 PMFlow OS API 并 refetch truth-state / execution-summary;demo 模式仍只在显式 `?demo=1` 下走本地演示 mutation。 - 本阶段只沉淀下一轮 iteration recommendation 所需数据,不自动创建 ChangeSet,不触发真实发布,不接外部支付,不接邮件/桌面推送。 @@ -1427,7 +1796,7 @@ P8 可以三条 lane 并行,但共享高冲突文件必须由 Main Flow 或 Sh 未完成或风险: -- 本阶段只接入 feedback ingest 与 releaseSignals 读模型;RevenueEvent / ComplianceCheckRun 已可被后续扩展继续纳入更完整发布后运营读模型。 +- Release Business Signal Loop P1 已将 RevenueEvent、ROISnapshot 与 ComplianceCheckRun 纳入 `releaseSignals` 读模型;当前仍只做轻量经营/风险信号,不接真实支付、不做完整财务或合规平台。 - iteration recommendation 已能生成下一轮 ChangeSet draft;P7 继续推进 recommendation quality batch,使同类建议可分组、去重、评估风险并批量进入待确认中心。 - 全量前端 typecheck/build 需在包含 `frontend/src/lib/*` helper 的集成环境或由 Shell / Integration owner 修正该 ignored 文件边界后重新验证。 @@ -1740,7 +2109,7 @@ P8 可以三条 lane 并行,但共享高冲突文件必须由 Main Flow 或 Sh 未完成或风险: - ReleaseGrowth P1 只生成发布判断和下一步动作,不触发真实部署、不写 release validation run。 -- 增长反馈、收入、ROI 和 compliance 的真实发布后反馈循环仍待后续 Packet 接入。 +- 增长反馈、收入、ROI 和 compliance 已进入统一 `releaseSignals` 读模型;后续重点是更多真实写入口和运营证据质量,不是用 mock 指标补页面。 ### 2026-04-26 / space-p2-release-attention-readmodel / implemented @@ -2474,7 +2843,7 @@ P8 可以三条 lane 并行,但共享高冲突文件必须由 Main Flow 或 Sh 剩余缺口: - C1 不实现 `/commit`,不写 `truth_nodes / tasks / semantic_facts`;accepted/edited 仅代表审查状态,不代表 confirmed Project Memory。 -- `POST /extractions` 本轮只接收 `proposed_items`,不默认调用 LLM 结构化提取;AI extraction 主链留给后续阶段。 +- 该历史 C1 记录已被 Main Flow AI Extraction Production Loop 覆盖:`POST /extractions` 未传 `proposed_items` 时会调用真实 AI extraction;失败显式返回结构化失败/证据不足,不创建模板假结果。 - 高风险 `risk_level=high` 本轮只记录风险等级和 approval,commit gate 留给 C2。 下一步建议: @@ -2563,9 +2932,986 @@ P8 可以三条 lane 并行,但共享高冲突文件必须由 Main Flow 或 Sh 验证: +- 后端编译:`/opt/homebrew/bin/python3.11 -m py_compile backend/app/api/ai_routing.py backend/app/api/llm_engines.py backend/app/services/ai_routing_governance_service.py backend/app/services/llm_service/mixin_engine_admin.py` 通过。 +- 后端 focused:`PYTHONPATH=backend /opt/homebrew/bin/python3.11 -m pytest backend/tests/test_ai_routing_governance_service.py backend/tests/test_ai_service_core_runtime.py -q` 通过,40 passed。 +- 前端 focused:`cd frontend && npm test -- src/pages/ai-routing/AIRoutingPages.test.tsx` 通过,12 passed。 +- 前端类型检查:`cd frontend && npm run typecheck` 通过。 +- 前端构建:`cd frontend && npm run build` 通过。 +- 热重启:`docker compose restart backend && docker compose ps backend` 已执行,`pmflow-backend` healthy。 + +验证: + - 残留检索:计划指定的 legacy create 入口、direct create API 名称、旧项目文本创建类型名与旧中文入口名均无命中。 - 后端:`/opt/homebrew/bin/python3.11 -m compileall -q app` 通过。 - 后端:`/opt/homebrew/bin/python3.11 -m pytest tests/test_ai_steward_project_from_text_contract.py tests/test_context_cache_profile_catalog.py tests/test_pmflow_launch_api.py -q` 通过,27 passed。 - 前端:`npx vitest run src/router/legacyAIStewardRedirect.test.ts src/components/layout/Sidebar.test.tsx src/components/ai-steward/qaPageAnchor.test.ts` 通过,9 passed。 - 前端:`npm run typecheck` 通过。 - 前端:`npm run build` 通过。 + +### 2026-04-28 / Workspace vs Platform AI Ops Boundary / done + +完成内容: + +- 按“三平面模型”收口账号体系:`users` 只代表登录身份,`tenant_memberships` 表达客户个人/团队空间角色,新增 `platform_memberships` 表达 PMFlow 内部平台运营身份;平台角色按 `user_id + role` 唯一,允许一个内部账号组合多个平台职责。 +- 登录响应和 `/users/me` 新增 `platform_context`,前端可用 `platform_context.capabilities` 判断 `/platform/*` 入口;客户空间 `OWNER/REVIEWER` 不再等同 PMFlow 平台管理员。 +- 后端将平台治理能力从客户角色中剥离:LLM 引擎、AI 路由 overview/admin-console/diagnostics、高级模型路由、route/context 治理、semantic facts 治理改为要求 `platform.*` capability;团队 AI 策略仍保留为空间 Owner/Reviewer 可见的客户侧策略。 +- 前端新增 `/platform` 平台 AI Operations Console 首页,并将 AI 路由与供应商、高级路由、Route Context 治理、Semantic Facts 治理放入平台权限边界;`/ai-config` 收敛为空间级 AI 使用设置,不再向普通客户 Owner 展示平台诊断组。 +- 文档同步更新 `AGENTS.md`、`CLAUDE.md`、`API_CONTRACTS.md`、`RUNTIME_GUARDRAILS.md`、`CODE_REFERENCE_INDEX.md` 与 `ARCHITECTURE_INVENTORY.md`,明确客户空间 Owner 与平台运营账号的边界。 + +涉及文件: + +- `backend/app/models/platform_membership.py` +- `backend/alembic/versions/20260428_add_platform_memberships.py` +- `backend/app/services/platform_access_service.py` +- `backend/app/dependencies/permissions.py` +- `backend/app/api/auth.py` +- `backend/app/services/phone_auth_service.py` +- `backend/app/api/users_api/routes_query.py` +- `backend/app/api/ai_routing.py` +- `backend/app/api/llm_engines.py` +- `backend/app/api/model_routing.py` +- `backend/app/api/rag_api/routes_route_governance.py` +- `backend/app/api/semantic_facts_api/routes.py` +- `frontend/src/auth/RouteBoundary.tsx` +- `frontend/src/lib/rbac.ts` +- `frontend/src/router/index.tsx` +- `frontend/src/pages/platform/PlatformConsolePage.tsx` +- `frontend/src/pages/ai-config/AIConfigPage.tsx` +- `frontend/src/pages/ai-config/constants.ts` +- `frontend/src/pages/ai-config/components/AISetupWizard.tsx` +- `frontend/src/pages/ai-config/components/GovernanceToolsCard.tsx` +- `frontend/src/pages/ai-routing/AIRoutingConsolePage.tsx` +- `frontend/src/pages/query-route-governance/QueryRouteGovernancePage.tsx` +- `frontend/src/pages/semantic-facts-board/SemanticFactsBoardPage.tsx` + +验证: + +- 后端:`python3 -m py_compile backend/app/models/platform_membership.py backend/app/services/platform_access_service.py backend/app/dependencies/permissions.py backend/app/api/auth.py backend/app/services/phone_auth_service.py backend/app/api/users_api/routes_query.py backend/app/api/ai_routing.py backend/app/api/llm_engines.py backend/app/api/model_routing.py backend/app/api/rag_api/routes_route_governance.py backend/app/api/semantic_facts_api/routes.py` 通过;当前环境没有 `python` 命令,使用 `python3` 执行。 +- 前端:`npm run typecheck` 通过。 +- 前端:`npx vitest run src/pages/ai-config/AIConfigPage.test.tsx src/pages/ai-routing/AIRoutingPages.test.tsx src/pages/query-route-governance/QueryRouteGovernancePage.test.tsx src/auth/RouteBoundary.test.tsx src/router/legacyAIStewardRedirect.test.ts` 通过,21 passed。 +- 前端:`npm run build` 通过。 +- 部署生效:`docker compose exec -T backend alembic upgrade head` 已执行,`alembic current` 为 `20260428_add_platform_memberships (head)`;`platform_memberships` 表和 `uq_platform_memberships_user_role` 约束已在本地 Postgres 存在;`docker compose restart backend` 已执行,`docker compose ps backend` 显示 healthy。 + +下一步: + +- 如需给真实 PMFlow 内部账号开平台权限,优先写入 `platform_memberships`;临时 bootstrap 可用 `PMFLOW_PLATFORM_ADMIN_USERS` 或 `PMFLOW_AI_OPS_USERS` 环境变量。 + +### 2026-04-28 / Platform Shell & Navigation IA / done + +完成内容: + +- `/platform/*` 从旧项目工作台侧栏/顶部条中剥离,统一使用平台专属 `PlatformSidebar + PlatformTopBar`。 +- 平台后台信息架构确定为“运行总览 / AI 运行层 / 上下文与语义层 / 质量与事件层”:只承载 PMFlow 内部 AI 运行、供应商路由、上下文、语义事实、评测和事故复盘,不承载客户空间项目、成员、容量或项目主链菜单。 +- `/platform` 首页改为平台 AI Operations Console 总览,按当前 `platform_context.capabilities` 展示可操作工具,并明确客户空间 Owner 不进入平台控制面。 +- 平台诊断旧路径从 `/ai-config/*` 兼容迁移到 `/platform/*`:AI 路由与供应商、高级路由、Contract Sync、Route Context、Knowledge Graph、Semantic Facts 均不再套入空间配置壳。 + +涉及文件: + +- `frontend/src/components/layout/Layout.tsx` +- `frontend/src/auth/RouteBoundary.tsx` +- `frontend/src/router/index.tsx` +- `frontend/src/pages/platform/PlatformChrome.tsx` +- `frontend/src/pages/platform/PlatformConsolePage.tsx` +- `frontend/src/pages/ai-config/constants.ts` +- `CLAUDE.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/ARCHITECTURE_INVENTORY.md` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- 前端:`npm run typecheck` 通过。 +- 前端:`npx vitest run src/pages/ai-config/AIConfigPage.test.tsx src/pages/ai-routing/AIRoutingPages.test.tsx src/pages/query-route-governance/QueryRouteGovernancePage.test.tsx src/auth/RouteBoundary.test.tsx` 通过,14 passed。 +- 前端:`npm run build` 通过。 +- 浏览器烟测:`http://localhost:5176/platform` 已显示平台专属侧栏和顶部条,并出现 Contract Sync 入口;旧“指挥台 / 项目工作台 / 决策与待确认 / 开发执行 / 发布与增长 / 团队层 / 项目记忆”菜单不再出现;旧 `/ai-config/knowledge-graph-board` 已跳转到 `/platform/knowledge-graph-board` 并进入同一平台壳。该页面当前仍命中旧 `GET /api/ai-steward/knowledge-graph/capability-summary` 404,这是治理页 API 路径的存量问题,不影响平台导航壳迁移结论。 + +### 2026-04-28 / AI Readiness 双层表达与平台诊断边界 / done + +完成内容: + +- `AIReadiness` 前端合同升级为双层表达:客户层输出 `customerState / title / summary / blockers / customerActions / workspaceOwnerActions`,平台层输出 `platformDiagnostic(causeCode / causeOwner / rawStatus / affectedScenes / platformActions)`。 +- 用户端默认只表达业务影响和可继续动作:普通成员看到“AI 草案生成暂不可用,可继续记录想法、处理待确认、推进现有项目”;空间负责人看到空间 AI 可用状态、团队策略、工具连接、知识治理和确认边界;平台人员进入用户端也先看客户视图,只能通过显式平台诊断卡进入具体 cause/CTA。 +- `/platform/*` 明确作为 PMFlow 平台 AI Ops 控制面,默认展示供应商、全局路由、模型槽位、诊断、失败样本、额度和连接测试等平台控制面内容;这些内容不得出现在普通客户空间默认视图。 +- `/ai-config?tab=setup` 改为“空间 AI 可用状态 / AI 使用设置”,不再展示“启用供应商 / 运行测试并启用 / 额度诊断”类平台 CTA;空间侧只保留团队策略、工具连接、知识治理和人工确认边界。 +- Space 首屏、顶部状态、项目启动台、项目级 AI 副驾、空间 AI 管家、`/ai-config` 与 `/platform` 统一消费同一 readiness 合同,避免不同入口各自编文案导致权限边界漂移。 +- 同步更新 `USER.md`、`CLAUDE.md`、`CODE_REFERENCE_INDEX.md`、`ARCHITECTURE_INVENTORY.md`,把该方向记录为后续 AI 可用状态、空间配置和平台诊断相关功能的产品边界。 + +验证: + +- 前端:`npm run typecheck` 通过。 +- 前端:`npx vitest run src/features/personal-space/PersonalSpacePage.test.tsx src/pages/ai-config/AIConfigPage.test.tsx src/pages/ai-routing/AIRoutingPages.test.tsx src/components/layout/Layout.test.tsx` 通过,13 passed。 +- 前端:`npm run build` 通过。 + +涉及文件: + +- `frontend/src/features/ai-readiness/types.ts` +- `frontend/src/features/ai-readiness/readiness.ts` +- `frontend/src/features/ai-readiness/components.tsx` +- `frontend/src/features/project-launch/components/LaunchCanvases.tsx` +- `frontend/src/features/pmflow-os/components/PMFlowOsFrame.tsx` +- `frontend/src/features/personal-space/components/SpaceAIConcierge.tsx` +- `frontend/src/pages/ai-config/components/AISetupWizard.tsx` +- `frontend/src/lib/rbac.ts` +- `frontend/src/features/personal-space/PersonalSpacePage.test.tsx` +- `CLAUDE.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/ARCHITECTURE_INVENTORY.md` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- 前端:`npx vitest run src/features/personal-space/PersonalSpacePage.test.tsx src/pages/ai-config/AIConfigPage.test.tsx src/pages/ai-routing/AIRoutingPages.test.tsx` 通过,12 passed。 +- 前端:`npm run typecheck` 通过。 +- 前端:`npm run build` 通过。 + +### 2026-04-28 / Space Saved Ideas Pre-project Loop / implemented + +完成内容: + +- 将“暂存想法”从前端临时提示升级为真实 pre-project idea seed 闭环:用户在 Project Launch 可保存未成型想法,`/space` 可看到真实列表、详情、原始输入、准备度、缺失问题、归档和“生成项目草案”动作。 +- 后端新增 `saved_ideas` 表与 Space API:`GET/POST/PATCH /space/saved-ideas`、`POST /space/saved-ideas/{idea_id}/archive`、`POST /space/saved-ideas/{idea_id}/promote`。该表只保存原始想法、来源、状态、准备度、缺失问题和 Project Launch draft 关联,不写 Project Memory、ChangeSet、任务、执行事件或发布信号。 +- `promote` 只复用 Project Launch draft 生成链,生成后通过前端 `project_launch_draft` pending intent 回到 `/project-launch` human review;仍需用户显式确认后才创建正式项目。 +- Space 首页继续作为聚合与导航层:除 `saved_ideas` pre-project seed 外,不写 PMFlow OS 主链对象;后续 team-ready 扩展必须围绕共享、补充、协作权限扩展 seed,不得把暂存想法直接升级成项目事实。 +- 同步更新 `CLAUDE.md`、`API_CONTRACTS.md`、`CODE_REFERENCE_INDEX.md`、`ARCHITECTURE_INVENTORY.md` 与 `RUNTIME_GUARDRAILS.md`,把该方向固化为后续持续推进边界。 + +涉及文件: + +- `backend/alembic/versions/20260428_add_saved_ideas.py` +- `backend/app/models/saved_idea.py` +- `backend/app/schemas/space.py` +- `backend/app/services/space_saved_idea_service.py` +- `backend/app/api/space_api/__init__.py` +- `backend/app/api/router.py` +- `backend/tests/test_space_saved_ideas_api.py` +- `frontend/src/api/space.ts` +- `frontend/src/lib/pendingIntent.ts` +- `frontend/src/features/project-launch/types.ts` +- `frontend/src/features/project-launch/useProjectLaunchState.ts` +- `frontend/src/features/project-launch/components/LaunchCanvases.tsx` +- `frontend/src/features/project-launch/components/LaunchShell.tsx` +- `frontend/src/features/project-launch/components/MobileLaunchLayout.tsx` +- `frontend/src/features/project-launch/useProjectLaunchState.test.ts` +- `frontend/src/features/project-launch/components/LaunchCanvasesFailure.test.tsx` +- `frontend/src/features/personal-space/PersonalSpacePage.tsx` +- `frontend/src/features/personal-space/PersonalSpacePage.test.tsx` +- `CLAUDE.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/API_CONTRACTS.md` +- `docs/architecture/ARCHITECTURE_INVENTORY.md` +- `docs/architecture/RUNTIME_GUARDRAILS.md` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- 后端编译:`python3 -m py_compile backend/app/models/saved_idea.py backend/app/schemas/space.py backend/app/services/space_saved_idea_service.py backend/app/api/space_api/__init__.py backend/app/api/router.py backend/tests/test_space_saved_ideas_api.py` 通过。 +- 后端 focused:`cd backend && /opt/homebrew/bin/python3.11 -m pytest tests/test_space_saved_ideas_api.py` 通过,2 passed。 +- 前端:`cd frontend && npm run typecheck` 通过。 + +### 2026-04-28 / Space Saved Ideas Structured Enrichment / implemented + +完成内容: + +- 将暂存想法的“信息不足 / 继续补充会更准 / 准备度”从纯系统初判升级为 AI-ready 结构化判断:保存或继续完善原始输入时,后端先调用 `space_saved_idea_enrichment_v1`,结构化输出 `summary / target_user / problem / value_hypothesis / validation_signal / constraints / missing_questions / readiness_score / status / confidence`。 +- `saved_ideas` 新增 enrichment 元数据:`enrichment_status / enrichment_payload / enrichment_error / enriched_at`。`ai_enriched` 代表 AI 结构化成功;`ai_failed` 代表 AI 不可用或解析失败并显式回落 `system_baseline`;`baseline` 代表只有系统初判。任何 fallback 都不能冒充 AI 结论。 +- `/space` 暂存想法详情新增“完整度判断”区块,展示判断来源、AI 结构化字段和继续补充问题;“继续完善”保存后会重新触发结构化判断。“生成项目草案”仍只进入 Project Launch human review,确认前不创建项目、不写项目记忆。 +- 后续 team-ready 方向继续沿用该 seed 模型扩展共享、协作补充、权限和审计,不把暂存想法直接升级成 Project Memory、ChangeSet、任务或执行事件。 + +涉及文件: + +- `backend/alembic/versions/20260428_add_saved_idea_enrichment.py` +- `backend/app/models/saved_idea.py` +- `backend/app/schemas/space.py` +- `backend/app/services/space_saved_idea_service.py` +- `backend/app/config/ai_prompts.py` +- `backend/app/config/ai_prompts_catalog/__init__.py` +- `backend/app/config/ai_prompts_catalog/part_scenes_06.py` +- `backend/tests/test_space_saved_ideas_api.py` +- `frontend/src/api/space.ts` +- `frontend/src/features/personal-space/PersonalSpacePage.tsx` +- `frontend/src/features/personal-space/PersonalSpacePage.test.tsx` +- `frontend/src/features/project-launch/useProjectLaunchState.test.ts` +- `CLAUDE.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/API_CONTRACTS.md` +- `docs/architecture/ARCHITECTURE_INVENTORY.md` +- `docs/architecture/RUNTIME_GUARDRAILS.md` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- 后端编译:`python3 -m py_compile backend/app/models/saved_idea.py backend/app/schemas/space.py backend/app/services/space_saved_idea_service.py backend/app/config/ai_prompts.py backend/app/config/ai_prompts_catalog/__init__.py backend/app/config/ai_prompts_catalog/part_scenes_06.py backend/tests/test_space_saved_ideas_api.py` 通过。 +- 后端 focused:`cd backend && /opt/homebrew/bin/python3.11 -m pytest tests/test_space_saved_ideas_api.py` 通过,3 passed。 +- 前端 focused:`cd frontend && npx vitest run src/features/personal-space/PersonalSpacePage.test.tsx src/features/project-launch/useProjectLaunchState.test.ts` 通过,19 passed。 +- 前端类型检查:`cd frontend && npm run typecheck` 通过。 +- 前端构建:`cd frontend && npm run build` 通过。 +- 迁移与热重启:`docker compose exec -T backend alembic upgrade head` 已升级到 `20260428_add_saved_idea_enrichment (head)`;`docker compose restart backend` 后 `pmflow-backend` healthy。 + +### 2026-05-01 / Saved Idea Prompt Runtime Migration / implemented + +完成内容: + +- 将暂存想法结构化整理从直接 format `PROMPT_SAVED_IDEA_ENRICHMENT` 迁到 `PromptRuntimeService`,运行时优先读取平台 Prompt 运营中心 active release,并把 `prompt_key / prompt_release_id / prompt_source` 写入 `space_saved_idea_enrichment_v1` 的 routing meta。 +- 补齐截图场景的 prompt 约束:问候、测试词、无意义短语或无实质想法内容必须判为 `needs_more_info`、`readiness_score=0.0`,并给出“解决什么问题 / 目标用户 / 预期价值”三类补充问题。 +- 修正 prompt registry 中 `PROMPT_SAVED_IDEA_ENRICHMENT` 的 call site,并增加回归测试锁定 active release 覆盖与非想法输入约束。 + +涉及文件: + +- `backend/app/services/space_saved_idea_service.py` +- `backend/app/config/ai_prompts_catalog/part_scenes_06.py` +- `backend/app/config/ai_prompt_registry.py` +- `backend/tests/test_space_saved_ideas_api.py` +- `backend/tests/test_ai_prompt_registry_contract.py` +- `CLAUDE.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/ARCHITECTURE_INVENTORY.md` +- `docs/architecture/RUNTIME_GUARDRAILS.md` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- 后端编译:`PYTHONPATH=backend python3.11 -m py_compile backend/app/services/space_saved_idea_service.py backend/app/config/ai_prompts_catalog/part_scenes_06.py backend/app/config/ai_prompt_registry.py backend/tests/test_space_saved_ideas_api.py backend/tests/test_ai_prompt_registry_contract.py` 通过。 +- 后端 focused:`PYTHONPATH=backend python3.11 -m pytest backend/tests/test_space_saved_ideas_api.py backend/tests/test_ai_prompt_registry_contract.py -q` 通过,9 passed,保留既有 Pydantic deprecation warning。 +- Prompt runtime focused:`PYTHONPATH=backend python3.11 -m pytest backend/tests/test_prompt_runtime_service.py -q` 通过,2 passed。 +- 本轮仅改后端 runtime prompt 读取、prompt catalog/registry、测试和文档;未新增 migration。后端热重启:`docker compose restart backend` 后 `docker compose ps backend` 为 healthy。 + +### 2026-04-28 / Platform AI Ops Runtime Menus / implemented + +完成内容: + +- 将平台侧“AI 路由与供应商”从单页诊断聚合拆成平台运行总览 + AI 运行层子菜单:运行总览合并到 `/platform`,AI 运行层保留 `供应商与凭据 / 能力槽位 / 业务场景路由 / 成本、配额与缓存 / 失败诊断`。 +- 旧 `/platform/ai-routing-console` 只做兼容跳转到 `/platform`;新增 `/platform/ai-routing-providers`、`/platform/ai-routing-slots`、`/platform/ai-routing-scenes`、`/platform/ai-routing-costs`、`/platform/ai-routing-failures`,旧裸路径与 `/ai-config/ai-routing-*` 只做兼容跳转。 +- 平台侧栏同步新增“工具与外部能力层”规划组,MCP / 外部查询 / 工具权限暂以 planned 菜单表达,不冒充已接入真实运行数据。 +- 所有新增平台菜单继续消费现有脱敏 `GET /ai-routing/admin-console` 和只读 `POST /ai-routing/diagnostics/run`,不新增 provider secret 写入、不改 runtime route 真源、不把平台细节暴露到客户空间。 +- 同步更新 `CLAUDE.md`、`API_CONTRACTS.md`、`CODE_REFERENCE_INDEX.md`、`ARCHITECTURE_INVENTORY.md` 与 `RUNTIME_GUARDRAILS.md`。 + +涉及文件: + +- `frontend/src/pages/ai-routing/AIRoutingOperationsPages.tsx` +- `frontend/src/pages/ai-routing/AIRoutingConsolePage.tsx` +- `frontend/src/pages/AIRoutingProvidersPage.tsx` +- `frontend/src/pages/AIRoutingCapabilitySlotsPage.tsx` +- `frontend/src/pages/AIRoutingSceneRoutesPage.tsx` +- `frontend/src/pages/AIRoutingCostQuotaCachePage.tsx` +- `frontend/src/pages/AIRoutingFailureDiagnosticsPage.tsx` +- `frontend/src/pages/AIRoutingModuleMapPage.tsx` +- `frontend/src/pages/platform/PlatformChrome.tsx` +- `frontend/src/router/index.tsx` +- `frontend/src/pages/ai-routing/AIRoutingPages.test.tsx` +- `CLAUDE.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/API_CONTRACTS.md` +- `docs/architecture/ARCHITECTURE_INVENTORY.md` +- `docs/architecture/RUNTIME_GUARDRAILS.md` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- 前端 focused:`cd frontend && npx vitest run src/pages/ai-routing/AIRoutingPages.test.tsx` 通过,8 passed。 +- 前端类型检查:`cd frontend && npm run typecheck` 通过。 +- 前端构建:`cd frontend && npm run build` 通过。 + +### 2026-04-28 / Platform Provider Runtime Semantics / implemented + +完成内容: + +- 将供应商连接从“单个启用引擎”改成“多个连接可同时启用 + 一个全局兜底默认”:`active_engine_id` 保留兼容,新增语义以 `default_fallback_engine_id` 为准。 +- 平台供应商和函数级模型路由固定读取 PMFlow 内部平台运行作用域(默认 `PMFLOW_PLATFORM_RUNTIME_TENANT_ID=1`),不再随客户 workspace tenant 切换;客户空间仍只保存团队 AI 使用策略。 +- 供应商连接新增 `enabled / connection_status / last_test_status / last_test_at / last_error_summary` 运行状态;连接测试会回写测试状态,但不会自动改兜底默认。 +- LLM runtime 解析、low/high tier 选择和低阶失败升级会跳过 `enabled=false` 的连接;停用连接仍可保留、编辑和测试。 +- `/platform/ai-routing-providers` 文案和操作改为“启用/停用/设为兜底/测试/编辑/删除”,不再把供应商启用误表达为唯一默认调用模型。 +- 同步更新 `API_CONTRACTS.md`、`CODE_REFERENCE_INDEX.md` 与 `RUNTIME_GUARDRAILS.md`,明确平台供应商、多连接启用、能力槽位和兜底默认边界。 + +验证: + +- 后端编译与 focused:`docker exec pmflow-backend sh -lc "python -m py_compile ... && PYTHONPATH=/app/backend pytest -q tests/test_ai_service_core_runtime.py::test_default_fallback_resolution_skips_disabled_engine tests/test_ai_service_core_runtime.py::test_control_plane_rule_ignores_env_api_key_and_uses_saved_key tests/test_ai_service_core_runtime.py::test_engine_runtime_keeps_saved_key_when_env_placeholder_exists tests/test_ai_routing_governance_service.py"` 通过,10 passed。 +- 前端 focused:`cd frontend && npx vitest run src/pages/ai-routing/AIRoutingPages.test.tsx` 通过,9 passed。 +- 前端类型检查:`cd frontend && npm run typecheck` 通过。 + +### 2026-04-28 / Capability Slot Runtime Binding / implemented + +完成内容: + +- 将“能力槽位”从只读诊断卡升级为平台 AI Ops 可配置编排页:供应商与凭据负责维护 runtime inventory,能力槽位负责从已启用供应商连接中选择 primary runtime 与 fallback chain。 +- 新增 `integrations(type='ai_capability_slots')` 平台运行时配置,固定写入 `PMFLOW_PLATFORM_RUNTIME_TENANT_ID` 作用域;`low/high` 当前可配置,结构化、长上下文、工具、外部查询、私有、多模态槽继续作为 reserved slots 展示,不冒充已启用能力。 +- 新增 `GET /ai-routing/capability-slots` 与 `PUT /ai-routing/capability-slots/{slot_key}`;保存槽位配置会校验运行时存在、已启用、保护槽不可降级,并写入 `operation_logs(entity_type='ai_capability_slot')`。 +- `llm_service` 运行时解析已改为优先消费槽位绑定:control-plane rule、函数级 tier override、普通 low/high tier hint 和 low-to-high escalation 都先走 slot primary/fallback;`backend/.control_plane/pmflow-routing-rules.json` 退为 baseline/default rule pack 与冷启动 fallback。 +- 前端槽位卡片保留运营态势展示,同时提供“配置运行时 / 测试主运行时 / 保存槽位配置”,配置区用表单表达主运行时、备用链路、失败策略和保护槽降级边界。 +- 同步更新 `CLAUDE.md`、`API_CONTRACTS.md`、`CODE_REFERENCE_INDEX.md`、`ARCHITECTURE_INVENTORY.md` 与 `RUNTIME_GUARDRAILS.md`,明确 `供应商库存 -> 能力槽位绑定 -> 业务场景路由 -> 隐藏 Route Lab 只读诊断` 的产品与运行边界;函数级 override 不再作为运营常规入口暴露。 + +涉及文件: + +- `backend/app/api/ai_routing.py` +- `backend/app/services/ai_routing_governance_service.py` +- `backend/app/services/llm_service/mixin_engine_resolution_core.py` +- `backend/app/services/llm_service/mixin_control_plane_routing.py` +- `backend/app/services/llm_service/mixin_tier_engine_selection.py` +- `backend/app/services/llm_service/mixin_tier_escalation.py` +- `backend/app/services/llm_service/shared_part_01.py` +- `backend/tests/test_ai_routing_governance_service.py` +- `backend/tests/test_ai_service_core_runtime.py` +- `frontend/src/api/aiRouting.ts` +- `frontend/src/pages/ai-routing/AIRoutingOperationsPages.tsx` +- `frontend/src/pages/ai-routing/AIRoutingPages.test.tsx` +- `CLAUDE.md` +- `docs/architecture/API_CONTRACTS.md` +- `docs/architecture/ARCHITECTURE_INVENTORY.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/RUNTIME_GUARDRAILS.md` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- 后端编译:`/opt/homebrew/bin/python3.11 -m py_compile backend/app/services/ai_routing_governance_service.py backend/app/api/ai_routing.py backend/app/services/llm_service/mixin_engine_resolution_core.py backend/app/services/llm_service/mixin_control_plane_routing.py backend/app/services/llm_service/mixin_tier_engine_selection.py backend/app/services/llm_service/mixin_tier_escalation.py backend/app/services/llm_service/shared_part_01.py` 通过。 +- 后端 focused:`PYTHONPATH=backend /opt/homebrew/bin/python3.11 -m pytest backend/tests/test_ai_routing_governance_service.py backend/tests/test_ai_service_core_runtime.py -q` 通过,35 passed。 +- 前端 focused:`cd frontend && npm test -- src/pages/ai-routing/AIRoutingPages.test.tsx` 通过,10 passed。 +- 前端类型检查:`cd frontend && npm run typecheck` 通过。 + +### 2026-04-29 / AI Capability Slot Release Control / implemented + +完成内容: + +- 将能力槽位从 live binding 升级为 release-controlled binding:`integrations(type='ai_capability_slots')` 兼容旧 `{slots}`,并规范为 `active / draft / history / active_version / draft_version`。 +- `PUT /ai-routing/capability-slots/{slot_key}/draft` 和兼容 `PUT /ai-routing/capability-slots/{slot_key}` 只保存草稿;新增 `validate / publish / rollback`,发布成功才把 draft 提升为 active 并生成版本,回滚会以历史快照发布为新 active 版本。 +- `llm_service` runtime 只消费 active slots;draft 不参与 control-plane rule、函数级 override、low/high tier 解析或 low-to-high escalation。 +- 能力槽位页新增发布控制区,展示当前 active version、草稿 dirty 状态、阻塞/警告校验、发布草稿、回滚上一版;槽位卡片同时展示当前生效运行时和草稿配置。 +- 发布校验阻止缺失、停用、测试失败运行时、高阶槽绑定 Ollama、保护槽降级等关键风险;重复 fallback 或未测试连接保留 warning。 +- 同步更新 `CLAUDE.md`、`API_CONTRACTS.md`、`CODE_REFERENCE_INDEX.md`、`ARCHITECTURE_INVENTORY.md` 与 `RUNTIME_GUARDRAILS.md`,明确 draft 不生效、publish 才切 active、runtime active-only。 + +涉及文件: + +- `backend/app/api/ai_routing.py` +- `backend/app/services/ai_routing_governance_service.py` +- `backend/app/services/llm_service/mixin_engine_resolution_core.py` +- `backend/app/services/llm_service/mixin_control_plane_routing.py` +- `backend/app/services/llm_service/mixin_tier_engine_selection.py` +- `backend/tests/test_ai_routing_governance_service.py` +- `backend/tests/test_ai_service_core_runtime.py` +- `frontend/src/api/aiRouting.ts` +- `frontend/src/pages/ai-routing/AIRoutingOperationsPages.tsx` +- `frontend/src/pages/ai-routing/AIRoutingPages.test.tsx` +- `CLAUDE.md` +- `docs/architecture/API_CONTRACTS.md` +- `docs/architecture/ARCHITECTURE_INVENTORY.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/RUNTIME_GUARDRAILS.md` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- 后端编译:`/opt/homebrew/bin/python3.11 -m py_compile backend/app/api/ai_routing.py backend/app/services/ai_routing_governance_service.py backend/app/services/llm_service/mixin_tier_engine_selection.py backend/app/services/llm_service/mixin_control_plane_routing.py backend/app/services/llm_service/mixin_engine_resolution_core.py backend/app/services/llm_service/mixin_tier_escalation.py` 通过。 +- 后端 focused:`PYTHONPATH=backend /opt/homebrew/bin/python3.11 -m pytest backend/tests/test_ai_routing_governance_service.py backend/tests/test_ai_service_core_runtime.py -q` 通过,37 passed。 +- 前端 focused:`cd frontend && npm test -- src/pages/ai-routing/AIRoutingPages.test.tsx` 通过,11 passed。 +- 前端类型检查:`cd frontend && npm run typecheck` 通过。 + +### 2026-04-29 / AI Slot Capability Matrix / implemented + +完成内容: + +- 将供应商 runtime 从“连接信息”扩展为“连接信息 + 能力矩阵”:`integrations(type='llm_engine').config.engines[*].capabilities` 保存结构化输出、JSON Schema、工具调用、长上下文、多模态、本地/私有、外部查询、上下文窗口、成本层级和稳定性层级等标签;旧数据在服务层按 provider/model 归一化默认 capabilities,页面不会因历史配置缺字段崩溃。 +- 供应商新增/编辑接口和 `/platform/ai-routing-providers` 表单支持保存、展示和编辑能力标签;连接启用、兜底默认、连通测试语义不变。 +- `AI Routing Governance Service` 增加 `SLOT_REQUIREMENTS`,能力槽位发布校验会检查 draft 的 primary/fallback 是否满足槽位能力要求:high 禁止 local-only/Ollama,structured 要求 `structured_output + json_schema`,long_context 要求 `max_context_tokens >= 32768`,tool 要求 `tool_calling`。 +- `external_query / private / multimodal` 当前仍是 candidate/reserved 槽,只展示 requirements、候选 runtime、能力覆盖和缺口;除非 draft 已显式存在运行时配置,否则不接 runtime 消费,不冒充已启用。 +- `/platform/ai-routing-slots` 同时展示 active 当前生效、draft 编辑态、能力覆盖、缺口和发布阻塞项;publish 仍只在 validation 无 critical 时把 draft 提升为 active,runtime 仍只读取 active slots。 +- 同步更新 `CLAUDE.md`、`API_CONTRACTS.md`、`CODE_REFERENCE_INDEX.md`、`ARCHITECTURE_INVENTORY.md` 与 `RUNTIME_GUARDRAILS.md`,明确供应商 capabilities 是槽位发布门禁输入,不是 runtime 直接切换入口。 + +涉及文件: + +- `backend/app/api/llm_engines.py` +- `backend/app/services/llm_service/mixin_engine_admin.py` +- `backend/app/services/ai_routing_governance_service.py` +- `backend/tests/test_ai_routing_governance_service.py` +- `backend/tests/test_ai_service_core_runtime.py` +- `frontend/src/api/aiRouting.ts` +- `frontend/src/api/aiSteward.ts` +- `frontend/src/pages/ai-routing/AIRoutingOperationsPages.tsx` +- `frontend/src/pages/ai-routing/AIRoutingPages.test.tsx` +- `CLAUDE.md` +- `docs/architecture/API_CONTRACTS.md` +- `docs/architecture/ARCHITECTURE_INVENTORY.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/RUNTIME_GUARDRAILS.md` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- 后端编译:`/opt/homebrew/bin/python3.11 -m py_compile backend/app/api/llm_engines.py backend/app/services/llm_service/mixin_engine_admin.py backend/app/services/ai_routing_governance_service.py` 通过。 +- 后端 focused:`PYTHONPATH=backend /opt/homebrew/bin/python3.11 -m pytest backend/tests/test_ai_routing_governance_service.py backend/tests/test_ai_service_core_runtime.py -q` 通过,42 passed。 +- 前端 focused:`cd frontend && npx vitest run src/pages/ai-routing/AIRoutingPages.test.tsx` 通过,13 passed。 +- 前端类型检查:`cd frontend && npx tsc --noEmit` 通过。 +- 前端构建:`cd frontend && npm run build` 通过。 + +### 2026-04-29 / ai-slot-capability-probe-v1 / implemented + +完成内容: + +- 将供应商 capabilities 从纯人工/默认标签升级为可探测、可审计的 capability evidence:`POST /ai-steward/llm-engines/{engine_id}/capability-probe` 会执行结构化 JSON、工具调用和长上下文 v1 轻量探测,并把结果写回 `integrations(type='llm_engine').config.engines[*].capabilities.capability_probe`。 +- `capabilities` 新增规范 `capability_probe={status,last_verified_at,verified_capabilities,evidence,source}`,并继续同步兼容 `capability_source / capability_probe_status / capability_last_verified_at / capability_blocking_reasons / capability_evidence`,不新增 migration;旧 runtime 仍按 provider/model 归一化默认能力和 `capability_probe.status=untested`,但默认/人工标签不会冒充 verified evidence。 +- 能力槽位发布门禁在 `structured / tool / long_context` 上区分“缺能力”“缺证据”和“证据过期”:能力本身为 false 或低于阈值返回 `*_capability_missing`,能力标签存在但缺 verified probe evidence 返回 `*_capability_evidence_missing`,证据超过 TTL 返回 `*_capability_evidence_stale`,protected slot 会被阻止 publish;low 缺 probe 只 warning。 +- `/platform/ai-routing-providers` 展示 capability source、last verified、probe status、probe error summary 和阻塞原因,并提供“能力探测”操作;`/platform/ai-routing-slots` 的能力覆盖区域展示缺能力、缺证据和证据过期三类缺口。 +- 同步更新 `API_CONTRACTS.md`、`CODE_REFERENCE_INDEX.md` 与 `RUNTIME_GUARDRAILS.md`,明确 capability probe 是供应商 evidence 输入,不直接切换 runtime active slots。 + +涉及文件: + +- `backend/app/api/llm_engines.py` +- `backend/app/services/llm_service/mixin_engine_admin.py` +- `backend/app/services/ai_routing_governance_service.py` +- `backend/tests/test_ai_routing_governance_service.py` +- `backend/tests/test_ai_service_core_runtime.py` +- `frontend/src/api/aiRouting.ts` +- `frontend/src/api/aiSteward.ts` +- `frontend/src/pages/ai-routing/AIRoutingOperationsPages.tsx` +- `frontend/src/pages/ai-routing/AIRoutingPages.test.tsx` +- `docs/architecture/API_CONTRACTS.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/RUNTIME_GUARDRAILS.md` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- 后端编译:`/opt/homebrew/bin/python3.11 -m py_compile backend/app/api/llm_engines.py backend/app/services/llm_service/mixin_engine_admin.py backend/app/services/ai_routing_governance_service.py` 通过。 +- 后端 focused:`PYTHONPATH=backend /opt/homebrew/bin/python3.11 -m pytest backend/tests/test_ai_routing_governance_service.py backend/tests/test_ai_service_core_runtime.py -q` 通过,42 passed。 +- 前端 focused:`cd frontend && npx vitest run src/pages/ai-routing/AIRoutingPages.test.tsx` 通过,13 passed。 + +### 2026-04-29 / ai-slot-structured-runtime-v1 / implemented + +完成内容: + +- `llm_service.generate_structured_object` 的 DB runtime 路径现在在 `response_contract=structured` 时优先消费已发布 active `structured` capability slot 的 primary runtime + fallback chain。 +- active structured runtime 选择会跳过 disabled/deleted runtime 和不满足 `structured_output + json_schema` 的 runtime;primary 调用失败后只会继续尝试同一 active structured slot 的 fallback chain,全部 structured runtime 失败后显式失败,不再绕回模板假成功或无关 low/high 路由。 +- 未发布 draft structured slot 只参与平台 AI Ops 校验和发布预览,不影响 runtime;没有 active structured slot 时继续回到既有 low/high/control-plane 链路,保持旧 runtime 兼容。 +- active structured slot 已配置但没有可用 runtime 时,项目启动、需求结构化、任务拆解、ChangeSet extraction 等保护结构化函数显式失败;非保护结构化函数可降级到既有路径,但必须带 `slot_degraded=true`。 +- routing meta 允许审计 `planned_tier/executed_tier=structured`、`route_source=slot_binding`、`slot_key=structured`、`slot_version`、`slot_runtime_source`、`slot_fallback_index` 与 `slot_degraded`,用于区分结构化槽实际命中、slot fallback 和 degraded 旧路径。 +- structured slot publish 仍受 capability evidence gate 保护:缺 `structured_output/json_schema` 能力或缺 verified probe evidence 都会阻止 publish,禁止把人工标签当运行时发布证据。 + +涉及文件: + +- `backend/app/services/llm_service/shared_part_02.py` +- `backend/app/services/llm_service/mixin_tier_engine_selection.py` +- `backend/app/services/llm_service/mixin_engine_resolution_core.py` +- `backend/tests/test_ai_service_core_runtime.py` +- `backend/tests/test_ai_routing_governance_service.py` +- `docs/architecture/API_CONTRACTS.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/RUNTIME_GUARDRAILS.md` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- 后端编译:`/opt/homebrew/bin/python3.11 -m py_compile backend/app/services/llm_service/shared_part_02.py backend/app/services/llm_service/mixin_tier_engine_selection.py backend/app/services/llm_service/mixin_engine_resolution_core.py backend/app/services/ai_routing_governance_service.py` 通过。 +- 后端 focused:`PYTHONPATH=backend /opt/homebrew/bin/python3.11 -m pytest backend/tests/test_ai_service_core_runtime.py::test_structured_object_runtime_uses_active_structured_slot backend/tests/test_ai_service_core_runtime.py::test_structured_object_runtime_ignores_unpublished_structured_draft backend/tests/test_ai_routing_governance_service.py::test_reserved_slot_validation_distinguishes_missing_probe_evidence -q` 通过,3 passed。 +- 前端类型检查:`cd frontend && npx tsc --noEmit` 通过。 +- 前端构建:`cd frontend && npm run build` 通过。 + +### 2026-04-29 / ai-slot-runtime-activation-v2 / implemented + +完成内容: + +- 能力槽位从 `low/high` 可配置升级为 `low/high/structured/tool/long_context` 均可配置;这五类槽位都可保存 primary runtime、fallback chain 和 failure policy 草稿。 +- `external_query / private / multimodal` 保持不可配置候选槽,只展示候选状态、能力缺口和未来接入边界,不接 `llm_service` runtime。 +- publish validation 继续阻止 structured/tool/long_context 的缺能力、缺 verified probe evidence 和证据过期:structured 要求 `structured_output + json_schema`,long_context 要求 `long_context + max_context_tokens >= 32768`,tool 要求 `tool_calling`;low 缺证据只 warning,high 继续禁止 local-only/Ollama。 +- 能力槽位矩阵保持表格形态,列出 `槽位 / Active Runtime / Draft Runtime / 能力要求 / 证据状态 / 缺口 / 影响场景 / 操作`;structured/tool/long_context 行显示“配置运行时”。 +- 槽位详情弹层区分满足要求的候选 runtime 和不满足要求的 runtime 缺口原因;发布控制区新增发布预览,展示草稿影响场景、保护函数数量、critical blockers 和 warnings。 +- runtime 行为保持不变:业务运行时只消费已发布 active slots,draft 修改不会影响线上调用。 + +涉及文件: + +- `backend/app/services/ai_routing_governance_service.py` +- `frontend/src/api/aiRouting.ts` +- `frontend/src/pages/ai-routing/AIRoutingOperationsPages.tsx` +- `backend/tests/test_ai_routing_governance_service.py` +- `frontend/src/pages/ai-routing/AIRoutingPages.test.tsx` +- `docs/architecture/API_CONTRACTS.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/RUNTIME_GUARDRAILS.md` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- 后端编译:`python3.11 -m py_compile backend/app/api/ai_routing.py backend/app/services/ai_routing_governance_service.py backend/app/services/llm_service/mixin_tier_engine_selection.py backend/app/services/llm_service/mixin_engine_resolution_core.py` 通过。 +- 后端 focused:`PYTHONPATH=backend python3.11 -m pytest backend/tests/test_ai_routing_governance_service.py backend/tests/test_ai_service_core_runtime.py -q` 通过,59 passed。 +- 前端 focused:`cd frontend && npm test -- src/pages/ai-routing/AIRoutingPages.test.tsx` 通过,13 passed。 +- 前端类型检查:`cd frontend && npm run typecheck` 通过。 +- 前端构建:`cd frontend && npm run build` 通过。 +- 后端热重启:`docker compose restart backend && docker compose ps backend` + +### 2026-04-29 / ai-slot-publish-observability-v1 / implemented + +完成内容: + +- `GET /ai-routing/admin-console` 新增 `slot_observability`,按 active `low/high/structured/long_context/tool` 槽位聚合发布后运行表现。 +- 观测窗口固定为 24h 与 7d,返回调用量、失败量、失败率、fallback 命中、degraded 调用、平均延迟、估算成本和脱敏失败样本。 +- 聚合优先识别 `ai_call_logs` 中的 routing meta 语义:`slot_runtime_source=active_slot / active_slot_degraded`、`slot_fallback_index > 0` 与 `slot_degraded`;兼容旧日志的 `planned_tier / executed_tier / routing_decision` 槽位信号。 +- 无日志窗口返回 `status=empty` 与“暂无运行样本”,不伪造成功率、成本或延迟;成本和延迟只显示调用日志估算来源,不包装成供应商精确账单。 +- `/platform/ai-routing-slots` 主矩阵新增 7d 失败率和 fallback 命中摘要;槽位详情弹层新增“运行观测”区,展示 24h/7d 指标、active/degraded/fallback 样本和数据来源。 +- 不改变 slot publish/rollback 语义,不改变 runtime 路由决策。 + +涉及文件: + +- `backend/app/services/ai_routing_governance_service.py` +- `frontend/src/api/aiRouting.ts` +- `frontend/src/pages/ai-routing/AIRoutingOperationsPages.tsx` +- `backend/tests/test_ai_routing_governance_service.py` +- `frontend/src/pages/ai-routing/AIRoutingPages.test.tsx` +- `docs/architecture/API_CONTRACTS.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/RUNTIME_GUARDRAILS.md` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- 后端 focused:`PYTHONPATH=backend /opt/homebrew/bin/python3.11 -m pytest backend/tests/test_ai_routing_governance_service.py -q` 通过,19 passed。 +- 前端 focused:`cd frontend && npm test -- src/pages/ai-routing/AIRoutingPages.test.tsx` 通过,13 passed。 + +### 2026-04-29 / ai-slot-recommendation-v1 / implemented + +完成内容: + +- `GET /ai-routing/admin-console` 新增 `slot_recommendations`,基于能力证据、候选 runtime 与 24h/7d 运行观测生成确定性运营建议。 +- 建议规则不调用 LLM,也不自动修改 draft、不自动 publish:缺 verified evidence 建议运行 capability probe,证据过期建议重新 probe,7d failure/fallback/degraded 异常建议补 fallback、评估 fallback 升 primary、修复 active slot 或回滚。 +- low 槽在成本或延迟异常且存在候选 runtime 时给出低成本/低延迟切换建议;structured/tool/long_context 没有候选 runtime 时提示回供应商页补能力、补凭证或接入新 runtime。 +- 每条建议返回 `severity / slot_key / reason / evidence_summary / suggested_action`,可选 `target_engine_id`;证据不足时返回空数组,前端展示“暂无建议”,不编造优化动作。 +- `/platform/ai-routing-slots` 顶部新增“运营建议”,槽位详情弹层同步展示该 slot 的建议;按钮只跳转到槽位详情或供应商页,不触发发布。 + +涉及文件: + +- `backend/app/services/ai_routing_governance_service.py` +- `frontend/src/api/aiRouting.ts` +- `frontend/src/pages/ai-routing/AIRoutingOperationsPages.tsx` +- `backend/tests/test_ai_routing_governance_service.py` +- `frontend/src/pages/ai-routing/AIRoutingPages.test.tsx` +- `docs/architecture/RUNTIME_GUARDRAILS.md` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +### 2026-04-29 / ai-slot-external-query-runtime-v1 / implemented + +完成内容: + +- `external_query` 从候选槽升级为正式可配置槽位,但绑定对象不是普通 LLM engine,而是 external provider registry 的受控外部查询执行链。 +- external_query draft 归一化保存 `primary_provider_id / fallback_provider_ids / verifier_required=true / source_filtering_required=true / failure_policy=explicit_fail`;为兼容现有 API request,前端仍通过现有 `primary_engine_id/fallback_engine_ids` 字段提交 provider id,后端在 service 层归一化。 +- 发布校验 fail-closed:至少一个 external provider 可用,MCP adapter 或 bounded live search fallback 至少一个可用,verifier 可用,source filtering 必须 fail-closed,failure policy 必须 explicit_fail;不满足时返回 critical blocker。 +- AdaptiveRAG 外部查询入口只在 `route_mode=external` 或等价 external source policy 后读取 active external_query slot;internal query 和未发布 draft 不消费该槽。active slot 执行失败时返回 verifier/freshness guard 元信息,不回退普通 LLM 编造外部事实。 +- 能力槽位页将 external_query 行展示为“配置外部链”,详情弹层展示 provider registry、MCP adapter、bounded live search fallback、verifier 和 source filtering 状态,并可保存 provider chain 草稿。 + +涉及文件: + +- `backend/app/services/ai_routing_governance_service.py` +- `backend/app/services/external_query_service/registry.py` +- `backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py` +- `frontend/src/api/aiRouting.ts` +- `frontend/src/pages/ai-routing/AIRoutingOperationsPages.tsx` +- `backend/tests/test_mcp_external_query_bridge.py` +- `backend/tests/test_external_query_planner.py` +- `backend/tests/test_adaptive_rag_mainline_contract.py` +- `backend/tests/test_ai_routing_governance_service.py` +- `frontend/src/pages/ai-routing/AIRoutingPages.test.tsx` +- `docs/architecture/API_CONTRACTS.md` +- `docs/architecture/RUNTIME_GUARDRAILS.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +### 2026-04-29 / ai-slot-long-tool-runtime-v1 / implemented + +完成内容: + +- `llm_service` 在显式长上下文调用中接入已发布 active `long_context` capability slot:只有 `routing_meta.slot_key/planned_tier/tier_alias=long_context` 才会消费该槽,普通调用和 draft 不生效。 +- `llm_service` 在显式工具调用 / MCP execution planning 中接入已发布 active `tool` capability slot:只有 `routing_meta` 显式请求 `tool` 或 `execution_mode=local_planner|local_worker` 才会消费该槽。 +- long_context/tool runtime 选择会跳过 disabled/deleted runtime 和能力不足 runtime:长上下文要求 `long_context + max_context_tokens >= 32768`,工具槽要求 `tool_calling`。 +- active long_context/tool slot 已配置但没有可用 runtime 时显式失败;没有 active slot 时继续走既有 low/high/control-plane 路径。`external_query / private / multimodal` 保持候选槽,不接 `llm_service` runtime。 +- publish gate 继续要求 tool/long_context 的 verified probe evidence,缺证据不能 publish。 + +涉及文件: + +- `backend/app/services/llm_service/mixin_engine_resolution_core.py` +- `backend/app/services/llm_service/mixin_tier_engine_selection.py` +- `backend/tests/test_ai_service_core_runtime.py` +- `docs/architecture/API_CONTRACTS.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/RUNTIME_GUARDRAILS.md` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- 后端编译:`python3.11 -m py_compile backend/app/services/llm_service/mixin_engine_resolution_core.py backend/app/services/llm_service/mixin_tier_engine_selection.py backend/app/services/llm_service/shared_part_02.py backend/app/services/ai_routing_governance_service.py` 通过。 +- 后端 focused:`PYTHONPATH=backend python3.11 -m pytest backend/tests/test_ai_service_core_runtime.py backend/tests/test_ai_routing_governance_service.py -q` 通过,58 passed。 +- 后端热重启:`docker compose restart backend && docker compose ps backend` + +### 2026-04-29 / ai-scene-contract-runtime-v1 / implemented + +完成内容: + +- 将业务场景合同从前端展示映射升级为后端只读 registry:新增 `backend/app/services/ai_routing_scene_contracts.py`,`GET /ai-routing/admin-console` 的 `scene_routes[]` 与 `scene_slot_matrix[]` 注入 `scene_contract`。 +- `scene_slot_matrix[]` 新增 `scene_health` 与 `release_gate`:按 active runtime、能力缺口、证据状态、保护场景、7d 失败样本和 draft 影响计算健康状态、发布阻塞原因和建议动作;candidate 场景不参与 protected release block。 +- 扩展 candidate 场景合同:`release_review / retrospective_summary / change_review / project_memory_writeback / mcp_execution_planning / contract_sync` 只展示业务合同和预期槽位,不改 runtime、不强行接槽位。 +- `/platform/ai-routing-scenes` 优先消费后端 `scene_contract`,`sceneContracts.ts` 仅作为 fallback;页面新增场景健康、发布门禁、候选生命周期展示和健康度筛选。 +- 修复动作完成 deep-link 闭环:业务场景路由可跳到 `/platform/ai-routing-slots?slot=...&scene=...`、`/platform/ai-routing-providers?scene=...&capability=...`、`/platform/ai-routing-failures?scene=...`,目标页自动聚焦 slot / capability / scene failure samples。 +- `recent_failures[]` 增加 `scene_key / scene_label`,供失败诊断按业务场景过滤;仍不展示原始错误、prompt 或函数级细节。 + +涉及文件: + +- `backend/app/services/ai_routing_scene_contracts.py` +- `backend/app/services/ai_routing_governance_service.py` +- `backend/tests/test_ai_routing_governance_service.py` +- `frontend/src/api/aiRouting.ts` +- `frontend/src/pages/ai-routing/sceneContracts.ts` +- `frontend/src/pages/ai-routing/AIRoutingOperationsPages.tsx` +- `frontend/src/pages/ai-routing/AIRoutingPages.test.tsx` +- `CLAUDE.md` +- `docs/architecture/API_CONTRACTS.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/ARCHITECTURE_INVENTORY.md` +- `docs/architecture/RUNTIME_GUARDRAILS.md` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- 后端编译:`python3 -m py_compile backend/app/services/ai_routing_scene_contracts.py backend/app/services/ai_routing_governance_service.py` +- 后端 focused:`cd backend && python -m pytest tests/test_ai_routing_governance_service.py` +- 前端 focused:`cd frontend && npm run test -- src/pages/ai-routing/AIRoutingPages.test.tsx` +- 前端类型检查:`cd frontend && npm run typecheck` +- 前端构建:`cd frontend && npm run build` + +### 2026-04-29 / ai-scene-contract-impact-console-v2 / implemented + +完成内容: + +- `/platform/ai-routing-scenes` 从 scene-slot matrix v1 升级为 Scene Contract & Impact Console,只读展示业务目标、输入对象、输出合同、成功标准、失败策略、确认策略、证据策略、active/draft runtime、能力缺口和发布影响。 +- 新增 `frontend/src/pages/ai-routing/sceneContracts.ts`,按 `scene_key` 提供确定性业务合同映射,覆盖项目启动、需求结构化、任务拆解、人员匹配、内部问答、外部实时查询、飞书理解和飞书回复;该映射不调用 LLM,不参与 runtime 路由选择,后续可被后端 `scene_contract` 合同替换。 +- 业务场景路由新增发布影响预览、搜索、状态筛选、排序和确定性 Action Rail:Active 未绑定指向能力槽位,缺 probe evidence 指向供应商能力探测,缺 capability 指向供应商能力补齐,draft 变更指向槽位发布影响,失败样本指向失败诊断。 +- 主矩阵继续禁止展示 `function_name`、规则名和 override 来源;函数级检查仍只进入隐藏 Route Lab。 +- 不改后端 runtime 行为,不新增 migration,不修改 control-plane JSON。 + +涉及文件: + +- `frontend/src/pages/ai-routing/sceneContracts.ts` +- `frontend/src/pages/ai-routing/AIRoutingOperationsPages.tsx` +- `frontend/src/pages/ai-routing/AIRoutingPages.test.tsx` +- `CLAUDE.md` +- `docs/architecture/API_CONTRACTS.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/ARCHITECTURE_INVENTORY.md` +- `docs/architecture/RUNTIME_GUARDRAILS.md` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- 前端 focused:`cd frontend && npm run test -- src/pages/ai-routing/AIRoutingPages.test.tsx` +- 前端类型检查:`cd frontend && npm run typecheck` +- 前端构建:`cd frontend && npm run build` + +### 2026-04-29 / ai-slot-scene-impact-matrix-v1 / implemented + +完成内容: + +- `GET /ai-routing/admin-console` 补全 `scene_slot_matrix` 的发布影响字段:`required_slot / active_slot_runtime / draft_slot_runtime / draft_dirty / capability_gap / recent_failure_count`,同时兼容旧的 `current_slot / active_runtime / draft_runtime / recent_failure_rate`。 +- `capability_gap` 汇总场景所需 slot 的缺能力、缺 probe evidence、证据过期和阻塞原因,让发布前能区分“缺能力”和“缺证据”对业务场景的影响。 +- `/platform/ai-routing-scenes` 矩阵默认展示“场景 / 当前槽位 / Active Runtime / Draft Runtime / 保护等级 / 能力缺口 / 发布影响”,函数名、规则名和 override 来源不在业务场景路由页展示,函数级检查统一进入隐藏 Route Lab。 +- `/platform/ai-routing-slots` 的槽位详情弹层新增“影响场景”,直接展示当前 slot 发布会影响哪些 PMFlow 场景及 active/draft runtime 状态。 +- 不改 runtime 行为,不新增 migration,不修改 control-plane JSON。 + +涉及文件: + +- `backend/app/services/ai_routing_governance_service.py` +- `backend/tests/test_ai_routing_governance_service.py` +- `frontend/src/api/aiRouting.ts` +- `frontend/src/pages/ai-routing/AIRoutingOperationsPages.tsx` +- `frontend/src/pages/ai-routing/AIRoutingPages.test.tsx` +- `docs/architecture/API_CONTRACTS.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/RUNTIME_GUARDRAILS.md` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- 后端编译:`python3.11 -m py_compile backend/app/services/ai_routing_governance_service.py` +- 后端 focused:`PYTHONPATH=backend python3.11 -m pytest backend/tests/test_ai_routing_governance_service.py backend/tests/test_ai_service_core_runtime.py -q` +- 前端 focused:`cd frontend && npm test -- src/pages/ai-routing/AIRoutingPages.test.tsx` +- 前端类型检查:`cd frontend && npm run typecheck` +- 前端构建:`cd frontend && npm run build` +- 后端热重启:`docker compose restart backend && docker compose ps backend` + +### 2026-04-29 / ai-slot-scene-matrix-v1 / implemented + +完成内容: + +- `GET /ai-routing/admin-console` 新增/扩展 `scene_slot_matrix`,把 PMFlow 业务场景解释为 `scene -> current slot -> active/draft runtime -> risk -> runtime version`,运营可直接看到一次槽位发布会影响项目启动、需求结构化、任务拆解、人员匹配、问答和飞书场景。 +- 矩阵按 active capability slot 版本聚合,同时带 draft 预览:每个场景返回 `scene_key / scene_label / current_slot / required_capabilities / protected / active_runtime / draft_runtime / active_version / recent_failure_rate / impact_summary`,以及能力覆盖、缺能力/缺证据/未发布绑定风险和高级函数列表。 +- 默认映射落为 project_launch、requirement_structuring、task_breakdown -> structured,assignee_matching -> high,internal_qa -> low/high mixed,external_query -> external_query candidate,feishu_understanding、feishu_reply -> low。 +- `/platform/ai-routing-scenes` 改为默认展示业务场景、当前槽位、必需能力、active/draft runtime、近期失败率、风险和运行版本;函数名、规则名和 override 来源不在业务场景路由页展示,函数级检查统一进入隐藏 Route Lab。 +- 函数级模型路由覆盖继续只接受 `low/high` 槽位别名,不开放任意模型名编辑;受保护业务场景不能被保存或展示为降级到不满足能力要求的 slot。 +- 同步更新 `API_CONTRACTS.md`、`CODE_REFERENCE_INDEX.md` 与 `RUNTIME_GUARDRAILS.md`,明确 scene-slot matrix 是只读 explainability 层,不直接写 provider、slot 或模型路由。 + +涉及文件: + +- `backend/app/services/ai_routing_governance_service.py` +- `backend/app/api/model_routing.py` +- `backend/app/services/model/model_routing/service.py` +- `backend/tests/test_ai_routing_governance_service.py` +- `backend/tests/test_model_routing_service.py` +- `frontend/src/api/aiRouting.ts` +- `frontend/src/pages/ai-routing/AIRoutingOperationsPages.tsx` +- `frontend/src/pages/ai-routing/AIRoutingPages.test.tsx` +- `docs/architecture/API_CONTRACTS.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/RUNTIME_GUARDRAILS.md` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- 后端编译:`/opt/homebrew/bin/python3.11 -m py_compile backend/app/services/ai_routing_governance_service.py backend/app/services/model/model_routing/service.py backend/app/api/model_routing.py` 通过。 +- 后端 focused:`PYTHONPATH=backend /opt/homebrew/bin/python3.11 -m pytest backend/tests/test_ai_routing_governance_service.py backend/tests/test_model_routing_service.py -q` 通过,25 passed。 +- 前端 focused:`cd frontend && npx vitest run src/pages/ai-routing/AIRoutingPages.test.tsx` 通过,13 passed。 +- 前端类型检查:`cd frontend && npx tsc --noEmit` 通过。 +- 前端构建:`cd frontend && npm run build` 通过。 + +### 2026-04-29 / prompt-asset-center-foundation / implemented + +完成内容: + +- 新增平台 Prompt 运营中心基础层:`/platform/prompt-assets` 展示 prompt key、业务场景、family、风险、发布策略、默认模板、active release、draft 数、调用点和迁移缺口。 +- 后端新增 `PromptAssetService`,聚合 `ai_prompt_registry`、`ai_prompts` 默认 seed 和 `prompt_releases` active/draft 状态;新增 core `GET /prompt-assets` 与 detail 接口,均要求 `platform.ai_routing.manage`。 +- `ai_prompt_registry` 扩展 `runtime_source / editable_policy / risk_level`,补齐 catalog 漏项,并登记 runtime guardrail、semantic fact prompt 与本轮梳理出的 inline discovered prompt。`inline_discovered` 当前只代表已形成清单,运行时调用点仍需后续迁移到 release 读取。 +- 平台侧新增 `frontend/src/api/promptAssets.ts` 与 `frontend/src/pages/platform/PromptAssetsPage.tsx`,入口挂在 Platform Console “质量与事件层”;客户空间、Space、Project Launch 与项目级 AI 副驾不暴露 prompt body。 +- 同步更新 `CLAUDE.md`、`API_CONTRACTS.md`、`CODE_REFERENCE_INDEX.md`、`ARCHITECTURE_INVENTORY.md` 与 `RUNTIME_GUARDRAILS.md`,明确 Prompt 资产中心是平台控制面,待迁移项不能被表达为已生效配置。 + +涉及文件: + +- `backend/app/config/ai_prompt_registry.py` +- `backend/app/api/prompt_assets_api.py` +- `backend/app/services/prompt_assets_service.py` +- `backend/app/api/ai_steward_api/routes_reply_iteration.py` +- `backend/tests/test_ai_prompt_registry_contract.py` +- `backend/tests/test_prompt_assets_api.py` +- `backend/tests/test_prompt_assets_service.py` +- `frontend/src/api/promptAssets.ts` +- `frontend/src/pages/platform/PromptAssetsPage.tsx` +- `frontend/src/pages/platform/PlatformChrome.tsx` +- `frontend/src/router/index.tsx` +- `CLAUDE.md` +- `docs/architecture/API_CONTRACTS.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/ARCHITECTURE_INVENTORY.md` +- `docs/architecture/RUNTIME_GUARDRAILS.md` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- 后端编译:`/opt/homebrew/bin/python3.11 -m py_compile backend/app/config/ai_prompt_registry.py backend/app/services/prompt_assets_service.py backend/app/api/prompt_assets_api.py backend/app/api/router.py backend/app/api/ai_steward_api/routes_reply_iteration.py backend/tests/test_prompt_assets_service.py backend/tests/test_prompt_assets_api.py` 通过。 +- 后端 focused:`PYTHONPATH=backend /opt/homebrew/bin/python3.11 -m pytest backend/tests/test_ai_prompt_registry_contract.py backend/tests/test_prompt_assets_service.py backend/tests/test_prompt_assets_api.py backend/tests/test_reply_iteration_service.py::test_prompt_release_service_prefers_active_release_and_falls_back_on_format_error -q` 通过,9 passed。 +- 前端类型检查:`cd frontend && npx tsc --noEmit --pretty false --incremental false` 通过。 +- 前端构建:`cd frontend && npm run build` 通过。 +- 后端热重启:`docker compose restart backend` 后 `docker compose ps backend` 为 healthy;未登录 `GET /prompt-assets` 返回 401,确认 core route 已挂载且受鉴权保护。 + +### 2026-04-29 / prompt-release-console-p1 / implemented + +完成内容: + +- 将 Prompt 运营中心从只读资产清单推进到 P1 发布控制台:`managed_release` 且已配置化的 prompt 支持创建草稿 release、激活 release、回滚 archived release。 +- 后端新增 platform core 写接口:`POST /prompt-assets/{prompt_key}/releases`、`POST /prompt-assets/{prompt_key}/releases/{release_id}/activate`、`POST /prompt-assets/{prompt_key}/releases/{release_id}/rollback`,继续要求 `platform.ai_routing.manage`。 +- `PromptAssetService` 增加发布策略校验:`guarded_release` 必须等待 replay/schema/golden case 门禁,`needs_migration` / `inline_discovered` 只读;release 模板不能引入默认模板变量契约之外的新变量。 +- 新增 `PromptRuntimeService` 作为运行时读取 active release 的统一薄层,并把 external answer presenter 第一批低风险呈现 prompt 接入该层;active release 格式异常时回退默认模板。 +- 前端详情弹层升级为基础信息、模板 Diff、版本历史和运行信息;guarded/待迁移项显示禁用原因,避免被运营误认为可直接上线。 + +涉及文件: + +- `backend/app/api/prompt_assets_api.py` +- `backend/app/schemas/prompt_assets.py` +- `backend/app/services/prompt_assets_service.py` +- `backend/app/services/prompt_release/service.py` +- `backend/app/services/prompt_runtime_service.py` +- `backend/app/services/external_query_service/presenter.py` +- `backend/tests/test_prompt_assets_api.py` +- `backend/tests/test_prompt_assets_service.py` +- `backend/tests/test_prompt_runtime_service.py` +- `frontend/src/api/promptAssets.ts` +- `frontend/src/pages/platform/PromptAssetsPage.tsx` +- `CLAUDE.md` +- `docs/architecture/API_CONTRACTS.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/ARCHITECTURE_INVENTORY.md` +- `docs/architecture/RUNTIME_GUARDRAILS.md` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- 后端编译:`PYTHONPATH=backend /opt/homebrew/bin/python3.11 -m py_compile backend/app/api/prompt_assets_api.py backend/app/schemas/prompt_assets.py backend/app/services/prompt_assets_service.py backend/app/services/prompt_release/service.py backend/app/services/prompt_runtime_service.py backend/app/services/external_query_service/presenter.py` 通过。 +- 后端 focused:`PYTHONPATH=backend /opt/homebrew/bin/python3.11 -m pytest backend/tests/test_prompt_assets_service.py backend/tests/test_prompt_assets_api.py backend/tests/test_prompt_runtime_service.py backend/tests/test_reply_iteration_service.py::test_prompt_release_service_prefers_active_release_and_falls_back_on_format_error -q` 通过,10 passed。 +- 前端类型检查:`cd frontend && npx tsc --noEmit --pretty false --incremental false` 通过。 +- 前端构建:`cd frontend && npm run build` 通过。 +- 后端热重启:`docker compose restart backend` 后 `docker compose ps backend` 为 healthy;未登录 `GET /prompt-assets` 返回 401,确认 core route 仍受鉴权保护。 +- Playwright smoke:使用本地 admin 登录态打开 `/platform/prompt-assets`,点击首行“查看”并切到“模板 Diff”,确认详情弹层标题、草稿创建入口和禁用说明可见,且无页面级 JS 错误或 Dialog 可访问性告警。 + +### 2026-04-29 / prompt-release-gate-p2 / implemented + +完成内容: + +- 新增 Prompt Release Gate MVP:`prompt_release_checks` 记录 release 级门禁检查,支持 `variable_contract / schema_smoke / golden_replay` 三类检查。 +- 后端新增 platform core 门禁接口:`GET /prompt-assets/{prompt_key}/releases/{release_id}/checks` 与 `POST /prompt-assets/{prompt_key}/releases/{release_id}/checks/run`,继续要求 `platform.ai_routing.manage`。 +- 调整发布边界:`guarded_release` 现在可创建候选 release,但激活必须通过门禁;当前 `golden_replay` 无样本时返回 `skipped` 且 blocking,禁止伪造成通过。`needs_migration` / `inline_discovered` 仍只读。 +- `PromptReleaseService.activate_release` 增加 guarded gate 兜底拦截,避免绕过 `PromptAssetService` 直接激活受保护 prompt。 +- 前端 Prompt 运营中心详情弹层新增“发布门禁”页签,版本历史显示门禁状态,可运行检查;guarded release 未通过门禁时激活/回滚按钮保持禁用并展示阻塞原因。 +- 本地开发数据库已通过 Alembic 升级到 `20260429_add_prompt_release_checks`。 + +涉及文件: + +- `backend/alembic/versions/20260429_add_prompt_release_checks.py` +- `backend/app/models/reply_iteration.py` +- `backend/app/models/__init__.py` +- `backend/app/api/prompt_assets_api.py` +- `backend/app/schemas/prompt_assets.py` +- `backend/app/services/prompt_assets_service.py` +- `backend/app/services/prompt_release/service.py` +- `backend/tests/test_prompt_assets_api.py` +- `backend/tests/test_prompt_assets_service.py` +- `frontend/src/api/promptAssets.ts` +- `frontend/src/pages/platform/PromptAssetsPage.tsx` +- `CLAUDE.md` +- `docs/architecture/API_CONTRACTS.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/ARCHITECTURE_INVENTORY.md` +- `docs/architecture/RUNTIME_GUARDRAILS.md` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- 后端编译:`PYTHONPATH=backend /opt/homebrew/bin/python3.11 -m py_compile backend/app/api/prompt_assets_api.py backend/app/schemas/prompt_assets.py backend/app/services/prompt_assets_service.py backend/app/services/prompt_release/service.py backend/app/services/prompt_runtime_service.py backend/app/models/reply_iteration.py backend/alembic/versions/20260429_add_prompt_release_checks.py backend/tests/test_prompt_assets_api.py backend/tests/test_prompt_assets_service.py` 通过。 +- 后端 focused:`PYTHONPATH=backend /opt/homebrew/bin/python3.11 -m pytest backend/tests/test_prompt_assets_service.py backend/tests/test_prompt_assets_api.py backend/tests/test_prompt_runtime_service.py backend/tests/test_reply_iteration_service.py::test_prompt_release_service_prefers_active_release_and_falls_back_on_format_error -q` 通过,12 passed。 +- Alembic:`docker compose exec -T backend alembic upgrade head` 通过;`alembic current` 返回 `20260429_add_prompt_release_checks (head)`。 +- 前端类型检查:`cd frontend && npx tsc --noEmit --pretty false --incremental false` 通过。 +- 前端构建:`cd frontend && npm run build` 通过。 +- 后端热重启:`docker compose restart backend` 后 `docker compose ps backend` 为 healthy;未登录 `GET /prompt-assets` 返回 401。 +- Playwright smoke:使用本地 admin 登录态打开 `/platform/prompt-assets`,点击首行“查看”并切到“发布门禁”,确认门禁页签可见且无页面级 JS 错误。 + +### 2026-04-29 / prompt-quality-linkage-p3 / implemented + +完成内容: + +- 将 Prompt 运营中心推进到质量归因与 golden case 基础层:`ai_call_logs` 新增 `prompt_key / prompt_release_id / prompt_source`,AI 调用审计支持按 prompt 维度过滤,详情展示 prompt 版本来源。 +- `PromptRuntimeService` 渲染结果返回 `release_id` 和可直接写入 `routing_meta` 的 prompt 归因元数据;已接入的 external answer presenter 会把 prompt_key/release/source 写入 LLM 调用日志。 +- 新增 `prompt_golden_cases` 表与 platform core 接口:`GET/POST /prompt-assets/{prompt_key}/golden-cases`、`POST /prompt-assets/{prompt_key}/golden-cases/from-call/{ai_call_log_id}`。 +- 新增 `GET /prompt-assets/{prompt_key}/quality-summary`,返回 24h/7d/30d 维度的调用数、失败率、默认回退、active release 调用、成本、延迟、失败样本和 release breakdown。 +- Prompt Release Gate 的 `golden_replay` 从固定 no-case 阻塞升级为:无 active golden case 时继续 blocking;有样本时只做确定性契约冒烟并明确 `contract_smoke_no_llm`,不伪造成真实 LLM replay。 +- 前端 Prompt 运营中心新增“质量归因”页签,展示 7 天质量摘要、失败样本、active golden cases,可 deep-link 到 AI 调用审计并从失败调用生成 golden case;AI 调用审计新增 prompt_key/source 筛选。 + +涉及文件: + +- `backend/alembic/versions/20260429_add_prompt_quality_linkage.py` +- `backend/app/models/ai_call_log.py` +- `backend/app/models/reply_iteration.py` +- `backend/app/models/__init__.py` +- `backend/app/api/prompt_assets_api.py` +- `backend/app/api/ai_call_traces_api.py` +- `backend/app/schemas/prompt_assets.py` +- `backend/app/services/prompt_assets_service.py` +- `backend/app/services/prompt_runtime_service.py` +- `backend/app/services/prompt_release/service.py` +- `backend/app/services/external_query_service/presenter.py` +- `backend/app/services/ai_call_trace_service.py` +- `backend/app/services/llm_service/shared_part_02.py` +- `backend/app/services/llm_service/mixin_prompt_profile_runtime.py` +- `backend/app/services/llm_service/mixin_generate_db_chat.py` +- `backend/app/services/llm_service/mixin_generate_db_structured.py` +- `backend/tests/test_prompt_assets_api.py` +- `backend/tests/test_prompt_assets_service.py` +- `backend/tests/test_prompt_runtime_service.py` +- `backend/tests/test_ai_call_traces_api.py` +- `frontend/src/api/promptAssets.ts` +- `frontend/src/api/aiCallTraces.ts` +- `frontend/src/pages/platform/PromptAssetsPage.tsx` +- `frontend/src/pages/platform/AICallTracesPage.tsx` +- `CLAUDE.md` +- `docs/architecture/API_CONTRACTS.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/ARCHITECTURE_INVENTORY.md` +- `docs/architecture/RUNTIME_GUARDRAILS.md` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- 后端编译:`PYTHONPATH=backend /opt/homebrew/bin/python3.11 -m py_compile ...` 覆盖本次新增/修改后端文件与测试文件,通过。 +- 后端 focused:`PYTHONPATH=backend /opt/homebrew/bin/python3.11 -m pytest backend/tests/test_prompt_assets_service.py backend/tests/test_prompt_assets_api.py backend/tests/test_prompt_runtime_service.py backend/tests/test_ai_call_traces_api.py -q` 通过,14 passed。 +- 前端类型检查:`cd frontend && npx tsc --noEmit --pretty false --incremental false` 通过。 +- 前端构建:`cd frontend && npm run build` 通过。 +- Alembic:当前环境存在两个并行 head(`20260429_add_ai_call_log_provider_payloads` 与 `20260429_add_prompt_quality_linkage`),`alembic upgrade head` 被 Alembic 拦截;已定向执行 `docker compose exec -T backend alembic upgrade 20260429_add_prompt_quality_linkage` 并通过,`alembic current` 返回本次 head 与另一个并行 head。 +- 后端热重启:`docker compose restart backend` 后 `docker compose ps backend` 为 healthy;未登录 `GET /prompt-assets` 返回 401。 +- Playwright smoke:使用本地 admin 登录态打开 `/platform/prompt-assets`,点击首行“查看”并切到“质量归因”,确认质量页签可见;打开 `/platform/ai-call-traces?prompt_key=PROMPT_EXTERNAL_DIRECT_FACT_QA&status=failed`,确认 Prompt Key 查询框带入参数且无页面级 JS 错误。 + +### 2026-04-30 / prompt-replay-review-p4 / implemented + +完成内容: + +- 将 Prompt 运营中心从“有 golden case 才放行”推进到真实 release replay run 记录:新增 `prompt_replay_runs` 和 `prompt_replay_case_results`,每次回放有 run 级摘要、样本明细、风险标记和计数。 +- 后端新增 platform core 回放接口:`POST /prompt-assets/{prompt_key}/releases/{release_id}/replay-runs`、`GET /prompt-assets/{prompt_key}/releases/{release_id}/replay-runs`、`GET /prompt-assets/{prompt_key}/replay-runs/{run_id}`,继续要求 `platform.ai_routing.manage`。 +- `PromptAssetService` 的 guarded release 门禁更新为:无 active golden case 阻塞;有样本但未执行最新 release replay run 仍阻塞;最新 replay run 失败样本数大于 0 时阻塞;当前 replay 只做确定性契约冒烟,不执行真实 LLM。 +- 前端 Prompt 运营中心详情弹层新增“回放评审”页签,版本历史可直接运行回放,发布概览展示最新 replay 摘要;AI 调用审计详情可从 prompt-attributed 调用直接生成 Golden case。 +- 文档同步了 Prompt 运营中心的新数据表、接口、门禁边界、平台/客户可见边界和当前非 LLM replay 的能力边界。 + +涉及文件: + +- `backend/alembic/versions/20260430_add_prompt_replay_runs.py` +- `backend/app/models/reply_iteration.py` +- `backend/app/models/__init__.py` +- `backend/app/api/prompt_assets_api.py` +- `backend/app/schemas/prompt_assets.py` +- `backend/app/services/prompt_assets_service.py` +- `backend/tests/test_prompt_assets_api.py` +- `backend/tests/test_prompt_assets_service.py` +- `frontend/src/api/promptAssets.ts` +- `frontend/src/pages/platform/PromptAssetsPage.tsx` +- `frontend/src/pages/platform/AICallTracesPage.tsx` +- `CLAUDE.md` +- `docs/architecture/API_CONTRACTS.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/ARCHITECTURE_INVENTORY.md` +- `docs/architecture/RUNTIME_GUARDRAILS.md` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- 后端编译:`PYTHONPATH=backend /opt/homebrew/bin/python3.11 -m py_compile backend/app/models/reply_iteration.py backend/app/models/__init__.py backend/app/services/prompt_assets_service.py backend/app/api/prompt_assets_api.py backend/app/schemas/prompt_assets.py backend/alembic/versions/20260430_add_prompt_replay_runs.py backend/tests/test_prompt_assets_service.py backend/tests/test_prompt_assets_api.py` 通过。 +- 后端 focused:`PYTHONPATH=backend /opt/homebrew/bin/python3.11 -m pytest backend/tests/test_prompt_assets_service.py backend/tests/test_prompt_assets_api.py backend/tests/test_prompt_runtime_service.py backend/tests/test_ai_call_traces_api.py -q` 通过,16 passed。 +- 前端类型检查:`cd frontend && npx tsc --noEmit --pretty false --incremental false` 通过。 +- 前端构建:`cd frontend && npm run build` 通过。 +- Alembic:当前环境仍有两个并行 head(`20260430_add_prompt_replay_runs` 与 `20260429_add_ai_call_log_provider_payloads`);已定向执行 `docker compose exec -T backend alembic upgrade 20260430_add_prompt_replay_runs` 并通过,`alembic current` 返回这两个 head;`prompt_replay_runs / prompt_replay_case_results` 已存在。 +- 后端热重启:`docker compose restart backend` 后 `docker compose ps backend` 为 healthy;未登录 `GET /prompt-assets` 返回 401。 +- Playwright smoke:使用本地 admin 登录态打开 `/platform/prompt-assets`,点击首行“查看”并切到“回放评审”,确认回放页签可见且无页面级 JS 错误。 + +### 2026-04-30 / prompt-evaluation-lab-p5 / implemented + +完成内容: + +- 将 Prompt 运营中心从“回放记录”推进到 Prompt Evaluation Lab:release replay 支持 `contract_smoke` 与 `llm_compare`,LLM 回放通过 `llm_service` + 显式 `routing_meta` 进入本地路由层,不硬编码 provider/model。 +- 新增 Prompt Eval Rubric 配置 `backend/app/config/ai_prompt_eval_rubrics.py`,按 prompt family 与重点 prompt key 提供评估维度,judge 结构化输出 `score / label / decision / reason / risk_flags / dimension_scores`。 +- 扩展 `prompt_golden_cases` 为分层样本:`case_type / priority / scenario_key / tags / last_run_status / last_failed_at`;从 AI 调用日志生成样本时自动写入 source、scenario 与 tags。`priority=blocking` 失败阻塞发布,`important/observation` 失败降为 warning 和人工复核。 +- 扩展 `prompt_replay_case_results`:记录 baseline/candidate AI call log、judge score/label/reason;回放明细可追溯到真实 AI 调用审计。 +- 新增 release watch 摘要接口 `GET /prompt-assets/{prompt_key}/releases/{release_id}/watch-summary`:按 24h/7d 聚合 release 级调用、失败、结构化异常、默认回退和失败样本,并输出 `healthy / watch / needs_review / rollback_suggested` 建议;只建议,不自动回滚。 +- 前端回放评审页拆出“契约回放 / LLM 回放”按钮,展示 run mode、judge 评分、judge 理由、关联 call log 与风险标记;发布概览新增 release watch 卡片;Active Golden Cases 展示 priority、case_type、last_run_status、scenario 和 tags。 +- 本地开发数据库已通过 Alembic 定向升级到 `20260430_extend_prompt_eval_replay`,当前仍保留另一个既有并行 head `20260429_add_ai_call_log_provider_payloads`。 + +涉及文件: + +- `backend/alembic/versions/20260430_extend_prompt_eval_replay.py` +- `backend/app/config/ai_prompt_eval_rubrics.py` +- `backend/app/models/reply_iteration.py` +- `backend/app/api/prompt_assets_api.py` +- `backend/app/schemas/prompt_assets.py` +- `backend/app/services/prompt_assets_service.py` +- `backend/tests/test_prompt_assets_api.py` +- `backend/tests/test_prompt_assets_service.py` +- `frontend/src/api/promptAssets.ts` +- `frontend/src/pages/platform/PromptAssetsPage.tsx` +- `CLAUDE.md` +- `docs/architecture/API_CONTRACTS.md` +- `docs/architecture/CODE_REFERENCE_INDEX.md` +- `docs/architecture/ARCHITECTURE_INVENTORY.md` +- `docs/architecture/RUNTIME_GUARDRAILS.md` +- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` + +验证: + +- 后端编译:`python3.11 -m py_compile backend/app/config/ai_prompt_eval_rubrics.py backend/alembic/versions/20260430_extend_prompt_eval_replay.py backend/app/models/reply_iteration.py backend/app/schemas/prompt_assets.py backend/app/api/prompt_assets_api.py backend/app/services/prompt_assets_service.py backend/tests/test_prompt_assets_api.py backend/tests/test_prompt_assets_service.py` 通过。 +- 后端 focused:`DATABASE_URL=postgresql+asyncpg://pmflow:pmflow_pass@127.0.0.1:5435/pmflow PYTHONPATH=backend python3.11 -m pytest backend/tests/test_prompt_assets_service.py backend/tests/test_prompt_assets_api.py backend/tests/test_prompt_runtime_service.py backend/tests/test_ai_call_traces_api.py -q` 通过,19 passed,保留既有 Pydantic deprecation warning。 +- 前端类型检查:`cd frontend && npx tsc --noEmit --pretty false --incremental false` 通过。 +- 前端构建:`cd frontend && npm run build` 通过。 +- Alembic:`docker compose exec -T backend alembic upgrade 20260430_extend_prompt_eval_replay` 通过;`alembic current` 返回 `20260430_extend_prompt_eval_replay (head)` 与 `20260429_add_ai_call_log_provider_payloads (head)`。 +- 后端热重启:`docker compose restart backend` 后 `docker compose ps backend` 为 healthy;未登录 `GET /prompt-assets` 返回 401。 +- Playwright smoke:使用本地 admin 登录态打开 `/platform/prompt-assets`,点击首行“查看”并切到“回放评审”,本地创建一个草稿 release 验证“契约回放 / LLM 回放”和 release watch 卡片可见;浏览器 console 0 error;截图保存到 `output/playwright/prompt-assets-p5-replay.png`。 diff --git a/docs/PMFLOW_UI_CONTRACT.md b/docs/PMFLOW_UI_CONTRACT.md index 69a59ab1..7c8e36df 100644 --- a/docs/PMFLOW_UI_CONTRACT.md +++ b/docs/PMFLOW_UI_CONTRACT.md @@ -125,6 +125,7 @@ PMFlow 的反馈系统必须服务于“AI 推进、人类拍板、系统留痕 Toast 标准: - 只用于短反馈,不承载关键业务事实、风险证据、确认动作或长文本。 +- 表格/矩阵行级短操作如连接测试、能力探测、启停、保存完成,应优先使用 toast 返回结果;不要在主信息区插入临时横幅,避免挤占列表和诊断信息。 - 桌面端位置:右上角,避开顶部条,宽度 `320-380px`,圆角 `12px`,轻阴影,最多同时展示 3 条。 - 移动端位置:底部导航上方或顶部安全区下方,不能遮挡底部主 CTA、底部导航和键盘。 - 默认 3 秒自动消失;错误可延长到 5 秒;带操作按钮的 toast 必须可手动关闭。 @@ -135,6 +136,7 @@ Toast 标准: - 内联提示贴近问题位置,优先放在输入框下方、卡片顶部或模块空态中。 - 页面横幅用于当前页面级状态,放在主画布顶部,背景使用浅色,不抢主 CTA。 +- 会刷新整张列表、摘要或路由状态的操作进行中,必须覆盖当前页面内容区并给出不可操作态;推荐使用半透明玻璃遮罩 + 简短状态文案(如 `连接测试中`、`能力探测中`),完成后用 toast 返回结果。只有完全局部、不会影响其它行的操作,才使用行/卡片级遮罩。 - AI 相关横幅使用浅蓝底和 `#2463EB`,系统状态使用灰色,待确认使用浅橙或浅蓝,风险只在高风险时使用红色。 - 横幅必须说明状态、影响和下一步,不只显示标签。 @@ -142,6 +144,8 @@ Toast 标准: - 只用于高影响动作:删除、覆盖、发布、批量写入、权限变更、邀请外部成员、成本/模型配置变更。 - 桌面端宽度默认 `480px`;涉及影响范围、证据列表或多选项时可用 `640px`;不得超过 `760px`,超过应改为页面或抽屉。 +- 弹窗高度必须限制在视口内,默认不超过 `86vh`;标题区固定可见,正文使用内部滚动,不得让长文本、表格、代码块或 JSON 内容撑出弹窗边界。 +- 长文本、代码块和 JSON 区块必须有自身最大高度和 `overflow-auto`,允许在区块内上下滑动;禁止依赖页面外层滚动来查看弹窗内容。 - 弹窗结构固定为:标题、影响说明、对象/证据摘要、主要动作、次要动作;危险动作按钮使用克制红色。 - 主按钮文案必须具体,例如 `确认写入项目记忆`、`发布当前版本`、`删除这条风险`,禁止只写 `确定`。 - 次要按钮使用 `取消`、`返回修改`、`暂不处理` 等明确动作。 @@ -236,6 +240,44 @@ PMFlow 专属规则: - 项目层左侧栏的基础尺寸与空间层左侧栏保持一致:宽度 `244px`、内边距 `px-5 py-5`、背景 `#FBFCFF`、品牌到首组导航 `mt-8`、导航组内间距 `space-y-2`、分组间分割线 `my-7`、导航项高度 `44px`、圆角 `12px`、图标 `16px`,不得另起一套更宽、更紧或更重的侧栏样式。 - 项目层页面不能各自发明不同的导航壳。 +### 4.4 表格与列表操作列 + +PMFlow 的表格、矩阵和列表行操作必须避免横向堆满按钮。操作列统一遵守“一个主操作 + 更多菜单”标准。 + +规则: + +- 每行只有 1 个可用操作时,直接显示该操作按钮。 +- 每行有 2 个及以上操作时,操作列只显示第 1 个主操作按钮,第 2 个位置固定显示 `更多` 按钮;其余操作全部放入 `更多` 菜单。 +- `更多` 菜单必须使用标准 dropdown menu,不得用多个次级按钮继续横向排列。 +- 第 1 个主操作必须是该行最常用、最符合当前状态的动作,例如 `配置运行时`、`停用/启用`、`查看场景`、`处理风险`。 +- 危险动作如 `删除` 只能放在 `更多` 菜单内,并使用克制红色;除非该页面唯一动作就是删除,否则不得作为首个主操作。 +- 行操作必须跟随后端状态合同,只渲染当前状态下可执行的动作;不可执行动作不要以 disabled 按钮占位。供应商与凭据必须优先消费后端 `available_actions`,例如未通过连接测试不能显示 `设为兜底` / `能力探测`,已停用只显示 `启用`、`编辑` 和允许时的 `删除`。 +- 操作列应固定在表格右侧时保持宽度稳定,避免因按钮数量变化导致列宽、行高或横向滚动抖动。 +- 该标准适用于平台 AI 运行层的 `供应商与凭据`、`能力槽位`、`业务场景路由`,也适用于后续其它 PMFlow 表格/矩阵操作列。 + +### 4.5 平台表格查询与分页 + +PMFlow 平台运营类表格必须使用统一的查询卡片和分页栏,避免同一控制台内出现多套筛选、页码和页容量样式。 + +查询条件标准: + +- 查询区统一使用标题为 `查询条件` 的折叠卡片,外层为 `rounded-xl border border-slate-200 bg-white`。 +- 卡片标题行固定为左侧 `查询条件`、右侧 `收起 / 展开`,点击标题行切换展开状态。 +- 展开内容使用 `border-t border-slate-100 px-4 pb-4 pt-3`,字段使用 `Label + Input/select`,select 高度为 `h-10`、`rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-900`。 +- 筛选输入默认进入 draft 状态,只有点击蓝色 `查询` 按钮后才提交;不得在用户每输入一个字符时立即触发列表请求。 +- `查询 / 重置` 按钮必须独立成一行固定放在查询卡片展开区右下角,使用 `mt-4 flex justify-end gap-2`;不得混入字段 grid,也不得放在第一行字段右侧。 +- `查询` 使用主按钮样式 `rounded-xl bg-[#2463EB] text-white hover:bg-[#1f58d5]`;`重置` 使用 outline `rounded-xl`。 +- 提交查询后可收起查询卡,列表上方不再额外放一套筛选按钮。 + +分页标准: + +- 表格底部统一使用 `flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 px-4 py-3`。 +- 左侧文案统一为 `共 N 条,当前 A-B`。 +- 右侧顺序固定为:`10条/页` 下拉、`上一页`、`当前页 / 总页`、`下一页`。 +- 页容量可选项统一为 `10条/页 / 20条/页 / 50条/页`。 +- 当前页展示使用 `rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700`。 +- 该标准适用于 `/platform/*` 下所有表格型页面,包括 `供应商与凭据`、`能力槽位`、`业务场景路由`、`AI 调用审计` 及后续平台治理列表。 + ## 5. 移动端规则 ### 5.1 创建前 @@ -359,13 +401,42 @@ PMFlow 专属规则: - 接受 - 修改 - 暂不落库 +- 拒绝 - 批量接受 - 稍后处理 +- 请求审查 +- 通过审查 +- 要求修改 +- 确认写入项目记忆 不可变项: - AI 建议内容和将写入内容必须可区分。 - 待确认项必须展示来源证据和影响范围。 +- `接受 / 修改 / 暂不落库 / 拒绝` 只处理候选 ChangeItem,不写 Project Memory。 +- `确认写入项目记忆` 是唯一正式写入动作,必须在待写入项审查完成、必要角色审查满足、证据可追溯后才显示为可执行。 +- 团队层只能展示审查路由、审查进展、阻塞原因和跳转入口,不在团队层直接提供写入 Project Memory 的按钮。 + +按钮使用限制: + +| 按钮 | 使用场景 | 不允许出现或执行的场景 | +| --- | --- | --- | +| `接受` | 用户认可 AI 候选项原文,可进入待写入集合 | ChangeSet 已 committed;item 已 rejected / committed / skipped;缺必需 evidence;高风险且尚未满足 required reviewer gate | +| `修改` | 候选项方向正确但字段、范围、优先级、验收标准或风险说明需要人工修订 | ChangeSet 已 committed;item 已 committed / skipped;对象类型当前不支持编辑;修改后 payload 无法满足对象合同 | +| `暂不落库` | 内容可能有用,但当前证据不足、时机不对、范围暂不纳入;保留审计并允许后续恢复处理 | 用户已经明确拒绝;ChangeSet 已 committed;该项已写入 Project Memory | +| `拒绝` | AI 误读、证据错误、方向错误、重复或不应进入项目真相层;作为 terminal discard 进入审计 | 只是暂时不做或信息不足时不得用拒绝,应使用 `暂不落库` 或 `要求修改` | +| `批量接受` | 多个低风险、证据充分、同类且不需要角色审查的候选项可一次进入待写入集合 | 包含高风险、跨角色、缺证据、unsupported object type、已 requested changes / rejected 的项 | +| `稍后处理` | 用户现在不处理队列,只保留 pending 状态并可调整提醒 / 排序 | 不改变 ChangeItem 审查状态,不得被后端当成 approval、defer 或 reject | +| `请求审查` | 负责人需要产品、技术、QA、增长等角色对同一 ChangeSet 异步审查 | 单人低风险项不强制;缺 reviewer 时只显示阻塞 / 邀请 / 配置入口,不虚构 reviewer | +| `通过审查` | reviewer 确认候选变更质量可进入 accepted / approval gate | 不写 Project Memory,不创建正式任务,不触发执行桥 | +| `要求修改` | reviewer 认为方向可接受但需补字段、证据、范围或验收标准 | 不应把 item 终止;应回到 owner 修改后再次审查 | +| `确认写入项目记忆` | accepted / edited 项通过 gate 后,由负责人把它们 atomic commit 到 Project Memory / Work Graph | 仍有 pending required review、没有 accepted / edited item、高风险缺 approval、对象类型不支持、证据缺失或 ChangeSet 已 committed | + +写入后的执行入口限制: + +- `发起执行交接 / 推送到 IDE / 分派给 agent` 只能出现在正式 Task 已由 Project Memory commit 产生或更新之后。 +- 候选 Task 在 ChangeSet 阶段可以展示“预计执行模式 / 建议 reviewer / 建议 agent”,但按钮必须是预览或不可执行状态。 +- 执行动作必须说明执行模式、责任人或 agent、上下文包、repo / runtime 准备度、测试与回写证据;缺任一关键条件时显示阻塞原因和下一步,不显示可执行主按钮。 ### 6.6 项目记忆 diff --git a/docs/architecture/API_CONTRACTS.md b/docs/architecture/API_CONTRACTS.md index 278c2e10..df3b76b2 100644 --- a/docs/architecture/API_CONTRACTS.md +++ b/docs/architecture/API_CONTRACTS.md @@ -2,6 +2,33 @@ ## PMFlow OS +### POST `/pmflow-os/projects/{project_id}/extractions` +- Purpose: turn a project thread message into an open ChangeSet with pending_review ChangeItems. +- Auth: project member, `OWNER`, or `REVIEWER`; tenant comes from JWT. +- Request body: + - `thread_id: string` + - `message_id: string` + - `scope?: string`, default `latest_message` + - `summary?: string`, `headline?: string` + - `routing_meta?: object`, audit/routing metadata only + - `llm_call_id?: string` + - `proposed_items?: PMFlowProposedChangeItem[]` +- Behavior: + - If `proposed_items` is present and non-empty, the endpoint preserves the existing explicit ChangeSet draft path. + - If `proposed_items` is omitted or empty, the backend runs real structured AI extraction through `llm_service.generate_structured_object` using the dedicated `change_review` protected scene, `pmflow_change_review_extraction_v1` Function Runtime Profile, and `PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION` prompt asset, then normalizes requirements/tasks/risks/decisions into ChangeItems. + - Success creates one `pmflow_change_sets` row with `status=open`, pending_review `pmflow_change_items`, and a `pmflow_evidence_links` row pointing to the source thread message. + - Failure is fail-closed: no ChangeSet or ChangeItem is created. The API returns product-semantic errors such as `ai_extraction_failed` or `extraction_evidence_insufficient`. + - This endpoint never commits Project Memory. ChangeReview remains responsible for accept/edit/defer and ChangeSet commit. +- Response data: `PMFlowChangeSetDetailResponse` + - `changeSet`: serialized open ChangeSet with `routingMeta.aiExtraction` when AI extraction was used. + - `items[]`: pending_review ChangeItems. + - `evidence[]`: source thread message evidence. + - `approvals[]`: initially empty unless later review actions were recorded. +- Failure semantics: + - `404 message_not_found` + - `409 extraction_evidence_insufficient` + - `502 ai_extraction_failed` + ### POST `/pmflow-os/change-sets/{change_set_id}/commit` - Purpose: commit reviewed ChangeSet items into the official project truth layer. - Auth: project member, `OWNER`, or `REVIEWER`; tenant comes from JWT. @@ -19,8 +46,8 @@ - `Task` writes `truth_nodes(node_type='task')` plus `tasks.truth_node_id`. - High-risk items require item-level approval with `decision in ('approved','edited')` and `after_status in ('accepted','edited')`. - Response data: - - `changeSet`: `{ id, status, rawStatus, committedAt, committedItemIds, skippedItemIds, failedItemIds }` - - `committedItems[]`: `{ changeItemId, objectType, targetType, targetId, status, evidenceIds, semanticFactStatus }` + - `changeSet`: `{ id, status, rawStatus, committedAt, writebackAt, committedById, committedBy, committedItemIds, skippedItemIds, failedItemIds }` + - `committedItems[]`: `{ changeItemId, objectType, targetType, targetId, status, evidenceIds, semanticFactStatus, committedAt, committedById, committedBy }` - `skippedItems[]`: `{ changeItemId, objectType, status, reason }` - `failedItems[]`: `{ changeItemId, objectType, code, message }` - `projectTruthState?`: optional backend projection after commit. @@ -35,8 +62,234 @@ - `422 invalid_change_payload` - `500 commit_failed` +## Personal Space + +### Saved Ideas +- Purpose: keep pre-project idea seeds visible in `/space` without polluting Project Memory or PMFlow OS truth objects. +- Boundary: + - `saved_ideas` stores raw input, source, summary, readiness, missing questions, status, enrichment metadata, and optional Project Launch draft linkage. + - Enrichment metadata is explicit: `enrichment_status="ai_enriched"` means the AI structured completeness pass succeeded; `ai_failed` means AI did not complete and the row is showing deterministic baseline fields; `baseline` means no AI enrichment is present. + - Saving an idea never creates a project, task, ChangeSet, Project Memory entry, execution event, release signal, or capacity item. + - Promoting an idea only generates a Project Launch draft and returns the user to human review. Project creation still requires `POST /pmflow-launch/drafts/{draft_id}/confirm`. + +### GET `/space/saved-ideas` +- Auth: logged-in user; tenant comes from JWT. +- Query: + - `limit?: number`, default `20`, max `100` + - `include_archived?: boolean`, default `false` +- Response data: `{ items: SavedIdeaRead[] }`. `SavedIdeaRead` includes `id/title/raw_text/summary/source_type/source_ref/input_mode/attachments/status/status_label/ai_summary/missing_questions/readiness_score/enrichment_status/enrichment_payload/enrichment_error/enriched_at/launch_draft_id/promoted_project_id/created_at/updated_at/href`. + +### POST `/space/saved-ideas` +- Auth: logged-in user; tenant comes from JWT. +- Request body: + - `raw_text: string` + - `title?: string` + - `summary?: string` + - `source_type?: "project_launch" | "ai_concierge" | "logout_guard" | "feishu" | "upload" | "manual"` + - `source_ref?: string` + - `input_mode?: "idea" | "import" | "template"` + - `attachments?: SavedIdeaAttachment[]` +- Behavior: writes one `saved_ideas` row for the current user and workspace. The service first tries `space_saved_idea_enrichment_v1` structured AI enrichment for `summary / target_user / problem / value_hypothesis / validation_signal / constraints / missing_questions / readiness_score / status / confidence`; if AI is unavailable or parsing fails, it stores `enrichment_status="ai_failed"` with a deterministic `system_baseline` payload instead of pretending AI succeeded. +- Response data: `SavedIdeaRead`. + +### PATCH `/space/saved-ideas/{idea_id}` +- Purpose: update title/raw text/summary/status for the current user's own idea. When `raw_text` changes, the same structured enrichment attempt runs again and refreshes readiness, missing questions, status and enrichment metadata. +- Response data: `SavedIdeaRead`. + +### POST `/space/saved-ideas/{idea_id}/archive` +- Purpose: remove a saved idea from active Space lists while preserving audit/history. +- Response data: `SavedIdeaRead` with `status="archived"`. + +### POST `/space/saved-ideas/{idea_id}/promote` +- Purpose: convert a saved idea into a reviewable Project Launch draft. +- Behavior: + - Calls the same structured draft generation path as `POST /pmflow-launch/drafts`. + - Updates `saved_ideas.status="promoted"` and stores `launch_draft_id`. + - Does not confirm the draft or create a project. +- Response data: `{ idea: SavedIdeaRead, draft: PMFlowLaunchDraftResponse, redirect_url: "/project-launch?state=human_review" }`. +- `POST /pmflow-launch/drafts` rejects low-information launch input before calling the LLM with `422` and message prefix `input_needs_more_info`, so pure greetings or inputs without enough product/user/problem evidence stay in the supplement-input path instead of producing an empty draft. +- Project Launch taskized generation: + - `POST /pmflow-launch/draft-jobs` creates a `pmflow_launch_draft_jobs` row with `job_id / revision_id / status=queued`, supersedes active jobs for the same user, and starts the structured generation worker. Response returns job status only; clients must poll. + - `GET /pmflow-launch/draft-jobs/{job_id}` returns `queued | running | superseded | completed | failed | cancelled`; `completed` includes the generated `PMFlowLaunchDraftResponse` and `changed_fields`. + - `POST /pmflow-launch/draft-jobs/{job_id}/cancel` marks a queued/running job as cancelled. If the provider call already completed, workers must ignore non-running jobs before persisting a draft. + - `POST /pmflow-launch/drafts/{draft_id}/revise` creates a new `merge_supplement` job using the parent draft plus the user's supplement; it does not confirm or write a project. +- Project Launch draft generation uses the managed `PROMPT_PMFLOW_LAUNCH_DRAFT` active release when available. The expected structured draft is a monetizable MVP launch plan, targeting 7-10 requirements, 10-16 tasks, 4-7 risks and 3-6 decisions; count or business-loop gaps are surfaced in draft warnings instead of silently accepting a thin feature-demo plan. + +## AI Readiness + +### GET `/ai-readiness/project-launch` +- Purpose: derive the customer-facing Project Launch AI readiness state from backend runtime facts, not from frontend local state. +- Auth: logged-in user; tenant comes from JWT. +- Behavior: + - Reads platform runtime configuration from the platform runtime tenant, including `integrations(type='llm_engine')` and published `integrations(type='ai_capability_slots').active.slots`. + - Verifies the Project Launch scene contract, the published `structured` slot, the bound runtime connection state, structured output / JSON Schema capability evidence, and the latest tenant-scoped `ai_call_logs(function_name='pmflow_launch_draft_v1')`. + - Returns customer-safe copy and actions by default. Supplier, slot, probe, recent call, raw status and platform repair actions are exposed only inside `platform / platform_diagnostic` for platform operations views. + - `can_generate_project_draft=true` only when the required runtime, structured slot, capability evidence and latest draft smoke/call evidence are all healthy. Missing active structured slot, unavailable runtime or missing capability proof keeps Project Launch draft generation blocked. +- Response data: `AIReadiness` + - `scene: "project_launch"` + - `status: "ready" | "blocked" | "recovering" | "unknown"` + - `customer_state`, `title`, `summary`, `short_label`, `updated_at_label` + - `can_generate_project_draft: boolean` + - `blockers[]`, `customer_actions[]`, `workspace_owner_actions[]` + - `platform.checks`, `platform.blockers`, `platform.latest_call`, `platform.last_checked_at` + - `platform_diagnostic` + +### POST `/ai-readiness/project-launch/check` +- Purpose: run a protected Project Launch smoke check without creating a draft or project. +- Auth: platform AI operations capability, via `platform.ai_routing.manage`. +- Behavior: + - Calls the same `pmflow_launch_draft_v1` structured generation path used by real Project Launch, with `routing_meta.tier_alias="structured"` and `source="system_smoke"`. + - Normalizes and validates the generated draft payload, but does not write `pmflow_launch_drafts`, `projects`, Project Memory, tasks, ChangeSet or semantic facts. + - Updates the returned readiness payload from the fresh smoke result and latest `ai_call_logs` evidence. +- Response data: same as `GET /ai-readiness/project-launch`, with `platform.last_smoke_error` when the smoke check fails. + +## Platform AI Operations + +### Provider code boundary +- Runtime provider identity must come from configured `integrations(type='llm_engine').config.engines[*].provider`; UI, API, audit and routing code may display product labels, but must preserve the raw configured provider code separately. +- DashScope/Qwen runtime provider code is `dashscope`. The old internal provider code `glm` has been removed and must not be reintroduced as an alias, fallback, display value or audit value. `glm-5` may still appear only as a model name when a configured provider points to that model. +- Historical `integrations.config` and `ai_call_logs` rows are migrated by `backend/alembic/versions/20260429_rename_glm_provider_to_dashscope.py`; new writes must persist `dashscope` for DashScope/Qwen suppliers and keep `provider_label / runtime_label / engine_name` as display-only fields. + +### Prompt operations center +- Scope: `/prompt-assets*` is platform-only and requires `platform.ai_routing.manage`. It is not a customer workspace Prompt strategy surface and must not be exposed in Space, Project Launch or project-level AI copilot. +- Main read endpoints: + - `GET /prompt-assets` + - `GET /prompt-assets/{prompt_key}` + - `GET /prompt-assets/{prompt_key}/quality-summary` + - `GET /prompt-assets/{prompt_key}/golden-cases` + - `GET /prompt-assets/{prompt_key}/releases/{release_id}/checks` + - `GET /prompt-assets/{prompt_key}/releases/{release_id}/replay-runs` + - `GET /prompt-assets/{prompt_key}/replay-runs/{run_id}` + - `GET /prompt-assets/{prompt_key}/releases/{release_id}/watch-summary` +- Main write endpoints: + - `POST /prompt-assets/{prompt_key}/releases` + - `POST /prompt-assets/{prompt_key}/releases/{release_id}/activate` + - `POST /prompt-assets/{prompt_key}/releases/{release_id}/rollback` + - `POST /prompt-assets/{prompt_key}/releases/{release_id}/checks/run` + - `POST /prompt-assets/{prompt_key}/golden-cases` + - `POST /prompt-assets/{prompt_key}/golden-cases/from-call/{ai_call_log_id}` + - `POST /prompt-assets/{prompt_key}/releases/{release_id}/replay-runs` +- Golden case request supports `case_type`, `priority`, `scenario_key` and `tags`. `priority=blocking` failures block guarded release activation; `important/observation` failures are warning-only and require human review. +- Replay request supports `mode=contract_smoke|compare|llm_compare`, `include_llm`, `judge_enabled`, `case_status` and `max_cases`. `include_llm=true` forces `llm_compare` and caps max cases. LLM replay must call `llm_service` with explicit `routing_meta` (`prompt_key`, `prompt_release_id`, `prompt_source`, `tier_alias`, `scene_key`, `run_id`) and must not hardcode provider/model. +- Replay case results persist deterministic contract status plus optional `baseline_ai_call_log_id`, `candidate_ai_call_log_id`, `judge_score`, `judge_label`, `judge_reason`, `risk_flags_json`, latency and cost estimate. Judge rubric comes from `backend/app/config/ai_prompt_eval_rubrics.py`. +- Release watch summary returns 24h/7d quality windows, `structured_invalid_24h`, `fallback_rate_24h`, recent failures and `recommendation=healthy|watch|needs_review|rollback_suggested`. It is advisory only and must not auto-rollback. +- `inline_discovered` and `needs_migration` assets are read-only inventory until their runtime call sites are migrated to `PromptRuntimeService` / `PromptReleaseService`; UI and API copy must not imply these prompts are already configurable in production runtime. + ## Signal & SSTC +All `/projects/{project_id}/...` SSTC and model-capability context read endpoints must enforce project visibility before returning business data: project member, `OWNER`, or `REVIEWER`. This includes overview, stages, risks, governance, chain, charters, signals, tasks, releases, reviews, truth nodes, and experiments. + +## Workspace / Membership + +Product language uses workspace/space; the compatibility table name remains `tenants`. + +### POST `/auth/phone/send-code` +- Purpose: send an SMS-style verification code for phone login, self-registration, or invitation binding. +- Request body: + - `phone_country_code: string`, default `+86` + - `phone_number: string` + - `purpose?: "login" | "bind_invitation" | "reset"` + - `invite_token?: string` +- Behavior: + - Phone is normalized to global `users.phone_e164`; duplicate accounts for the same verified phone are not allowed. + - Development environments may return `dev_code` to keep local verification testable. Production must not expose the code to users. + +### POST `/auth/phone/verify` +- Purpose: verify the phone code, log in the account, and create the account if it does not exist. +- Request body: + - `phone_country_code: string`, default `+86` + - `phone_number: string` + - `code: string` + - `purpose?: "login" | "bind_invitation" | "reset"` + - `name?: string` + - `invite_token?: string` +- Behavior: + - If the phone has no existing account, the backend creates a global user account plus a personal workspace. + - If `invite_token` is present and bound to the same verified phone, the backend accepts the team invitation and switches the active workspace to that team. + - Existing users keep their personal workspace; joining or leaving a team never transfers ownership of the account itself. +- Response data: regular login payload with `access_token`, `token_type`, `user.workspace_context`, and `user.platform_context`. + - `workspace_context` describes the customer's active personal/team workspace membership. + - `platform_context` is PMFlow-internal access only: `{ has_platform_access, roles, capabilities }`. A customer workspace `OWNER` does not imply any platform capability. + +### POST `/auth/register` +- Purpose: create a global account plus that user's personal workspace. +- Behavior: compatibility username/password registration path. The preferred user-facing path is phone verification via `/auth/phone/verify`. The new user becomes `OWNER` of the personal workspace; this workspace is not removed when the user later leaves team workspaces. + +### Platform membership +- Table/model: `platform_memberships`. +- Purpose: grant PMFlow-internal operators access to the platform AI operations control plane. +- Roles: `PLATFORM_OWNER`, `PLATFORM_ADMIN`, `AI_OPS`, `SUPPORT`, `BILLING_OPS`, `SECURITY_AUDITOR`. +- Shape: one user may hold multiple platform roles; uniqueness is `user_id + role`, and `platform_context.capabilities` is the union of active roles. +- Boundary: platform roles are not workspace roles. They never come from `tenant_memberships.role` or legacy `users.role`. +- Bootstrap: `PMFLOW_PLATFORM_ADMIN_USERS` and `PMFLOW_AI_OPS_USERS` may grant temporary env-based platform access before DB grants exist. + +### GET `/workspaces/me` +- Purpose: list all active workspaces for the current account. +- Response data: `{ active_workspace, workspaces[] }`; each workspace includes `workspace_type` (`personal` or `team`) and membership `role`. + +### POST `/workspaces/teams` +- Purpose: create a small-team workspace from a solo account. +- Behavior: current user becomes `OWNER` and the new team workspace becomes active. + +### POST `/workspaces/invitations` +- Purpose: invite a member by phone into the current team workspace. +- Auth: active workspace role must be `OWNER` or `REVIEWER`; only `OWNER` may invite `REVIEWER`. +- Request body: + - `phone_country_code: string`, default `+86` + - `phone_number: string` + - `role: "OWNER" | "BUILDER" | "OPERATOR" | "REVIEWER"` + - `email?: string`, optional compatibility/contact field + - `project_ids?: number[]` +- Behavior: invite may include initial `project_ids` to create project membership after acceptance. Re-inviting the same pending `tenant_id + phone_e164` refreshes the token and role instead of creating duplicate pending invitations. + +### POST `/workspaces/invitations/accept` +- Purpose: accept a team invitation. +- Behavior: legacy token acceptance remains supported. Phone-bound invitations require the accepting account's verified phone to match the invitation phone. Existing users keep their personal workspace; new users created from an invitation also receive a personal workspace before joining the team. + +### POST `/workspaces/{tenant_id}/leave` +- Purpose: leave a team workspace. +- Boundary: personal workspaces cannot be left; the last `OWNER` of a team must transfer ownership before leaving. + +### GET `/projects/` +- Purpose: list project rows or summary rows for project index and personal Space aggregation. +- Auth: logged-in user; tenant comes from JWT. +- Query: + - `summary?: 0 | 1` + - `status?: string` + - `assigned_role?: string` +- Visibility: + - `OWNER` and `REVIEWER` receive all non-filtered projects in the current tenant. + - Other roles only receive projects they own (`projects.owner_id`) or projects linked through `user_projects`. + - The same visibility rule applies to `GET /projects/{project_id}`; inaccessible projects return `404` rather than leaking existence. +- Response data: + - `summary=1`: `{ projects: ProjectSummary[] }` + - otherwise: `Project[]` + +### POST `/projects/` +- Purpose: create a project shell/title and membership record. This endpoint does not generate requirements, tasks, risks, or Project Memory. +- Auth: `OWNER` or `REVIEWER`; tenant comes from the active workspace membership. +- Request body: + - `title: string` + - `description?: string` + - `status?: string`, default `active` + - `domain_id?: number` + - `assigned_role?: string` + - `member_user_ids?: number[]` +- Behavior: + - Project titles are compacted and must be unique per `tenant_id` across non-`inactive` projects. + - A duplicate title returns `409 project title already exists`. + - The endpoint never calls AI and never writes `truth_nodes`, `tasks`, ChangeSets, or semantic facts. +- Response data: serialized project row `{ id, title, description, status, domain_id, assigned_role, signal_count, created_at, updated_at }` + +### GET `/ai-pmo/work-items` +- Purpose: compatibility read model for Space legacy manual gates and draft confirmations. +- Auth: logged-in user; tenant comes from JWT. +- Visibility: + - `OWNER` and `REVIEWER` may inspect tenant-level legacy work items. + - Other roles only receive work items owned by themselves (`work_items.owner_user_id`) or bound to a project they can access through `user_projects`. + - Unbound legacy work items without `owner_user_id` are hidden from non-manager users. +- Boundary: this endpoint is not a team inbox product surface; Space consumes it only as a compatibility adapter. + ### GET `/projects/{project_id}/signals` - Response data: - `items[]` includes planner fields: @@ -48,36 +301,103 @@ ## Model Routing ### GET `/model-routing/functions/{function_name}` -- Purpose: query function-level advanced routing override. This is an admin/advanced entry, not the default product UI for members or team leads. +- Purpose: query function-level routing diagnostics for hidden Route Lab. This is not shown in member, team-lead, or regular AI Ops navigation. +- Auth: PMFlow platform capability `platform.ai_routing.manage`. - Response data: - `function_name` - `tier` - `source: "default" | "override"` ### PUT `/model-routing/functions/{function_name}` -- Auth: manager/admin only +- Auth: PMFlow platform capability `platform.ai_routing.manage`. +- UI exposure: hidden from the current Route Lab UI. Future exposure must add reason, TTL, dry-run, audit review, and auto-expiry controls before operators can edit function-level overrides. - Request body: - - `tier` + - `tier_alias: "low" | "high"` - Side effects: - persist override into `integrations(type='model_routing')` - append audit to `operation_logs` +- Boundary: + - The API only accepts slot aliases, not provider/model/base URL strings; arbitrary model-name editing remains owned by provider connection setup. + - Protected high-requirement functions such as requirement planning and assignee matching cannot be downgraded to `low`; the service returns a validation error instead of saving an override that would violate scene-slot capability requirements. ## AI Routing & Providers -- UI placement: these endpoints power the team-level AI 运营中心 under `/ai-config`; they are not project-scoped and must not be presented as per-project provider/model settings. +- UI placement: split by control plane. + - Customer workspace UI under `/ai-config` only shows AI usage settings: readiness, team policy, input sources, knowledge, MCP template use, and quotas. + - PMFlow platform AI operations UI under `/platform/*` owns provider health, global routing, route/context governance, semantic facts, prompt/eval diagnostics, and cross-tenant operations metrics. + - Project pages consume AI state only through AI 副驾 status and read-only inherited policy summaries. +- Platform AI Ops UI is now menu-split under the AI runtime layer: + - `/platform`: platform runtime overview, affected scenes, diagnostics, and next actions; old `/platform/ai-routing-console` redirects here for compatibility. + - `/platform/ai-routing-providers`: provider/runtime connection, independent enable/disable state, masked credential state, connection test state, normalized capability matrix, executable capability probe evidence, and one global default fallback runtime. + - `/platform/ai-routing-slots`: PMFlow capability slots; fast/deep are configurable now, structured is consumed by `llm_service.generate_structured_object` once published active, long-context is consumed only by explicit long-document/long-thread/cross-project-context calls, and tool is consumed only by explicit tool-calling or MCP execution-planning calls. External-query/private/multimodal remain reserved candidate slots. A slot consumes enabled supplier runtimes through primary runtime + fallback chain, publish validation checks provider capabilities and probe evidence, and business/runtime code consumes published active slots instead of hardcoded model names. + - `/platform/ai-routing-scenes`: business-scene to capability-slot matrix; advanced function details are hidden by default. + - `/platform/ai-routing-costs`: call cost, upgrade cost, quota/cache gaps, and provider usage integration gaps. + - `/platform/ai-routing-failures`: sanitized risk diagnostics and recent failure samples. + - `/platform/ai-call-traces`: AI call trace ledger for provider/model invocations, redacted prompt/response previews, routing metadata, slot evidence, latency/token/cost estimates, and trace detail drill-down. + - `/platform/prompt-assets`: Prompt asset inventory and operation view; lists registry prompts, default seed prompt availability, active/draft prompt releases, call sites, risk level, release policy, and inline prompt migration gaps. +- These platform menus consume `GET /ai-routing/admin-console`, `GET/PUT/POST /ai-routing/capability-slots*`, `POST /ai-routing/diagnostics/run`, `GET /ai-call-traces*`, and `GET/POST /prompt-assets*`; provider writes are owned by `/ai-steward/llm-engines`. Runtime route mutation remains restricted to platform AI Ops. Provider rows expose `available_actions` so the UI only renders operations that are valid for the saved connection state. + +### LLM Engine Provider Connections +- Auth: PMFlow platform capability `platform.ai_routing.manage`. +- Storage: `integrations(type='llm_engine')` under the platform AI Ops runtime scope. The database table remains tenant-scoped for compatibility, but platform supplier/runtime config is pinned to the internal platform runtime scope (`PMFLOW_PLATFORM_RUNTIME_TENANT_ID`, default `1`) and is not a customer workspace setting. Existing `active_engine_id` is treated as legacy compatibility for `default_fallback_engine_id`. +- Runtime semantics: + - Multiple supplier connections may be enabled at the same time. + - `enabled=false` connections are retained for editing/testing history but skipped by runtime routing, low/high tier selection, and low-to-high escalation. + - `default_fallback_engine_id` is the global fallback when no function rule, capability slot, or tier route resolves. It is not a supplier enable switch. + - Connection tests update `connection_status`, `last_test_status`, `last_test_at`, and `last_error_summary`; they do not by themselves make a connection the default fallback. + - Each connection stores a normalized `capabilities` object inside `integrations(type='llm_engine').config.engines[*]`; legacy rows without capabilities are normalized at read time using provider/model defaults so UI and validation do not crash. + - Capability fields currently include `structured_output`, `json_schema`, `tool_calling`, `long_context`, `max_context_tokens`, `vision`, `audio`, `local_only`, `private_deployment`, `external_search_ready`, `avg_latency_ms`, `cost_tier`, and `reliability_tier`. + - Capability audit fields include the normalized nested `capability_probe` object plus backward-compatible flat fields `capability_source`, `capability_probe_status`, `capability_last_verified_at`, `capability_blocking_reasons`, and `capability_evidence`. `capability_probe` is stored inside the engine config and has `{status, last_verified_at, verified_capabilities, evidence, source}` where `source` is `inferred | manual | probed`; each evidence row records `status/method/evidence_strength/request_summary/response_summary/failure_code/latency_ms` and may carry operator evidence metadata (`source_type/source_url/source_note/verified_by/verified_at/expires_at`). Read responses also expose `declared_capabilities` and backend-derived `effective_capabilities`; legacy rows without `capability_probe` normalize to `status=untested` and do not crash old runtime reads. +- Endpoints: + - `GET /ai-steward/llm-engines`: list masked connections, settings, normalized `capabilities`, `available_actions`, and `default_fallback_engine_id`. + - `POST /ai-steward/llm-engines`: create an enabled supplier connection; accepts optional `capabilities`; optional `set_active` remains accepted for backward compatibility and now means “set as default fallback”. + - `PUT /ai-steward/llm-engines/{engine_id}`: update name/provider/model/base URL/key/capabilities and reset connection test state when runtime fields change. + - `PUT /ai-steward/llm-engines/{engine_id}/enabled`: enable or disable a saved connection. + - `POST /ai-steward/llm-engines/{engine_id}/default-fallback`: set the global fallback runtime; only enabled connections with `connection_status=available` can be selected. + - `POST /ai-steward/llm-engines/{engine_id}/test`: test one enabled saved connection using its saved provider/model/base URL/key. + - `POST /ai-steward/llm-engines/{engine_id}/capability-probe`: only runs for enabled connections that already passed connection test; accepts `mode=quick|full|advanced`, optional `targets[]`, and `allow_high_cost`. The default full plan runs basic chat, a low-cost model self-report reference call, plus the declared capability scope: structured JSON parse, independent JSON Schema parameter, tool-calling, streaming observability, and long-context declared-window gates only when long-context is declared or explicitly targeted. Model self-report is stored as `status=declared_only/evidence_strength=model_self_report`; it is visible in provider evidence and AI call audit because it is a real upstream call, but it never counts as protected slot verified evidence or aggregate probe pass/partial/fail status. Provider UI must render model self-report only as a separate reference card, not as a probe-detail capability pill; the long-context probe-detail row is the single user-facing capability item for context window status. Streaming is a transport/runtime trait for `stream=true` incremental responses, not a declared model capability matrix item; provider UI may show it in probe detail and a separate runtime traits area, but must not mix it into the verified model capability list. Declared capabilities must be displayed without arbitrary truncation; if any declared capability lacks live/provider-contract/operator evidence, aggregate status cannot be `passed` and must stay `partial` or `untested`. Undeclared optional capabilities do not produce blocking failures or downgrade the aggregate probe status. Long-context gates read the configured `max_context_tokens`; provider/model specs that PMFlow knows from official contracts, such as DashScope `qwen-plus`, are stored as `status=verified/evidence_strength=provider_contract` without sending high-cost long text to the endpoint. Unknown provider declared windows remain `declared_only` and do not count as verified slot evidence. A verified higher long-context window covers lower threshold rows such as 32K/128K for aggregate status and UI display. Results write back into `capabilities.capability_probe`, `capability_evidence`, and derived `effective_capabilities` without adding a migration. + - `POST /ai-steward/llm-engines/{engine_id}/capability-attest`: platform operator manually confirms a high-cost capability evidence item, currently scoped to `capability_key=long_context`. The request records `tested_tokens`, evidence source type, optional source URL/note, and optional expiry. It writes `evidence_strength=operator_verified/method=operator_attestation` into `capability_probe.evidence` and compatibility `capability_evidence`, updates derived `effective_capabilities`, and can satisfy long-context protected slot publishing until the evidence expires. This endpoint does not call the upstream provider endpoint and therefore does not create an `ai_call_logs` row; AI call audit continues to show only real upstream endpoint interactions. + - `DELETE /ai-steward/llm-engines/{engine_id}`: delete a saved connection only after it is disabled and is not the default fallback. + +### Capability Slots +- Auth: PMFlow platform capability `platform.ai_routing.manage`. +- Storage: `integrations(type='ai_capability_slots')` under the same platform runtime scope as provider connections (`PMFLOW_PLATFORM_RUNTIME_TENANT_ID`, default `1`). +- Runtime semantics: + - Provider connections are the runtime inventory; capability slots bind enabled provider connections into PMFlow ability abstractions. + - Slot config is release-controlled: edits write `draft.slots`; only `publish` promotes the draft into `active.slots`. Runtime code reads active slots only and never consumes unpublished draft. + - `low/high` tier resolution, control-plane rule resolution, function override resolution, and low-to-high escalation first consume active slot binding when present. + - `backend/.control_plane/pmflow-routing-rules.json` remains the baseline/default rule pack and cold-start fallback, not the only live binding source once a slot is configured. + - Disabled provider connections cannot be saved as primary or fallback slot runtimes. + - Draft validation applies `SLOT_REQUIREMENTS` from `backend/app/services/ai_routing_governance_service.py`: high forbids local-only/Ollama, structured requires `structured_output + json_schema`, long-context requires `long_context` and `max_context_tokens >= 32768`, and tool requires `tool_calling`. Structured/tool/long-context are configurable draft slots; external/private/multimodal remain non-configurable candidate slots and cannot be saved as runtime bindings. + - Provider capability probing treats `long_context` as the provider's configured `max_context_tokens` window, not as a hard-coded 32K test. A provider configured with 16K verifies that declared 16K window when the provider/model has a known official contract covering it, but it still cannot satisfy the long-context slot publish gate unless `max_context_tokens >= 32768`; `long_context_32k/128k` are explicit threshold probes and slot-publish gates only. Editing provider/model/base URL/API key or declared capabilities invalidates previous capability probe evidence and resets the engine to `capability_probe_status=untested`; name-only edits preserve evidence. + - For structured/tool/long-context slots, validation distinguishes missing capability, missing probe evidence, and stale probe evidence: `*_capability_missing` means the normalized declared capability is false or below threshold; `*_capability_evidence_missing` means labels claim support but no verified `capability_probe.evidence`/compatibility `capability_evidence` with live, provider-contract, or operator-verified evidence strength exists; `*_capability_evidence_stale` means verified evidence is older than `AI_CAPABILITY_PROBE_EVIDENCE_TTL_DAYS` (default 90) or its explicit `expires_at` has passed. `declared_only/manual/inferred/model_self_report` evidence never satisfies protected slot publishing. `low` can publish without probe evidence but returns a warning. + - Runtime effect is release-controlled: `generate_structured_object` only consumes the published active `structured` slot. Draft structured bindings are visible for validation/review but do not affect runtime until publish succeeds. When an active structured slot exists, runtime tries the structured primary first and then its fallback chain, skipping disabled/deleted runtimes and runtimes that do not satisfy `structured_output + json_schema`. If all configured structured slot runtimes fail after invocation, the call fails explicitly instead of falling back to templates or an unrelated low/high route. If the published active structured slot is configured but has no usable runtime before invocation, protected structured functions fail explicitly with a structured-slot runtime error; non-protected structured calls may fall back to the existing high/low/default path only with degraded routing metadata. + - Long-context and tool runtime effects are narrower than structured: runtime consumes active `long_context` only when `routing_meta` explicitly requests `slot_key/planned_tier/tier_alias=long_context`, and consumes active `tool` only when `routing_meta` explicitly requests `tool` or uses MCP execution-planning modes `execution_mode=local_planner|local_worker`. These slots skip disabled/deleted runtimes and runtimes missing `long_context + max_context_tokens >= 32768` or `tool_calling`. If the requested active slot is configured but has no usable runtime, the call fails with `{slot}_slot_runtime_unavailable`; if no active slot is published, the existing low/high/control-plane path remains in force. External-query/private/multimodal slots are not runtime consumers in this phase even if an active candidate binding exists. + - Capability slot routing metadata includes `slot_key`, `slot_version`, `slot_runtime_source=active_slot`, `slot_fallback_index`, and `slot_degraded=false`; degraded non-protected structured fallback uses `slot_runtime_source=active_slot_degraded` and `slot_degraded=true`. + - Slot detail payloads expose `candidate_engines[]` for runtimes satisfying the slot requirements and `blocked_candidate_engines[]` with product-level `gap_reasons[]` for enabled runtimes that are missing capability, missing probe evidence, or stale evidence. + - Deep/high slot is protected and cannot enable customer-space downgrade; high slot cannot publish a local Ollama runtime. + - Existing legacy `{slots: ...}` configs are read as both active and draft during normalization so upgrades do not drop live routing. +- Endpoints: + - `GET /ai-routing/capability-slots`: returns `active_version`, `draft_version`, `dirty`, `validation`, `history`, `active_slots`, `draft_slots`, configurable runtime slots, external-query provider-chain slots, and non-configurable candidate slots with masked runtime metadata. + - `PUT /ai-routing/capability-slots/{slot_key}/draft`: update `primary_engine_id`, `fallback_engine_ids`, `failure_policy`, and `allow_scene_downgrade` for configurable `low/high/structured/tool/long_context` draft slots; for `external_query`, the same request fields carry `primary_provider_id` and `fallback_provider_ids` and are normalized into the slot binding as `primary_provider_id / fallback_provider_ids / verifier_required=true / source_filtering_required=true / failure_policy=explicit_fail`. The endpoint appends `operation_logs(entity_type='ai_capability_slot', action='draft_update')`. `private/multimodal` return a product error because they are candidate slots and do not consume runtime in this phase. + - `PUT /ai-routing/capability-slots/{slot_key}`: backward-compatible alias for draft update; it no longer makes runtime routing immediately consume the change. + - `POST /ai-routing/capability-slots/validate`: validate draft slots for missing/disabled/test-failed runtimes, duplicate fallback, high-slot Ollama, protected downgrade, capability requirement misses, missing capability evidence, and stale capability evidence. + - `POST /ai-routing/capability-slots/publish`: promote draft slots to active when validation has no critical item, generate a release version, and append `operation_logs(entity_type='ai_capability_slot_release', action='publish')`. + - `POST /ai-routing/capability-slots/rollback`: restore a prior published slot snapshot as a new active version and append `operation_logs(entity_type='ai_capability_slot_release', action='rollback')`. ### GET `/ai-routing/overview` -- Purpose: productized AI routing overview for team leads and AI operators. -- Auth: `OWNER` or `REVIEWER` via `can_view_ai_policy`. +- Purpose: platform AI routing overview for PMFlow AI operators. +- Auth: PMFlow platform capability `platform.ai_routing.manage`. - Response data: - `control_plane`: `{ version, source, updated_at, baseline_note }` - - `slots[]`: low/high slot summaries with Chinese labels, provider/model display summary, connection state, and masked key status. + - `slots[]`: low/high slot summaries with Chinese labels, provider/model display summary, connection state, capability coverage, and masked key status. + - `capability_slots[]`: configurable `low/high/structured/tool/long_context/external_query` slots and candidate `private/multimodal` slots. LLM slots include `primary_engine_id`, fallback chain, failure policy, protected state, requirements, capability coverage, candidate count, candidate runtime previews, and blocked candidate reasons. `external_query` includes `primary_provider_id`, `fallback_provider_ids`, `verifier_required`, `source_filtering_required`, and `external_provider_registry` status for MCP adapter, bounded live search fallback, verifier, and fail-closed source filtering. - `metrics`: recent 24h/7d calls, failures, failure rate, escalation rate, structured failure rate, average latency, and cost estimate. - - `diagnostics[]`: risk cards such as env key override, high slot unavailable, same runtime for low/high, protected downgrade, structured failure spike, or local Ollama scope risk. + - `diagnostics[]`: risk cards such as high slot unavailable, same runtime for low/high, protected downgrade, structured failure spike, or local Ollama scope risk. - Boundary: no cleartext key, `api_env`, prompt, raw provider secret, raw error, or raw routing meta. ### GET `/ai-routing/team-policy` - Purpose: team-lead-facing business policy view for project launch, requirement structuring, task breakdown, assignee matching, internal QA, external query, Feishu understanding, and Feishu reply. -- Auth: `OWNER` or `REVIEWER`. +- Auth: current workspace `OWNER` or `REVIEWER`. - Response data: - `strategy`: `quality_first | balanced | speed_first` - `scenes[]`: Chinese business scene policies, preferred mode, escalation rule, fallback behavior, and editable business note. @@ -86,7 +406,7 @@ ### PUT `/ai-routing/team-policy` - Purpose: save team AI policy display/governance state. -- Auth: `OWNER` only via `can_approve_ai_routing_change`. +- Auth: current workspace `OWNER` only via `can_approve_ai_routing_change`. - Request body: - `strategy?: "quality_first" | "balanced" | "speed_first"` - `scenes?: [{ key, policy, mode?, human_confirmation?, protected? }]` @@ -99,20 +419,197 @@ - Boundary: never writes `backend/.control_plane/pmflow-routing-rules.json`, provider secrets, or model-routing overrides. ### GET `/ai-routing/admin-console` -- Purpose: AI admin/operator console aggregating provider connection summaries, low/high slots, business-scene routes, recent failure samples, protected scenes, and env override diagnostics. -- Auth: `OWNER` or `REVIEWER`. +- Purpose: AI admin/operator console aggregating provider connection summaries, capability slots, post-publish slot observability, business-scene routes, recent failure samples, protected scenes, and supplier/runtime diagnostics. +- Auth: PMFlow platform capability `platform.ai_routing.manage`. - Response data: - - `providers[]`: display-only provider health and masked key state. - - `slots[]`: fast/deep slot summaries. - - `scene_routes[]`: scene label, mode, contract, protection state; raw `function_name` may only be present in admin data and must be hidden by default in UI advanced details. - - `recent_failures[]`: sanitized failure samples without raw error or prompt. - - `protected_scenes[]`, `environment_overrides[]`, `diagnostics[]` + - `providers[]`: display-only provider health, enable state, fallback marker, connection test state, masked key state, normalized capability tags, capability source, probe status, last verified time, probe error summaries, probe blocking reasons, and state-derived `available_actions`. + - `capability_slots[]`: configurable `low/high/structured/tool/long_context/external_query` slot binding summaries and candidate `private/multimodal` placeholders with requirements, coverage, missing capability labels, missing evidence labels, stale evidence labels, candidate runtimes, and external provider registry state for the external-query slot. + - `capability_slot_release`: release-control payload from `GET /ai-routing/capability-slots`; the UI uses draft slots for editing and active slots for current runtime display. + - `slot_observability[]`: post-publish read model for active `low/high/structured/long_context/tool/external_query` slots. Each item includes `slot_key`, `slot_label`, `active_version`, `active_runtime`, and `windows.last_24h / windows.last_7d` with `status=sampled|empty`, `total_calls`, `failed_calls`, `failure_rate`, `fallback_hits`, `fallback_rate`, `degraded_calls`, `degraded_rate`, `active_slot_calls`, `active_slot_degraded_calls`, `average_latency_ms`, `estimated_cost`, source labels, and sanitized `recent_failure_samples[]`. Aggregation is based on `ai_call_logs` routing metadata when present (`slot_key`, `slot_runtime_source`, `slot_fallback_index`, `slot_degraded`) and compatible slot columns (`planned_tier/executed_tier/routing_decision`) for old logs. Empty windows return zero counts and `sample_state_label=暂无运行样本`; cost and latency are operational estimates from logs, not provider bills or SLA measurements. + - `external_query` publish validation fails closed when no external provider is available, neither MCP adapter nor bounded live search fallback is available, verifier is unavailable, source filtering is not fail-closed, or failure policy is not `explicit_fail`. Runtime only consumes the active external-query slot after `route_mode=external`; internal query and unpublished drafts do not consume it. + - `scene_routes[]`: scene label, mode, backend `scene_contract`, protection state, lifecycle, and diagnostic function mapping; raw `function_name` may only be present in admin data and must not be shown in the business-scene route UI. + - `scene_slot_matrix[]`: explainability matrix for operators. Each row includes `scene_key`, `scene_label`, `scene_contract`, `lifecycle`, `scene_family`, `current_slot`, `required_slot`, `current_slot_label`, `required_capabilities[]`, `protected`, `active_runtime`, `active_slot_runtime`, `draft_runtime`, `draft_slot_runtime`, `active_version`, `draft_dirty`, `capability_gap`, `recent_failure_rate`, `recent_failure_count`, `recent_failed_calls`, `recent_total_calls`, `scene_health`, `release_gate`, `impact_summary`, aggregate `risk_level/risk_label`, `slots[]`, and advanced `functions[]`. `scene_contract` includes business goal, input contract, output contract, success criteria, failure policy, confirmation policy, evidence policy, protected reason, expected slot, scene family, owner, and lifecycle. `scene_health` includes `status=healthy|warning|blocked|unknown`, score, blockers, warnings, and evidence summary. `release_gate` includes whether publish is blocked, blocking reason, draft impact, protected-scene regression, and suggested action. `active_runtime` / `active_slot_runtime` and `draft_runtime` / `draft_slot_runtime` summarize slot key/label, release version, configured state, runtime label/source, and primary runtime id. `capability_gap` distinguishes missing capability, missing probe evidence, stale evidence, and human-readable blocking reasons for release review. Each slot row includes `slot_key`, slot label, whether it is required by the scene, active runtime/source, release version, capability coverage, and risk reasons such as missing active binding, missing capability, missing probe evidence, stale probe evidence, or protected-scene downgrade. `functions[]` is diagnostic payload only; business-scene route UI must stay at scene/slot level, consume backend scene contracts, and route function-level inspection to hidden Route Lab. + - `recent_failures[]`: sanitized failure samples without raw error or prompt; includes `scene_key / scene_label` when the function belongs to a registered business scene so `/platform/ai-routing-failures?scene=...` can filter deterministically. + - `protected_scenes[]`, `diagnostics[]` + +### GET `/ai-call-traces` +- Purpose: platform AI call trace ledger for PMFlow AI operators to inspect provider/model call history, sanitized request/response evidence, runtime routing metadata, slot usage, latency, tokens, cost estimates, and recent failure context. +- Auth: PMFlow platform capability `platform.ai_routing.manage`. +- Storage: reads from `ai_call_logs`; new runtime writes should populate `trace_id`, `user_id` when the request has an authenticated operator, raw configured `provider` code, `model`, `latency_ms`, `routing_meta`, prompt attribution fields `prompt_key / prompt_release_id / prompt_source`, and true upstream provider payload fields `provider_request_payload / provider_response_payload / provider_attempts` in addition to existing prompt/response/token/cost/status fields. Operator-facing responses must distinguish raw provider code from product/runtime display labels; DashScope/Qwen calls must persist provider code `dashscope`, never the removed historical code `glm`. +- Query params: + - `page`, `page_size` + - `status` + - `provider`, `model` + - `function_name`, `scene_key`, `slot_key` + - `prompt_key`, `prompt_source`, `prompt_release_id` + - `trace_id` + - `tenant_id` + - `keyword` + - `since_hours` +- Response data: + - `summary`: total calls, failed calls, total tokens, estimated cost, average latency, and top provider codes/display labels for the current filter set. + - `items[]`: log id, trace id, tenant/user, created time, raw `function_name` plus Chinese `function_label`, scene display metadata, status, raw `provider_code`, `provider_label`, `runtime_label`, optional `engine_id/engine_name`, model, slot key/label, prompt key/release/source, routing decision/source/rule, token/cost/latency metrics, upstream usage-derived `prompt_tokens / completion_tokens / total_tokens`, request `max_tokens`, `finish_reason`, Function Runtime Profile version/max token metadata, redacted trigger preview, and error summary. + - `page`, `page_size`, `total`, `has_next`. +- Capability probe display: AI call audit follows the real upstream endpoint interaction boundary. New provider capability probes do not write an extra `probe_engine_capabilities` aggregate log. Rows whose `function_name` starts with `probe_capability_` are visible by default only when they correspond to an upstream provider call; the model self-report probe is shown because it is a short upstream call, while non-upstream declared/contract gates are stored in provider capability evidence but are not written as AI call logs. Historical `probe_engine_capabilities` aggregate rows are hidden from default list/summary results and remain queryable only through an explicit `function_name` filter. +- Boundary: prompt, response, and provider payload content are always redacted before returning to the UI. Provider secrets, cleartext bearer tokens, API keys, phone numbers, emails, and raw transport headers must not be returned. The database stores compatibility `prompt/response` for legacy search and previews, but operator detail UI should prioritize true upstream `provider_request_payload / provider_response_payload / provider_attempts` and routing/probe metadata. This view is platform-only and must not appear in customer workspace AI settings, Project Launch, Space, or project-level AI copilot surfaces. + +### GET `/ai-call-traces/{trace_id_or_log_id}` +- Purpose: open one AI call trace detail by `trace_id` or numeric `ai_call_logs.id`. +- Auth: PMFlow platform capability `platform.ai_routing.manage`. +- Response data: + - `trace`: list-row fields plus routing metadata, probe metadata, redacted upstream payload evidence, and audit policy notes. + - `trace.raw_payload_available=true` when `ai_call_logs.provider_request_payload`, `provider_response_payload`, or `provider_attempts` exists; detail response includes `request.provider_request_payload_redacted`, `response.provider_response_payload_redacted`, and `response.provider_attempts_redacted`. Raw encrypted provider payload replay/export is not part of this API. +- Boundary: 404 returns the standard response envelope with `data=null`; details follow the same redaction and platform-only visibility rules as the list endpoint. + +### GET `/prompt-assets` +- Purpose: platform Prompt asset center for PMFlow AI operators to inspect all registered and discovered runtime prompts, release state, and migration gaps before editing or optimizing prompt behavior. +- Auth: PMFlow platform capability `platform.ai_routing.manage`. +- Storage/read model: combines `backend/app/config/ai_prompt_registry.py`, default templates exported by `backend/app/config/ai_prompts.py`, tenant-scoped `prompt_releases`, prompt release gate checks in `prompt_release_checks`, golden cases in `prompt_golden_cases`, replay runs/results in `prompt_replay_runs / prompt_replay_case_results`, and prompt-attributed quality metrics from `ai_call_logs`. It does not expose secrets or raw provider payloads. +- Query params: + - `family`: `classification | extraction | planning | response | governance` + - `runtime_source`: `catalog | ai_prompts | inline_discovered` + - `editable_policy`: `managed_release | guarded_release | needs_migration` + - `keyword`: matches prompt key, business scenario, module, or call site. +- Response data: + - `summary`: total assets, active release count, needs-migration count, and guarded count for the current tenant. + - `items[]`: prompt key, module, business scenario, call sites, input/output contract, family, responsibility scope, priority/audience, runtime source, editable policy, risk level, template variables, default template hash/preview, active release summary, latest release summary, release gate summary, latest replay run summary, draft count, release count, and migration status. +- Boundary: this is a platform-only operational inventory. `inline_discovered` means the prompt has been found in code but the runtime call site still needs migration to read active releases; the UI must not present such entries as already configurable or active. Customer workspace settings, Space, Project Launch, and project-level AI copilot surfaces must not expose prompt bodies. + +### GET `/prompt-assets/{prompt_key}` +- Purpose: open one prompt asset detail, including the default template body when available, active/draft release state, call sites, contracts, and migration status. +- Auth: PMFlow platform capability `platform.ai_routing.manage`. +- Response data: + - Same fields as the list item, plus `default_template_body`, release `body_snapshot` values for platform-side review and diff, and a 7-day `quality_summary` based on `ai_call_logs.prompt_key`. +- Boundary: 404 returns the standard response envelope with `data=null`. Guarded prompts are visible for audit, but activation policy still belongs to the prompt release / replay / evaluation workflow and must not bypass release review. + +### POST `/prompt-assets/{prompt_key}/releases` +- Purpose: create a tenant-scoped draft prompt release for a managed or guarded prompt asset. +- Auth: PMFlow platform capability `platform.ai_routing.manage`. +- Request data: + - `body_snapshot`: required prompt template body. + - `notes`: optional release note / change reason. +- Validation: + - `prompt_key` must exist in `ai_prompt_registry`. + - Only `editable_policy=managed_release | guarded_release` and non-inline assets with a default template can create releases. + - Template placeholders must not introduce variables outside the default template contract. +- Boundary: `needs_migration` and `inline_discovered` return the standard error envelope and remain read-only until runtime migration is implemented. `guarded_release` can create candidate releases, but cannot activate until release gate checks pass. + +### POST `/prompt-assets/{prompt_key}/releases/{release_id}/activate` +- Purpose: activate a draft prompt release for the current tenant; any existing active release for the same prompt becomes archived. +- Auth: PMFlow platform capability `platform.ai_routing.manage`. +- Boundary: managed releases can activate directly. Guarded releases require passing gate checks before activation; `golden_replay` remains blocking when no active golden cases exist, and with active cases it requires a completed release replay run with zero failed cases. Current replay mode is deterministic contract smoke and explicitly records that no real LLM replay was executed. + +### POST `/prompt-assets/{prompt_key}/releases/{release_id}/rollback` +- Purpose: reactivate an archived managed prompt release as a rollback target. +- Auth: PMFlow platform capability `platform.ai_routing.manage`. +- Boundary: only archived releases can be rolled back. Draft releases must use activate, and guarded prompts remain blocked until release gates exist. + +### GET `/prompt-assets/{prompt_key}/quality-summary` +- Purpose: aggregate prompt-attributed AI call quality for one prompt asset. +- Auth: PMFlow platform capability `platform.ai_routing.manage`. +- Query params: + - `window`: `24h | 7d | 30d`, default `7d`. + - `status`: optional status filter. + - `release_id`: optional prompt release id filter. +- Response data: `total_calls`, `failed_calls`, `failure_rate`, `fallback_calls`, `active_release_calls`, `default_fallback_calls`, `average_latency_ms`, `estimated_cost`, `recent_failures`, and `release_breakdown`. +- Boundary: no attributed call rows returns zero metrics and empty samples; the API must not infer quality from unrelated function names or raw prompt text. + +### GET `/prompt-assets/{prompt_key}/golden-cases` +- Purpose: list deterministic golden cases for a prompt asset. +- Auth: PMFlow platform capability `platform.ai_routing.manage`. +- Query params: `status`, default can be omitted; UI usually reads `active`. +- Response data: `items[]`, `total`, `active_count`. + +### POST `/prompt-assets/{prompt_key}/golden-cases` +- Purpose: create a manual golden case for future replay and current guarded release gate. +- Auth: PMFlow platform capability `platform.ai_routing.manage`. +- Request data: `case_name`, `input_payload`, optional `expected_contract`, `expected_contains`, `notes`. +- Boundary: stores JSON contracts only; it does not execute an LLM and does not publish a prompt. + +### POST `/prompt-assets/{prompt_key}/golden-cases/from-call/{ai_call_log_id}` +- Purpose: convert a prompt-attributed AI call log into a golden case seed. +- Auth: PMFlow platform capability `platform.ai_routing.manage`. +- Boundary: the source call must have the same `prompt_key`. The generated case stores compact prompt/response previews and metadata, not raw provider payloads or secrets. + +### GET `/prompt-assets/{prompt_key}/releases/{release_id}/checks` +- Purpose: list release gate checks for one prompt release and return the derived gate summary. +- Auth: PMFlow platform capability `platform.ai_routing.manage`. +- Response data: + - `items[]`: check id, prompt key, release id, check type, status, severity, summary, details, creator, created/completed time. + - `gate`: derived status `not_run | passed | blocked`, blocking reasons, required checks. + +### POST `/prompt-assets/{prompt_key}/releases/{release_id}/checks/run` +- Purpose: run MVP prompt release gate checks for a candidate release. +- Auth: PMFlow platform capability `platform.ai_routing.manage`. +- Current checks: + - `variable_contract`: verifies the candidate template does not introduce placeholders outside the default template contract. + - `schema_smoke`: checks whether structured/JSON prompt contracts declare JSON/schema constraints. + - `golden_replay`: records blocking `skipped/no_cases` when no active golden cases exist; with active cases it remains blocking until a release replay run exists, and passes only when the latest completed replay run has zero failed cases. Warnings are recorded for human review and do not pretend to be real LLM replay evidence. +- Boundary: this endpoint does not call an LLM, does not mutate runtime routing, and does not automatically activate a release. Guarded releases stay blocked unless all required checks pass. + +### POST `/prompt-assets/{prompt_key}/releases/{release_id}/replay-runs` +- Purpose: run deterministic replay review for one prompt release against active golden cases. +- Auth: PMFlow platform capability `platform.ai_routing.manage`. +- Request data: + - `mode`: default `contract_smoke`. + - `case_status`: default `active`. + - `max_cases`: default `20`, max `100`. + - `include_llm`: must be `false` in this phase; true returns a standard error. + - `reason`: optional audit note. +- Behavior: + - Renders candidate release templates with golden case `input_payload`. + - Optionally renders the current active release as baseline when present. + - Evaluates deterministic `expected_contract` fields such as `required_input_keys`, `required_prompt_contains`, `expected_contains`, and `forbidden_terms`. + - Persists one `prompt_replay_runs` row and per-case `prompt_replay_case_results` rows. +- Response data: replay run summary with `run_status`, counts, risk flags, summary, baseline release id, and case results. +- Boundary: does not call an LLM, does not write runtime routing, and does not activate a release. Missing active golden cases, unsupported replay mode, or `include_llm=true` fail closed. + +### GET `/prompt-assets/{prompt_key}/releases/{release_id}/replay-runs` +- Purpose: list replay runs for one prompt release. +- Auth: PMFlow platform capability `platform.ai_routing.manage`. +- Response data: `{ prompt_key, release_id, items[], total }`; item rows include run status, counts, summary and risk flags without per-case results. + +### GET `/prompt-assets/{prompt_key}/replay-runs/{run_id}` +- Purpose: read one replay run with per-case results. +- Auth: PMFlow platform capability `platform.ai_routing.manage`. +- Boundary: 404 returns the standard response envelope with `data=null`; replay result payloads contain rendered prompt snapshots and must remain platform-only. ### POST `/ai-routing/diagnostics/run` - Purpose: run read-only diagnostics for routing/provider health. -- Auth: `OWNER` or `REVIEWER` via `can_operate_ai_routing`. +- Auth: PMFlow platform capability `platform.ai_routing.manage`. - Side effects: none; does not modify provider, slot, team policy, or runtime routing. +## PMFlow OS Project Memory Read Models + +All endpoints return `{ code, message, data }` and require project visibility through membership or manager access. These endpoints are read-only: they must not commit ChangeSets, mutate truth nodes, execute AI actions, or write Project Memory. + +### GET `/pmflow-os/projects/{project_id}/project-memory/overview` +- Purpose: return the Project Knowledge Hub overview for the memory homepage. +- Response data: `{ projectId, projectName, generatedAt, metrics[], facts[], requirements[], decisions[], constraints[], evidence[], contextPacks[], writebacks[], aiSuggestions[], warnings[] }`. +- Boundary: `overview` is a backend read model over confirmed truth, indexed assets, context pack projections, and writeback audit rows; frontend must not reconstruct the authoritative memory overview from raw `truth-state` except as a degraded fallback. + +### GET `/pmflow-os/projects/{project_id}/project-memory/items` +- Purpose: list confirmed project memory objects across facts, requirements, decisions, risks, constraints, tasks, tests, releases, and metrics. +- Query params: `category`, `stage`, `source_type`, `status`, `query`, `limit`, `offset`. +- Response data: `{ projectId, total, limit, offset, items[] }`; each item includes `evidenceRefs`, `impactRefs`, timestamps, status, and metadata. + +### GET `/pmflow-os/projects/{project_id}/project-memory/items/{item_id}` +- Purpose: read one memory item for detail drawers or deep inspection. +- Response data: `{ projectId, item }`. + +### GET `/pmflow-os/projects/{project_id}/project-memory/assets` +- Purpose: list project assets such as conversation evidence, documents, code/PR evidence, tests, release records, feedback, metrics, and external research assets. +- Query params: `asset_type`, `source_type`, `status`, `query`, `limit`, `offset`. +- Boundary: assets may be indexed automatically, but they do not become Project Truth until a Change Review or trusted rule confirms them. + +### GET `/pmflow-os/projects/{project_id}/project-memory/context-packs` +- Purpose: list AI-readable context packs grouped by execution scenario. +- Response data: `{ projectId, total, contextPacks[] }`; packs expose included item/asset ids, usage, warnings, and freshness. + +### GET `/pmflow-os/projects/{project_id}/project-memory/writebacks` +- Purpose: list ChangeSet writeback/version audit rows. +- Response data: `{ projectId, total, limit, offset, writebacks[] }`; rows include item counts, committed/skipped/pending status, source ChangeSet id, committer display fields (`committedBy / committedById`), and item summaries. + ## PMFlow OS Adaptive Capacity All endpoints return `{ code, message, data }`. Project members may read. Override simulate/apply requires `OWNER` or `REVIEWER`; write endpoints only persist capacity override audit records and must not assign tasks, request review, write ChangeSet, or mutate Project Memory. diff --git a/docs/architecture/ARCHITECTURE_INVENTORY.md b/docs/architecture/ARCHITECTURE_INVENTORY.md index b7c5fa9e..187e1ba3 100644 --- a/docs/architecture/ARCHITECTURE_INVENTORY.md +++ b/docs/architecture/ARCHITECTURE_INVENTORY.md @@ -40,6 +40,7 @@ - `backend/app/api/rag_api/` - `backend/app/api/sstc_api/` - `backend/app/api/pmflow_launch_api/` +- `backend/app/api/space_api/` - `backend/app/api/pmflow_os_api/` - `backend/app/api/signals_api/` - `backend/app/api/signal_sources_api/` @@ -74,6 +75,8 @@ - `backend/app/config/ai_prompts.py` - `backend/app/config/ai_prompts_catalog/` - `backend/app/config/ai_prompt_registry.py` +- `backend/app/services/prompt_assets_service.py` +- `backend/app/services/prompt_runtime_service.py` - `backend/app/services/model/model_routing/service.py` - `backend/app/services/model/model_config.py` @@ -91,8 +94,10 @@ - `backend/app/utils/timezone.py` - `backend/app/utils/audited_write.py` - `backend/alembic/versions/` +- `backend/alembic/versions/20260429_rename_glm_provider_to_dashscope.py` 是供应商编码迁移:把历史 `integrations.config` 与 `ai_call_logs.routing_meta/provider` 中的旧内部 provider code `glm` 迁移为配置化 `dashscope`;后续不得恢复 `glm` provider alias,`glm-5` 只可作为模型名出现。 - `backend/app/main.py` 现已把模型连通性探测、飞书长连接、World Model 预热、调度器与后台队列消费收口为显式环境开关;开发环境可直接通过 `STARTUP_*` / `FEISHU_LONG_CONNECTION_ENABLED` 关闭重型副作用,避免热重载每次都付全量启动成本。 - `backend/app/api/router.py` 现改为按模块路径动态注册路由;默认保持 `full`,并支持通过 `API_ROUTE_PROFILE` 与 `API_OPTIONAL_ROUTE_GROUPS` 在开发场景下裁掉重型 optional route group,减少 eager import 开销。 +- `backend/app/api/space_api/` + `backend/app/services/space_saved_idea_service.py` + `backend/app/models/saved_idea.py` 是 Personal Space 暂存想法闭环;`saved_ideas` 作为 pre-project idea seed 保存原始输入、来源、准备度、缺失问题、状态、结构化 enrichment 元数据和 Project Launch draft 关联。创建与编辑原始输入时会尝试 `space_saved_idea_enrichment_v1` 结构化判断目标用户、问题、价值假设、验证信号和补充问题,prompt key 为 `PROMPT_SAVED_IDEA_ENRICHMENT`,运行时经 `PromptRuntimeService` 读取平台 active release 并记录 prompt 归因;问候/测试/无实质想法输入必须判为信息不足与 0 准备度,AI 失败时显式保存 `ai_failed/system_baseline`,不冒充 AI 成功。该链路允许创建、列表、更新、归档和 promote 到 Project Launch 草案,但不写 Project Memory、ChangeSet、执行事件或发布对象。 ### 4.5 Mounted Runtime / 训练相关 - `backend/app/models/reply_iteration.py` @@ -102,6 +107,7 @@ - `backend/app/models/semantic_fact.py` - `backend/app/models/semantic_fact_extraction_run.py` - `backend/app/models/pmflow_launch.py` +- `backend/app/models/saved_idea.py` - `backend/app/models/pmflow_change_review.py` - `backend/app/api/ai_steward_api/routes_reply_iteration.py` - `backend/app/api/ai_steward_api/routes_training_pipeline.py` @@ -112,7 +118,7 @@ - API canonical 入口优先使用 `*_api/` 子包,不再优先引用顶层兼容壳。 - 运行时业务动作归因优先走“对象/上下文绑定 -> `SemanticContextPacket` -> `SemanticDecision` -> gatekeeper”。 - non-plan `query.answer` 现已在 `AdaptiveRAG` 内增加 `Query Route Gateway`:只消费上游单次结构化 `query_profile/source_policy/effective_source_policy` 与显式 hint,结合 `ProjectOperatingContext` 归一化产出 `route_mode / decision_origin / reply_contract / probe_snapshot(contract audit) / execution_recipe`,不再在运行时执行 internal/external probe 或 route memory 强拍板,再进入检索与回答主链。 -- query semantics 上游真源也已继续收口:`PROMPT_RAG_QUERY_SEMANTICS` + `mixin_core_intent.py` 现在只把 `query_profile(needs_private_context, needs_freshness, confidence)` 与 `BusinessSemanticFrame` 写回运行时,`subject_focus / query_profile.referenced_objects` 已从 production backend 移除,不再参与 route snapshot、session scope continuity 或任何 read-side 分流。 +- query semantics 上游真源也已继续收口:`PROMPT_RAG_QUERY_SEMANTICS` + `mixin_core_intent.py` 现在只把 `query_profile(needs_private_context, needs_freshness, confidence)` 与 `BusinessSemanticFrame` 写回运行时,`query_profile.referenced_objects` 已从 production backend 移除,不再参与 route snapshot、session scope continuity 或任何 read-side 分流;`subject_focus` 只作为历史结构化输入兼容字段进入 `shared_utils.py` 的实体归一化,不得重新作为 route/session 二次拍板来源。 - `WorkflowSemanticPlanner` 冷启动 bundle 现已补上四挡 `interaction_boundary` 合同:`decision_schema.py` 定义 `InteractionBoundaryPacket / AnswerControlDecision / SelfReviewResult`,planner 会把显式 `status / recommendation / draft / execute` 边界与识别出的 `workflow_plans` 合成 `answer_control`,并把 `requested_level / forbidden_levels / effective_level / blocked_workflows` 落进 bundle diagnostics。对“只给建议/先别出草稿”这类 recommendation-only 问法,planner 允许识别到底层 `signal_source_flow`,但最终会按 boundary 把 bundle 降回 `query_flow`,避免误滑到 `draft_pending_confirm`;旧的无 boundary bundle 仍走兼容透传,不会压坏既有写链路。 - `AdaptiveRAG` 与 AI PMO 主链里几个最容易回流硬语义的入口也已继续收口:project progress fast answer 只按结构化 `interaction_level / goal_kind / resource_role / expected_answer_shape` 决定是否绕开 fast path;casual chat 直答只消费 `message_intent=casual_chat` 的结构化结果;typed executor 对 `project_lookup` 直答只认结构化 `message_intent=project_lookup(_question) + query_semantics`,不再被 `项目列表/项目清单/项目#123` 这类词面带偏;signal 数量和“先分析再入库”这类分析模式也只读 `requested_count / analysis_mode / operation_intent_contract`,不再从自然语言里额外解析“3个信号/先分析后入库”。 - internal config 这条 read-side 也继续结构化了:`AdaptiveRAGTargetResolutionMixin` 不再把泛化 `resource_role=config` 自动映射成 `config:system`,必须依赖显式 `object_binding` 区分 `config:model_routing` 与 `config:mcp_template_center`;对应的 deep guard 直答也拆成模型路由和 MCP 配置两个子口径。 @@ -120,12 +126,12 @@ - `AdaptiveRAG` 的 follow-up clarification 现在也继续往结构化真源收:`mixin_followup_anchor.py` 不再在 runtime 用“第几条/标签名/最近回复 match_text”去消费 `pending_clarification`;`query_route_selection / followup_anchor_selection` 都先经过统一结构化澄清解析,再把选中的 `source_policy_hint / query_semantics_overrides / selected_qa_id` 透回 `mixin_answer_mainline.py`。这样用户说“内部那个 / 第二条”后,主链会带着明确的 route override 或 anchor QA id 继续,不会再把原问题拿回去后靠最近回复词面重猜。 - internal `Operation Contract / Target Resolver` 已按 business-frame-first 收口:`mixin_target_resolution.py` 现在只消费 `goal_kind / target_scope / resource_role / project_binding / object_binding / expected_answer_shape` 来判定 `project / task / signal / config / feature / doc / user` 绑定,不再让 `subject_focus / referenced_objects` 这类 coarse 字段单独拍板内部对象类型。 - internal object/domain clarify 继续往下一层时,当前由 `backend/app/services/adaptive_rag_service/internal_domain_catalog.py` 提供 catalog 真源;catalog 已覆盖 `project / task / signal / config / feature / doc / user` 这批 internal domain。`mixin_target_resolution.py` 会在只确认大域(如 `resource_role=config` 或 `resource_role=doc`)但尚未唯一落到子对象时生成 `candidate_summary`,`mixin_answer_payload.py` / `mixin_answer_precheck.py` / `mixin_followup_anchor.py` 再把它转成 `internal_target_selection` pending clarification,并在 follow-up 后回填 `query_semantics_overrides` 继续主链。 -- `AdaptiveRAG` read-side 那批旧 compatibility consumer 也已一起收口:`shared_utils.py` 统一提供 `BusinessSemanticFrame -> structured entity/project binding` 归一化,`mixin_deterministic_query_core.py`、`mixin_fast_answer_project_main.py`、`mixin_fast_answer_semantic_p1.py`、`mixin_deterministic_query_count.py`、`mixin_deterministic_query_list.py`、`mixin_retrieval_router_db.py`、`mixin_deep_guard_answer.py`、`mixin_answer_mainline.py`、`mixin_manifest_feature_p2.py` 现在只看这层结构化结果决定 `project / task / signal / config / feature / doc / user` 的快答、检索、deep guard 与 internal query plan,不再读取 `subject_focus / referenced_objects` 做二次业务分流。 +- `AdaptiveRAG` read-side 那批旧 compatibility consumer 也已一起收口:`shared_utils.py` 统一提供 `BusinessSemanticFrame -> structured entity/project binding` 归一化,`mixin_deterministic_query_core.py`、`mixin_fast_answer_project_main.py`、`mixin_fast_answer_semantic_p1.py`、`mixin_deterministic_query_count.py`、`mixin_deterministic_query_list.py`、`mixin_retrieval_router_db.py`、`mixin_deep_guard_answer.py`、`mixin_answer_mainline.py`、`mixin_manifest_feature_p2.py` 现在只看这层结构化结果决定 `project / task / signal / config / feature / doc / user` 的快答、检索、deep guard 与 internal query plan;`subject_focus` 仅在 `shared_utils.py` 作为上游旧结构化输入的实体类型补充,不允许各 consumer 分散读取或恢复 `referenced_objects` 二次业务分流。 - `ProjectOperatingContext` 当前由 `project_operating_context_builder.py` 统一组装:最小范围固定为“当前项目 + 当前仓库摘要 + 页面/线程锚点”;world model 建完后会回填 `resolved_project_id / repo_scope`,再供 `Query Route Gateway`、`mixin_project_fit_brief.py` 与 answer payload 消费。 - `analysis_packet` 现作为单独的 analysis layer 增量接到问答主链:`analysis_packets.py` 定义 `project_fit_analysis / external_impact_analysis / internal_explain_analysis / followup_explain_analysis` 这组 typed packet,`analysis_packet_builder.py` 只消费 `ProjectOperatingContext / ExecutionRecipe / fit_brief / external_recommendation / InternalContextPacket / clarification` 等 runtime truth 组包,不从回答正文反推。当前 richer packet 会把 `current_subject / candidate_position / rejected_position / reason_chain` 这类对象级分析字段显式落盘,避免 presenter 再用泛句补戏;共享 richer project snapshot 由 `analysis_project_snapshot_loader.py` 统一补 `tasks/signals + description/summary + analysis_object_refs`,供 AdaptiveRAG payload 和 Feishu presenter 复用。最新 snapshot loader 已 fail-closed:没有真实结构锚点时,不再从 task/signal 标题描述推 `fit_candidate_* / fit_rejected_* / primary_*` 关系,也不再生成 `task_signal_scores / signal_task_links` 这类 token-overlap 产物;builder 主路径只消费显式 object ref、精确文本绑定和唯一候选这三类非语义结构证据。 - `analysis_packet_builder.py` 里原来那层“工程任务/信号源占位/展示层”词面 second-guess 已继续收掉:generic fallback、direct-match generic 分支和 internal explain target fallback 现在都不再靠 `_looks_like_engineering_task / _looks_like_signal_placeholder / _task_presentation_hint / token-overlap / partial-name match` 去反驳上游对象关系;没有显式 ref 时只能诚实降级,最多允许“唯一候选”这种纯结构兜底。 - internal `query.answer` 的下一阶段基线已经确认:`Query Route Gateway` 之后不再直接靠快答分支拍板,而是统一进入 `Operation Contract -> Target Resolver -> Query DSL Plan -> InternalContextPacket -> 白名单受控执行器 -> semantic facts 优先 -> verifier` 主链;`InternalContextPacket` 由 `backend/app/services/adaptive_rag_service/internal_query_context.py` 定义,并作为 internal executor / verifier / runtime summary 的运行时单一真源;相关设计规格见 `docs/superpowers/specs/2026-04-15-internal-query-dsl-plan-design.md` 与 `docs/superpowers/specs/2026-04-15-semantic-facts-read-model-design.md`。 -- internal 主链现已补上 packet 预检:`mixin_answer_mainline.py` 在组完 `InternalContextPacket` 后,会先拦截 `missing packet / missing operation contract / target clarify / missing query plan contract` 这类上下文不完整场景,再决定是否进入 deterministic fast path、internal model-config fast path、GLM semantic fast path 或 retrieval;`query_semantics` 对 `operation_contract / target_resolution / query_plan` 的镜像也已收口到单点兼容写回。 +- internal 主链现已补上 packet 预检:`mixin_answer_mainline.py` 在组完 `InternalContextPacket` 后,会先拦截 `missing packet / missing operation contract / target clarify / missing query plan contract` 这类上下文不完整场景,再决定是否进入 deterministic fast path、internal model-config fast path、provider-neutral project semantic fast path 或 retrieval;`query_semantics` 对 `operation_contract / target_resolution / query_plan` 的镜像也已收口到单点兼容写回。 - external `query.answer` 已完成 second-pass 收口:`Query Route Gateway` 之后统一进入 `backend/app/services/external_query_service/`,按 `ProjectFitBrief -> External Query Planner -> External Query Gateway -> External Provider Registry -> MCP adapter 优先 -> bounded fallback live search -> external answer verifier -> ExternalContextPacket -> ExternalAnswerPresenter` 执行;planner 会显式消费 `fit_brief_summary / project_scope_hint / recommendation_goal / project_object_anchors / signal_source_objectives`,其中 formal object anchors 优先来自 `ProjectFitBrief.signal_source_anchors`,项目任务目标锚点来自 `ProjectFitBrief.signal_source_objectives`,不再拿 feature capability 名称或固定类别模板直接冒充 formal project object;payload 会拆出 `fit_brief / external_recommendation`,AdaptiveRAG external probe、freshness fetch 与 external route retrieval 已接到这条链上,并对 external sources 执行 fail-closed 过滤。当前 planner 还补了 recommendation query seed guard:命中“推荐外部信号源”问法时,会优先把当前项目任务目标和 formal anchor 驱动的 query 排到 `search_queries` 前面,避免 structured planner 用 `industry best practices / external data sources` 这类泛化词覆盖掉更贴当前项目的 seed query;同时 `interaction_level=recommendation` 会从统一语义规划层经 `answer_control/source_policy_hint` 透传到 `ExternalQueryPlan`,recommendation 分支不再由 presenter 本地 cue list 单独拍板。`presenter.py` 使用独立 external-only prompt 家族,并强制区分 `verifier_blocked / no_relevant_results / retrieval_failed / indirect_only`,不再回流 `industry_trend/external_trend` scene prompt;对 recommendation ask,presenter 现在会先生成 `SignalSourceRecommendationPacket(candidates + caveat)`,再渲染成业务中文的 `1/2/3` 候选建议,确保输出真的贴当前项目对象,而不是只靠自由文本兜一句“先看上游输入”。同时仍保留 overclaim guard:当结构化候选或文本里出现百分比、已确认、无需验证等高确定性措辞时,会 fail-closed 回退到 customer-safe candidate 文案。相关设计规格见 `docs/superpowers/specs/2026-04-15-external-query-gateway-design.md`。 - non-plan recommendation -> signal-source write 当前已补齐结构 carry-over:external recommendation 不再只返回文本,`SignalSourceRecommendationPacket` 里的 candidate set 绑定会经 `reply_anchor_qa_context + action_contract` 一路沉到 `signal_source.onboard` 执行结果,并在 follow-up `把推荐的写进系统 / 确认入库` 时由 `mixin_followup_project_signal_resume.py` 重建回 `operation_intent_contract(candidate_set_binding/candidate_refs/project_binding)`。`signal_source.onboard` 直写链会在 signal 已 materialize 且项目关联成功后把业务结果视为 `succeeded`,同时仅通过 `post_process_status / signal_source_workflow.status` 暴露后续 onboarding/manual 状态;`ai_pmo_reply_renderer.py` 也会把批量 signal source 直写回执渲染成自然业务中文,而不是 requirement draft 或 pending-manual 漂移文案。 - QA session detail 持久化现已补齐 `routing_snapshot.response_payload`:`mixin_core_intent.py` 在写 `QARecord` 时会把 `project_operating_context / execution_recipe / fit_brief / external_recommendation / analysis_packet / handoff_mode / can_enter_plan / recommended_next_step` 一并归档,`mixin_qa_session_list_ops_p1.py` 再将其投影给 AI Steward 历史回放,避免即时回答与 session detail 出现两套合同。 @@ -155,7 +161,7 @@ - `frontend/src/pages/recipes/` - `frontend/src/pages/feishu-config/` - `frontend/src/pages/ai-config/` -- `frontend/src/pages/model-routing-config/` +- `frontend/src/pages/model-routing-config/`(隐藏 Route Lab,只读函数级路由诊断) - `frontend/src/pages/ai-config/mcp-config/` - `frontend/src/pages/query-route-governance/` - `frontend/src/pages/task-brief-center/` @@ -167,6 +173,8 @@ - `frontend/src/pages/knowledge-graph-context-board/` - `frontend/src/pages/semantic-facts-board/` - `frontend/src/pages/login/` +- `frontend/public/brand/`(PMFlow Logo / favicon 源资产) +- `frontend/src/components/brand/` - `frontend/src/components/login/` - `frontend/src/features/pmflow-os/pages/` - `frontend/src/features/pmflow-os/components/` @@ -182,6 +190,7 @@ ### 5.2 API 与共享模块 - `frontend/src/api/aiPmo.ts` - `frontend/src/api/aiSteward.ts` +- `frontend/src/components/brand/PMFlowLogo.tsx` 与 `frontend/src/components/brand/PMFlowUserAvatar.tsx`(PMFlow Logo 与本地品牌头像统一入口;应用壳、营销页、登录视觉和 favicon 读取同一组品牌图,账号头像不依赖随机外链服务) - `frontend/src/components/marketing/` - `frontend/src/components/login/` - `frontend/src/components/layout/LazyBoundary.tsx` @@ -189,22 +198,25 @@ - `frontend/src/components/ai-steward/QAConversationStatusBar.tsx` - `frontend/src/components/ai-steward/QAResponseMetaCard.tsx` - `frontend/src/components/ai-steward/QAResponseMetaCard.test.tsx` -- AI Steward 问答浮窗现分三层显性化:`FloatingQAChat.tsx` 负责即时回答/历史回放归一化,`QAConversationStatusBar.tsx` 展示对象/来源/仓库/执行通道/plan handoff,`QAResponseMetaCard.tsx` 展示 `fit_brief / external_recommendation / recommended_next_step`,对应前端回归由 `FloatingQAChat.test.tsx` 与 `QAResponseMetaCard.test.tsx` 锁定。 -- 登录页与营销页仍共享 `frontend/src/components/marketing/` 的部分视觉基线;公开登录页现改由 `frontend/src/components/login/` 目录承载,其中 `LoginExperience.tsx` 负责登录壳层、`LoginWorkflowVisual.tsx` 负责左侧场景、`loginScene.ts` / `loginMath.ts` 拆出展示配置与路径几何、`login.css` 负责登录页专属样式边界,`frontend/src/pages/login/LoginPage.tsx` 继续负责真实鉴权、错误态和 redirect 提示;未登录访问 `/` 会经 `frontend/src/router/index.tsx` 直接重定向到 `/login`,无显式 `redirect` 的成功登录现在默认进入 `/command-center`。 -- 前端主包治理已切到“同步首屏最小集 + route/page-section lazy + 统一身份边界”模式:`frontend/src/router/index.tsx` 保留 `LoginPage / Layout` 这组同步入口,其余后台重页面通过 `React.lazy + Suspense` 懒加载;统一 fallback 在 `frontend/src/components/layout/LazyBoundary.tsx`,统一身份/权限边界在 `frontend/src/auth/RouteBoundary.tsx`。P0 公共壳冻结后,大模块 route 归属为:`/space` 归 Space lane,真实 `/project-launch` 是启动层入口,`/pmflow-os` 与 `/change-review / /structured-extraction / /memory-writeback-result / /execution-bridge / /release-growth` 归 Main Flow lane,`/me` 归 Account Settings lane,管理/治理类页面统一走 OWNER/REVIEWER 边界,`/settings` 当前仅为 reserved route、尚未实现,新增必须由 Shell / Integration owner 串行处理。`/ai-steward` 只做 legacy redirect:`tab=create` 迁到 `/project-launch`,`tab=engine / tab=feedback-center` 迁到 `/ai-config`,其它 legacy tab 迁到 `/space`;导航不再暴露该入口。`/command-center` 保留免登录产品预览壳;`/space`、真实 `/project-launch`、主链 standalone 与 `/me` 均需登录;`/project-launch?demo=1` 或 `?dataSource=demo` 是唯一匿名启动台演示入口。`frontend/src/features/personal-space/` 作为登录后的空间层入口,承载继续推进、待我确认、AI 正在推进、暂存想法、草案箱与新建项目入口。`frontend/src/features/project-launch/` 真实模式默认调用 `pmflow-launch` draft/confirm API,创建成功态主动作跳转后端返回的 `/pmflow-os?projectId=`,401/无 token 必须保存 pending input 并提示重新登录,不展示 Axios 原始错误;只有显式 demo 才启用本地 mock 状态机。`frontend/src/features/account-settings/` 作为登录后的 Account Settings 产品域,通过 `/me` 提供个人资料、默认语言、AI 回答深度、证据展示偏好、待确认/执行回写/发布门禁/失败提醒通知偏好和账户操作入口;该域通过 `frontend/src/api/accountSettings.ts` 建立前端合同,有 token 时优先调用已挂入 `backend/app/api/router.py` 的 `backend/app/api/account_settings_api/` `GET /account-settings/me` / `PUT /account-settings/me` 同步当前账号偏好,后端不可用时明确回落本机暂存,不修改 OAuth/JWT/auth core。`frontend/src/features/pmflow-os/` 作为登录后的真实项目产品域,通过 `PMFlowOsProvider` 在应用根部挂载共享 truth store,并提供 `CommandCenter / ChangeReview / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主入口;当前 PMFlow OS 页面已切到专属轻壳,桌面端默认隐藏旧 TopBar,并由 `frontend/src/features/pmflow-os/components/PMFlowOsFrame.tsx` 统一承接“左侧稳定导航 + 顶部上下文条 + 中央视图 + 右侧 AI 副驾”的全局骨架,同时承接项目内 `个人空间 / 当前项目名` 面包屑和项目切换器;账号退出登录由 `frontend/src/features/pmflow-os/components/AccountControls.tsx` 统一承接桌面头像下拉、退出确认与移动端 bottom sheet,正式应用壳移动端底部导航统一由 `frontend/src/features/pmflow-os/components/MobileBottomNav.tsx` 承接。 +- AI Steward 问答展示现分三层显性化:`FloatingQAChat.tsx` 保留为旧浮窗兼容代码并负责即时回答/历史回放归一化能力,`QAConversationStatusBar.tsx` 展示对象/来源/仓库/执行通道/plan handoff,`QAResponseMetaCard.tsx` 展示 `fit_brief / external_recommendation / recommended_next_step`,对应前端回归由 `FloatingQAChat.test.tsx` 与 `QAResponseMetaCard.test.tsx` 锁定;当前产品壳不再全局挂载 `FloatingQAChat`,空间级入口由 `SpaceAIConcierge.tsx` 承接。 +- 登录页与营销页仍共享 `frontend/src/components/marketing/` 的部分视觉基线;公开登录页现改由 `frontend/src/components/login/` 目录承载,其中 `LoginExperience.tsx` 负责登录壳层、`LoginForm.tsx` 默认承接手机号验证码登录/自注册,用户名密码仅作为兼容入口,`LoginWorkflowVisual.tsx` 负责左侧场景、`loginScene.ts` / `loginMath.ts` 拆出展示配置与路径几何、`login.css` 负责登录页专属样式边界,`frontend/src/pages/login/LoginPage.tsx` 继续负责真实鉴权、错误态和 redirect 提示;未登录访问 `/` 会经 `frontend/src/router/index.tsx` 直接重定向到 `/login`,无显式 `redirect` 的成功登录现在默认进入 `/command-center`。 +- 前端主包治理已切到“同步首屏最小集 + route/page-section lazy + 统一身份边界”模式:`frontend/src/router/index.tsx` 保留 `LoginPage / Layout` 这组同步入口,其余后台重页面通过 `React.lazy + Suspense` 懒加载;统一 fallback 在 `frontend/src/components/layout/LazyBoundary.tsx`,统一身份/权限边界在 `frontend/src/auth/RouteBoundary.tsx`。P0 公共壳冻结后,大模块 route 归属为:`/space` 归 Space lane,真实 `/project-launch` 是启动层入口,`/pmflow-os` 与 `/change-review / /structured-extraction / /memory-writeback-result / /execution-bridge / /release-growth` 归 Main Flow lane,`/me` 归 Account Settings lane,`/ai-config` 是客户空间 AI 使用设置,`/platform/*` 是 PMFlow 平台 AI Operations Console;空间管理入口使用 workspace role,平台运营入口使用 `platform_context.capabilities`,后端 JWT/RBAC 仍是最终校验;`/settings` 当前仅为 reserved route、尚未实现,新增必须由 Shell / Integration owner 串行处理。`/ai-steward` 只做 legacy redirect:`tab=create` 迁到 `/project-launch`,`tab=engine` 迁到 `/ai-config`,`tab=feedback-center` 有 `project_id` 时迁到 `/release-growth?projectId=`、否则迁到 `/space`,其它 legacy tab 迁到 `/space`;导航不再暴露该入口。`/command-center` 保留免登录产品预览壳;`/space`、真实 `/project-launch`、主链 standalone 与 `/me` 均需登录;`/project-launch?demo=1` 或 `?dataSource=demo` 是唯一匿名启动台演示入口。`frontend/src/features/personal-space/` 作为登录后的空间层入口,承载继续推进、待我确认、AI 正在推进、暂存想法、草案箱与新建项目入口。`frontend/src/features/project-launch/` 真实模式默认调用 `pmflow-launch` draft/confirm API,创建成功态主动作跳转后端返回的 `/pmflow-os?projectId=`,401/无 token 必须保存 pending input 并提示重新登录,不展示 Axios 原始错误;只有显式 demo 才启用本地 mock 状态机。`frontend/src/features/account-settings/` 作为登录后的 Account Settings 产品域,通过 `/me` 提供个人资料、默认语言、AI 回答深度、证据展示偏好、待确认/执行回写/发布门禁/失败提醒通知偏好和账户操作入口;该域通过 `frontend/src/api/accountSettings.ts` 建立前端合同,有 token 时优先调用已挂入 `backend/app/api/router.py` 的 `backend/app/api/account_settings_api/` `GET /account-settings/me` / `PUT /account-settings/me` 同步当前账号偏好,后端不可用时明确回落本机暂存,不修改 OAuth/JWT/auth core。`frontend/src/features/pmflow-os/` 作为登录后的真实项目产品域,通过 `PMFlowOsProvider` 在应用根部挂载共享 truth store,并提供 `CommandCenter / ChangeReview / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主入口;当前 PMFlow OS 页面已切到专属轻壳,桌面端默认隐藏旧 TopBar,并由 `frontend/src/features/pmflow-os/components/PMFlowOsFrame.tsx` 统一承接“左侧稳定导航 + 顶部上下文条 + 中央视图 + 右侧 AI 副驾”的全局骨架,同时承接项目内 `个人空间 / 当前项目名` 面包屑和项目切换器;账号退出登录由 `frontend/src/features/pmflow-os/components/AccountControls.tsx` 统一承接桌面头像下拉、退出确认与移动端 bottom sheet,正式应用壳移动端底部导航统一由 `frontend/src/features/pmflow-os/components/MobileBottomNav.tsx` 承接。 - PMFlow P8 Review Decision Loop 已由 `big-module-integration-p8` 收口:Main Flow `truth-state.reviewerRouting` 与 `POST /pmflow-os/change-sets/{change_set_id}/review-decisions` 是 reviewer decision / approval audit 真源;Space 只读消费为 `/space` 的 `review_attention` 下一步队列;Account 只提供 `getReviewDecisionNotificationPreference` 页面内提醒偏好;P8 未新增 route/nav,未接真实通知中心,不自动 assign reviewer 或 commit ChangeSet。 -- `frontend/src/features/personal-space/PersonalSpacePage.tsx` 是空间首页视图层,`frontend/src/api/space.ts` 是空间工作面只读聚合 API:从 `projects` summary、PMFlow OS `truth-state`、`execution-summary` 与 `execution-capabilities` 生成 `nextActionQueue` 我的下一步队列、执行准备度、继续推进、待确认、发布关注、发布后反馈关注、下一轮迭代草稿关注、执行中 / AI 正在推进、暂存想法、草案箱、最近项目摘要;Space P6 的 `nextActionQueue` 按高风险 ChangeSet 待确认、release blocked / failed evidence、handoff consumed 但无后续 progress、iteration draft 待确认、feedback/revenue/compliance 信号待处理排序,并只提供 `/change-review`、`/execution-bridge`、`/release-growth` 跳转。执行准备度只表达“我需要判断什么、AI 能继续做什么、工具链缺什么”,动作只跳 `/execution-bridge`、`/change-review`、`/release-growth` 或 `/team-layer`;缺少能力快照、工具连接、执行证据或阻塞数据时显示明确 empty/warning,不用传统技能表或 mock 数据冒充真实能力。Space P3 的 `releaseAttention` 优先消费 `truth-state.releases` 中由 release validation run 投影出的 release 状态与检查项,把“发布验证阻塞 / 发布验证待确认”显示到 `/space`,没有发布验证投影时才回落到 `execution-summary.releaseGate` 的发布候选或发布门禁阻塞;Space P4/P5 的 `feedbackAttention` 只读消费 `execution-summary.releaseSignals`,把发布后 feedback、revenue、ROI、compliance、iteration recommendation 与已生成的下一轮 ChangeSet draft 关注项显示到 `/space`。发布验证、发布后信号或下一轮 draft 缺失时显示明确空态,不虚构验证、反馈、收入、ROI、合规、建议或 ChangeSet draft 结果。项目主动作进入 `/pmflow-os?projectId=`,待确认项进入 `/change-review?projectId=&changeSetId=`,发布关注和发布后反馈关注项进入 `/release-growth?projectId=`,下一轮 draft 关注项进入 `/change-review?projectId=&changeSetId=`,执行中项进入 `/execution-bridge?projectId=`;暂存想法后端只读聚合尚未接入时只显示 empty/warning,不使用 mock 数据冒充真实内容;移动端为空间首页独立 App 布局,不直接压缩桌面卡片,并保留最近项目区块;`frontend/src/pages/PersonalSpacePage.tsx` 是薄入口。该域只表达个人空间和主流程导航,不做团队空间强切换、企业组织管理、Project Memory 写入、ChangeSet 状态写入、ProgressEvent 写入、ReleaseValidationRun 写入、FeedbackIngest 写入或发布执行。 -- 前端主包治理已切到“同步首屏最小集 + route/page-section lazy”模式:`frontend/src/router/index.tsx` 保留 `LoginPage / Layout` 这组同步入口,其余后台重页面通过 `React.lazy + Suspense` 懒加载;统一 fallback 在 `frontend/src/components/layout/LazyBoundary.tsx`。P0 公共壳冻结后,大模块 route 归属为:`/space` 归 Space lane,`/project-launch` 是启动层入口,`/pmflow-os` 与 `/command-center / /change-review / /execution-bridge / /release-growth` 归 Main Flow lane,`/me` 归 Account Settings lane,`/settings` 当前仅为 reserved route、尚未实现,新增必须由 Shell / Integration owner 串行处理。`frontend/src/features/personal-space/` 作为空间层入口,通过登录后的 `/space` 提供空间入口,承载继续推进、待我确认、AI 正在推进、暂存想法、草案箱与新建项目入口。`frontend/src/features/project-launch/` 作为首次进入页 / 新项目启动台产品域,通过登录后的真实 `/project-launch` 提供启动入口(显式 `?demo=1` 或 `?dataSource=demo` 可匿名演示);Phase B 后默认调用真实 `pmflow-launch` draft/confirm API,创建成功态主动作跳转后端返回的 `/pmflow-os?projectId=`,只有显式 `?demo=1` 或 `?dataSource=demo` 才启用本地 mock 状态机。`frontend/src/features/account-settings/` 作为 Account Settings 产品域,通过 `/me` 提供个人资料、默认语言、AI 回答深度、证据展示偏好、待确认/执行回写/发布门禁/失败提醒通知偏好和账户操作入口;该域通过 `frontend/src/api/accountSettings.ts` 建立前端合同,有 token 时优先调用已挂入 `backend/app/api/router.py` 的 `backend/app/api/account_settings_api/` `GET /account-settings/me` / `PUT /account-settings/me` 同步当前账号偏好,后端不可用时明确回落本机暂存,不修改 OAuth/JWT/auth core。`frontend/src/features/pmflow-os/` 作为新产品域,通过 `PMFlowOsProvider` 在应用根部挂载共享 truth store,并提供 `CommandCenter / ChangeReview / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主入口;当前 PMFlow OS 页面已切到专属轻壳,桌面端默认隐藏旧 TopBar,并由 `frontend/src/features/pmflow-os/components/PMFlowOsFrame.tsx` 统一承接“左侧稳定导航 + 顶部上下文条 + 中央视图 + 右侧 AI 副驾”的全局骨架,同时承接项目内 `个人空间 / 当前项目名` 面包屑和项目切换器;`ChangeReviewPage.tsx` 现在可以请求审查、通过、要求修改或拒绝当前 ChangeSet,并展示审查状态、审查意见与 high-risk approval gate;`TeamLayerPage.tsx` 现在消费 `ProjectTruthState.reviewerRouting(status/summary/items)` 展示待审 ChangeSet、来源、风险等级、建议审查人或 fallback role、审查原因、待审 item 数、证据数、warning、审查状态、审查意见和请求/决策时间,并复用 `TeamFeedPanel` 展示 `ProjectTruthState.feed` 中的 review request、review decision、ChangeSet 状态、execution progress 与 release gate 只读动态,动作跳 `/change-review?projectId=&changeSetId=`;账号退出登录由 `frontend/src/features/pmflow-os/components/AccountControls.tsx` 统一承接桌面头像下拉、退出确认与移动端 bottom sheet,`frontend/src/features/pmflow-os/pages/AccountPage.tsx` 提供 `/me` 入口并委托 Account Settings 域渲染设置页;正式应用壳移动端底部导航统一由 `frontend/src/features/pmflow-os/components/MobileBottomNav.tsx` 承接,固定为 `空间 / 指挥台 / 待确认 / 动态 / 我的`,其中 `动态` 当前仍是 `/command-center` 占位。`/command-center` 额外提供免登录产品预览壳,`/pmflow-os` 是真实项目上下文 alias。主视觉按 `#2463EB + 白色` 的 Linear-like 高密度原型展开。`UsersPage` 把创建/删除/重置/导入/批量分配对话框拆为按需 chunk,`UserSkillsPage` 与 `WorkloadDetailPage` 的图表也分别拆到了 `frontend/src/pages/user-skills/components/UserSkillsRadarPanel.tsx` 与 `frontend/src/pages/workload-detail/components/WorkloadTrendChart.tsx`。 -- `frontend/src/features/personal-space/PersonalSpacePage.tsx` 是空间首页视图层,`frontend/src/api/space.ts` 是空间工作面只读聚合 API:从 `projects` summary、PMFlow OS `truth-state`、`execution-summary` 与 `execution-capabilities` 生成 `nextActionQueue` 我的下一步队列、执行准备度、继续推进、待确认、发布关注、发布后反馈关注、下一轮迭代草稿关注、执行中 / AI 正在推进、暂存想法、草案箱、最近项目摘要;Space P6 的 `nextActionQueue` 按高风险 ChangeSet 待确认、release blocked / failed evidence、handoff consumed 但无后续 progress、iteration draft 待确认、feedback/revenue/compliance 信号待处理排序,并只提供 `/change-review`、`/execution-bridge`、`/release-growth` 跳转;Space P7 新增 `review_attention`,只读消费 `truth-state.reviewerRouting`,把高风险待我审、`request_changes / rejected` 后待处理、`reviewer_missing` warning 与普通 pending review 排入“我的下一步”,待审/退回/拒绝项只跳 `/change-review?projectId=&changeSetId=`,reviewer 缺失 warning 只跳已有 `/team-layer?projectId=`。执行准备度只表达“我需要判断什么、AI 能继续做什么、工具链缺什么”,动作只跳 `/execution-bridge`、`/change-review`、`/release-growth` 或 `/team-layer`。Space P3 的 `releaseAttention` 优先消费 `truth-state.releases` 中由 release validation run 投影出的 release 状态与检查项,把“发布验证阻塞 / 发布验证待确认”显示到 `/space`,没有发布验证投影时才回落到 `execution-summary.releaseGate` 的发布候选或发布门禁阻塞;Space P4/P5 的 `feedbackAttention` 只读消费 `execution-summary.releaseSignals`,把发布后 feedback、revenue、ROI、compliance、iteration recommendation 与已生成的下一轮 ChangeSet draft 关注项显示到 `/space`。reviewerRouting、execution-capabilities、发布验证、发布后信号或下一轮 draft 缺失时显示明确空态,不虚构 reviewer、审查意见、待审 ChangeSet、工具连接、执行能力、验证、反馈、收入、ROI、合规、建议或 ChangeSet draft 结果。项目主动作进入 `/pmflow-os?projectId=`,待确认项进入 `/change-review?projectId=&changeSetId=`,发布关注和发布后反馈关注项进入 `/release-growth?projectId=`,下一轮 draft 关注项进入 `/change-review?projectId=&changeSetId=`,执行中项进入 `/execution-bridge?projectId=`;暂存想法后端只读聚合尚未接入时只显示 empty/warning,不使用 mock 数据冒充真实内容;移动端为空间首页独立 App 布局,不直接压缩桌面卡片,并保留最近项目区块;`frontend/src/pages/PersonalSpacePage.tsx` 是薄入口。该域只表达个人空间和主流程导航,不做团队空间强切换、企业组织管理、Project Memory 写入、ChangeSet 状态写入、PMFlowApproval 写入、ProgressEvent 写入、ReleaseValidationRun 写入、FeedbackIngest 写入或发布执行。 +- `frontend/src/features/personal-space/PersonalSpacePage.tsx` 是空间首页视图层,`frontend/src/api/space.ts` 是空间工作面只读聚合 API:从 `projects` summary、PMFlow OS `truth-state`、`execution-summary` 与 `execution-capabilities` 生成 `nextActionQueue` 我的下一步队列、执行准备度、继续推进、待确认、发布关注、发布后反馈关注、下一轮迭代草稿关注、执行中 / AI 正在推进、暂存想法、草案箱、最近项目摘要;`frontend/src/features/personal-space/components/SpaceAIConcierge.tsx` 是空间级 AI 管家入口,只挂在 `/space`,通过空间上下文包调用现有 `/rag/ask` 并复用 QA 历史能力,回答范围限定在个人空间、跨项目下一步、待确认、执行中、草案、暂存想法和 AI 能力状态,真实写动作只给跳转或待确认建议。全局 `frontend/src/components/layout/Layout.tsx` 不再挂载旧 `FloatingQAChat` 悬浮球,普通后台页不显示无上下文聊天球。Space 的 `nextActionQueue` 按 AI extraction 生成的 ChangeSet 待确认、review attention、execution failed/blocked/delivery failed、release blocked / failed evidence、handoff consumed 但无后续 progress、feedback/revenue/ROI/compliance 信号待处理和 iteration draft 待确认排序,并只提供 `/change-review`、`/execution-bridge`、`/release-growth` 跳转。执行准备度只表达“我需要判断什么、AI 能继续做什么、工具链缺什么”,动作只跳 `/execution-bridge`、`/change-review`、`/release-growth` 或 `/team-layer`;缺少能力快照、工具连接、执行证据或阻塞数据时显示明确 empty/warning,不用传统技能表或 mock 数据冒充真实能力。Space P3 的 `releaseAttention` 优先消费 `truth-state.releases` 中由 release validation run 投影出的 release 状态与检查项,把“发布验证阻塞 / 发布验证待确认”显示到 `/space`,没有发布验证投影时才回落到 `execution-summary.releaseGate` 的发布候选或发布门禁阻塞;Space P4/P5 的 `feedbackAttention` 只读消费 `execution-summary.releaseSignals`,把发布后 feedback、revenue、ROI、compliance、iteration recommendation 与已生成的下一轮 ChangeSet draft 关注项显示到 `/space`。发布验证、发布后信号或下一轮 draft 缺失时显示明确空态,不虚构验证、反馈、收入、ROI、合规、建议或 ChangeSet draft 结果。项目主动作进入 `/pmflow-os?projectId=`,待确认项进入 `/change-review?projectId=&changeSetId=`,发布关注和发布后反馈关注项进入 `/release-growth?projectId=`,下一轮 draft 关注项进入 `/change-review?projectId=&changeSetId=`,执行中、执行失败和回写失败项进入 `/execution-bridge?projectId=`;暂存想法通过真实 `GET /space/saved-ideas` 聚合,缺数据时显示 empty/warning,不使用 mock 数据冒充真实内容;移动端为空间首页独立 App 布局,不直接压缩桌面卡片,并保留最近项目区块;`frontend/src/pages/PersonalSpacePage.tsx` 是薄入口。该域只表达个人空间和主流程导航,不做团队空间强切换、企业组织管理、Project Memory 写入、ChangeSet 状态写入、PMFlowApproval 写入、ProgressEvent 写入、ReleaseValidationRun 写入、FeedbackIngest 写入、RevenueEvent 写入、ROISnapshot 写入、ComplianceCheckRun 写入或发布执行。 +- 前端主包治理已切到“同步首屏最小集 + route/page-section lazy”模式:`frontend/src/router/index.tsx` 保留 `LoginPage / Layout` 这组同步入口,其余后台重页面通过 `React.lazy + Suspense` 懒加载;统一 fallback 在 `frontend/src/components/layout/LazyBoundary.tsx`。P0 公共壳冻结后,大模块 route 归属为:`/space` 归 Space lane,`/project-launch` 是启动层入口,`/pmflow-os` 与 `/command-center / /change-review / /execution-bridge / /release-growth` 归 Main Flow lane,`/me` 归 Account Settings lane,`/settings` 当前仅为 reserved route、尚未实现,新增必须由 Shell / Integration owner 串行处理。`frontend/src/features/personal-space/` 作为空间层入口,通过登录后的 `/space` 提供空间入口,承载继续推进、待我确认、AI 正在推进、暂存想法、草案箱与新建项目入口。`frontend/src/features/project-launch/` 作为首次进入页 / 新项目启动台产品域,通过登录后的真实 `/project-launch` 提供启动入口(显式 `?demo=1` 或 `?dataSource=demo` 可匿名演示);Phase B 后默认调用真实 `pmflow-launch` draft/confirm API,创建成功态主动作跳转后端返回的 `/pmflow-os?projectId=`,只有显式 `?demo=1` 或 `?dataSource=demo` 才启用本地 mock 状态机。`frontend/src/features/account-settings/` 作为 Account Settings 产品域,通过 `/me` 提供个人资料、默认语言、AI 回答深度、证据展示偏好、待确认/执行回写/发布门禁/失败提醒通知偏好和账户操作入口;该域通过 `frontend/src/api/accountSettings.ts` 建立前端合同,有 token 时优先调用已挂入 `backend/app/api/router.py` 的 `backend/app/api/account_settings_api/` `GET /account-settings/me` / `PUT /account-settings/me` 同步当前账号偏好,后端不可用时明确回落本机暂存,不修改 OAuth/JWT/auth core。`frontend/src/features/pmflow-os/` 作为新产品域,通过 `PMFlowOsProvider` 在应用根部挂载共享 truth store,并提供 `CommandCenter / ChangeReview / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主入口;当前 PMFlow OS 页面已切到专属轻壳,桌面端默认隐藏旧 TopBar,并由 `frontend/src/features/pmflow-os/components/PMFlowOsFrame.tsx` 统一承接“左侧稳定导航 + 顶部上下文条 + 中央视图 + 右侧 AI 副驾”的全局骨架,同时承接项目内 `个人空间 / 当前项目名` 面包屑和项目切换器;`ChangeReviewPage.tsx` 现在可以请求审查、通过、要求修改或拒绝当前变更集,并展示审查状态、审查意见与高风险审批门禁;`TeamLayerPage.tsx` 现在消费 `ProjectTruthState.reviewerRouting(status/summary/items)`、`ProjectTruthState.feed` 与 `execution-capabilities`,以 `团队动态 / 决策台 / 角色收件箱 / 智能体工位 / AI 副驾` 组织团队协作态势,展示待审变更责任路由、审查人建议、审查状态、证据数量、执行单元信号和容量调度;动作只跳 `/change-review?projectId=&changeSetId=` 或下方容量区,不自动分派、不自动审查、不写 Project Memory。账号退出登录由 `frontend/src/features/pmflow-os/components/AccountControls.tsx` 统一承接桌面头像下拉、退出确认与移动端 bottom sheet,`frontend/src/features/pmflow-os/pages/AccountPage.tsx` 提供 `/me` 入口并委托 Account Settings 域渲染设置页;正式应用壳移动端底部导航统一由 `frontend/src/features/pmflow-os/components/MobileBottomNav.tsx` 承接,固定为 `空间 / 指挥台 / 待确认 / 动态 / 我的`,其中 `动态` 当前仍是 `/command-center` 占位。`/command-center` 额外提供免登录产品预览壳,`/pmflow-os` 是真实项目上下文 alias。主视觉按 `#2463EB + 白色` 的 Linear-like 高密度原型展开。`UsersPage` 把创建/删除/重置/导入/批量分配对话框拆为按需 chunk,`UserSkillsPage` 与 `WorkloadDetailPage` 的图表也分别拆到了 `frontend/src/pages/user-skills/components/UserSkillsRadarPanel.tsx` 与 `frontend/src/pages/workload-detail/components/WorkloadTrendChart.tsx`。 +- `frontend/src/features/personal-space/PersonalSpacePage.tsx` 是空间首页视图层,`frontend/src/api/space.ts` 是空间工作面只读聚合 API:从 `projects` summary、PMFlow OS `truth-state`、`execution-summary` 与 `execution-capabilities` 生成 `nextActionQueue` 我的下一步队列、执行准备度、继续推进、待确认、发布关注、发布后反馈关注、下一轮迭代草稿关注、执行中 / AI 正在推进、暂存想法、草案箱、最近项目摘要;Space 的 `nextActionQueue` 按 AI extraction 生成的 ChangeSet 待确认、review attention、execution failed/blocked/delivery failed、release blocked / failed evidence、handoff consumed 但无后续 progress、feedback/revenue/ROI/compliance 信号待处理和 iteration draft 待确认排序,并只提供 `/change-review`、`/execution-bridge`、`/release-growth` 跳转;Space P7 新增 `review_attention`,只读消费 `truth-state.reviewerRouting`,把高风险待我审、`request_changes / rejected` 后待处理、`reviewer_missing` warning 与普通 pending review 排入“我的下一步”,待审/退回/拒绝项只跳 `/change-review?projectId=&changeSetId=`,reviewer 缺失 warning 只跳已有 `/team-layer?projectId=`。执行准备度只表达“我需要判断什么、AI 能继续做什么、工具链缺什么”,动作只跳 `/execution-bridge`、`/change-review`、`/release-growth` 或 `/team-layer`。Space P3 的 `releaseAttention` 优先消费 `truth-state.releases` 中由 release validation run 投影出的 release 状态与检查项,把“发布验证阻塞 / 发布验证待确认”显示到 `/space`,没有发布验证投影时才回落到 `execution-summary.releaseGate` 的发布候选或发布门禁阻塞;Space P4/P5 的 `feedbackAttention` 只读消费 `execution-summary.releaseSignals`,把发布后 feedback、revenue、ROI、compliance、iteration recommendation 与已生成的下一轮 ChangeSet draft 关注项显示到 `/space`。reviewerRouting、execution-capabilities、发布验证、发布后信号或下一轮 draft 缺失时显示明确空态,不虚构 reviewer、审查意见、待审 ChangeSet、工具连接、执行能力、验证、反馈、收入、ROI、合规、建议或 ChangeSet draft 结果。项目主动作进入 `/pmflow-os?projectId=`,待确认项进入 `/change-review?projectId=&changeSetId=`,发布关注和发布后反馈关注项进入 `/release-growth?projectId=`,下一轮 draft 关注项进入 `/change-review?projectId=&changeSetId=`,执行中、执行失败和回写失败项进入 `/execution-bridge?projectId=`;暂存想法通过真实 `GET /space/saved-ideas` 聚合,缺数据时显示 empty/warning,不使用 mock 数据冒充真实内容;移动端为空间首页独立 App 布局,不直接压缩桌面卡片,并保留最近项目区块;`frontend/src/pages/PersonalSpacePage.tsx` 是薄入口。该域只表达个人空间和主流程导航,不做团队空间强切换、企业组织管理、Project Memory 写入、ChangeSet 状态写入、PMFlowApproval 写入、ProgressEvent 写入、ReleaseValidationRun 写入、FeedbackIngest 写入、RevenueEvent 写入、ROISnapshot 写入、ComplianceCheckRun 写入或发布执行。 +- `frontend/src/features/personal-space/SpaceCollectionPages.tsx` 承接空间首页红框区块的二级页面:`/space/ideas` 是暂存想法收集箱,支持状态筛选、搜索、补充、归档与转 Project Launch 草案;`/space/drafts` 是草案箱 / 待确认事项页,支持按待确认、信息不足、冲突、已确认、挂起筛选并回主流程确认。空间首页只预览暂存想法、草案箱和最近项目各少量条目;最近项目完整查看复用 `/space/projects?view=recent`,`ProjectsPage` 内提供“全部 / 最近 / 待处理 / AI 进行中 / 已归档”筛选,不单独新增最近项目页。 - 旧独立收件页已物理删除,`/inbox` route 和侧边栏入口已退场;旧 AI PMO `waiting_manual / draft_pending_confirm` 不再通过独立页面承载,而由 `frontend/src/api/space.ts` 作为 legacy adapter 只读接入 `/space` 的“我的下一步”和草案箱。`missing_project_scope` 只允许在 Space 选择项目后复用 `POST /ai-pmo/work-items/{id}/decision` 继续既有审计链,不新增旁路接口;底层 `notifications` primitive 暂留为未来 in-app reminder/偏好消费基础,不作为真实通知中心暴露。 -- 前端主包治理已切到“同步首屏最小集 + route/page-section lazy”模式:`frontend/src/router/index.tsx` 保留 `LoginPage / Layout` 这组同步入口,其余后台重页面通过 `React.lazy + Suspense` 懒加载;统一 fallback 在 `frontend/src/components/layout/LazyBoundary.tsx`。P0 公共壳冻结后,大模块 route 归属为:`/space` 归 Space lane,`/project-launch` 是启动层入口,`/pmflow-os` 与 `/command-center / /change-review / /execution-bridge / /release-growth` 归 Main Flow lane,`/me` 归 Account Settings lane,`/settings` 当前仅为 reserved route、尚未实现,新增必须由 Shell / Integration owner 串行处理。`frontend/src/features/personal-space/` 作为空间层入口,通过登录后的 `/space` 提供空间入口,承载继续推进、待我确认、AI 正在推进、暂存想法、草案箱与新建项目入口。`frontend/src/features/project-launch/` 作为首次进入页 / 新项目启动台产品域,通过登录后的真实 `/project-launch` 提供启动入口(显式 `?demo=1` 或 `?dataSource=demo` 可匿名演示);Phase B 后默认调用真实 `pmflow-launch` draft/confirm API,创建成功态主动作跳转后端返回的 `/pmflow-os?projectId=`,只有显式 `?demo=1` 或 `?dataSource=demo` 才启用本地 mock 状态机。`frontend/src/features/account-settings/` 作为 Account Settings 产品域,通过 `/me` 提供个人资料、默认语言、AI 回答深度、证据展示偏好、待确认/执行回写/发布门禁/失败提醒通知偏好和账户操作入口;该域通过 `frontend/src/api/accountSettings.ts` 建立前端合同,有 token 时优先调用已挂入 `backend/app/api/router.py` 的 `backend/app/api/account_settings_api/` `GET /account-settings/me` / `PUT /account-settings/me` 同步当前账号偏好,后端不可用时明确回落本机暂存,不修改 OAuth/JWT/auth core。`frontend/src/features/pmflow-os/` 作为新产品域,通过 `PMFlowOsProvider` 在应用根部挂载共享 truth store,并提供 `CommandCenter / ChangeReview / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主入口;当前 PMFlow OS 页面已切到专属轻壳,桌面端默认隐藏旧 TopBar,并由 `frontend/src/features/pmflow-os/components/PMFlowOsFrame.tsx` 统一承接“左侧稳定导航 + 顶部上下文条 + 中央视图 + 右侧 AI 副驾”的全局骨架,同时承接项目内 `个人空间 / 当前项目名` 面包屑和项目切换器;`TeamLayerPage.tsx` 现在消费 `ProjectTruthState.reviewerRouting(status/summary/items)` 展示待审 ChangeSet、来源、风险等级、建议审查人或 fallback role、审查原因、待审 item 数、证据数和 warning,并复用 `TeamFeedPanel` 展示 `ProjectTruthState.feed` 中的 review request、review decision、ChangeSet 状态、execution progress 与 release gate 只读动态,动作只跳 `/change-review?projectId=&changeSetId=`,不自动 assign reviewer;账号退出登录由 `frontend/src/features/pmflow-os/components/AccountControls.tsx` 统一承接桌面头像下拉、退出确认与移动端 bottom sheet,`frontend/src/features/pmflow-os/pages/AccountPage.tsx` 提供 `/me` 入口并委托 Account Settings 域渲染设置页;正式应用壳移动端底部导航统一由 `frontend/src/features/pmflow-os/components/MobileBottomNav.tsx` 承接,固定为 `空间 / 指挥台 / 待确认 / 动态 / 我的`,其中 `动态` 当前仍是 `/command-center` 占位。`/command-center` 额外提供免登录产品预览壳,`/pmflow-os` 是真实项目上下文 alias。主视觉按 `#2463EB + 白色` 的 Linear-like 高密度原型展开。`UsersPage` 把创建/删除/重置/导入/批量分配对话框拆为按需 chunk,`UserSkillsPage` 与 `WorkloadDetailPage` 的图表也分别拆到了 `frontend/src/pages/user-skills/components/UserSkillsRadarPanel.tsx` 与 `frontend/src/pages/workload-detail/components/WorkloadTrendChart.tsx`。 -- `frontend/src/features/project-launch/types.ts`、`mockData.ts`、`useProjectLaunchState.ts` 是新项目启动台状态层;默认真实模式通过 `frontend/src/api/pmflowLaunch.ts` 生成草案并确认创建真实项目,API 失败进入明确失败态,不静默 fallback 成 mock 成功;`mockData.ts` 仅在显式 `?demo=1` 或 `?dataSource=demo` 下作为演示状态机使用。`components/LaunchShell.tsx` 负责桌面/移动分流,桌面端复用 `frontend/src/features/personal-space/components/SpaceChrome.tsx` 的空间左侧栏和顶部条,`LaunchCanvases.tsx` 和 `MobileLaunchLayout.tsx` 承接高保真图中的意图输入、AI 工作、人类确认、草案修改、生成失败、创建成功过渡与移动端 bottom sheet 交互。 -- `frontend/src/features/pmflow-os/store.tsx` 当前是 PMFlow OS 共享 truth store:`?projectId=` 时通过 React Query 调用 `frontend/src/api/pmflowOs.ts` 读取真实 `GET /pmflow-os/projects/{project_id}/truth-state` 与 `GET /pmflow-os/projects/{project_id}/execution-summary`,真实模式下项目内 message、`proposed_items` extraction、ChangeItem `accept/edit/defer`、ChangeSet commit、review decision、Execution Handoff create/consume、execution progress writeback、release validation run create、release feedback ingest、单条 iteration recommendation -> ChangeSet draft 与同组 iteration recommendation 批量 -> ChangeSet draft 均调用 `/pmflow-os` 后端并 refetch;只有显式 `?demo=1` 或 `?dataSource=demo` 时才启用本地 mock 演示状态机。真实读取/动作失败会进入错误/空态,不静默 fallback 成 mock 成功。 -- `frontend/src/features/pmflow-os/store.tsx` 仍保留演示用项目内 AI 结构化闭环状态机:`command_idle / ai_extracting / extraction_ready / review_open / writeback_success`。`frontend/src/features/pmflow-os/pages/StructuredExtractionPage.tsx` 是本轮结构化提取页,`ChangeReviewPage.tsx` 是决策与待确认中心,`MemoryWritebackResultPage.tsx` 是项目记忆写入结果页,`WorkGraphPage.tsx` 是项目工作台关系总览与对象投影视图,`ExecutionBridgePage.tsx` 是执行桥上下文包、交接按钮、运行端领取确认、PR/测试与回写状态页;`ReleaseGrowthPage.tsx` 真实模式读取 `execution-summary.releaseGate`、`latestValidationRun` 与 `releaseSignals`,从 branch、commit、PR、test、failed evidence 展示 `可发布 / 阻塞 / 需要人工确认 / 缺少证据` 判断、阻塞原因、下一步动作、发布验证记录和发布后 feedback/revenue/recommendation 信号,缺少发布后信号时显示 empty/warning,不执行发布命令、不展示 mock 发布结果;页面可展示 recommendation quality grouping,把真实 recommendation 单条或同组批量生成下一轮待确认 ChangeSet draft,生成后仍进入 Change Review accept/edit/defer,不自动 commit、不写 Project Memory、不创建正式任务;`ChangeReviewPage.tsx` 会展示 `iteration_recommendation_group` evidence 的来源组、feedback 数、recommendation 数、风险、影响和审查优先级;同时读取 Account notification preference helper,用 `releaseGate` 偏好控制发布门禁强调提醒,用 `failureAlert` 偏好控制失败强调提醒,偏好关闭时只静默提醒表达强度,不隐藏 release evidence、阻塞原因、审计状态、validation run 或 releaseSignals;静态发布/增长原型只保留在显式 demo 模式。A3 已接入后端 truth-state,C1 已接入真实 message/extraction/review 状态机,C2 已接入真实 ChangeSet commit,Phase D 已接入真实 execution-summary/dev-sync,D.2 已接入 `POST /execution-handoffs` 创建 PromptPackage + handoff_created progress event,P1 已接入 `POST /execution-handoffs/{prompt_package_id}/consume` 创建 handoff_consumed progress event 并把 execution status 投影为 coding,Release Growth P1 已接入 execution evidence gate,Release Validation P1 已接入 `POST /release-validation-runs` 创建 `ReleaseValidationRun` 快照,Release Feedback P1 已接入 `POST /release-feedback-ingests` 写入 `FeedbackIngest` 并生成 `IterationRecommendation`,Iteration Loop P1 已接入 `POST /iteration-recommendations/{recommendation_id}/change-set-drafts` 把 recommendation 回流到 Change Review,Iteration Loop P2 已接入 `POST /iteration-recommendation-groups/{group_key}/change-set-drafts` 并在 `releaseSignals.recommendationGroups` 暴露分组/去重/风险/影响合同;当前仍不调用真实 AI 提取,不把 mock 当真实失败 fallback。`ProjectMemoryPage.tsx` 在真实模式下展示后端 `truth_nodes + semantic_facts` 的 `projectMemory` 投影,accepted/edited ChangeItem 只有 commit 成功后才进入 Project Memory。`frontend/src/features/pmflow-os/components/ProjectStructuredLoop.tsx` 提供 `StatusLabel / EvidenceSnippet / ObjectChangeCard / ProjectConversationPanel / StructuredExtractionPanel / ProjectAICopilotPanel` 等闭环组件,并复用契约化 `InlineNotice` 表达“未确认不得写入”的局部提示。 +- 前端主包治理已切到“同步首屏最小集 + route/page-section lazy”模式:`frontend/src/router/index.tsx` 保留 `LoginPage / Layout` 这组同步入口,其余后台重页面通过 `React.lazy + Suspense` 懒加载;统一 fallback 在 `frontend/src/components/layout/LazyBoundary.tsx`。P0 公共壳冻结后,大模块 route 归属为:`/space` 归 Space lane,`/project-launch` 是启动层入口,`/pmflow-os` 与 `/command-center / /change-review / /execution-bridge / /release-growth` 归 Main Flow lane,`/me` 归 Account Settings lane,`/settings` 当前仅为 reserved route、尚未实现,新增必须由 Shell / Integration owner 串行处理。`frontend/src/features/personal-space/` 作为空间层入口,通过登录后的 `/space` 提供空间入口,承载继续推进、待我确认、AI 正在推进、暂存想法、草案箱与新建项目入口。`frontend/src/features/project-launch/` 作为首次进入页 / 新项目启动台产品域,通过登录后的真实 `/project-launch` 提供启动入口(显式 `?demo=1` 或 `?dataSource=demo` 可匿名演示);Phase B 后默认调用真实 `pmflow-launch` draft/confirm API,创建成功态主动作跳转后端返回的 `/pmflow-os?projectId=`,只有显式 `?demo=1` 或 `?dataSource=demo` 才启用本地 mock 状态机。`frontend/src/features/account-settings/` 作为 Account Settings 产品域,通过 `/me` 提供个人资料、默认语言、AI 回答深度、证据展示偏好、待确认/执行回写/发布门禁/失败提醒通知偏好和账户操作入口;该域通过 `frontend/src/api/accountSettings.ts` 建立前端合同,有 token 时优先调用已挂入 `backend/app/api/router.py` 的 `backend/app/api/account_settings_api/` `GET /account-settings/me` / `PUT /account-settings/me` 同步当前账号偏好,后端不可用时明确回落本机暂存,不修改 OAuth/JWT/auth core。`frontend/src/features/pmflow-os/` 作为新产品域,通过 `PMFlowOsProvider` 在应用根部挂载共享 truth store,并提供 `CommandCenter / ChangeReview / WorkGraph / ExecutionBridge / ReleaseGrowth / TeamLayer / ProjectMemory / Account` 主入口;当前 PMFlow OS 页面已切到专属轻壳,桌面端默认隐藏旧 TopBar,并由 `frontend/src/features/pmflow-os/components/PMFlowOsFrame.tsx` 统一承接“左侧稳定导航 + 顶部上下文条 + 中央视图 + 右侧 AI 副驾”的全局骨架,同时承接项目内 `个人空间 / 当前项目名` 面包屑和项目切换器;`TeamLayerPage.tsx` 现在消费 `ProjectTruthState.reviewerRouting(status/summary/items)`、`ProjectTruthState.feed` 与 `execution-capabilities`,以 `团队动态 / 决策台 / 角色收件箱 / 智能体工位 / AI 副驾` 组织团队协作态势,展示待审变更责任路由、审查人建议、审查状态、证据数量、执行单元信号和容量调度;决策台与待审路由卡片按真实条目密度排布,1-2 条不强行占 3 列,操作按钮不挤压正文宽度;动作只跳 `/change-review?projectId=&changeSetId=` 或下方容量区,不自动分派、不自动审查、不写 Project Memory;账号退出登录由 `frontend/src/features/pmflow-os/components/AccountControls.tsx` 统一承接桌面头像下拉、退出确认与移动端 bottom sheet,`frontend/src/features/pmflow-os/pages/AccountPage.tsx` 提供 `/me` 入口并委托 Account Settings 域渲染设置页;正式应用壳移动端底部导航统一由 `frontend/src/features/pmflow-os/components/MobileBottomNav.tsx` 承接,固定为 `空间 / 指挥台 / 待确认 / 动态 / 我的`,其中 `动态` 当前仍是 `/command-center` 占位。`/command-center` 额外提供免登录产品预览壳,`/pmflow-os` 是真实项目上下文 alias。主视觉按 `#2463EB + 白色` 的 Linear-like 高密度原型展开。`UsersPage` 把创建/删除/重置/导入/批量分配对话框拆为按需 chunk,`UserSkillsPage` 与 `WorkloadDetailPage` 的图表也分别拆到了 `frontend/src/pages/user-skills/components/UserSkillsRadarPanel.tsx` 与 `frontend/src/pages/workload-detail/components/WorkloadTrendChart.tsx`。 +- `backend/app/api/ai_readiness_api/` + `backend/app/services/ai_readiness_service.py` + `frontend/src/api/aiReadiness.ts` + `frontend/src/features/ai-readiness/` 是空间级 AI 可用状态与 Project Launch 草案门禁合同:后端 `GET /ai-readiness/project-launch` 从平台运行租户供应商配置、published active `structured` 槽、Project Launch scene registry、结构化能力证据和租户最近 24h `pmflow_launch_draft_v1` 调用日志推导 `ready / blocked / recovering / unknown`;单次最近生成失败只作为平台 warning,若 24h 内已有成功且连续失败少于 3 次,用户端仍保持可生成,连续失败达到阈值才进入 recovering;`POST /ai-readiness/project-launch/check` 仅允许 platform AI Ops 触发真实结构化 smoke,复用 Project Launch 草案生成链但不写 draft/project/task/Project Memory;前端 `useAIReadiness()` 默认消费后端结果,本机 localStorage 只保留 test 或 `VITE_AI_READINESS_LOCAL_OVERRIDE=true` 覆盖。`/space` 顶部与首屏、真实 `/project-launch`、项目级 `ResponsiveAICopilot`、`/ai-config?tab=setup` 和 `/platform/*` 共用该合同;用户端默认只消费客户可理解的业务影响、保护规则和可继续动作,空间负责人额外看到空间 AI 状态、团队策略和工具连接入口,平台 AI Ops 只有在显式诊断卡或 `/platform/*` 才看到 cause、raw status、供应商、路由、诊断、smoke 和 CTA。 +- `frontend/src/features/project-launch/types.ts`、`mockData.ts`、`useProjectLaunchState.ts` 是新项目启动台状态层;默认真实模式通过 `frontend/src/api/pmflowLaunch.ts` 创建 `pmflow_launch_draft_jobs`、轮询 job 状态、拿到草案后再确认创建真实项目,API 失败进入明确失败态,不静默 fallback 成 mock 成功;`mockData.ts` 仅在显式 `?demo=1` 或 `?dataSource=demo` 下作为演示状态机使用,真实模式的步骤摘要、操作流、审查建议、附件引用和计数从当前输入、job 状态、真实草案、warnings/evidence 派生。`components/LaunchShell.tsx` 负责桌面/移动分流,桌面端复用 `frontend/src/features/personal-space/components/SpaceChrome.tsx` 的空间左侧栏和顶部条,并通过真实补充输入弹层提交 `merge_supplement` revision,禁止硬编码追加示例文本;`SpaceSidebar` 承载稳定空间目的地(空间首页、暂存想法、草案箱、所有项目和管理配置),不展示最近项目、待我确认或 AI 正在推进等首页工作台区块项;真实模式下若 `AIReadiness.canGenerateProjectDraft=false`,优先渲染 `AISetupRequiredPanel`,普通成员只记录想法,空间负责人查看空间 AI 可用状态,平台 AI Ops 才进入平台 AI 能力处理,不提供空项目创建入口;低信息输入由后端 `input_needs_more_info` 422 拦截并进入补充输入失败态。`LaunchCanvases.tsx` 和 `MobileLaunchLayout.tsx` 承接高保真图中的意图输入、AI 工作、人类确认、草案修改、生成失败、创建成功过渡与移动端 bottom sheet 交互。 +- `frontend/src/features/pmflow-os/store.tsx` 当前是 PMFlow OS 共享 truth store:`?projectId=` 时通过 React Query 调用 `frontend/src/api/pmflowOs.ts` 读取真实 `GET /pmflow-os/projects/{project_id}/truth-state` 与 `GET /pmflow-os/projects/{project_id}/execution-summary`,真实模式下项目内 `sendMessage` 会写 thread message 并触发后端真实 AI extraction;显式 `proposed_items` extraction、ChangeItem `accept/edit/defer`、ChangeSet commit、review decision、Execution Handoff create/consume、execution progress writeback、release validation run create、release feedback ingest、单条 iteration recommendation -> ChangeSet draft 与同组 iteration recommendation 批量 -> ChangeSet draft 均调用 `/pmflow-os` 后端并 refetch;只有显式 `?demo=1` 或 `?dataSource=demo` 时才启用本地 mock 演示状态机。真实读取/动作失败会进入错误/空态,不静默 fallback 成 mock 成功;AI extraction 失败显示“结构化失败/待补充”,不创建模板 ChangeSet。 +- `frontend/src/features/pmflow-os/store.tsx` 仍保留演示用项目内 AI 结构化闭环状态机:`command_idle / ai_extracting / extraction_ready / review_open / writeback_success`。`frontend/src/features/pmflow-os/pages/StructuredExtractionPage.tsx` 是本轮结构化提取页,也是结构化读取检查层:从指挥台进入时主动作进入待确认或继续补充;从 `ChangeReviewPage.tsx` 进入时通过 `origin=change_review / returnTo=change_review / changeSetId / itemId` 保留审查上下文,主动作返回待确认队列,补充证据才回指挥台;已 committed 的轮次显示为结构化写入快照并转向项目记忆或新变更链路。`ChangeReviewPage.tsx` 是决策与待确认中心,真实模式下顶部识别统计、待确认列表与右侧 AI 副驾评估都只消费后端 `changeSets / changeItems / evidence`,没有真实待审项时显示空态,禁止用 demo 审查项补位;确认写入 Project Memory 前,`pending_review / accepted / edited / deferred` ChangeItem 均可继续 accept/edit/defer,只有 `committed / skipped` 或关闭轮次不可再操作;`MemoryWritebackResultPage.tsx` 是项目记忆写入结果页,`WorkGraphPage.tsx` 是项目工作台关系总览与对象投影视图,`ExecutionBridgePage.tsx` 是执行桥上下文包、交接按钮、运行端领取确认、PR/测试与回写状态页;`ReleaseGrowthPage.tsx` 真实模式读取 `execution-summary.releaseGate`、`latestValidationRun` 与 `releaseSignals`,从 branch、commit、PR、test、failed evidence 展示 `可发布 / 阻塞 / 需要人工确认 / 缺少证据` 判断、阻塞原因、下一步动作、发布验证记录和发布后 feedback/revenue/ROI/compliance/recommendation 信号,缺少发布后信号时显示 empty/warning,不执行发布命令、不展示 mock 发布结果;页面可展示 recommendation quality grouping,把真实 recommendation 单条或同组批量生成下一轮待确认 ChangeSet draft,生成后仍进入 Change Review accept/edit/defer,不自动 commit、不写 Project Memory、不创建正式任务;`ChangeReviewPage.tsx` 会展示 `iteration_recommendation_group` evidence 的来源组、feedback 数、recommendation 数、风险、影响和审查优先级;同时读取 Account notification preference helper,用 `releaseGate` 偏好控制发布门禁强调提醒,用 `failureAlert` 偏好控制失败强调提醒,偏好关闭时只静默提醒表达强度,不隐藏 release evidence、阻塞原因、审计状态、validation run 或 releaseSignals;静态发布/增长原型只保留在显式 demo 模式。A3 已接入后端 truth-state,C1 已接入真实 message/extraction/review 状态机,C2 已接入真实 ChangeSet commit,Phase D 已接入真实 execution-summary/dev-sync,D.2 已接入 `POST /execution-handoffs` 创建 PromptPackage + handoff_created progress event,P1 已接入 `POST /execution-handoffs/{prompt_package_id}/consume` 创建 handoff_consumed progress event 并把 execution status 投影为 coding,Release Growth P1 已接入 execution evidence gate,Release Validation P1 已接入 `POST /release-validation-runs` 创建 `ReleaseValidationRun` 快照,Release Feedback P1 已接入 `POST /release-feedback-ingests` 写入 `FeedbackIngest` 并生成 `IterationRecommendation`,Iteration Loop P1 已接入 `POST /iteration-recommendations/{recommendation_id}/change-set-drafts` 把 recommendation 回流到 Change Review,Iteration Loop P2 已接入 `POST /iteration-recommendation-groups/{group_key}/change-set-drafts` 并在 `releaseSignals.recommendationGroups` 暴露分组/去重/风险/影响合同;真实 AI extraction 已接入独立 `change_review` protected scene、`pmflow_change_review_extraction_v1` 运行合同、`PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION` Prompt 资产与后端 `llm_service.generate_structured_object`,成功时生成 open ChangeSet、pending_review ChangeItems 与 thread message evidence,失败时 fail-closed,不把 mock 当真实失败 fallback,也不复用 Project Launch draft prompt/function。`ProjectMemoryPage.tsx` 在真实模式下展示后端 `truth_nodes + semantic_facts` 的 `projectMemory` 投影,accepted/edited ChangeItem 只有 commit 成功后才进入 Project Memory。`frontend/src/features/pmflow-os/components/ProjectStructuredLoop.tsx` 提供 `StatusLabel / EvidenceSnippet / ObjectChangeCard / ProjectConversationPanel / StructuredExtractionPanel / ProjectAICopilotPanel` 等闭环组件,并复用契约化 `InlineNotice` 表达“未确认不得写入”的局部提示。 +- Project Knowledge Hub 读模型由 `GET /pmflow-os/projects/{project_id}/project-memory/overview`、`items`、`items/{item_id}`、`assets`、`context-packs`、`writebacks` 承载,后端在 `backend/app/services/pmflow_os_projection_service.py` 从 `truth_nodes / tasks / semantic_facts / pmflow_evidence_links / pmflow_thread_messages / pmflow_change_sets / pmflow_change_items` 聚合首页雷达、全部记忆、项目资产、上下文包和写入版本记录。前端对应 `ProjectMemoryPage.tsx`、`ProjectMemoryCollectionPages.tsx` 与 `projectMemoryUtils.ts`:列表时间使用短格式,抽屉显示完整时间和北京时间;首页卡片与二级列表单条点击打开右侧详情抽屉,展示来源证据、影响对象和 AI 执行动作跳转;全部记忆支持类型、阶段、来源、状态、搜索筛选。该读模型只读,资产可自动索引,进入项目真相层仍必须经过 Change Review 或可信规则确认。 - `frontend/src/features/account-settings/AccountSettingsPanel.tsx` 是 `/me` 设置页实现:桌面端为个人资料摘要 + 分区设置面板,移动端为分组列表 + `MobileBottomSheet` 编辑;当前覆盖个人资料、默认语言、AI 回答深度、证据展示偏好、待确认、执行回写、发布门禁和失败提醒通知偏好,并在通知偏好区展示页面内提醒消费说明、提醒强度和证据展示配置。`frontend/src/api/accountSettings.ts` 是前端合同层;有 token 时优先调用 `backend/app/api/account_settings_api/`,失败时回落浏览器本机暂存;P2 新增 `shouldShowAccountNotification` 与 `getAccountNotificationConsumption`,将四类通知偏好映射为本地 UI 的待确认、Execution Bridge、Release/Growth 和失败提醒显示/静默状态;P3 新增 `getReleaseGateNotificationPreference(settings, event)`,把 Release/Growth 的 `release_gate_* / release_validation_*` 事件映射到 `releaseGate` 偏好,把 `failed_event` 映射到 `failureAlert` 偏好;P5/P6 的 `getPostReleaseNotificationPreference(settings, event)` 把发布后 `feedback_received / revenue_signal / compliance_blocked` 映射到 `releaseGate` 偏好,把 `iteration_recommendation / next_change_review` 映射到 `pendingConfirmation` 偏好和 `change_review` surface,并返回 `spaceReadable / evidencePolicy / reminderPolicy` 供 Main Flow 与 Space 消费;P7 将通知偏好扩展为 `reminderIntensity=silent|normal|prominent` 与 `evidenceDisplay=compact|expanded`,并新增 `getAccountReminderConsumption(settings, event)` 覆盖 `change_review_high_risk / iteration_draft_ready / release_blocked / execution_stalled`,输出 `notificationTransport=in_app_only / evidencePolicy=preserve_business_evidence / auditPolicy=preserve_audit_state`;P8 新增 `getReviewDecisionNotificationPreference(settings, event)` 覆盖 `review_requested / review_approved / review_changes_requested / review_rejected / high_risk_approval_required`,继续使用 `pendingConfirmation` 偏好和 `change_review` surface,并声明 reviewerRouting、approval、evidence、audit state 均必须保留。该合同只控制页面内提醒表达,不代表邮件、桌面推送或系统通知中心已接入;偏好关闭也不得隐藏业务证据、reviewerRouting、approval、recommendation、ChangeSet、执行状态或审计状态。`backend/app/schemas/account_settings.py` 定义账号偏好合同,`backend/app/services/account_settings_service.py` 当前复用既有 `integrations` 表按 `tenant_id + user_id` 保存 `account_settings_preferences` 配置包,`backend/app/api/router.py` 已挂载该 API;未改用户表、OAuth/JWT/auth core 或 PMFlow OS truth store。 -- `frontend/src/features/pmflow-os/store.tsx` 当前是 PMFlow OS 共享 truth store:`?projectId=` 时通过 React Query 调用 `frontend/src/api/pmflowOs.ts` 读取真实 `GET /pmflow-os/projects/{project_id}/truth-state` 与 `GET /pmflow-os/projects/{project_id}/execution-summary`,真实模式下项目内 message、`proposed_items` extraction、ChangeItem `accept/edit/defer`、ChangeSet commit、review decision、Execution Handoff create/consume、execution progress writeback、release validation run create、release feedback ingest 与 iteration recommendation -> ChangeSet draft 均调用 `/pmflow-os` 后端并 refetch;只有显式 `?demo=1` 或 `?dataSource=demo` 时才启用本地 mock 演示状态机。真实读取/动作失败会进入错误/空态,不静默 fallback 成 mock 成功。 -- `frontend/src/features/pmflow-os/store.tsx` 仍保留演示用项目内 AI 结构化闭环状态机:`command_idle / ai_extracting / extraction_ready / review_open / writeback_success`。`frontend/src/features/pmflow-os/pages/StructuredExtractionPage.tsx` 是本轮结构化提取页,`ChangeReviewPage.tsx` 是决策与待确认中心,`MemoryWritebackResultPage.tsx` 是项目记忆写入结果页,`WorkGraphPage.tsx` 是项目工作台关系总览与对象投影视图,`ExecutionBridgePage.tsx` 是执行桥上下文包、交接按钮、运行端领取确认、PR/测试与回写状态页;`ReleaseGrowthPage.tsx` 真实模式读取 `execution-summary.releaseGate`、`latestValidationRun` 与 `releaseSignals`,从 branch、commit、PR、test、failed evidence 展示 `可发布 / 阻塞 / 需要人工确认 / 缺少证据` 判断、阻塞原因、下一步动作、发布验证记录和发布后 feedback/revenue/recommendation 信号,缺少发布后信号时显示 empty/warning,不执行发布命令、不展示 mock 发布结果;页面可把真实 recommendation 生成下一轮待确认 ChangeSet draft,生成后仍进入 Change Review accept/edit/defer,不自动 commit、不写 Project Memory、不创建正式任务;同时读取 Account notification preference helper,用 `releaseGate` 偏好控制发布门禁强调提醒,用 `failureAlert` 偏好控制失败强调提醒,偏好关闭时只静默提醒表达强度,不隐藏 release evidence、阻塞原因、审计状态、validation run 或 releaseSignals;静态发布/增长原型只保留在显式 demo 模式。A3 已接入后端 truth-state,C1 已接入真实 message/extraction/review 状态机,C2 已接入真实 ChangeSet commit,Phase D 已接入真实 execution-summary/dev-sync,D.2 已接入 `POST /execution-handoffs` 创建 PromptPackage + handoff_created progress event,P1 已接入 `POST /execution-handoffs/{prompt_package_id}/consume` 创建 handoff_consumed progress event 并把 execution status 投影为 coding,Release Growth P1 已接入 execution evidence gate,Release Validation P1 已接入 `POST /release-validation-runs` 创建 `ReleaseValidationRun` 快照,Release Feedback P1 已接入 `POST /release-feedback-ingests` 写入 `FeedbackIngest` 并生成 `IterationRecommendation`,Iteration Loop P1 已接入 `POST /iteration-recommendations/{recommendation_id}/change-set-drafts` 把 recommendation 回流到 Change Review;当前仍不调用真实 AI 提取,不把 mock 当真实失败 fallback。`ProjectMemoryPage.tsx` 在真实模式下展示后端 `truth_nodes + semantic_facts` 的 `projectMemory` 投影,accepted/edited ChangeItem 只有 commit 成功后才进入 Project Memory。`frontend/src/features/pmflow-os/components/ProjectStructuredLoop.tsx` 提供 `StatusLabel / EvidenceSnippet / ObjectChangeCard / ProjectConversationPanel / StructuredExtractionPanel / ProjectAICopilotPanel` 等闭环组件,并复用契约化 `InlineNotice` 表达“未确认不得写入”的局部提示。 -- PMFlow OS 后端入口位于 `backend/app/api/pmflow_os_api/`,projection service 位于 `backend/app/services/pmflow_os_projection_service.py`,Execution Capability Graph 只读聚合位于 `backend/app/services/team_execution_capability_service.py`,Change Review / commit / review decision 服务位于 `backend/app/services/pmflow_change_review_service.py`,Pydantic 合同位于 `backend/app/schemas/pmflow_os.py`;当前聚合 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync`,`Thread / Message` 来自 `pmflow_threads / pmflow_thread_messages`,`ChangeSet / ChangeItem / Approval / EvidenceLink` 来自 `backend/app/models/pmflow_change_review.py` 对应真实表。`GET /pmflow-os/projects/{project_id}/execution-capabilities` 返回 `executionModes / personalReadiness / teamSignals / warnings`,复用用户、项目成员、技能绑定、负载、reviewer routing、execution summary 与 workspace binding 数据,只给建议、阻塞和解释,不自动分派任务、不自动请求审查、不写 ChangeSet;缺 repo、工具、测试命令、reviewer 或证据时返回 warning、`blocked` 或 `needs_human_judgement`,不虚构能力结论。`ProjectTruthState.reviewerRouting` 是 Team layer 的 reviewer routing + decision 读模型:从 open/pending ChangeSet、待审 item 风险、跨对象类型、`iteration_recommendation` evidence、项目成员与 `OWNER / REVIEWER` 角色推导 `{status, warningCode, summary, items}`;item 内含 `sourceType / riskLevel / priority / suggestedReviewer / fallbackReviewerRole / targetUrl / reviewDecision / approvalState / requestedAt / decidedAt / decisionReason`;缺成员、缺角色或缺证据时 fail-closed 给 warning,不虚构 reviewer。P8 新增 `POST /pmflow-os/change-sets/{change_set_id}/review-decisions`,支持 `request_review / approve / request_changes / reject`,`approve / request_changes / reject` 写 `PMFlowApproval` 审计,high-risk approve 后才满足 C2 approval gate,所有 decision 都不自动 commit。C1 已实现 message、`proposed_items` extraction、ChangeSet detail、ChangeItem `accept/edit/defer`;C2 backend/frontend 已实现 `POST /pmflow-os/change-sets/{change_set_id}/commit` 与真实模式前端接入,默认 atomic,将 `Requirement / Decision / Risk` 写入 `truth_nodes`,将 `Task` 写入 `truth_nodes(node_type='task') + tasks`,高风险项强制 approval gate;D.2 已实现 `POST /pmflow-os/projects/{project_id}/execution-handoffs`,校验 task/repo project 边界,生成/复用 `PromptPackage(package_kind='execution_handoff')` 并写 `ProgressEvent(event_type='handoff_created')` 起点;P1 已实现 `POST /pmflow-os/projects/{project_id}/execution-handoffs/{prompt_package_id}/consume`,校验 execution handoff package 后更新 `PromptPackage.package_payload.latest_handoff.handoff_state=consumed` 并写 `ProgressEvent(event_type='handoff_consumed', derived_task_status='coding')`;Release Growth P1 在 `ExecutionSummaryProjection.releaseGate` 中从 `ProgressEvent` 推导发布门禁状态,失败事件 fail-closed 阻塞发布,无证据返回 empty/warning;Release Validation P1 新增 `POST /pmflow-os/projects/{project_id}/release-validation-runs`,复用现有 `ReleaseValidationRun` 模型固化当前 `releaseGate` 快照、人工决策、证据、阻塞原因和下一步动作,并在 `releaseGate.latestValidationRun` 返回最新记录;Release Feedback P1 新增 `POST /pmflow-os/projects/{project_id}/release-feedback-ingests`,复用现有 `FeedbackIngest / IterationRecommendation`,在 `ExecutionSummaryProjection.releaseSignals` 中返回真实发布后 feedback、revenue event 和 iteration recommendation 读模型;Iteration Loop P1 新增 `POST /pmflow-os/projects/{project_id}/iteration-recommendations/{recommendation_id}/change-set-drafts`,把真实 recommendation 转为 open ChangeSet、pending_review Requirement/Task/Risk/Decision items 与 `iteration_recommendation` evidence,重复调用复用既有 draft 并在 `releaseSignals` 标记 `changeSetCreated/changeSetId`;这些步骤都不直接执行本机命令、真实发布命令、外部支付、真实推送、自动 Project Memory commit 或正式任务创建;`ModelDecision / MetricSignal` 在 C2 V1 仍 fail-closed。 +- `frontend/src/features/pmflow-os/store.tsx` 当前是 PMFlow OS 共享 truth store:`?projectId=` 时通过 React Query 调用 `frontend/src/api/pmflowOs.ts` 读取真实 `GET /pmflow-os/projects/{project_id}/truth-state` 与 `GET /pmflow-os/projects/{project_id}/execution-summary`,真实模式下项目内 `sendMessage` 会写 thread message 并触发后端真实 AI extraction;显式 `proposed_items` extraction、ChangeItem `accept/edit/defer`、ChangeSet commit、review decision、AI feedback、Execution Handoff create/consume、execution progress writeback、release validation run create、release feedback ingest 与 iteration recommendation -> ChangeSet draft 均调用 `/pmflow-os` 后端并 refetch;AI feedback 写入 `pmflow_ai_feedback_events`,在 `truth-state.aiFeedback` 恢复当前用户选择,并由后端按 `prompt_key` 生成待复核 `JudgeRun / OptimizerCandidate` 质量优化候选,用户改回赞同时挂起此前候选,Prompt Asset quality summary 会聚合 PMFlow 反馈与优化候选;只有显式 `?demo=1` 或 `?dataSource=demo` 时才启用本地 mock 演示状态机。真实读取/动作失败会进入错误/空态,不静默 fallback 成 mock 成功;AI extraction 失败显示“结构化失败/待补充”,不创建模板 ChangeSet。 +- `frontend/src/features/pmflow-os/store.tsx` 仍保留演示用项目内 AI 结构化闭环状态机:`command_idle / ai_extracting / extraction_ready / review_open / writeback_success`。`frontend/src/features/pmflow-os/pages/StructuredExtractionPage.tsx` 是本轮结构化提取页,`ChangeReviewPage.tsx` 是决策与待确认中心,真实模式下顶部识别统计、待确认列表与右侧 AI 副驾评估都只消费后端 `changeSets / changeItems / evidence`,没有真实待审项时显示空态,禁止用 demo 审查项补位;`MemoryWritebackResultPage.tsx` 是项目记忆写入结果页,`WorkGraphPage.tsx` 是项目工作台关系总览与对象投影视图,`ExecutionBridgePage.tsx` 是执行桥上下文包、交接按钮、运行端领取确认、PR/测试与回写状态页;`ReleaseGrowthPage.tsx` 真实模式读取 `execution-summary.releaseGate`、`latestValidationRun` 与 `releaseSignals`,从 branch、commit、PR、test、failed evidence 展示 `可发布 / 阻塞 / 需要人工确认 / 缺少证据` 判断、阻塞原因、下一步动作、发布验证记录和发布后 feedback/revenue/ROI/compliance/recommendation 信号,缺少发布后信号时显示 empty/warning,不执行发布命令、不展示 mock 发布结果;页面可把真实 recommendation 生成下一轮待确认 ChangeSet draft,生成后仍进入 Change Review accept/edit/defer,不自动 commit、不写 Project Memory、不创建正式任务;同时读取 Account notification preference helper,用 `releaseGate` 偏好控制发布门禁强调提醒,用 `failureAlert` 偏好控制失败强调提醒,偏好关闭时只静默提醒表达强度,不隐藏 release evidence、阻塞原因、审计状态、validation run 或 releaseSignals;静态发布/增长原型只保留在显式 demo 模式。A3 已接入后端 truth-state,C1 已接入真实 message/extraction/review 状态机,C2 已接入真实 ChangeSet commit,Phase D 已接入真实 execution-summary/dev-sync,D.2 已接入 `POST /execution-handoffs` 创建 PromptPackage + handoff_created progress event,P1 已接入 `POST /execution-handoffs/{prompt_package_id}/consume` 创建 handoff_consumed progress event 并把 execution status 投影为 coding,Release Growth P1 已接入 execution evidence gate,Release Validation P1 已接入 `POST /release-validation-runs` 创建 `ReleaseValidationRun` 快照,Release Feedback P1 已接入 `POST /release-feedback-ingests` 写入 `FeedbackIngest` 并生成 `IterationRecommendation`,Iteration Loop P1 已接入 `POST /iteration-recommendations/{recommendation_id}/change-set-drafts` 把 recommendation 回流到 Change Review;真实 AI extraction 已接入后端 `llm_service.generate_structured_object` 与 PMFlow prompt/config/routing 层,成功时生成 open ChangeSet、pending_review ChangeItems 与 thread message evidence,失败时 fail-closed,不把 mock 当真实失败 fallback。`ProjectMemoryPage.tsx` 在真实模式下展示后端 `truth_nodes + semantic_facts` 的 `projectMemory` 投影,accepted/edited ChangeItem 只有 commit 成功后才进入 Project Memory。`frontend/src/features/pmflow-os/components/ProjectStructuredLoop.tsx` 提供 `StatusLabel / EvidenceSnippet / ObjectChangeCard / ProjectConversationPanel / StructuredExtractionPanel / ProjectAICopilotPanel` 等闭环组件,并复用契约化 `InlineNotice` 表达“未确认不得写入”的局部提示。 +- PMFlow OS 后端入口位于 `backend/app/api/pmflow_os_api/`,projection service 位于 `backend/app/services/pmflow_os_projection_service.py`,Execution Capability Graph 只读聚合位于 `backend/app/services/team_execution_capability_service.py`,Change Review / commit / review decision 服务位于 `backend/app/services/pmflow_change_review_service.py`,AI feedback 服务位于 `backend/app/services/pmflow_ai_feedback_service.py`,Pydantic 合同位于 `backend/app/schemas/pmflow_os.py`;当前聚合 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync`,`Thread / Message` 来自 `pmflow_threads / pmflow_thread_messages`,`ChangeSet / ChangeItem / Approval / EvidenceLink` 来自 `backend/app/models/pmflow_change_review.py` 对应真实表,ChangeSet committer 持久化在 `pmflow_change_sets.committed_by` 并由 `truth-state.lastWritebackBy`、commit result 与 Project Memory writebacks 投影,AI feedback 来自 `pmflow_ai_feedback_events` 并归因到 `scene_key / prompt_key / prompt_release_id / runtime_profile_id`。负反馈只创建待复核 `JudgeRun / OptimizerCandidate`,由 Prompt 资产运营页展示,不直接修改 active prompt 或自动发布;用户改回赞同时自动挂起此前候选。`GET /pmflow-os/projects/{project_id}/execution-capabilities` 返回 `executionModes / personalReadiness / teamSignals / warnings`,复用用户、项目成员、技能绑定、负载、reviewer routing、execution summary 与 workspace binding 数据,只给建议、阻塞和解释,不自动分派任务、不自动请求审查、不写 ChangeSet;缺 repo、工具、测试命令、reviewer 或证据时返回 warning、`blocked` 或 `needs_human_judgement`,不虚构能力结论。`ProjectTruthState.reviewerRouting` 是 Team layer 的 reviewer routing + decision 读模型:从 open/pending ChangeSet、待审 item 风险、跨对象类型、`iteration_recommendation` evidence、项目成员与 `OWNER / REVIEWER` 角色推导 `{status, warningCode, summary, items}`;item 内含 `sourceType / riskLevel / priority / suggestedReviewer / fallbackReviewerRole / targetUrl / reviewDecision / approvalState / requestedAt / decidedAt / decisionReason`;缺成员、缺角色或缺证据时 fail-closed 给 warning,不虚构 reviewer。P8 新增 `POST /pmflow-os/change-sets/{change_set_id}/review-decisions`,支持 `request_review / approve / request_changes / reject`,`approve / request_changes / reject` 写 `PMFlowApproval` 审计,high-risk approve 后才满足 C2 approval gate,所有 decision 都不自动 commit。C1 已实现 message、真实 AI extraction、显式 `proposed_items` extraction、ChangeSet detail、ChangeItem `accept/edit/defer` 与 AI feedback;真实 AI extraction 失败返回 `ai_extraction_failed / extraction_evidence_insufficient` 且不创建模板假结果;C2 backend/frontend 已实现 `POST /pmflow-os/change-sets/{change_set_id}/commit` 与真实模式前端接入,默认 atomic,将 `Requirement / Decision / Risk` 写入 `truth_nodes`,将 `Task` 写入 `truth_nodes(node_type='task') + tasks`,高风险项强制 approval gate;D.2 已实现 `POST /pmflow-os/projects/{project_id}/execution-handoffs`,校验 task/repo project 边界,生成/复用 `PromptPackage(package_kind='execution_handoff')` 并写 `ProgressEvent(event_type='handoff_created')` 起点;P1 已实现 `POST /pmflow-os/projects/{project_id}/execution-handoffs/{prompt_package_id}/consume`,校验 execution handoff package 后更新 `PromptPackage.package_payload.latest_handoff.handoff_state=consumed` 并写 `ProgressEvent(event_type='handoff_consumed', derived_task_status='coding')`;Release Growth P1 在 `ExecutionSummaryProjection.releaseGate` 中从 `ProgressEvent` 推导发布门禁状态,失败事件 fail-closed 阻塞发布,无证据返回 empty/warning;Release Validation P1 新增 `POST /pmflow-os/projects/{project_id}/release-validation-runs`,复用现有 `ReleaseValidationRun` 模型固化当前 `releaseGate` 快照、人工决策、证据、阻塞原因和下一步动作,并在 `releaseGate.latestValidationRun` 返回最新记录;Release Feedback P1 新增 `POST /pmflow-os/projects/{project_id}/release-feedback-ingests`,复用现有 `FeedbackIngest / IterationRecommendation`,在 `ExecutionSummaryProjection.releaseSignals` 中返回真实发布后 feedback、revenue event、ROI snapshot、compliance check 和 iteration recommendation 读模型;Release Business Signal Loop P1 已把 RevenueEvent、ROISnapshot 与 ComplianceCheckRun 纳入 `ExecutionSummaryProjection.releaseSignals` 风险读模型;Iteration Loop P1 新增 `POST /pmflow-os/projects/{project_id}/iteration-recommendations/{recommendation_id}/change-set-drafts`,把真实 recommendation 转为 open ChangeSet、pending_review Requirement/Task/Risk/Decision items 与 `iteration_recommendation` evidence,重复调用复用既有 draft 并在 `releaseSignals` 标记 `changeSetCreated/changeSetId`;这些步骤都不直接执行本机命令、真实发布命令、外部支付、真实推送、自动 Project Memory commit 或正式任务创建;`ModelDecision / MetricSignal` 在 C2 V1 仍 fail-closed。 - Adaptive Human Capacity 后端由 `backend/app/services/adaptive_capacity_service.py`、`backend/app/models/human_capacity_override.py`、`backend/alembic/versions/20260427_add_human_capacity_overrides.py` 与 `backend/app/schemas/pmflow_os.py` 承载;API 位于 `backend/app/api/pmflow_os_api/`:`GET /pmflow-os/projects/{project_id}/capacity-plan` 实时计算每日容量计划,`POST /pmflow-os/projects/{project_id}/capacity-overrides/simulate` 只模拟,`POST /pmflow-os/projects/{project_id}/capacity-overrides` 只写 override 审计,`GET /pmflow-os/projects/{project_id}/capacity-overrides` 返回调整记录;`execution-capabilities` 同步返回 `adaptiveCapacitySummary`。该模型不持久化每日计划快照,不写固定基线,不自动分配任务、不请求审查、不写 ChangeSet;旧 `UserWorkload.workload_ratio` 仅保留为成本参考信号。 - PMFlow Launch 后端 Phase B 创建链位于 `backend/app/api/pmflow_launch_api/`、`backend/app/services/pmflow_launch_service.py`、`backend/app/schemas/pmflow_launch.py` 与 `backend/app/models/pmflow_launch.py`;`POST /pmflow-launch/drafts` 只写 `pmflow_launch_drafts`,`POST /pmflow-launch/drafts/{draft_id}/confirm` 在同一事务内写 `projects / user_projects / pmflow_threads / pmflow_thread_messages / truth_nodes / tasks / semantic_facts seed / operation_logs`,并把 `launch_draft_id / thread_id / thread_message_id` 写入正式对象 metadata。旧 `POST /ai-steward/project-from-text` 仅由 `backend/app/api/legacy_ai_steward_api.py` 保留 410 响应一个版本周期,且该模块在 lean profile 下也会挂载;禁止绕过草案确认直接创建正式项目。 - `frontend/src/features/pmflow-os/components/ChangeReviewGroup.tsx`、`WorkbenchPanel.tsx`、`ExecutionBridgeCards.tsx`、`PMFlowOsFrame.tsx`、`AccountControls.tsx`、`FeedbackPrimitives.tsx` 是高保真原型新增的行为组件层:分别承载变更 diff 审查块、工作台对象卡组、执行桥上下文/日志卡片、全局上下文条与 AI 副驾、账号菜单/退出登录确认交互,以及 `PageBanner / InlineNotice / HumanDecisionBar / ConfirmDialog / MobileBottomSheet` 这组提示、提醒、确认与移动端 bottom sheet 基础组件,避免再把这些业务片段内联塞回 `pages/`。 @@ -223,7 +235,12 @@ - `frontend/src/api/sstc.ts` - `frontend/src/components/sstc/` - `frontend/src/pages/scheduler-center/components/` -- `frontend/src/pages/ai-routing/` +- `frontend/src/pages/ai-routing/`(平台 AI 运行层页面;业务场景路由优先消费后端 `scene_contract / scene_health / release_gate`,`sceneContracts.ts` 仅作为前端 fallback) +- `backend/app/services/ai_routing_scene_contracts.py`(业务场景路由合同 registry;active 场景参与健康度和发布门禁,candidate 场景只展示合同) +- `backend/app/config/ai_function_runtime_profiles.py` + `backend/app/services/ai_function_runtime_profile_service.py`(Function Runtime Profile 配置与发布服务;保存场景内生成预算、修复预算、temperature、响应模式、前台等待、provider/job 超时、最大重试、大输入策略、冒烟结果和发布历史到 `integrations(type='ai_function_runtime_profiles')`,当前 Project Launch 草案生成运行时只消费 active profile,draft 只用于平台 AI Ops 校验、冒烟和发布预览;active profile 实际控制 provider HTTP timeout、draft job timeout、transient retry budget、large 输入预处理和前端等待窗口) +- `frontend/src/pages/platform/AICallTracesPage.tsx` + `frontend/src/api/aiCallTraces.ts` + `backend/app/api/ai_call_traces_api.py` + `backend/app/services/ai_call_trace_service.py`(平台 AI 调用审计;只读消费 `ai_call_logs`,按 provider/model/scene/slot/trace/status/time/keyword 过滤,默认只展示真实上游调用记录,能力探测按 `probe_capability_*` 单项上游调用显示,其中 `probe_capability_model_self_report` 是模型自报参考而非 verified 槽位证据;历史 `probe_engine_capabilities` 汇总行默认隐藏;详情只展示完整脱敏 provider 请求/响应 payload、上游尝试链、routing/probe meta、tokens/cost/latency、错误诊断和场景 timeout 策略,不在详情中展示兼容 prompt/response 字段;`structured_timeout` 独立展示为模型超时,不与 JSON 无效/空响应混淆) +- `backend/app/services/ai_scene_runtime_policy.py`(AI 场景运行策略服务;按业务场景、function 和输入规模桶输出 `foreground_wait_seconds / provider_timeout_seconds / job_timeout_seconds / scene_fallback_policy / large_input_policy` 等显式 routing meta。Project Launch draft 当前由该服务提供 small/normal/large 默认策略,再由已发布 Function Runtime Profile 覆盖可运营字段,large 输入标记为预处理/分段生成方向) +- `frontend/src/pages/platform/PromptAssetsPage.tsx` + `frontend/src/api/promptAssets.ts` + `backend/app/api/prompt_assets_api.py` + `backend/app/services/prompt_assets_service.py` + `backend/app/services/prompt_runtime_service.py` + `backend/app/config/ai_prompt_eval_rubrics.py`(平台 Prompt 运营中心;聚合 prompt registry、默认 seed、`prompt_releases` active/draft、`prompt_release_checks` 门禁状态、分层 `prompt_golden_cases`、`prompt_replay_runs / prompt_replay_case_results`、`ai_call_logs` prompt 质量摘要、跨客户空间 PMFlow OS `pmflow_ai_feedback_events` 用户反馈、`optimizer_candidates` 优化候选、调用点和迁移状态,并对 `managed_release` prompt 提供草稿创建、激活和回滚;`guarded_release` 可创建候选并运行契约回放或 LLM 回放,LLM replay 走 `llm_service` 本地路由层并记录 baseline/candidate call log、judge score/label/reason;PMFlow OS 负反馈只生成待复核 `JudgeRun / OptimizerCandidate`,由平台 AI Ops 人工复核后再进入 release 流程,不直接修改 active prompt;golden replay 无样本或无 replay run 时 fail-closed,blocking 样本失败阻塞发布,important/observation 样本失败进入 warning/人工复核;release watch 汇总 24h/7d 质量、结构化异常、默认回退与健康/复核/回滚建议;`inline_discovered` 只表示已梳理但待迁移的代码散落 prompt) - `frontend/src/pages/ai-steward/components/` - `frontend/src/lib/` @@ -233,9 +250,11 @@ - `frontend/src/components/sstc/` 继续作为 SSTC 共享组件目录维护。 - AI Steward 浮窗的“当前对象/来源模式/证据状态”必须直接消费 `/rag/ask` 与 QA session API 的显式 contract 字段,禁止在前端靠回答正文反推 internal/external 或 plan 状态。 - 网页问答显式锚点 v1 只能消费稳定强锚点:项目页路径中的 `project_id` 与 URL query 中已显式存在的 `project_id/source_chat_id`;前端禁止自行猜测 `source_chat_id`。 -- `/ai-config` 是团队级 AI 运营中心,技术路由继续保留但用户可见不再叫“管理后台”或泛化 AI Config;首页按策略 / 连接 / 治理 / 高级分组组织 `/ai-config/team-ai-policy`、`/ai-config/ai-routing-console`、`/ai-config/mcp-config`、`/ai-config/query-route-governance` 与 `/ai-config/semantic-facts-board` 等入口,并明确这些配置影响当前团队空间内所有项目。治理页默认只对 `OWNER/REVIEWER` 或既有管理角色开放。AI 路由与供应商控制台由 `backend/app/api/ai_routing.py` 与 `backend/app/services/ai_routing_governance_service.py` 提供聚合,负责人视图只展示业务场景策略,管理员视图展示供应商连接、快速/深度槽位、诊断、高级函数详情和审计提示;团队策略只写 `integrations(type='ai_team_policy')`,不改 `llm_service` runtime 真源或 `backend/.control_plane/pmflow-routing-rules.json`。MCP 页负责模板化配置、后端直连/Local Bridge 状态展示、runtime probe、命令预览与本机确认;route/context 页负责 mismatch、clarify-heavy 与 negative feedback 样本排查;semantic facts 页负责 KPI、队列、对象表与详情抽屉。 -- AI 路由与供应商用户可见层必须脱敏:成员视图不得出现 low/high、provider、model、`function_name`、`api_env`、raw routing meta 或明文 key;负责人只看“质量优先 / 成本均衡 / 速度优先”和项目启动、需求结构化、任务拆解、人员匹配、内部问答、外部查询、飞书理解、飞书回复等场景策略;管理员高级详情默认隐藏 `function_name`,只在明确展开时展示。 -- 左侧导航不再展示团队负载、能力管理和用户管理作为一级产品入口;旧 `/users`、`/skills`、`/workloads`、`/users/:id/skills`、`/workloads/:id` 路由保留为高级设置/明细能力,用户可见语义分别收敛为成员与权限、能力词库、容量明细。`/workloads` 只保留传统工时参考、今日容量计划和 override 记录,不再作为 AI 分配核心。团队相关主入口统一为 `/team-layer`(团队层),承载 reviewer routing、Team Feed、执行能力、自适应容量、负责人容量模拟/调整与团队空间预留;AI 路由、供应商和语义治理归入“AI 运营中心”,成员不显示 AI 运营中心入口。项目级页面只消费 AI 处理模式与可信状态,不提供供应商、模型或底层路由配置。 +- `/ai-config` 是空间级 AI 使用设置,技术路由继续保留但用户可见不再叫“AI 运营中心”“管理后台”或泛化 AI Config;`/ai-config` 及其子路由必须使用空间配置壳 `SpaceSidebar(activeLabel="AI 使用设置") + SpaceTopBar`,让用户仍处在个人空间/团队空间的配置上下文,不得切入项目工作台侧栏;`/ai-config?tab=setup` 由 `AISetupWizard` 承接 AI 可用状态和项目生成能力门禁,只展示空间业务影响、团队策略和连接入口,不展示“启用供应商/路由/连接测试/额度诊断”类平台动作;连接、知识、团队策略、MCP 模板使用和 Prompt 策略继续组织客户空间可控能力,并明确这些配置影响当前团队空间内所有项目。`AIReadiness` 必须遵守双层表达:用户端默认客户视图,平台人员在用户端也只能通过显式 `platformDiagnostic` 查看 cause/CTA;`/platform/*` 默认平台诊断视图,展示供应商、全局路由、模型槽位、诊断、失败样本和处理动作。平台供应商、全局路由、route/context、semantic facts、高级函数路由和跨租户运营诊断不在客户空间暴露,统一进入 `/platform/*`,由 `PlatformBoundary` 和 `platform_context.capabilities` 保护;旧 `/ai-config/contract-sync-board`、`/ai-config/query-route-governance`、`/ai-config/ai-routing-console`、`/ai-config/model-routing-config`、`/ai-config/knowledge-graph-board` 和 `/ai-config/semantic-facts-board` 只做兼容跳转到 `/platform/*`,其中 model routing 旧入口跳到隐藏 `/platform/route-lab`,避免平台页套入空间壳。`/platform/*` 使用 `PlatformSidebar + PlatformTopBar` 专属壳,侧栏按运行总览、AI 运行层、工具与外部能力层、上下文与语义层、质量与事件层组织,不复用旧项目工作台导航;隐藏 Route Lab 不进侧栏,只读展示函数级规则、最近执行与历史 override 痕迹。AI 路由与供应商控制台由 `backend/app/api/ai_routing.py` 与 `backend/app/services/ai_routing_governance_service.py` 提供聚合,客户负责人只看业务场景策略,平台 AI Ops 才看供应商连接、快速/深度槽位、场景路由、诊断和审计提示;平台 AI 运行总览已合并进 `/platform`,旧 `/platform/ai-routing-console` 只做兼容跳转;AI 运行层前端保留 `/platform/ai-routing-providers`、`/platform/ai-routing-slots`、`/platform/ai-routing-scenes`、`/platform/ai-routing-costs`、`/platform/ai-routing-failures` 五个明确子菜单,共享同一脱敏 admin-console 合同;质量与事件层新增 `/platform/ai-call-traces` AI 调用审计,只给平台 AI Ops 查看 `ai_call_logs` 的脱敏出入参、完整脱敏 provider 请求/响应 payload、上游尝试链、provider/model/slot/routing/tokens/cost/latency 和错误摘要,未脱敏 provider payload、headers 与密钥不暴露;团队策略只写 `integrations(type='ai_team_policy')`,供应商连接写 `integrations(type='llm_engine')` 并在 engine config 内分层保存声明 capabilities、`capability_probe` live evidence 与后端派生 `effective_capabilities`,旧 flat `capability_evidence` 只做兼容;能力探测必须按 registry 逐项验证,JSON Schema 独立于普通 JSON 输出,长上下文声明窗口只能作为 `declared_only`;供应商页允许平台操作人对高成本长上下文能力执行“确认长上下文”,写入带来源、确认人和过期时间的 `operator_verified/operator_attestation` 证据,满足发布校验但不进入 AI 调用审计;能力槽位写 `integrations(type='ai_capability_slots')`,但只有发布后的 `active.slots` 会由 `llm_service` low/high/structured tier 解析消费,draft 只用于平台编辑、校验、发布和回滚;能力槽位发布校验按 `SLOT_REQUIREMENTS` 对 draft primary/fallback 的能力矩阵、缺证据和证据过期做阻塞检查,low 缺 probe 只 warning,reserved 槽当前只展示候选/缺口,除非 draft 显式配置运行时,否则不接 runtime 消费;`backend/.control_plane/pmflow-routing-rules.json` 只作为 baseline rule pack 与冷启动 fallback。MCP 页负责模板化配置、后端直连/Local Bridge 状态展示、runtime probe、命令预览与本机确认;contract sync、route/context、knowledge graph 和 semantic facts 页属于平台 AI Ops 排障治理入口。 +- `/platform/prompt-assets` 属于平台质量与事件层,不是客户空间 Prompt 策略页。它可展示平台内部 prompt 正文、默认模板 hash、active/draft release、release gate、replay run、质量归因、golden case、调用点和迁移缺口;客户空间、Space、Project Launch 和项目级 AI 副驾不得暴露 prompt body。标记为 `inline_discovered` / `needs_migration` 的资产只代表已完成清单梳理,运行时仍需单独迁移调用点到 PromptRuntimeService/PromptReleaseService 后才算可运营生效;`guarded_release` 可创建候选但激活必须通过 replay/schema/golden case 门禁,当前 golden replay 无 active golden case 或无最新 replay run 时保持阻塞;LLM 回放只允许通过本地路由层和显式 routing_meta 执行,不在运营页硬选模型。 +- Workspace / Platform 账号边界:当前新增兼容层 `tenant_memberships / tenant_invitations`,把产品语义从“企业租户账号”收敛到 `solo-first, team-ready` 的个人空间/团队空间。手机号是登录、自注册与邀请绑定的主身份,后端通过 `phone_verification_codes`、`users.phone_e164` 和 `tenant_invitations.phone_e164` 串起“验证手机号 -> 创建或绑定账号 -> 创建个人空间 -> 接受团队邀请”的链路。用户账号不归团队所有;注册或受邀创建账号时必须有个人空间,接受邀请只是增加团队空间 membership,退出团队空间后仍保留个人空间。PMFlow 内部平台身份由 `platform_memberships` 单独承载,并在登录响应和 `/users/me` 返回 `platform_context`,客户空间 `OWNER` 不自动拥有平台能力。历史数据修复由 `backend/alembic/versions/20260428_repair_admin_yangyu_workspace_state.py` 承接:`tenant_id=1` 保留为旧团队空间,admin/yangyu 的当前激活 `users.tenant_id` 分别指向自己的 personal workspace。 +- AI 路由与供应商用户可见层必须脱敏并分平面:成员视图不得出现 low/high、provider、model、`function_name`、`api_env`、raw routing meta 或明文 key;客户空间负责人只看“质量优先 / 成本均衡 / 速度优先”和项目启动、需求结构化、任务拆解、人员匹配、内部问答、外部查询、飞书理解、飞书回复等场景策略;平台 AI Ops 的业务场景路由也不展示 `function_name`,函数级路线只允许隐藏 Route Lab 只读诊断。 +- 左侧导航不再展示团队负载、能力管理、用户管理或 AI 运营中心作为一级产品入口;旧 `/projects`、`/users`、`/users/:id/profile`、`/users/:id/skills`、`/users/:id/skill-tasks/:skillId`、`/workloads`、`/workloads/:id` 与 `/dashboard` 均只作为兼容入口:`/projects` 跳转 `/space/projects`,用户相关旧路径跳转 `/space/settings/members` 或成员详情,`/workloads*` 跳转 `/team-layer?view=capacity`,`/dashboard` 跳转 `/space`。用户可见语义分别收敛为所有项目、成员与权限、团队今日容量;`/space/projects` 使用空间壳承载项目集合和主流程分流,删除等高风险项目动作只放入高级操作;`/team-layer` 承载 reviewer routing、Team Feed、执行能力、自适应容量、负责人容量模拟/调整与团队空间预留;客户侧 AI 使用设置归入 `/ai-config`,PMFlow 平台 AI 路由、供应商和语义治理归入 `/platform/*`。项目级页面通过顶部/右侧 `AI 副驾` 消费 AI 能力,只展示副驾状态、上下文缺口、待确认和项目流程跳转,不提供供应商、模型、MCP 模板或底层路由配置。 - PMFlow OS 原型页是前端独立产品域,不复用 `AI Steward` / `Project Board` 的旧信息架构;新页面只允许消费 `frontend/src/features/pmflow-os/store.tsx` 的共享状态和行为组件,避免把原型再拖回 SSTC/治理后台语义。个人空间首页、新项目启动台和 Account Settings 同样是独立产品域:Space 只能通过 `frontend/src/api/space.ts` 做项目摘要、PMFlow OS `truth-state / execution-summary / execution-capabilities` 的只读聚合并跳回主流程;Project Launch 默认走真实 draft/confirm 创建链,只有显式 demo 参数才使用本地状态机;Account Settings 只管理个人资料、偏好和通知,不改 OAuth/JWT/auth core、PMFlow OS truth store、Project Memory、ChangeSet 或 Execution Bridge 写回。 ## 6. 工具与运行库存 diff --git a/docs/architecture/CODE_REFERENCE_INDEX.md b/docs/architecture/CODE_REFERENCE_INDEX.md index fbbbbb54..653b53c1 100644 --- a/docs/architecture/CODE_REFERENCE_INDEX.md +++ b/docs/architecture/CODE_REFERENCE_INDEX.md @@ -15,13 +15,20 @@ - 用户管理接口:参考 `backend/app/api/users_api/`(统一前缀 `GET /users/`、`GET /users/me`、`GET /users/available`、`GET /users/{user_id}`) - 审计查询接口:参考 `backend/app/api/audit.py`(`GET /audit/operations`) - 配置读取类接口:参考 `backend/app/api/llm_providers.py`(`GET /ai-steward/llm/providers`) -- LLM 引擎设置接口:参考 `backend/app/api/llm_engines.py`(`PUT /ai-steward/llm-engines/settings`) -- AI 路由与供应商产品化控制台接口:参考 `backend/app/api/ai_routing.py`(`GET /ai-routing/overview`、`GET/PUT /ai-routing/team-policy`、`GET /ai-routing/admin-console`、`POST /ai-routing/diagnostics/run`);该接口由 `backend/app/api/router.py` 核心路由加载,`API_ROUTE_PROFILE=lean` 下也必须可用 +- LLM 引擎与供应商连接接口:参考 `backend/app/api/llm_engines.py`(`GET/POST /ai-steward/llm-engines`、`PUT/DELETE /ai-steward/llm-engines/{engine_id}`、`PUT /ai-steward/llm-engines/{engine_id}/enabled`、`POST /ai-steward/llm-engines/{engine_id}/default-fallback`、`POST /ai-steward/llm-engines/{engine_id}/test`、`POST /ai-steward/llm-engines/{engine_id}/capability-probe`、`POST /ai-steward/llm-engines/{engine_id}/capability-attest`、`PUT /ai-steward/llm-engines/settings`);该接口属于 PMFlow platform AI Ops,要求 `platform.ai_routing.manage`,并由 `backend/app/api/router.py` 核心路由加载,`API_ROUTE_PROFILE=lean` 下也必须可用;供应商连接支持多个同时启用,`default_fallback_engine_id` 只表示无规则命中时的兜底运行时,旧 `active_engine_id` 仅兼容历史返回;平台供应商配置固定读取内部平台运行作用域(默认 `PMFLOW_PLATFORM_RUNTIME_TENANT_ID=1`),不随客户 workspace tenant 切换;供应商 runtime 在 `integrations(type='llm_engine').config.engines[*].capabilities` 分层保存声明能力、`capability_probe` 探测证据和后端派生 `effective_capabilities`,并同步兼容 `capability_evidence`;能力探测按目标 registry 执行 basic_chat/model_self_report/structured_output/json_schema/tool_calling/streaming/long_context_32k 等,JSON Schema 独立于普通 JSON 输出;streaming 是 `stream=true` 增量响应的传输/运行特性,不属于供应商编辑弹窗中的声明能力矩阵,供应商页只可在“探测明细”和独立“运行特性”区域展示,禁止混入“已验证可用”模型能力列表;声明能力必须完整展示,不得用固定数量截断;任一声明能力缺少 live/provider_contract/operator_verified 证据时,能力状态不得显示为 passed;model_self_report 是低成本真实上游调用的自报参考,只作为 `evidence_strength=model_self_report` 辅助证据,不满足 protected slot 发布,也不参与能力总状态聚合,供应商页只能在独立“模型自报参考”块展示,不得作为“探测明细”能力项 pill;长上下文按配置窗口记录,已知官方模型规格(如 DashScope `qwen-plus`)可作为 `provider_contract` verified evidence,未知供应商声明窗口只记录 `declared_only`;高成本长上下文可由平台操作人在供应商页“确认长上下文”写入 `operator_verified/operator_attestation` 证据,必须带确认窗口和来源元数据,满足发布校验但不写 AI 调用审计,因为没有真实上游 endpoint 交互;已确认的高窗口覆盖低阈值展示,1024K 不再额外展示 128K;旧数据由服务层按 provider/model 归一化默认能力但不会冒充 probe evidence;DashScope/Qwen 的 provider code 统一为配置值 `dashscope`,旧内部 code `glm` 已物理移除,不得在 API、前端、审计或路由 fallback 中恢复;列表响应返回 `available_actions` 作为前端行操作真源,后端同时阻止未连通设为兜底、未连通能力探测、停用连接测试、启用中删除和兜底默认删除 +- AI 路由与供应商产品化控制台接口:参考 `backend/app/api/ai_routing.py`(`GET /ai-routing/overview`、`GET/PUT /ai-routing/team-policy`、`GET /ai-routing/admin-console`、`GET/PUT/POST /ai-routing/capability-slots*`、`POST /ai-routing/diagnostics/run`);team policy 属于客户空间 AI 使用设置,overview/admin-console/diagnostics/capability-slots 属于 `/platform/*` AI Operations Console;`admin-console.providers[]` 返回脱敏供应商状态和 `available_actions`,前端只渲染当前单据状态可执行的操作;能力槽位发布校验读取 `SLOT_REQUIREMENTS`、供应商 capabilities 与 `capability_probe` evidence,draft 不满足要求、缺 verified evidence 或 evidence 过期时返回 critical 并阻止 protected slot publish;`operator_verified` 只作为长上下文等高成本能力的人工确认发布证据,过期后视为 stale;`low` 缺 probe 只 warning;`structured/tool/long_context/external_query` 均为正式 configurable 槽位,可保存 draft 并通过 validate/publish/rollback 控制上线;`structured` 槽只有 publish 后才会被 `llm_service.generate_structured_object` 运行时消费,未发布 draft 不生效,active structured runtime 选择会跳过 disabled/deleted runtime 和缺 `structured_output/json_schema` 能力的 runtime,primary 失败后只走同一 slot 的 fallback chain,全部失败则显式失败,active slot 已配置但无可用 runtime 时保护结构化函数显式失败、非保护函数带 `slot_degraded=true` 降级;`long_context` active slot 只在 routing meta 显式请求长上下文槽时消费,`tool` active slot 只在 routing meta 显式请求工具槽或 `execution_mode=local_planner|local_worker` 时消费,二者都会跳过 disabled/deleted runtime 和能力不足 runtime;`external_query` active slot 绑定 external provider registry/MCP adapter/bounded live search fallback,而不是普通 LLM engine,只在 `route_mode=external` 后由 `external_query_service` 消费,internal query 和 draft 不生效,provider/verifier/source filtering 不满足时 publish 被阻止;`private/multimodal` 仍是不可配置候选槽;`admin-console.scene_slot_matrix` 由 `AIRoutingGovernanceService._build_scene_slot_matrix()` 聚合业务场景、required/current slot、active/draft slot runtime、active version、draft dirty、capability gap、近期失败计数、影响摘要、能力覆盖和保护场景风险;`admin-console.slot_observability` 按 active `low/high/structured/long_context/tool/external_query` 槽位从 `ai_call_logs` 聚合 24h/7d 调用、失败、fallback、degraded、成本估算、延迟估算和脱敏失败样本;该接口由 `backend/app/api/router.py` 核心路由加载,`API_ROUTE_PROFILE=lean` 下也必须可用 +- Function Runtime Profile 接口:参考 `backend/app/api/ai_routing.py`、`backend/app/config/ai_function_runtime_profiles.py` 与 `backend/app/services/ai_function_runtime_profile_service.py`(`GET /ai-routing/function-runtime-profiles`、`PUT /ai-routing/function-runtime-profiles/{function_name}/draft`、`POST /ai-routing/function-runtime-profiles/validate`、`POST /ai-routing/function-runtime-profiles/{function_name}/smoke`、`POST /ai-routing/function-runtime-profiles/publish`);该能力归属 `/platform/*` AI Operations Console,要求 `platform.ai_routing.manage`,配置保存到 `integrations(type='ai_function_runtime_profiles')`。业务场景路由通过 `admin-console.scene_slot_matrix[].function_runtime_profiles` 展示场景内生成运行合同、active/draft token 预算、冒烟状态和 7d 观测;运行时只消费 active profile,draft 仅用于编辑、校验、真实 smoke 和发布门禁。当前 `pmflow_launch_draft_v1` 使用 active profile 控制 `max_tokens / repair_max_tokens / temperature / response_mode`,并在 `routing_meta` 写入 `function_runtime_profile_version / function_runtime_max_tokens / function_runtime_repair_max_tokens`。 +- AI 调用审计接口:参考 `backend/app/api/ai_call_traces_api.py` 与 `backend/app/services/ai_call_trace_service.py`(`GET /ai-call-traces`、`GET /ai-call-traces/{trace_id_or_log_id}`);该接口属于 `/platform/*` AI Operations Console,要求 `platform.ai_routing.manage`,由 `backend/app/api/router.py` 核心路由加载,`API_ROUTE_PROFILE=lean` 下也必须可用;数据源是 `ai_call_logs`,追踪字段 `trace_id / user_id / provider / model / latency_ms / routing_meta / prompt_key / prompt_release_id / prompt_source` 与真实上游 provider payload 字段 `provider_request_payload / provider_response_payload / provider_attempts` 由 `llm_service` 写入;能力探测日志必须带 `direct_operation/probe_run_id/probe_target/probe_method/evidence_strength/failure_code`,审计列表默认展示真实上游 `probe_capability_*` 调用行,不再写入或默认展示额外 `probe_engine_capabilities` 汇总行;`probe_capability_model_self_report` 属于真实上游短调用但只表示自报参考;非上游声明/合同检查只进入能力证据,不进入 AI 调用审计;详情页展示探测元数据而不是误标为主槽位路由;列表支持 prompt_key/source/release 过滤;接口必须区分 raw provider code 与面向运营展示的 `provider_label/runtime_label/engine_name`,DashScope 供应商编码统一为 `dashscope`,不得恢复旧 provider code;接口返回 `function_label/scene_label` 作为中文展示字段,raw function/provider/routing key 只作为诊断信息;`structured_timeout` 必须独立展示为“模型超时”并返回 `error_diagnosis/runtime_policy`,不得与 JSON 无效、空响应、HTTP 异常混为同一种结构化失败;详情可返回完整脱敏 provider 请求/响应 payload 与尝试链,不返回明文 key、bearer、手机号、邮箱、原始 provider headers 或未脱敏 raw payload。 +- Prompt 资产运营接口:参考 `backend/app/api/prompt_assets_api.py`、`backend/app/services/prompt_assets_service.py`、`backend/app/services/prompt_runtime_service.py` 与 `backend/app/config/ai_prompt_eval_rubrics.py`(`GET /prompt-assets`、`GET /prompt-assets/{prompt_key}`、`GET /prompt-assets/{prompt_key}/quality-summary`、`GET/POST /prompt-assets/{prompt_key}/golden-cases*`、`POST /prompt-assets/{prompt_key}/releases*`、`GET/POST /prompt-assets/{prompt_key}/releases/{release_id}/checks*`、`GET/POST /prompt-assets/{prompt_key}/releases/{release_id}/replay-runs`、`GET /prompt-assets/{prompt_key}/replay-runs/{run_id}`、`GET /prompt-assets/{prompt_key}/releases/{release_id}/watch-summary`);该接口属于 `/platform/*` AI Operations Console,要求 `platform.ai_routing.manage`,聚合 `backend/app/config/ai_prompt_registry.py`、`backend/app/config/ai_prompts.py` 默认 seed、`prompt_releases` active/draft 状态、`prompt_release_checks` 门禁状态、分层 `prompt_golden_cases`、`prompt_replay_runs / prompt_replay_case_results`、`ai_call_logs` 质量摘要、跨客户空间 PMFlow OS `pmflow_ai_feedback_events` 用户反馈和 `optimizer_candidates` 优化候选,返回 prompt family、业务场景、调用点、风险等级、发布策略、默认模板 hash/预览/正文、模板变量、PMFlow 赞同/不赞同信号、负反馈原因分布和迁移状态;`managed_release` 可创建草稿、激活和回滚,`guarded_release` 可创建候选版本但激活必须通过门禁,`golden_replay` 无 active golden case 或无最新 replay run 时保持阻塞。release replay 支持 `contract_smoke` 与 `llm_compare`:LLM replay 必须经 `llm_service` 和显式 `routing_meta` 走本地路由层,不硬编码模型,case result 记录 baseline/candidate AI call log、judge score/label/reason;blocking golden case 失败阻塞发布,important/observation 失败降为 warning 进入人工复核。PMFlow OS 负反馈只自动生成待复核 `JudgeRun / OptimizerCandidate`,不得直接改 active prompt 或自动发布。`inline_discovered` 只表示已梳理到代码散落 prompt,运行时尚未迁移到 release 读取,不能在 UI 或运营说明中表达为已生效配置。 +- AI readiness / Project Launch 草案门禁接口:参考 `backend/app/api/ai_readiness_api/` 与 `backend/app/services/ai_readiness_service.py`(`GET /ai-readiness/project-launch`、`POST /ai-readiness/project-launch/check`);readiness 由后端从平台运行租户供应商配置、published active `structured` 槽、Project Launch scene registry、结构化能力证据和租户最近 24h `pmflow_launch_draft_v1` 调用日志实时推导;单次最近生成失败只进入平台 warning,若 24h 内已有成功且连续失败少于 3 次,用户端仍保持可生成,连续失败达到阈值才进入 recovering;前端 localStorage 只允许作为 `VITE_AI_READINESS_LOCAL_OVERRIDE=true` 或 test 模式覆盖;平台 smoke check 复用 `PMFlowLaunchService.generate_draft_payload_for_readiness_check()`,调用真实结构化链但不写 draft/project/task/Project Memory。 +- Space AI Concierge 产品接口:参考 `backend/app/api/space_api/`(`GET /space/ai-concierge/status`、`POST /space/ai-concierge/ask`、`GET /space/ai-concierge/sessions`、`GET /space/ai-concierge/sessions/{session_id}`);该接口是个人空间 AI 管家的前端产品边界,由 core `space_api` 在 `API_ROUTE_PROFILE=lean` 下加载,内部复用 `AIPMOKernelService.process_ingress` 与 `AdaptiveRAGService` 的 QA session 读模型。前端 `SpaceAIConcierge` 不直接依赖 optional `/rag/*` 路由;历史读取失败只影响历史视图,不得阻断当前新会话提问。 +- 手机号登录/自注册/邀请绑定接口:参考 `backend/app/api/auth.py`(`POST /auth/phone/send-code`、`POST /auth/phone/verify`)、`backend/app/services/phone_auth_service.py`、`backend/app/models/phone_verification_code.py` 与 `backend/alembic/versions/20260428_add_phone_auth_identity.py`;手机号身份统一写入 `users.phone_e164`,同一手机号只允许一个全局账号 +- 平台账号权限平面:参考 `backend/app/models/platform_membership.py`、`backend/app/services/platform_access_service.py` 与 `backend/alembic/versions/20260428_add_platform_memberships.py`;`platform_memberships` 只给 PMFlow 内部人员发放 `platform.*` capability,登录响应和 `/users/me` 会返回 `platform_context`,客户空间 `OWNER/REVIEWER` 不自动拥有平台权限 ### 2.2 AI Steward / Training / Mounted Runtime - 项目创建接口:参考 `backend/app/api/ai_steward_api/routes_capture.py` -- 模型路由配置接口:参考 `backend/app/api/model_routing.py`;作为 AI 运营中心“高级路由详情”的兼容入口,`API_ROUTE_PROFILE=lean` 下也必须由核心路由加载 -- AI 路由治理聚合服务:参考 `backend/app/services/ai_routing_governance_service.py`;聚合 `integrations(type='llm_engine'|'model_routing'|'ai_team_policy')`、`ai_call_logs` 与 `backend/.control_plane/pmflow-routing-rules.json`,只返回中文展示字段与 masked key,不返回明文 key、`api_env`、prompt、raw error 或 raw routing meta +- 模型路由配置接口:参考 `backend/app/api/model_routing.py`;作为 platform AI Operations Console 隐藏 Route Lab 的诊断接口,要求 `platform.ai_routing.manage`,`API_ROUTE_PROFILE=lean` 下也必须由核心路由加载;运营导航不暴露函数级编辑入口 +- AI 路由治理聚合服务:参考 `backend/app/services/ai_routing_governance_service.py`;聚合 `integrations(type='llm_engine'|'model_routing'|'ai_team_policy'|'ai_capability_slots'|'ai_function_runtime_profiles')`、`ai_call_logs` 与 `backend/.control_plane/pmflow-routing-rules.json`,维护 `SLOT_REQUIREMENTS`、`SCENE_DEFAULT_SLOT`、槽位能力覆盖、函数运行 profile 摘要、缺能力/缺证据发布阻塞项、scene-slot matrix、active slot observability 和函数级输出 token 观测;函数运行 profile 摘要必须包含 token 预算、temperature、前台等待、provider/job 超时、最大重试和大输入策略,用于 `/platform/ai-routing-scenes` 右侧“生成运行合同”编辑;观测聚合只输出产品化状态、fallback/degraded 计数、估算成本/延迟来源、输出 token p95、截断/结构化失败计数与脱敏失败样本,只返回中文展示字段、能力标签、probe status、last verified 与 masked key,不返回明文 key、`api_env`、prompt、raw error 或 raw routing meta - 回复质量闭环接口:参考 `backend/app/api/ai_steward_api/routes_reply_iteration.py` - 训练/微调管线接口:参考 `backend/app/api/ai_steward_api/routes_training_pipeline.py` - Mounted Runtime 控制台接口:参考 `backend/app/api/ai_steward_api/routes_mounted_ai.py` @@ -38,18 +45,18 @@ - Analysis packet family 合同与 builder:参考 `backend/app/schemas/analysis_packets.py`、`backend/app/services/analysis_packet_builder.py`、`backend/app/services/analysis_project_snapshot_loader.py`;当前 `project_fit_analysis / external_impact_analysis / internal_explain_analysis` 已显式带 `current_subject / candidate_position / rejected_position / reason_chain`,共享 richer project snapshot 由 loader 统一补 `tasks/signals + description/summary + analysis_object_refs`;但 loader 现已 fail-closed,没有真实结构锚点时不再从 task/signal 标题描述推导 object refs。builder 只消费显式 object ref、精确文本绑定和唯一候选这三类非语义结构证据;`direct_match` 若缺 dependent task ref,就只保留 anchor signal,不再硬挂任务;`internal_explain` 若缺 `target_resolution`,也只允许精确文本绑定,不再做 partial-name/overlap 反绑。 - sparse project-fit 降级规则也在 `backend/app/services/analysis_packet_builder.py`:当前若 `preferred/rejected` 结构对象缺失,会直接按“结构对象缺失”降级,不再靠“解释层/当前主链”占位词表、token-overlap 或 partial-name match 决定 fallback 分支。 - Query 路由网关与显式来源回复合同:参考 `backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py` -- query semantics 真源与 business-frame 输出:参考 `backend/app/config/ai_prompts.py`、`backend/app/services/adaptive_rag_service/mixin_core_intent.py`、`backend/app/services/ai_pmo/semantic_context/business_frame_schema.py`;当前 query semantics 上游只保留 `query_profile(needs_private_context, needs_freshness, confidence)` 与 `business_frame` 这组运行时真源,不再输出/持久化 `subject_focus / query_profile.referenced_objects` +- query semantics 真源与 business-frame 输出:参考 `backend/app/config/ai_prompts.py`、`backend/app/services/adaptive_rag_service/mixin_core_intent.py`、`backend/app/services/ai_pmo/semantic_context/business_frame_schema.py`;当前 query semantics 上游只保留 `query_profile(needs_private_context, needs_freshness, confidence)` 与 `business_frame` 这组运行时真源,不再输出/持久化 `query_profile.referenced_objects`;`subject_focus` 只允许作为旧结构化输入在 `shared_utils.py` 做实体归一化兼容,不得重新进入 route snapshot 或 session 二次拍板 - `ProjectFitBrief` 构建与 external handoff 接缝:参考 `backend/app/services/adaptive_rag_service/mixin_project_fit_brief.py`、`backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py`;formal `signal_source_anchors` 与当前项目任务目标 `signal_source_objectives` 都在这里生成 - `execution_recipe / fit_brief / external_recommendation / analysis_packet / recommended_next_step / handoff_mode` 回答接缝:参考 `backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py`、`mixin_answer_mainline.py`、`mixin_answer_payload.py` - follow-up session rewrite gate 与“新项目/新业务 scope reset”判定:参考 `backend/app/services/adaptive_rag_service/mixin_session_state.py` 与 `mixin_answer_mainline.py`;当前实现会先比对上一轮 `QARecord.routing_snapshot` 的 `project_id / target_resolution / business_frame binding` 与当前原始问句解析出的同组结构,再决定是否允许 `state_subject/state_entities` 重写,避免在 `session_state` 里靠项目名/正则延续旧主题 - follow-up anchor / pending clarification 结构化恢复:参考 `backend/app/services/adaptive_rag_service/mixin_followup_anchor.py` 与 `mixin_answer_mainline.py`;当前 `query_route_selection / followup_anchor_selection` 都先经过统一结构化澄清解析,再把选中的 `source_policy_hint / query_semantics_overrides / selected_qa_id` 透回主链。`selected_qa_id` 会继续传给 follow-up anchor resolver,避免“第二条/那条”澄清后又回到最近回复词面重猜 - Internal Query `Operation Contract / Target Resolver` 的 business-frame-first 入口:参考 `backend/app/services/adaptive_rag_service/mixin_target_resolution.py`;当前 `goal_kind / target_scope / resource_role / project_binding / object_binding / expected_answer_shape` 会先被收进 operation contract,再由 target resolution 直接据此判定 `project / task / signal / config / feature / doc / user` 绑定,不再靠 `subject_focus / referenced_objects` 这类 coarse 字段补拍对象 -- `AdaptiveRAG` read-side business-frame-first 归一化入口:参考 `backend/app/services/adaptive_rag_service/shared_utils.py`;`_structured_runtime_entity_type / _structured_project_requested` 会把 `business_frame + target_resolution + query_plan` 合成统一 entity/project binding,供 `mixin_deterministic_query_core.py`、`mixin_fast_answer_project_main.py`、`mixin_fast_answer_semantic_p1.py`、`mixin_deterministic_query_count.py`、`mixin_deterministic_query_list.py`、`mixin_retrieval_router_db.py`、`mixin_deep_guard_answer.py`、`mixin_answer_mainline.py`、`mixin_manifest_feature_p2.py` 复用;这些 read-side consumer 现已不再读取 `subject_focus / referenced_objects` 做 `project / task / signal / config / feature / doc / user` 的快答、检索和 deep guard 分流 +- `AdaptiveRAG` read-side business-frame-first 归一化入口:参考 `backend/app/services/adaptive_rag_service/shared_utils.py`;`_structured_runtime_entity_type / _structured_project_requested` 会把 `business_frame + target_resolution + query_plan` 合成统一 entity/project binding,并只在该单点兼容旧 `subject_focus` 实体字段,供 `mixin_deterministic_query_core.py`、`mixin_fast_answer_project_main.py`、`mixin_fast_answer_semantic_p1.py`、`mixin_deterministic_query_count.py`、`mixin_deterministic_query_list.py`、`mixin_retrieval_router_db.py`、`mixin_deep_guard_answer.py`、`mixin_answer_mainline.py`、`mixin_manifest_feature_p2.py` 复用;这些 read-side consumer 不得分散读取 `subject_focus / referenced_objects` 做 `project / task / signal / config / feature / doc / user` 的快答、检索和 deep guard 分流 - unified handoff rollback guard:参考 `backend/app/services/adaptive_rag_service/shared_utils.py`(env flags)、`mixin_query_route_gateway.py`、`mixin_answer_retrieval.py`、`mixin_answer_payload.py` - QA session detail 对 handoff 元数据的持久化/回放:参考 `backend/app/services/adaptive_rag_service/mixin_core_intent.py`、`mixin_qa_session_list_ops_p1.py` - Feishu QA 文本呈现与 action card 分流:参考 `backend/app/services/integrations/feishu_integration_service/mixin_receive_routing.py`、`mixin_card_action.py`、`backend/tests/test_feishu_card_action_contract.py`;当前实现为“共享 analysis_packet -> 受控改写 -> blocked-entity 校验 -> deterministic fallback”的两段式 presenter,相关 prompt 在 `backend/app/config/ai_prompts_catalog/part_01.py`。`qa_direct` 还额外承担中文化 presentation policy:原始 answer 若泄出 `Project database / signal_total / 当前盘面 / 建议动作 / External Freshness Guard` 等英文或内部术语,或自然化结果直接复述 `分析包 / analysis_packet / missing_mapping / next_step / focus_objects / reason_chain` 这类内部 packet jargon,会优先压回 packet 文本,并最多只补一行中文来源说明;命中外部信号源 recommendation 呈现时,现在优先吃 `rag_result.external_query_plan/query_semantics` 里的结构化 `interaction_level=recommendation`,不再靠 Feishu 本地问句 cue list 决定是否直出推荐正文。对 sparse project-fit fallback,当前 deterministic reply 也会被重写成业务中文,不再原样暴露 packet 缺口字段 - Query route governance 只读审计接口:参考 `backend/app/api/rag_api/routes_route_governance.py` 与 `backend/app/services/adaptive_rag_service/mixin_query_route_governance.py` -- External Query Service 主链:参考 `backend/app/services/external_query_service/`(`planner.py`、`gateway.py`、`registry.py`、`schemas.py`、`presenter.py`、`adapters/`);`planner.py` 当前会对“推荐外部信号源”问法优先补 `signal_source_objectives / signal_source_anchors` 驱动的项目锚点查询,并在 structured plan 返回过泛 query 时仍把这些项目锚点 seed queries 保留在前面;同时 `ExternalQueryPlan.interaction_level` 会承接统一语义规划层透传下来的 `recommendation` 层级,供后续 presenter 分支消费。`schemas.py` 现在还定义了 recommendation 内容层的 `SignalSourceRecommendationPacket / SignalSourceRecommendationCandidate`,`presenter.py` 会先生成这层结构化候选,再渲染成业务中文的 `top-N candidate + why`;recommendation 分支现在优先看 `plan.interaction_level` 与结构化 recommendation goal/anchors,不再靠 presenter 本地问题 cue list 拍板。当前 presenter 仍保留 overclaim guard:若候选里出现百分比、已确认、无需验证或内部术语,会 fail-closed 回退到 customer-safe fallback candidate,而不是继续把高确定性或架构腔文本吐给用户 +- External Query Service 主链:参考 `backend/app/services/external_query_service/`(`planner.py`、`gateway.py`、`registry.py`、`schemas.py`、`presenter.py`、`adapters/`);`registry.py` 暴露 external-query slot 使用的受控 provider 状态(MCP adapter、bounded live search fallback、verifier、fail-closed source filtering)。`planner.py` 当前会对“推荐外部信号源”问法优先补 `signal_source_objectives / signal_source_anchors` 驱动的项目锚点查询,并在 structured plan 返回过泛 query 时仍把这些项目锚点 seed queries 保留在前面;同时 `ExternalQueryPlan.interaction_level` 会承接统一语义规划层透传下来的 `recommendation` 层级,供后续 presenter 分支消费。`schemas.py` 现在还定义了 recommendation 内容层的 `SignalSourceRecommendationPacket / SignalSourceRecommendationCandidate`,`presenter.py` 会先生成这层结构化候选,再渲染成业务中文的 `top-N candidate + why`;recommendation 分支现在优先看 `plan.interaction_level` 与结构化 recommendation goal/anchors,不再靠 presenter 本地问题 cue list 拍板。当前 presenter 仍保留 overclaim guard:若候选里出现百分比、已确认、无需验证或内部术语,会 fail-closed 回退到 customer-safe fallback candidate,而不是继续把高确定性或架构腔文本吐给用户 - non-plan 外部推荐 -> signal source 直写链:参考 `backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_signal_source.py`、`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py`、`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal_resume.py`、`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py`、`backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py`;这条链当前会把 recommendation candidate set 经 `reply_anchor_qa_context + action_contract` 沉到执行结果,再在后续 `把推荐的写进系统 / 确认入库` 时复用 `candidate_set_binding + candidate_refs + project_binding` 继续 `prepare_write -> confirm_write -> execute_write`,非 `plan mode` 不再默认 requirement draft,且一旦 signal 已 materialize 并写入项目,用户可见结果视为成功,后续 onboarding/manual 状态只通过 `post_process_status` 暴露 - requirement-plan / draft confirm 协议优先级与碎片化收敛主链:参考 `backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py`、`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py`、`backend/tests/test_ai_pmo_ingress_mainline.py`;当前实现明确把 raw `A/B/C/确认/取消/重出/修改` 协议放在 semantic backfill 之前,active requirement 的碎片化短回复优先留在 collect / contextual promotion 主链,且 `ready / draft_pending_confirm` 状态下无锚点单句不再劫持旧草稿确认链 - 去 lexical fallback 的运行时入口:`backend/app/services/adaptive_rag_service/mixin_fast_answer_project_main.py` 里的 project progress fast answer bypass 现在只吃结构化 `interaction_level / goal_kind / resource_role / expected_answer_shape`;`backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py` 的 casual chat 直答只吃 `message_intent=casual_chat`,`project_lookup` shortcut 只吃结构化 `message_intent=project_lookup(_question) + query_semantics`,不再靠问句词面、`项目列表/项目清单` 正则或 `extract_project_lookup_keyword` 提前抢答;`backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_mixins_parse.py` 与 `backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/mixin_analysis.py` 的 signal 数量/strict analysis 也只消费结构化 `requested_count / analysis_mode / operation_intent_contract`,不再从自由文本里解析“3个信号”“先分析后入库” @@ -72,16 +79,18 @@ - AI PMO 恢复接口:参考 `POST /ai-pmo/work-items/{work_item_id}/resume` - AI PMO 草稿决策接口:参考 `POST /ai-pmo/work-items/{work_item_id}/draft-decision` - AI PMO 审批接口:参考 `POST /ai-pmo/work-items/{work_item_id}/decision` -- AI PMO legacy 工作项列表接口:参考 `GET /ai-pmo/work-items`;当前只作为 Space legacy adapter 输入,独立收件页已退场 +- AI PMO legacy 工作项列表接口:参考 `GET /ai-pmo/work-items`;当前只作为 Space legacy adapter 输入,独立收件页已退场;`OWNER/REVIEWER` 可看租户级 legacy 工作项,其他角色只看本人 `owner_user_id` 或自己参与项目绑定的工作项 - AI PMO `process-text/work-item` 的 `plan_mode / plan_status / requirement_plan_context_summary` 投影:参考 `backend/app/api/ai_pmo_api/helpers_inbox_payload.py` 与 `backend/app/api/ai_pmo_api/routes_core.py` - `/rag/ask` 与 `/ai-pmo/process-text` 注入 `project_operating_context`:参考 `backend/app/api/rag_ask_contract.py` 与 `backend/app/api/ai_pmo_api/routes_core.py` ### 2.4 Signal / Project / SSTC +- Workspace / Membership 兼容层:参考 `backend/app/models/tenant.py`、`backend/app/models/tenant_membership.py`、`backend/app/models/tenant_invitation.py`、`backend/app/services/workspace_service.py`、`backend/app/api/workspaces_api.py` 与 `frontend/src/api/workspaces.ts`;产品语义为个人空间/团队空间,数据库沿用 `tenants`,手机号邀请按 `tenant_id + phone_e164` 幂等刷新 pending invitation,验证手机号后绑定或创建账号并加入团队空间;邀请进入团队空间不迁移账号所有权,退出团队空间后回到个人空间。历史账号空间修复参考 `backend/alembic/versions/20260428_repair_admin_yangyu_workspace_state.py`:补回 `tenant_id=1` 旧团队空间,并把 admin/yangyu 当前激活空间切到各自个人空间。 - 需求点落信号源:参考 `backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py` - 飞书捕获模拟接口:`POST /ai-steward/signal/capture-feishu` -- 项目删除接口:参考 `backend/app/api/projects.py` -- 项目成员分配接口:参考 `backend/app/api/projects.py` + `backend/app/services/project_member/service.py` +- 项目列表/创建/删除接口:参考 `backend/app/api/projects_api/`;`GET /projects/` 与 `GET /projects/{project_id}` 是 Space、`/space/projects` 所有项目视图和项目选择器的项目可见性真源,`OWNER/REVIEWER` 看当前租户全量项目,其他角色只看 `owner_id` 或 `user_projects` 绑定项目;`POST /projects/` 只负责创建正式项目壳,重复标题返回 409,项目启动台不再提供未配置 AI 时的空项目创建入口;旧 `/projects` 前端路由只做兼容跳转,不再作为一级产品入口。 +- 项目成员分配接口:参考 `backend/app/api/projects_api/` + `backend/app/services/project_member/service.py` - 通用任务拆解接口:参考 `POST /tasks/breakdown` +- SSTC 项目级读接口均需先通过项目成员或 `OWNER/REVIEWER` 可见性校验;非成员不可读取项目总览、链路、信号、宪章、任务、发布、复盘或模型上下文。 - SSTC 总览接口:参考 `GET /projects/{project_id}/sstc/overview` - SSTC 信号列表接口:参考 `GET /projects/{project_id}/signals` - SSTC 宪章详情接口:参考 `GET /projects/{project_id}/charters/{charter_id}` @@ -91,9 +100,11 @@ - SSTC 负责人推荐接口:参考 `POST /projects/{project_id}/tasks/{task_id}/recommend-assignee` - SSTC 任务更新接口:参考 `PATCH /projects/{project_id}/tasks/{task_id}` - SSTC 发布创建接口:参考 `POST /projects/{project_id}/releases` -- PMFlow OS 真实读路径、C1 Change Review API、C2 backend commit、Execution Handoff、Release Evidence Gate、Release Validation Run、Release Feedback Signal、Iteration Loop draft、Team reviewer decision loop、Execution Capability Graph 与 Team Feed 只读动态流:参考 `backend/app/api/pmflow_os_api/`、`backend/app/services/pmflow_os_projection_service.py`、`backend/app/services/pmflow_change_review_service.py`、`backend/app/services/team_execution_capability_service.py`、`backend/app/schemas/pmflow_os.py`;只读提供 `GET /pmflow-os/projects/{project_id}/truth-state`、`GET /pmflow-os/projects/{project_id}/execution-summary` 与 `GET /pmflow-os/projects/{project_id}/execution-capabilities`,复用 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync / users / user_projects / user_workloads / user_skill_bindings`;`execution-capabilities` 只输出执行模式、个人执行准备度、成员执行能力与容量信号、warning,不自动分配任务、不自动请求审查、不写 ChangeSet;`truth-state.reviewerRouting` 是 `{status, warningCode, summary, items}` 读模型,会从 open/pending ChangeSet、待审 item 风险、跨对象类型、evidence source、项目成员与 `OWNER / REVIEWER` 角色生成 `sourceType / riskLevel / priority / suggestedReviewer / fallbackReviewerRole / targetUrl`,并返回 `reviewDecision / approvalState / requestedAt / decidedAt / decisionReason` 审查决策只读字段;`truth-state.feed` 是 Team Feed 只读动态流,从 review request / review decision / ChangeSet 状态 / `ProgressEvent` / release gate 聚合,不写审批、不写 ChangeSet、不写通知;P8 新增 `POST /pmflow-os/change-sets/{change_set_id}/review-decisions`,支持 `request_review / approve / request_changes / reject`,复用 `PMFlowApproval` 写审计,`approve` 满足 high-risk C2 approval gate 但不自动 commit,`request_changes / reject` 不写 Project Memory;C1 写 API 包括 thread message、`proposed_items` extraction、ChangeSet detail、ChangeItem `accept/edit/defer`;C2 backend 已实现 `POST /pmflow-os/change-sets/{change_set_id}/commit`,默认 `mode=atomic`,将 `Requirement / Decision / Risk` 写入 `truth_nodes`,将 `Task` 写入 `truth_nodes(node_type='task') + tasks`,高风险项强制 approval gate;D.2 已实现 `POST /pmflow-os/projects/{project_id}/execution-handoffs`,对已确认任务生成/复用 `PromptPackage` 并创建 `ProgressEvent(event_type='handoff_created')` 起点;P1 已实现 `POST /pmflow-os/projects/{project_id}/execution-handoffs/{prompt_package_id}/consume`,记录运行端领取、更新 `PromptPackage.package_payload.latest_handoff.handoff_state=consumed` 并创建 `ProgressEvent(event_type='handoff_consumed', derived_task_status='coding')`;Release Growth P1 已在 `execution-summary.releaseGate` 中从 branch / commit / PR / test_passed / checks_passed / failed / blocked 等 `ProgressEvent` 推导 `ready / blocked / needs_confirmation / empty`;Release Validation P1 新增 `POST /pmflow-os/projects/{project_id}/release-validation-runs`,从当前 `releaseGate` 固化 `ReleaseValidationRun` 快照并在 `releaseGate.latestValidationRun` 返回最新记录,无 evidence 时返回 `release_validation_evidence_empty`;Release Feedback P1 新增 `POST /pmflow-os/projects/{project_id}/release-feedback-ingests`,复用 `FeedbackIngest / IterationRecommendation` 写入真实发布后反馈,并在 `execution-summary.releaseSignals` 投影真实 `feedback / revenueEvents / recommendations` 与 release gate / validation run 关系;Iteration Loop P1/P2 支持 recommendation 单条或同组生成 ChangeSet draft;这些步骤均不执行发布命令、外部支付、真实推送、自动 Project Memory commit 或正式 Task 创建。 -- Adaptive Human Capacity:参考 `backend/app/services/adaptive_capacity_service.py`、`backend/app/models/human_capacity_override.py`、`backend/alembic/versions/20260427_add_human_capacity_overrides.py`、`frontend/src/api/pmflowOs.ts`、`frontend/src/api/space.ts`、`frontend/src/features/pmflow-os/pages/TeamLayerPage.tsx`、`frontend/src/features/personal-space/PersonalSpacePage.tsx` 与 `frontend/src/pages/workloads/WorkloadsPage.tsx`;接口为 `GET /pmflow-os/projects/{project_id}/capacity-plan`、`POST /pmflow-os/projects/{project_id}/capacity-overrides/simulate`、`POST /pmflow-os/projects/{project_id}/capacity-overrides`、`GET /pmflow-os/projects/{project_id}/capacity-overrides`,并在 `execution-capabilities.adaptiveCapacitySummary` 返回今日容量摘要。每日计划由当前任务池实时计算,不持久化固定基线;`human_capacity_overrides` 只存负责人手工调整、原因、过期时间和审计;写接口不自动派任务、不请求审查、不写 ChangeSet。 -- Project Launch 真实创建链:参考 `backend/app/api/pmflow_launch_api/`、`backend/app/services/pmflow_launch_service.py`、`backend/app/schemas/pmflow_launch.py` 与模型 `backend/app/models/pmflow_launch.py`;接口为 `POST /pmflow-launch/drafts` 与 `POST /pmflow-launch/drafts/{draft_id}/confirm`,draft 只写 `pmflow_launch_drafts`,confirm 在同一事务内创建 `projects / user_projects / pmflow_threads / pmflow_thread_messages / truth_nodes / tasks / semantic_facts seed / operation_logs` +- PMFlow OS 真实读路径、C1 Change Review API、C2 backend commit、Execution Handoff、Release Evidence Gate、Release Validation Run、Release Feedback Signal、Iteration Loop draft、Team reviewer decision loop、Execution Capability Graph 与 Team Feed 只读动态流:参考 `backend/app/api/pmflow_os_api/`、`backend/app/services/pmflow_os_projection_service.py`、`backend/app/services/pmflow_change_review_service.py`、`backend/app/services/pmflow_ai_feedback_service.py`、`backend/app/services/team_execution_capability_service.py`、`backend/app/schemas/pmflow_os.py`;只读提供 `GET /pmflow-os/projects/{project_id}/truth-state`、`GET /pmflow-os/projects/{project_id}/execution-summary` 与 `GET /pmflow-os/projects/{project_id}/execution-capabilities`,复用 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync / users / user_projects / user_workloads / user_skill_bindings`;`truth-state.aiFeedback` 返回当前用户对 AI chat answer / structured extraction / copilot answer 的反馈事件,底表为 `pmflow_ai_feedback_events`,用于按 `scene_key / prompt_key / prompt_release_id / runtime_profile_id` 汇总 prompt 质量信号;负反馈会归因到真实 `prompt_key` 并生成待平台 AI Ops 复核的 `JudgeRun / OptimizerCandidate`,用户改回赞同时挂起此前候选,不直接改 active prompt;`execution-capabilities` 只输出执行模式、个人执行准备度、成员执行能力与容量信号、warning,不自动分配任务、不自动请求审查、不写 ChangeSet;`truth-state.reviewerRouting` 是 `{status, warningCode, summary, items}` 读模型,会从 open/pending ChangeSet、待审 item 风险、跨对象类型、evidence source、项目成员与 `OWNER / REVIEWER` 角色生成 `sourceType / riskLevel / priority / suggestedReviewer / fallbackReviewerRole / targetUrl`,并返回 `reviewDecision / approvalState / requestedAt / decidedAt / decisionReason` 审查决策只读字段;`truth-state.feed` 是 Team Feed 只读动态流,从 review request / review decision / ChangeSet 状态 / `ProgressEvent` / release gate 聚合,不写审批、不写 ChangeSet、不写通知;P8 新增 `POST /pmflow-os/change-sets/{change_set_id}/review-decisions`,支持 `request_review / approve / request_changes / reject`,复用 `PMFlowApproval` 写审计,`approve` 满足 high-risk C2 approval gate 但不自动 commit,`request_changes / reject` 不写 Project Memory;C1 写 API 包括 thread message、`POST /pmflow-os/projects/{project_id}/extractions` 的真实 AI extraction 或显式 `proposed_items` draft、ChangeSet detail、ChangeItem `accept/edit/defer`、`POST /pmflow-os/projects/{project_id}/ai-feedback` 记录 AI 回答赞同/不赞同与原因标签;AI extraction 复用 `llm_service.generate_structured_object` 与 PMFlow prompt/config/routing 层,成功时生成 open ChangeSet + pending_review ChangeItems + thread message evidence,失败时返回 `ai_extraction_failed / extraction_evidence_insufficient` 且不创建模板假结果;C2 backend 已实现 `POST /pmflow-os/change-sets/{change_set_id}/commit`,默认 `mode=atomic`,将 `Requirement / Decision / Risk` 写入 `truth_nodes`,将 `Task` 写入 `truth_nodes(node_type='task') + tasks`,高风险项强制 approval gate;commit 会持久化 `pmflow_change_sets.committed_by` 并在 commit result、truth-state 与 Project Memory writebacks 中返回 `committedBy/committedById`,写入结果页不得再用前端硬编码人员;D.2 已实现 `POST /pmflow-os/projects/{project_id}/execution-handoffs`,对已确认任务生成/复用 `PromptPackage` 并创建 `ProgressEvent(event_type='handoff_created')` 起点;P1 已实现 `POST /pmflow-os/projects/{project_id}/execution-handoffs/{prompt_package_id}/consume`,记录运行端领取、更新 `PromptPackage.package_payload.latest_handoff.handoff_state=consumed` 并创建 `ProgressEvent(event_type='handoff_consumed', derived_task_status='coding')`;Release Growth P1 已在 `execution-summary.releaseGate` 中从 branch / commit / PR / test_passed / checks_passed / failed / blocked 等 `ProgressEvent` 推导 `ready / blocked / needs_confirmation / empty`;Release Validation P1 新增 `POST /pmflow-os/projects/{project_id}/release-validation-runs`,从当前 `releaseGate` 固化 `ReleaseValidationRun` 快照并在 `releaseGate.latestValidationRun` 返回最新记录,无 evidence 时返回 `release_validation_evidence_empty`;Release Feedback P1 新增 `POST /pmflow-os/projects/{project_id}/release-feedback-ingests`,复用 `FeedbackIngest / IterationRecommendation` 写入真实发布后反馈,并在 `execution-summary.releaseSignals` 投影真实 `feedback / revenueEvents / roiSnapshots / complianceChecks / recommendations`、风险计数与 release gate / validation run / business signal types 关系;Iteration Loop P1/P2 支持 recommendation 单条或同组生成 ChangeSet draft;这些步骤均不执行发布命令、外部支付、真实推送、自动 Project Memory commit 或正式 Task 创建。 +- Project Knowledge Hub / 项目记忆读模型:参考 `backend/app/api/pmflow_os_api/`、`backend/app/services/pmflow_os_projection_service.py`、`frontend/src/api/pmflowOs.ts`、`frontend/src/features/pmflow-os/pages/ProjectMemoryPage.tsx`、`frontend/src/features/pmflow-os/pages/ProjectMemoryCollectionPages.tsx` 与 `frontend/src/features/pmflow-os/projectMemoryUtils.ts`;只读接口为 `GET /pmflow-os/projects/{project_id}/project-memory/overview`、`items`、`items/{item_id}`、`assets`、`context-packs`、`writebacks`。首页只做知识库雷达,二级页承接全部记忆、项目资产库、上下文包、写入与版本记录,writeback 行展示真实写入人 `committedBy` 与时间;时间展示统一走 `formatProjectMemoryTime`,列表短格式、抽屉完整格式并标注北京时间;`items` 支持类型/阶段/来源/状态/搜索筛选,`assets` 支持资产类型/来源/状态/搜索筛选;单条点击打开右侧抽屉展示来源证据、影响对象和 AI 可执行跳转。资产可自动索引,但进入项目真相层仍必须经 Change Review 或可信规则确认。 +- Adaptive Human Capacity:参考 `backend/app/services/adaptive_capacity_service.py`、`backend/app/models/human_capacity_override.py`、`backend/alembic/versions/20260427_add_human_capacity_overrides.py`、`frontend/src/api/pmflowOs.ts`、`frontend/src/api/space.ts`、`frontend/src/features/pmflow-os/pages/TeamLayerPage.tsx` 与 `frontend/src/features/personal-space/PersonalSpacePage.tsx`;接口为 `GET /pmflow-os/projects/{project_id}/capacity-plan`、`POST /pmflow-os/projects/{project_id}/capacity-overrides/simulate`、`POST /pmflow-os/projects/{project_id}/capacity-overrides`、`GET /pmflow-os/projects/{project_id}/capacity-overrides`,并在 `execution-capabilities.adaptiveCapacitySummary` 返回今日容量摘要。每日计划由当前任务池实时计算,不持久化固定基线;`human_capacity_overrides` 只存负责人手工调整、原因、过期时间和审计;写接口不自动派任务、不请求审查、不写 ChangeSet;旧 `/workloads*` 前端路由只兼容跳转到 `/team-layer?view=capacity`,传统工时明细不再作为用户可见一级入口。 +- Project Launch 真实创建链:参考 `backend/app/api/pmflow_launch_api/`、`backend/app/services/pmflow_launch_service.py`、`backend/app/services/ai_scene_runtime_policy.py`、`backend/app/services/ai_function_runtime_profile_service.py`、`backend/app/schemas/pmflow_launch.py` 与模型 `backend/app/models/pmflow_launch.py`;任务化生成接口为 `POST /pmflow-launch/draft-jobs`、`GET /pmflow-launch/draft-jobs/{job_id}`、`POST /pmflow-launch/draft-jobs/{job_id}/cancel` 与 `POST /pmflow-launch/drafts/{draft_id}/revise`,任务状态落 `pmflow_launch_draft_jobs(job_id/revision_id/status/result_draft_id/changed_fields)`,补充一句或重新生成会 supersede 同用户运行中的旧 job;兼容同步接口 `POST /pmflow-launch/drafts` 仍只写 `pmflow_launch_drafts`,`POST /pmflow-launch/drafts/{draft_id}/confirm` 在同一事务内创建 `projects / user_projects / pmflow_threads / pmflow_thread_messages / truth_nodes / tasks / semantic_facts seed / operation_logs`;生成入口在调用 LLM 前会拦截低信息输入并返回 `input_needs_more_info` 422,避免纯问候或无产品线索输入生成空草案;draft prompt 通过 `PROMPT_PMFLOW_LAUNCH_DRAFT` 和 `PromptRuntimeService` 优先读取平台 Prompt 运营中心 active release,生成预算、修复预算、temperature、响应模式、前台等待、provider/job 超时、最大重试和大输入策略通过 Function Runtime Profile active 版本读取;`provider_timeout_seconds` 进入 provider HTTP client,`job_timeout_seconds` 包住结构化生成任务,`max_retries` 覆盖 transient retry budget,`large_input_policy=preprocess_then_generate` 在 large 桶压缩材料后再渲染 prompt,draft job response 返回 `runtime_policy` 供前端按 `foreground_wait_seconds` 调整等待状态;默认按可变现 MVP 闭环生成 7-10 条 requirements、10-16 条 tasks、4-7 条 risks、3-6 条 decisions,数量或商业闭环不足时写入 warnings;调用 LLM 前必须先按输入规模写入 Project Launch 场景 timeout 默认策略,再由已发布 Function Runtime Profile 覆盖可运营字段,超时显式落 `structured_timeout/draft_job_timeout`,禁止冒充成功。 +- Personal Space 暂存想法闭环:参考 `backend/app/api/space_api/`、`backend/app/services/space_saved_idea_service.py`、`backend/app/schemas/space.py`、`backend/app/models/saved_idea.py`、`backend/alembic/versions/20260428_add_saved_ideas.py` 与 `backend/alembic/versions/20260428_add_saved_idea_enrichment.py`;结构化提示词在 `backend/app/config/ai_prompts_catalog/part_scenes_06.py` 的 `PROMPT_SAVED_IDEA_ENRICHMENT`,运行时通过 `PromptRuntimeService` 优先读取平台 Prompt 运营中心 active release 并向 `ai_call_logs` 写入 `prompt_key / prompt_release_id / prompt_source` 归因。接口为 `GET/POST/PATCH /space/saved-ideas`、`POST /space/saved-ideas/{idea_id}/archive`、`POST /space/saved-ideas/{idea_id}/promote`。`saved_ideas` 是 pre-project idea seed,只保存未成型想法、原始输入、来源、准备度、缺失问题、结构化 enrichment 元数据和 draft 关联;创建/编辑原始输入时尝试 `space_saved_idea_enrichment_v1`,问候/测试/无实质想法输入必须判为 `needs_more_info` 和 0 准备度,AI 失败显式落 `ai_failed/system_baseline`;`promote` 只生成 Project Launch 草案并回到 human review,确认前不创建项目、不写 Project Memory、不写 ChangeSet。 - Legacy project create 410 路由:参考 `backend/app/api/legacy_ai_steward_api.py`;该模块在 `CORE_ROUTE_MODULES` 中始终挂载,只保留 `POST /ai-steward/project-from-text` 的 410 包络响应,避免 lean profile 下未知外部调用落成 404。 - PMFlow Change Review 模型与 migration:参考 `backend/app/models/pmflow_change_review.py` 与 `backend/alembic/versions/20260426_add_pmflow_change_review_tables.py`;只包含 `pmflow_change_sets / pmflow_change_items / pmflow_approvals / pmflow_evidence_links`,本阶段不得新增或依赖 `/commit` @@ -102,20 +113,21 @@ - PMFlow 大模块并行公共壳:参考 `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` 的 `4.4.1-4.4.3` 与 `frontend/src/router/index.tsx`、`frontend/src/main.tsx`、`frontend/src/features/pmflow-os/components/MobileBottomNav.tsx`、`frontend/src/features/pmflow-os/components/PMFlowOsFrame.tsx`;`/space` 归 Space lane,`/pmflow-os / /command-center / /change-review / /execution-bridge / /release-growth` 归 Main Flow lane,`/me` 归 Account Settings lane,`/settings` 当前仅为 reserved route,新增或改动必须由 Shell / Integration owner 串行处理 - PMFlow P8 Review Decision Loop 已集成收口:参考 `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` 第 7 节和 `big-module-integration-p8` 台账;Main Flow 以 `truth-state.reviewerRouting` 和 `POST /pmflow-os/change-sets/{change_set_id}/review-decisions` 提供 reviewer decision / approval audit 合同,Space 只读消费为 `/space` 的 `review_attention` 下一步队列,Account 只提供 review decision / high-risk approval 页面内提醒偏好;P8 不新增战略页面、不改 router/nav、不接真实通知中心,继续使用既有 `/change-review / /team-layer / /space / /me` 表面 - PMFlow OS 产品合同真源:先读 `docs/architecture/PMFLOW_OS_PRODUCT_CONTRACT.md`,再实现 `pmflow-os` 真实后端接入;该文档锁定 `ProjectTruthState / Thread / ChangeSet / ChangeItem / Evidence / Approval / Project Memory / ExecutionContext` 与 `truth_nodes / tasks / semantic_facts / ai_project_control / dev_sync` 的复用边界和后续 API 草案 -- 新增前端 API 调用:参考 `frontend/src/api/sstc.ts`;Space 工作面只读聚合参考 `frontend/src/api/space.ts`,只消费项目摘要与 PMFlow OS `truth-state / execution-summary / execution-capabilities`,不写 Project Memory、ChangeSet 状态、ProgressEvent 或 ReleaseValidationRun;Space P6 的 `nextActionQueue` 在 Space 本地读模型内把高风险待确认、发布阻塞/失败证据、handoff consumed 后缺少后续 progress、下一轮 draft、发布后 feedback/revenue/compliance 信号统一排序为“我的下一步”,所有 item 只跳 `/change-review`、`/execution-bridge` 或 `/release-growth`;Space 的 `executionReadiness` 只读展示工具连接、待确认、待审查、待验收和阻塞原因;PMFlow OS 真实接入参考 `frontend/src/api/pmflowOs.ts` 的 `getPMFlowTruthState(projectId)`、`getPMFlowExecutionSummary(projectId)`、`getPMFlowExecutionCapabilities(projectId)`、`createPMFlowExecutionHandoff`、`consumePMFlowExecutionHandoff`、`createPMFlowExecutionProgressEvent`、`createPMFlowReleaseValidationRun`、`createPMFlowReleaseFeedbackIngest`、`createPMFlowIterationRecommendationChangeSetDraft`、`createPMFlowIterationRecommendationGroupChangeSetDraft`、`createPMFlowReviewDecision`、`createPMFlowThreadMessage`、`createPMFlowExtraction`、`getPMFlowChangeSet`、`acceptPMFlowChangeItem`、`editPMFlowChangeItem`、`deferPMFlowChangeItem`、`commitPMFlowChangeSet`;`PMFlowExecutionSummary.releaseGate` 是 ReleaseGrowth 真实发布门禁前端合同,`execution-summary.releaseSignals` 是 Release/Growth 发布后信号与下一轮 ChangeSet draft 状态前端合同;Project Launch 创建链参考 `frontend/src/api/pmflowLaunch.ts`。 -- Legacy AI Steward route:`/ai-steward` 不再作为产品页;参考 `frontend/src/router/index.tsx` 的 `getLegacyAIStewardRedirectPath`,`tab=create` 迁到 `/project-launch`,`tab=engine / tab=feedback-center` 迁到 `/ai-config`,其它 legacy tab 迁到 `/space`;`frontend/src/pages/ai-steward/` 仅保留迁移占位和已断链的历史 tab 壳,不作为新功能入口。 -- AI Steward 问答浮窗与上下文显性化组件:参考 `frontend/src/components/ai-steward/FloatingQAChat.tsx`、`frontend/src/components/ai-steward/QAConversationStatusBar.tsx`、`frontend/src/components/ai-steward/QAResponseMetaCard.tsx` +- 新增前端 API 调用:参考 `frontend/src/api/sstc.ts`;Space 工作面聚合参考 `frontend/src/api/space.ts`,消费项目摘要、PMFlow OS `truth-state / execution-summary / execution-capabilities` 与 `GET /space/saved-ideas`。Space 不写 Project Memory、ChangeSet 状态、PMFlowApproval、ProgressEvent、ReleaseValidationRun、FeedbackIngest、RevenueEvent、ROISnapshot 或 ComplianceCheckRun;唯一 Space 写入是 `saved_ideas` pre-project seed,`createSavedIdea` 保存未成型想法,`archiveSavedIdea` 归档,`promoteSavedIdea` 只生成 Project Launch draft 并通过 `project_launch_draft` pending intent 进入 `/project-launch` human review,不自动确认或创建项目。Space 的 `nextActionQueue` 在 Space 本地读模型内把 AI extraction 生成的 ChangeSet 待确认、review attention、execution failed/blocked/delivery failed、发布阻塞、handoff consumed 后缺少后续 progress、发布后 feedback/revenue/ROI/compliance 信号和下一轮 draft 统一排序为“我的下一步”,所有 item 只跳 `/change-review`、`/execution-bridge` 或 `/release-growth`;Space 的 `executionReadiness` 只读展示工具连接、待确认、待审查、待验收和阻塞原因;PMFlow OS 真实接入参考 `frontend/src/api/pmflowOs.ts` 的 `getPMFlowTruthState(projectId)`、`getPMFlowExecutionSummary(projectId)`、`getPMFlowExecutionCapabilities(projectId)`、`createPMFlowExecutionHandoff`、`consumePMFlowExecutionHandoff`、`createPMFlowExecutionProgressEvent`、`createPMFlowReleaseValidationRun`、`createPMFlowReleaseFeedbackIngest`、`createPMFlowIterationRecommendationChangeSetDraft`、`createPMFlowIterationRecommendationGroupChangeSetDraft`、`createPMFlowReviewDecision`、`createPMFlowThreadMessage`、`createPMFlowExtraction`、`getPMFlowChangeSet`、`acceptPMFlowChangeItem`、`editPMFlowChangeItem`、`deferPMFlowChangeItem`、`commitPMFlowChangeSet`;`PMFlowExecutionSummary.releaseGate` 是 ReleaseGrowth 真实发布门禁前端合同,`execution-summary.releaseSignals` 是 Release/Growth 发布后信号与下一轮 ChangeSet draft 状态前端合同;Project Launch 创建链参考 `frontend/src/api/pmflowLaunch.ts`。 +- Legacy AI Steward route:`/ai-steward` 不再作为产品页;参考 `frontend/src/router/index.tsx` 的 `getLegacyAIStewardRedirectPath`,`tab=create` 迁到 `/project-launch`,`tab=engine` 迁到 `/ai-config`,`tab=feedback-center` 有项目参数时迁到 `/release-growth?projectId=`、否则迁到 `/space`,其它 legacy tab 迁到 `/space`;`frontend/src/pages/ai-steward/` 仅保留迁移占位和已断链的历史 tab 壳,不作为新功能入口。 +- AI Steward 问答共享展示与旧浮窗兼容组件:参考 `frontend/src/components/ai-steward/FloatingQAChat.tsx`、`frontend/src/components/ai-steward/QAConversationStatusBar.tsx`、`frontend/src/components/ai-steward/QAResponseMetaCard.tsx`;`FloatingQAChat` 当前不再由全局 `Layout` 挂载,空间入口由 `frontend/src/features/personal-space/components/SpaceAIConcierge.tsx` 承接。 - AI Steward QA contract 前端类型(含 `analysis_packet`):参考 `frontend/src/api/aiSteward.ts` - AI Steward handoff 元卡片回归:参考 `frontend/src/components/ai-steward/FloatingQAChat.test.tsx`、`frontend/src/components/ai-steward/QAResponseMetaCard.test.tsx` - AI Steward / QA session 前端 contract:参考 `frontend/src/api/aiSteward.ts` - PMFlow OS 前端 MVP 产品域:参考 `frontend/src/features/pmflow-os/` -- 个人空间首页 / 空间层入口前端产品域:参考 `frontend/src/features/personal-space/`、`frontend/src/api/space.ts` 与薄入口 `frontend/src/pages/PersonalSpacePage.tsx`;登录后路由入口为 `/space`,未登录由 `frontend/src/auth/RouteBoundary.tsx` 跳转 `/login?redirect=/space`;首屏以 `nextActionQueue` 展示“我的下一步”,并保留执行准备度、继续推进、待我确认、发布关注、发布后反馈关注、下一轮迭代草稿关注、执行中 / AI 正在推进、暂存想法、草案箱、缺项目归属补全与最近项目明细;点击项目主动作进入 `/pmflow-os?projectId=`,待确认项进入 `/change-review?projectId=&changeSetId=`,review attention 待审项进入 `/change-review?projectId=&changeSetId=`,reviewer 缺失 warning 进入已有 `/team-layer?projectId=`,执行中与执行准备度项进入 `/execution-bridge?projectId=`,发布关注和发布后反馈关注项进入 `/release-growth?projectId=`;Space 只读聚合项目摘要、PMFlow OS `truth-state.reviewerRouting / truth-state.releases / execution-summary / execution-capabilities`,并兼容读取旧 AI PMO `waiting_manual / draft_pending_confirm` 工作项;`missing_project_scope` 仅在 Space 选择项目后复用 `POST /ai-pmo/work-items/{id}/decision`,不新增旁路接口,不直接写项目真相层。缺数据时显示明确空态或 warning,不虚构 reviewer、审查意见、待审 ChangeSet、执行能力、工具连接、验证、反馈、收入、ROI、合规、iteration recommendation 或 ChangeSet draft,不直接写 Project Memory、ChangeSet 状态、PMFlowApproval、execution event、ReleaseValidationRun、FeedbackIngest、RevenueEvent 或 ComplianceCheckRun;暂存想法后端只读聚合尚未接入时必须显示 empty/warning,不得用 mock 数据冒充真实内容;空间层共享左侧栏、顶部条与空间用量组件在 `frontend/src/features/personal-space/components/SpaceChrome.tsx`,`/project-launch` 桌面首次新建壳也复用该组件 -- 新项目启动台 / 首次进入页前端产品域:参考 `frontend/src/features/project-launch/` 与薄入口 `frontend/src/pages/ProjectLaunchPage.tsx`;真实模式登录后路由入口为 `/project-launch`,默认通过 `frontend/src/api/pmflowLaunch.ts` 调用后端 draft/confirm API;新建项目唯一写链路是 `POST /pmflow-launch/drafts -> POST /pmflow-launch/drafts/{draft_id}/confirm`,旧 `POST /ai-steward/project-from-text` 只返回 410 迁移错误;只有显式 `?demo=1` 或 `?dataSource=demo` 才允许匿名并启用 `mockData.ts` 本地演示状态机;401/无 token 通过 `frontend/src/lib/apiError.ts` 归一为 `auth_required/session_expired`,通过 `frontend/src/lib/pendingIntent.ts` 保留输入,用户可见失败态不得展示 `Request failed with status code 401` 或把 401 归为网络异常 -- 前端身份边界与产品化错误:参考 `frontend/src/auth/RouteBoundary.tsx`、`frontend/src/lib/apiError.ts`、`frontend/src/lib/pendingIntent.ts`;`/space`、真实 `/project-launch`、`/me`、主链 standalone 与管理/治理入口均在前端边界收口,管理/治理入口使用 OWNER/REVIEWER manager 判断,后端 JWT/RBAC 仍是最终校验;pending intent 第一批只恢复 project launch 输入,不自动重放写操作 -- PMFlow OS 共享 truth store 与主链状态动作:参考 `frontend/src/features/pmflow-os/store.tsx`;当前支持 `?projectId=` 走 React Query 读取真实 `truth-state` 与 `execution-summary`,真实模式下 thread message、`proposed_items` extraction、ChangeItem `accept/edit/defer`、ChangeSet commit、review decision、Execution Handoff create/consume、execution progress writeback、release validation run create、release feedback ingest、单条 iteration recommendation -> ChangeSet draft 与同组 iteration recommendation 批量 -> ChangeSet draft 均调后端并 refetch,不本地 promote 入 Project Memory、不伪造 approval、execution event、发布验证记录、发布后信号或下一轮 draft;`?demo=1` 才启用本地 mock 演示状态机和本地动作;项目内 AI 结构化闭环状态机为 `command_idle / ai_extracting / extraction_ready / review_open / writeback_success` +- AI readiness / 空间级 AI 能力门禁:参考 `frontend/src/features/ai-readiness/`、`frontend/src/api/aiReadiness.ts`、`frontend/src/pages/ai-config/components/AISetupWizard.tsx`、`frontend/src/features/personal-space/components/SpaceChrome.tsx`、`frontend/src/features/pmflow-os/components/PMFlowOsFrame.tsx` 与 `frontend/src/features/project-launch/components/LaunchCanvases.tsx`;`useAIReadiness()` 是 Space 顶部状态、AI 可用状态页、项目级 AI 副驾状态和 Project Launch 未就绪分流的共享前端合同,默认读取后端 `GET /ai-readiness/project-launch`,本机状态只允许在 test 或 `VITE_AI_READINESS_LOCAL_OVERRIDE=true` 时覆盖。该合同必须输出双层表达:用户端默认消费 `customerState / title / summary / blockers / customerActions / workspaceOwnerActions`,只表达业务影响、可继续动作和保护规则;平台人员进入用户端也先看客户视图,只有显式 `platformDiagnostic` 卡或 `/platform/*` 才展示 `causeCode / causeOwner / rawStatus / affectedScenes / platformActions`。普通成员不得看到供应商、路由、连接测试、模型槽位、trace 或函数名;空间负责人只看到空间 AI 状态、团队策略和工具连接入口;平台 AI Ops 在 `/platform/*` 默认看到具体诊断和 CTA,并可在运行总览触发 `POST /ai-readiness/project-launch/check` 草案 smoke。 +- 个人空间首页 / 空间层入口前端产品域:参考 `frontend/src/features/personal-space/`、`frontend/src/api/space.ts` 与薄入口 `frontend/src/pages/PersonalSpacePage.tsx`;登录后路由入口为 `/space`,未登录由 `frontend/src/auth/RouteBoundary.tsx` 跳转 `/login?redirect=/space`;首屏以 `nextActionQueue` 展示“我的下一步”,并保留执行准备度、继续推进、待我确认、发布关注、发布后反馈关注、下一轮迭代草稿关注、执行中 / AI 正在推进、暂存想法、草案箱、缺项目归属补全与最近项目明细;红框三块在首页只作为工作雷达预览,暂存想法、草案箱、最近项目均最多露出少量高优先级条目,完整处理分别进入 `/space/ideas`、`/space/drafts` 与 `/space/projects?view=recent`。`frontend/src/features/personal-space/SpaceCollectionPages.tsx` 承载二级页:`/space/ideas` 支持暂存想法筛选、搜索、补充、归档与转 Project Launch 草案,`/space/drafts` 支持待确认草案筛选、搜索与回主流程确认;这些页面仍使用空间壳,不直接写项目真相层。Space 同时展示 AI readiness 客户视图卡片,管理员可从侧边栏或顶部状态进入 `/ai-config?tab=setup`。空间级 AI 管家在 `frontend/src/features/personal-space/components/SpaceAIConcierge.tsx`,入口只挂 `/space`,向现有 `/rag/ask` 注入个人空间上下文包并复用 QA session 历史能力;它只能围绕个人空间、跨项目下一步、待确认、执行中、草案、暂存想法和 AI 能力状态工作,真实写操作必须回主流程确认或跳转,不得声称已执行。全局 `Layout` 不再挂载旧 `FloatingQAChat` 悬浮球,普通后台页不显示无上下文聊天球,项目页继续使用项目级 `AI 副驾`。Space 左侧导航只承载稳定空间目的地,当前保留“空间首页”“暂存想法”“草案箱”“所有项目”和管理员“AI 使用设置”。点击项目主动作进入 `/pmflow-os?projectId=`,待确认项进入 `/change-review?projectId=&changeSetId=`,review attention 待审项进入 `/change-review?projectId=&changeSetId=`,reviewer 缺失 warning 进入已有 `/team-layer?projectId=`,执行中与执行准备度项进入 `/execution-bridge?projectId=`,发布关注和发布后反馈关注项进入 `/release-growth?projectId=`;执行失败和回写失败项进入 `/execution-bridge?projectId=`;Space 聚合项目摘要、PMFlow OS `truth-state.reviewerRouting / truth-state.releases / execution-summary.releaseGate / execution-summary.releaseSignals / execution-summary.recentProgressEvents / execution-summary.recentDeliveries / execution-capabilities` 和真实 `GET /space/saved-ideas`,并兼容读取旧 AI PMO `waiting_manual / draft_pending_confirm` 工作项;`missing_project_scope` 仅在 Space 选择项目后复用 `POST /ai-pmo/work-items/{id}/decision`,不新增旁路接口,不直接写项目真相层。缺数据时显示明确空态或 warning,不虚构 reviewer、审查意见、待审 ChangeSet、执行能力、工具连接、验证、反馈、收入、ROI、合规、iteration recommendation 或 ChangeSet draft;暂存想法是唯一允许 Space 写入的 pre-project seed,保存原始输入、来源、状态、准备度和 draft 关联,生成草案只进入 Project Launch human review;除此之外不直接写 Project Memory、ChangeSet 状态、PMFlowApproval、execution event、ReleaseValidationRun、FeedbackIngest、RevenueEvent 或 ComplianceCheckRun;空间层共享左侧栏、顶部条与空间用量组件在 `frontend/src/features/personal-space/components/SpaceChrome.tsx`,`/project-launch` 桌面首次新建壳也复用该空间 Chrome +- 新项目启动台 / 首次进入页前端产品域:参考 `frontend/src/features/project-launch/` 与薄入口 `frontend/src/pages/ProjectLaunchPage.tsx`;真实模式登录后路由入口为 `/project-launch`,默认通过 `frontend/src/api/pmflowLaunch.ts` 调用后端 taskized draft job/confirm API;AI 草案创建链路是 `POST /pmflow-launch/draft-jobs -> GET /pmflow-launch/draft-jobs/{job_id} -> POST /pmflow-launch/drafts/{draft_id}/confirm`,补充一句走 `POST /pmflow-launch/drafts/{draft_id}/revise` 或创建新的 `merge_supplement` job,旧 `POST /ai-steward/project-from-text` 只返回 410 迁移错误;只有显式 `?demo=1` 或 `?dataSource=demo` 才允许匿名并启用 `mockData.ts` 本地演示状态机,真实模式的操作流、生成过程、审查建议、附件引用和计数从当前输入、job 状态与后端返回草案派生,不再使用周报 demo 文案兜底;真实模式下 AI 未就绪时先渲染 `AISetupRequiredPanel`,按权限展示记录想法、查看空间状态或平台 AI 能力处理 CTA,不提供空项目创建入口,避免在缺少结构化上下文时提前创建项目壳。401/无 token 通过 `frontend/src/lib/apiError.ts` 归一为 `auth_required/session_expired`,通过 `frontend/src/lib/pendingIntent.ts` 保留输入;`input_needs_more_info` 422 归一为 `input_invalid` 并停在补充输入失败态,用户可见失败态不得展示 `Request failed with status code 401` 或把 401 归为网络异常 +- 前端身份边界与产品化错误:参考 `frontend/src/auth/RouteBoundary.tsx`、`frontend/src/lib/rbac.ts`、`frontend/src/lib/apiError.ts`、`frontend/src/lib/pendingIntent.ts`;`/space`、真实 `/project-launch`、`/me`、主链 standalone、空间管理入口与平台运营入口均在前端边界收口。空间管理入口使用 workspace role,`/platform/*` 和平台治理入口使用 `platform_context.capabilities`,后端 JWT/RBAC 仍是最终校验;pending intent 只能恢复 project launch 输入或承接已经生成好的 Project Launch draft,不允许登录后自动重放真实写请求 +- PMFlow OS 共享 truth store 与主链状态动作:参考 `frontend/src/features/pmflow-os/store.tsx`;当前支持 `?projectId=` 走 React Query 读取真实 `truth-state` 与 `execution-summary`,真实模式下 `sendMessage` 会先写 thread message,再调用 `createPMFlowExtraction` 触发后端真实 AI extraction,成功后 refetch 出 open ChangeSet / pending_review ChangeItems;显式 `proposed_items` extraction 仍保留给受控 draft 场景;ChangeItem `accept/edit/defer`、ChangeSet commit、review decision、AI feedback、Execution Handoff create/consume、execution progress writeback、release validation run create、release feedback ingest、单条 iteration recommendation -> ChangeSet draft 与同组 iteration recommendation 批量 -> ChangeSet draft 均调后端并 refetch,不本地 promote 入 Project Memory、不伪造 approval、execution event、发布验证记录、发布后信号或下一轮 draft;AI feedback 通过 `createPMFlowAIFeedback` 写入 `pmflow_ai_feedback_events` 并在 `truth-state.aiFeedback` 恢复当前用户选择;AI extraction 失败在 CommandCenter / StructuredExtraction 显示“结构化失败/待补充”,不展示模板假候选;`?demo=1` 才启用本地 mock 演示状态机和本地动作;项目内 AI 结构化闭环状态机为 `command_idle / ai_extracting / extraction_ready / review_open / writeback_success` - Account Settings / `/me` 前端产品域与 P1-P8 偏好合同:参考 `frontend/src/features/account-settings/AccountSettingsPanel.tsx`、`frontend/src/features/pmflow-os/pages/AccountPage.tsx`、`frontend/src/api/accountSettings.ts`、`backend/app/api/account_settings_api/`、`backend/app/schemas/account_settings.py` 与 `backend/app/services/account_settings_service.py`;当前覆盖个人资料、默认语言、AI 回答深度、证据展示偏好、待确认、执行回写、发布门禁和失败提醒通知偏好,移动端使用分组列表与 bottom sheet,账户退出仍复用 `AccountControls.tsx`,不改 OAuth/JWT/auth core;P1 后端合同提供 `GET /account-settings/me` 与 `PUT /account-settings/me`,已由 `backend/app/api/router.py` 挂入核心路由组,按当前 `tenant_id + user_id` 保存账号偏好,前端有 token 时优先账号同步,后端不可用时明确回落 `仅本机暂存 / 保存失败`;P2 在 `frontend/src/api/accountSettings.ts` 提供 `shouldShowAccountNotification` 与 `getAccountNotificationConsumption`,把通知偏好映射为页面内提醒消费模型;P3 新增 `getReleaseGateNotificationPreference(settings, event)` 供 Release/Growth 后续消费:`release_gate_*` 与 `release_validation_*` 使用 `releaseGate` 偏好,`failed_event` 使用 `failureAlert` 偏好;P5/P6 的 `getPostReleaseNotificationPreference(settings, event)` 供发布后反馈与下一轮待确认消费:`feedback_received / revenue_signal / compliance_blocked` 使用 `releaseGate` 偏好,`iteration_recommendation / next_change_review` 使用 `pendingConfirmation` 偏好并落到 `change_review` surface;P7 将每类通知偏好扩展为 `reminderIntensity=silent|normal|prominent` 与 `evidenceDisplay=compact|expanded`,并新增 `getAccountReminderConsumption(settings, event)` 覆盖 `change_review_high_risk / iteration_draft_ready / release_blocked / execution_stalled`,稳定返回 `notificationTransport=in_app_only / evidencePolicy=preserve_business_evidence / auditPolicy=preserve_audit_state`;P8 新增 `getReviewDecisionNotificationPreference(settings, event)` 覆盖 `review_requested / review_approved / review_changes_requested / review_rejected / high_risk_approval_required`,统一使用 `pendingConfirmation` 偏好和 `change_review` surface,返回 `reviewerRoutingPolicy=preserve_reviewer_routing / approvalPolicy=preserve_approval_state`,偏好关闭只静默页面内提醒,不隐藏 reviewerRouting、approval、evidence 或 audit state;ReleaseGrowthPage 当前消费 `releaseGate` 与 `failureAlert` 两个偏好,只静默强调型提醒,不隐藏 release evidence、阻塞原因、审计状态、validation run、releaseSignals 或下一轮 ChangeSet draft;这些 helper 只控制本地 UI 提醒消费合同,不代表真实邮件、桌面推送或系统通知中心已接入 -- PMFlow OS 产品页入口:参考 `frontend/src/features/pmflow-os/pages/CommandCenterPage.tsx`、`StructuredExtractionPage.tsx`、`ChangeReviewPage.tsx`、`MemoryWritebackResultPage.tsx`、`WorkGraphPage.tsx`、`ExecutionBridgePage.tsx`、`ReleaseGrowthPage.tsx`、`TeamLayerPage.tsx`、`ProjectMemoryPage.tsx`、`AccountPage.tsx`;`ChangeReviewPage.tsx` 真实模式可对当前 ChangeSet 请求审查、通过、要求修改或拒绝,展示审查状态、建议 reviewer、审查意见和 high-risk approval gate,但不自动 commit;`TeamLayerPage.tsx` 真实模式读取 `state.reviewerRouting` 与 `execution-capabilities`,展示待审 ChangeSet 审查路由、执行能力与容量信号、个人执行准备度、工具准备度和 Team Feed,只给建议与跳转,不自动分派、不自动审查、不写 ChangeSet;`ReleaseGrowthPage.tsx` 真实模式展示 `execution-summary.releaseGate`、execution evidence、阻塞原因、下一步动作、`latestValidationRun` 和 `releaseSignals`,缺证据或缺发布后信号时显示 empty/warning,不展示 mock 发布结果;页面可调用真实 API 创建 release validation run、摄取 release feedback,从真实 recommendation 单条或同组批量生成下一轮待确认 ChangeSet draft;生成后仍进入 Change Review accept/edit/defer,不自动 commit、不写 Project Memory、不执行部署命令、不接外部支付;所有真实证据和审计状态仍可见,静态高保真发布/增长数据仅保留在显式 demo 模式 -- Team Feed 只读动态流:`TeamLayerPage.tsx` 复用 `frontend/src/features/pmflow-os/components/TeamFeedPanel.tsx` 展示 `state.feed`,由后端 `ProjectTruthState.feed` 聚合 review request、review decision、ChangeSet 状态、execution progress 与 release gate 事件;前端只做空态、摘要和跳转展示,不写审批、不写 ChangeSet、不写通知,也不在真实模式 fallback 到 mock 动态。 +- PMFlow OS 产品页入口:参考 `frontend/src/features/pmflow-os/pages/CommandCenterPage.tsx`、`StructuredExtractionPage.tsx`、`ChangeReviewPage.tsx`、`MemoryWritebackResultPage.tsx`、`WorkGraphPage.tsx`、`ExecutionBridgePage.tsx`、`ReleaseGrowthPage.tsx`、`TeamLayerPage.tsx`、`ProjectMemoryPage.tsx`、`AccountPage.tsx`;`StructuredExtractionPage.tsx` 根据 `origin=change_review / returnTo=change_review / changeSetId / itemId` 区分指挥台读取检查、待确认证据检查和已写入快照,从待确认进入时主动作必须返回 `/change-review` 原轮次,不把用户强制带回指挥台;`ChangeReviewPage.tsx` 真实模式可对当前变更集请求审查、通过、要求修改或拒绝,展示审查状态、建议审查人、审查意见和高风险审批门禁,但不自动 commit,且在确认写入 Project Memory 前允许 `pending_review / accepted / edited / deferred` ChangeItem 继续 accept/edit/defer;`TeamLayerPage.tsx` 真实模式读取 `state.reviewerRouting`、`state.feed` 与 `execution-capabilities`,以 `团队动态 / 决策台 / 角色收件箱 / 智能体工位 / AI 副驾` 展示团队协作态势、待审变更责任路由、执行能力与容量信号、个人执行准备度和工具准备度,只给责任建议、阻塞解释与跳转,不自动分派、不自动审查、不写 ChangeSet 或 Project Memory;决策台与待审路由卡片不得预留独立空白操作列,正文应使用完整卡片宽度,1-2 条路由按双列或单列密度展示;`ReleaseGrowthPage.tsx` 真实模式展示 `execution-summary.releaseGate`、execution evidence、阻塞原因、下一步动作、`latestValidationRun` 和 `releaseSignals`,缺证据或缺发布后信号时显示 empty/warning,不展示 mock 发布结果;页面可调用真实 API 创建 release validation run、摄取 release feedback,并展示真实 feedback、revenue、ROI、compliance 与 recommendation 风险态,从真实 recommendation 单条或同组批量生成下一轮待确认 ChangeSet draft;生成后仍进入 Change Review accept/edit/defer,不自动 commit、不写 Project Memory、不执行部署命令、不接外部支付、不接真实合规平台;所有真实证据和审计状态仍可见,静态高保真发布/增长数据仅保留在显式 demo 模式 +- 团队层只读动态与协作态势:`TeamLayerPage.tsx` 直接消费 `ProjectTruthState.feed` 聚合的 review request、review decision、变更集状态、execution progress 与 release gate 事件,并结合 `reviewerRouting`、`execution-capabilities` 和容量计划生成首屏摘要;前端只做空态、摘要、中文化展示和跳转,不写审批、不写 ChangeSet、不写通知,也不在真实模式 fallback 到 mock 动态。 - PMFlow OS 行为组件:参考 `frontend/src/features/pmflow-os/components/ProjectStructuredLoop.tsx`、`ProjectStageRail.tsx`、`ThreadComposer.tsx`、`ExtractionRail.tsx`、`ChangeGroupPanel.tsx`、`ObjectGraphSection.tsx`、`ContextPacketPanel.tsx`、`ExecutionTimeline.tsx`、`ReleaseGatePanel.tsx`、`TeamFeedPanel.tsx`、`ChangeReviewGroup.tsx`、`WorkbenchPanel.tsx`、`ExecutionBridgeCards.tsx`、`PMFlowOsFrame.tsx`、`AccountControls.tsx`、`FeedbackPrimitives.tsx`、`MobileBottomNav.tsx`;其中 `ProjectStructuredLoop.tsx` 承接 `StatusLabel / EvidenceSnippet / ObjectChangeCard / ProjectConversationPanel / StructuredExtractionPanel / ProjectAICopilotPanel`,`PMFlowOsFrame.tsx` 承接项目内面包屑和项目切换器,`AccountControls.tsx` 承接桌面头像菜单、退出登录确认与移动端 bottom sheet,`FeedbackPrimitives.tsx` 承接 `PageBanner / InlineNotice / HumanDecisionBar / ConfirmDialog / MobileBottomSheet`,`MobileBottomNav.tsx` 承接正式应用壳移动端固定底部导航 - PMFlow OS 原型状态回归测试:参考 `frontend/src/features/pmflow-os/store.test.ts`;真实 API 动作回归参考 `frontend/src/features/pmflow-os/store.api.test.tsx` - Space 工作面回归测试:参考 `frontend/src/features/personal-space/PersonalSpacePage.test.tsx`,覆盖空态、导航只读约束、执行准备度、发布/反馈/迭代草案队列,以及 reviewerRouting -> `review_attention` 的 next action 聚合排序与 `/change-review`、`/team-layer` 跳转合同。 @@ -123,35 +135,40 @@ - Account Settings 前端合同回归测试:参考 `frontend/src/features/account-settings/accountSettings.test.ts`,覆盖默认偏好、账号同步 fallback、通知偏好消费模型、提醒强度/证据展示合同、Release/Growth 通知消费合同、review decision / reviewer request / high-risk approval 偏好合同、失败事件映射和缺字段 fallback。 - 网页问答显式锚点与 Space AI 历史 URL 构造:参考 `frontend/src/components/ai-steward/qaPageAnchor.ts`;新生成的 QA 锚点进入 `/space?view=ai-history`,函数仍能解析 legacy `/ai-steward` URL 以兼容历史链接 - 登录页/营销页的共享视觉基线:参考 `frontend/src/components/marketing/MarketingFrame.tsx` 与 `frontend/src/components/marketing/MarketingVisuals.tsx` -- 公开登录页入口:参考 `frontend/src/pages/login/LoginPage.tsx`、`frontend/src/components/login/LoginExperience.tsx`、`frontend/src/components/login/LoginWorkflowVisual.tsx`、`frontend/src/components/login/loginScene.ts`、`frontend/src/components/login/loginMath.ts` 与 `frontend/src/router/index.tsx` +- 公开登录页入口:参考 `frontend/src/pages/login/LoginPage.tsx`、`frontend/src/components/login/LoginExperience.tsx`、`frontend/src/components/login/LoginForm.tsx`、`frontend/src/components/login/LoginWorkflowVisual.tsx`、`frontend/src/components/login/loginScene.ts`、`frontend/src/components/login/loginMath.ts` 与 `frontend/src/router/index.tsx`;默认手机号验证码登录/自注册,用户名密码仅作为兼容入口 - 登录页分级验证:参考 `frontend/package.json` 中的 `verify:login-fast / verify:login-responsive / verify:login-full`、`capture:login:matrix`、`frontend/scripts/login-live-check.mjs` 与 `frontend/src/pages/login/LoginPage.test.tsx` - 顶层路由懒加载与统一 fallback:参考 `frontend/src/router/index.tsx` 与 `frontend/src/components/layout/LazyBoundary.tsx` - 顶层前端 Provider 入口:参考 `frontend/src/main.tsx`(`PMFlowOsProvider` + `RouterProvider`) - AI Steward legacy redirect 入口:参考 `frontend/src/router/index.tsx` 与 `frontend/src/pages/ai-steward/AIStewardPage.tsx`;不再新增 tab 级业务入口 -- AI 运营中心 section 级懒加载入口:参考 `frontend/src/pages/ai-config/AIConfigPage.tsx`;`/ai-config` 技术路由保留,首页按策略 / 连接 / 治理 / 高级分组组织团队级 AI 配置与治理入口 -- 项目索引与旧项目路由兼容:项目索引参考 `frontend/src/pages/projects/ProjectsPage.tsx`;旧 `/projects/:id/board` 只在 `frontend/src/router/index.tsx` 中重定向到 `/pmflow-os?projectId=:id`,旧 `/projects/:id/charter/:charterId` 重定向到 `/project-memory?projectId=:id`;`frontend/src/pages/project-board/` 仅保留为待物理删除的 legacy 代码,不再作为新产品实现参考。 -- 用户页对话框懒加载入口:参考 `frontend/src/pages/users/UsersPage.tsx` +- AI 使用设置 section 级懒加载入口:参考 `frontend/src/pages/ai-config/AIConfigPage.tsx` 与 `frontend/src/components/layout/Layout.tsx`;`/ai-config` 技术路由保留,但它是空间级 AI 使用设置入口,必须渲染空间配置壳 `SpaceSidebar(activeLabel="AI 使用设置") + SpaceTopBar`,不得显示项目工作台侧栏;首页按状态 / 连接 / 知识 / 策略组织客户空间 AI 使用能力,平台供应商、全局路由、route/context 与 semantic facts 不在普通客户空间暴露。 +- Space 项目集合与旧项目路由兼容:所有项目视图参考 `frontend/src/pages/projects/ProjectsPage.tsx`,新入口为 `/space/projects` 并使用空间壳 `SpaceSidebar(activeLabel="所有项目") + SpaceTopBar`;从空间首页最近项目进入时可带 `/space/projects?view=recent`,页面内支持“全部 / 最近 / 待处理 / AI 进行中 / 已归档”筛选,承接首页最近项目二级查看,不单独新增最近项目页;旧 `/projects` 只在 `frontend/src/router/index.tsx` 中重定向到 `/space/projects`,旧 `/projects/:id/board` 重定向到 `/pmflow-os?projectId=:id`,旧 `/projects/:id/charter/:charterId` 重定向到 `/project-memory?projectId=:id`;`frontend/src/pages/project-board/` 仅保留为待物理删除的 legacy 代码,不再作为新产品实现参考。 +- 空间成员与权限入口:参考 `frontend/src/pages/users/UsersPage.tsx`、`frontend/src/pages/user-profile/UserProfilePage.tsx` 与 `frontend/src/components/users/UserFormDialog.tsx`;新入口为 `/space/settings/members` 和 `/space/settings/members/:id`,旧 `/users*` 路由只做兼容跳转;成员新增的用户可见动作是“按手机号邀请成员”,不是后台直接创建密码账号。 - 图表二级 chunk 入口:参考 `frontend/src/pages/user-skills/components/UserSkillsRadarPanel.tsx` 与 `frontend/src/pages/workload-detail/components/WorkloadTrendChart.tsx` - Vite chunk 分组与 watch 忽略策略:参考 `frontend/vite.config.ts` - Scheduler Center / Mounted AI Console:参考 `frontend/src/pages/scheduler-center/` - SSTC 工作流组件:参考 `frontend/src/components/sstc/` -- Model Routing 页面:参考 `frontend/src/pages/model-routing-config/` -- AI 路由与供应商控制台页面:参考 `frontend/src/pages/ai-routing/`;`TeamAIPolicyPage.tsx` 是负责人策略视图,`AIRoutingConsolePage.tsx` 是管理员诊断与槽位视图,薄入口位于 `frontend/src/pages/TeamAIPolicyPage.tsx`、`frontend/src/pages/AIRoutingConsolePage.tsx` +- Platform AI Operations Console 专属壳与首页:参考 `frontend/src/pages/platform/PlatformChrome.tsx` 与 `frontend/src/pages/platform/PlatformConsolePage.tsx`,入口为 `/platform`;`/platform/*` 必须使用平台专属 `PlatformSidebar + PlatformTopBar`,不得复用旧项目工作台侧栏/顶部条,侧栏按“运行总览 / AI 运行层 / 工具与外部能力层 / 上下文与语义层 / 质量与事件层”组织;旧 `/ai-config/*` 平台诊断路径只做兼容跳转。 +- Model Routing / Route Lab 页面:参考 `frontend/src/pages/model-routing-config/` 与 `backend/app/services/model/model_routing/service.py`,隐藏入口为 `/platform/route-lab`;旧 `/platform/model-routing-config`、`/ai-config/model-routing-config` 和裸 `/model-routing-config` 仅兼容跳转。当前 UI 只读展示函数级规则命中、最近执行与历史 override 痕迹,不提供保存入口;服务端写接口仍只接受 `low/high` 槽位别名,并阻止保护 high 场景函数降级到 low,后续若重新开放编辑必须补修改原因、TTL、dry-run、审计和自动过期。 +- AI 路由与供应商控制台页面:参考 `frontend/src/pages/ai-routing/`、`frontend/src/api/aiRouting.ts`、`backend/app/services/ai_routing_governance_service.py` 与 `backend/app/services/ai_routing_scene_contracts.py`;`TeamAIPolicyPage.tsx` 是客户空间负责人策略视图,平台 AI Ops 运行总览合并进 `/platform`,旧 `/platform/ai-routing-console` 仅兼容跳转;AI 运行层共享实现为 `AIRoutingOperationsPages.tsx`,并拆分为 `/platform/ai-routing-providers` 供应商与凭据、`/platform/ai-routing-slots` 能力槽位、`/platform/ai-routing-scenes` 业务场景路由、`/platform/ai-routing-costs` 成本/配额/缓存、`/platform/ai-routing-failures` 失败诊断;供应商页可执行 capability probe 并展示 capability source、last verified、probe status 与阻塞原因;能力槽位页负责从已启用供应商连接中选择 primary runtime 与 fallback chain,保存到 `integrations(type='ai_capability_slots')` 的 draft 后必须经过 validate/publish 才会变成 runtime active slots,结构化/工具/长上下文槽位发布必须区分缺能力与缺证据,槽位详情弹层必须展示该 slot 影响的业务场景和 24h/7d 运行观测;主矩阵展示 7d 失败率与 fallback 命中摘要,无样本时显示“暂无运行样本”;场景路由页消费 `scene_slot_matrix[].scene_contract / scene_health / release_gate / function_runtime_profiles`,定位为 Scene Contract & Impact Console:展示业务目标、输入对象、输出合同、成功标准、失败/确认/证据策略、active/draft runtime、能力缺口、发布影响、健康度、发布门禁、场景内生成运行合同、筛选排序和确定性修复动作,不展示 raw 函数名、规则名或 override 来源;`sceneContracts.ts` 仅作为后端合同缺失时的前端 fallback;修复动作 deep-link 到槽位、供应商和失败诊断页并自动聚焦 scene/slot/capability;旧裸路径和 `/ai-config/ai-routing-*` 仅做兼容跳转。 - AI 路由与供应商前端 API:参考 `frontend/src/api/aiRouting.ts` +- AI 调用审计页面:参考 `frontend/src/pages/platform/AICallTracesPage.tsx` 与 `frontend/src/api/aiCallTraces.ts`;入口 `/platform/ai-call-traces` 位于平台 Console 的“质量与事件层”,要求 `platform.ai_routing.manage`;页面提供按状态、供应商、模型、函数、业务场景、能力槽位、prompt key、trace id、时间窗口和关键词过滤,列表默认只展示真实上游调用记录,能力探测按 `probe_capability_*` 单项上游调用显示,不额外展示汇总行;列表展示触发预览、runtime label/model/slot/routing/tokens/cost/latency,并优先用上游 `usage` 展示 `prompt_tokens / completion_tokens / total_tokens`,同时展示请求 `max_tokens`、finish_reason 与 Function Runtime Profile 版本,便于区分模型空响应、JSON 无效、截断和 HTTP 异常;详情只展示完整脱敏 provider 请求 payload、响应 payload、上游尝试链和触发/路由/探测元数据,不展示兼容 `prompt/response` 字段以免误导;详情必须展示 `runtime_policy` 中的场景 timeout 策略、输入规模桶、provider/job timeout 和建议动作;内部 provider code 只作为详情诊断字段展示;带 prompt_key 的详情可直接生成 Golden case。客户空间 AI 使用设置、Space、Project Launch 和项目级 AI 副驾不得暴露该入口。 +- Prompt 运营中心页面:参考 `frontend/src/pages/platform/PromptAssetsPage.tsx` 与 `frontend/src/api/promptAssets.ts`;入口 `/platform/prompt-assets` 位于平台 Console 的“质量与事件层”,要求 `platform.ai_routing.manage`;页面展示 prompt key、业务场景、family、风险等级、发布策略、默认模板、active release、draft 数、门禁状态、最新 replay、调用点和迁移缺口,详情弹层提供基础信息、模板 Diff、版本历史、发布门禁、回放评审、质量归因和运行信息;`managed_release` 可创建草稿、激活和回滚,`guarded_release` 可创建候选版本、运行契约回放或 LLM 回放并通过门禁后激活;回放评审页展示 run mode、judge 评分/标签/理由、关联 AI call log 与风险标记,发布概览展示 release watch;质量归因页展示 7 天调用、失败率、默认回退、成本、PMFlow 用户赞同/不赞同反馈、负反馈原因、优化候选、失败样本和 active golden cases,并可 deep-link 到 AI 调用审计或从失败调用生成带 `case_type / priority / scenario_key / tags` 的 golden case。客户空间 AI 使用设置、Space、Project Launch 和项目级 AI 副驾不得暴露该入口或 prompt body。 - MCP 模板接入页:参考 `frontend/src/pages/ai-config/mcp-config/` -- Route / Context 治理页:参考 `frontend/src/pages/query-route-governance/` -- Semantic facts 治理页:参考 `frontend/src/pages/semantic-facts-board/` +- Contract Sync 看板:参考 `frontend/src/pages/contract-sync-board/`,入口为 `/platform/contract-sync-board`;旧 `/ai-config/contract-sync-board` 和裸 `/contract-sync-board` 只做兼容跳转。 +- Route / Context 治理页:参考 `frontend/src/pages/query-route-governance/`,入口为 `/platform/query-route-governance` +- Knowledge Graph 治理页:参考 `frontend/src/pages/knowledge-graph-context-board/`,入口为 `/platform/knowledge-graph-board`;旧裸 `/knowledge-graph-board` 只做兼容跳转。 +- Semantic facts 治理页:参考 `frontend/src/pages/semantic-facts-board/`,入口为 `/platform/semantic-facts-board` - MCP 前端 API:参考 `frontend/src/api/mcp.ts` - Route / Context 治理前端 API:参考 `frontend/src/api/queryRouteGovernance.ts` - Semantic facts 前端 API:参考 `frontend/src/api/semanticFacts.ts` -- AI 运营中心治理工具入口:参考 `frontend/src/pages/ai-config/constants.ts` 与 `frontend/src/router/index.tsx`;`GOVERNANCE_TOOL_GROUPS` 是首页分组真源,`GOVERNANCE_TOOLS` 保留给既有测试与入口兼容 +- AI 使用设置与平台工具入口:参考 `frontend/src/pages/ai-config/constants.ts` 与 `frontend/src/router/index.tsx`;`GOVERNANCE_TOOL_GROUPS` 是客户空间 AI 使用设置首页分组真源,平台诊断组仅对有 `platform_context` 的账号展示,`GOVERNANCE_TOOLS` 保留给既有测试与入口兼容 ## 4. 模型与配置真源 - 模型配置唯一来源:参考 `backend/app/services/model/model_config.py` - 路由规则真源:`backend/.control_plane/pmflow-routing-rules.json` - Prompt 聚合:`backend/app/config/ai_prompts.py` - Prompt 场景化分片:`backend/app/config/ai_prompts_catalog/` -- Prompt registry:`backend/app/config/ai_prompt_registry.py` +- Prompt registry:`backend/app/config/ai_prompt_registry.py`(静态 catalog + guarded runtime prompts + semantic fact prompts + inline discovered inventory;`editable_policy=needs_migration` 不代表运行时已可配置) - External Query Planner Prompt:`backend/app/config/ai_prompts.py` 中的 `PROMPT_EXTERNAL_QUERY_PLAN` - External Answer Presenter Prompts:`backend/app/config/ai_prompts.py` 中的 `PROMPT_EXTERNAL_DIRECT_FACT_QA`、`PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA`、`PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA` - 开发轻启动配置:参考 `backend/app/core/config.py`、`backend/app/main.py`、`backend/app/api/router.py` 与 `docker-compose.dev.yml` diff --git a/docs/architecture/DATAFLOW.md b/docs/architecture/DATAFLOW.md index 8fb9fbfb..e58ae9fa 100644 --- a/docs/architecture/DATAFLOW.md +++ b/docs/architecture/DATAFLOW.md @@ -20,10 +20,10 @@ - update `signals.planner_status=failed` - try enqueue compensate reply via `FeishuOutboxService.enqueue(...)` -## Model Routing Flow -1. Frontend `ModelRoutingConfigPage` reads current mapping via `/model-routing/functions/{function_name}`. -2. Manager updates mapping via PUT API. -3. Backend persists override into `integrations(type='model_routing')`. +## Model Routing / Route Lab Flow +1. Hidden frontend `ModelRoutingConfigPage` at `/platform/route-lab` reads function-level routing diagnostics from `/model-routing/functions/{function_name}`. +2. The visible UI is read-only: platform operators can inspect rule source, tier, recent execution and historical override traces, but cannot save function-level overrides from the page. +3. Backend `PUT /model-routing/functions/{function_name}` remains a platform-protected compatibility/internal write API and persists overrides into `integrations(type='model_routing')`. 4. `LLMService` resolves function-level override at runtime before default control-plane rules. ## Query Route Gateway Flow diff --git a/docs/architecture/PMFLOW_OS_PRODUCT_CONTRACT.md b/docs/architecture/PMFLOW_OS_PRODUCT_CONTRACT.md index c6d14439..f170afe4 100644 --- a/docs/architecture/PMFLOW_OS_PRODUCT_CONTRACT.md +++ b/docs/architecture/PMFLOW_OS_PRODUCT_CONTRACT.md @@ -99,6 +99,15 @@ execution: - `已写入项目记忆` 只能在 `ChangeSet.commit` 成功写入 `truth_nodes / tasks / semantic_facts` 后出现。 - `semantic_facts` 只用于 read-side facts-first 查询与 Project Memory projection,不作为人工确认动作记录。 +审查与写入顺序: + +- `Structured Extraction` 只生成 `ChangeSet / ChangeItem / EvidenceLink`,不得写正式对象。 +- `ChangeReviewPage` 是正式审查动作的唯一主入口;`accept / edit / defer / reject / review decision` 都只写审查状态、approval 和审计,不写 `truth_nodes / tasks / semantic_facts`。 +- `TeamLayerPage` 只消费 `reviewerRouting / reviewDecision / approvalState` 做责任路由、角色收件箱、决策台和阻塞解释;团队层不得直接执行 `commit`,也不得成为第二套 Change Review。 +- `reviewerRouting` 只是建议与状态投影;除非用户显式 `request_review` 或后续合同定义自动分派,否则不得把建议 reviewer 当成已分派任务。 +- 高风险、跨角色、缺证据或需要专业判断的 item 必须先满足 required reviewer / approval gate,再允许 `commit`。 +- `commit` 是 Project Memory 写入唯一边界;成功后才允许正式对象进入 Work Graph,并成为 Execution Bridge 的可执行候选。 + ## 4. API 合同草案 所有接口统一返回: @@ -253,6 +262,15 @@ POST /pmflow-os/change-items/{change_item_id}/defer - `edit`:保存人工修改后的 payload,状态为 `edited`,写 `pmflow_approvals`。 - `defer`:状态为 `deferred`,不进入项目真相层,但保留审计。 +产品动作语义: + +- `接受` 只能表示当前 actor 认可候选变更进入待写入集合;不得显示为“已写入”或触发执行桥。 +- `修改` 必须生成可审计 before/after,不得覆盖原始 AI proposal 和 evidence。 +- `暂不落库` 对应 `deferred`,用于当前不写入但保留后续恢复空间;它不是 reject。 +- `拒绝` 若前端或后端提供,必须作为 terminal discard / rejected 审计处理,不得被批量 commit 重新捞回;在未实现 reject API 前,UI 不得用 `defer` 冒充拒绝。 +- `稍后处理` 是队列提醒 / 排序动作,不应调用本节三个审查 API;它不得改变 ChangeItem 状态。 +- `批量接受` 只能调用一组满足低风险、证据充分、无需 required reviewer gate、对象类型受支持的 item;包含任一不满足项时必须 fail-closed 或拆分处理。 + 失败语义: - `409 changeset_not_open`:ChangeSet 非 open,不能继续审查。 @@ -405,6 +423,14 @@ POST /pmflow-os/projects/{project_id}/execution-handoffs - PMFlow backend 只创建 handoff 合同、PromptPackage 与 progress event,不直接执行本机命令。 - 本地 `stdio`、宿主机配置写入和真实执行必须经过 Local Bridge、Codex、GitHub agent 或兼容 MCP host;PMFlow 后端不得写 `~/.codex/config.toml`。 +执行责任与分派边界: + +- 执行交接只能针对已属于当前 project / tenant 的正式 `Task`,不得针对 `pending_review` ChangeItem 直接创建 handoff。 +- `Execution Capability Graph` 在 handoff 前产出执行模式建议:`ai_direct / ai_with_user_acceptance / ai_with_technical_review / needs_human_judgement / blocked`。 +- 人类或 agent 的执行责任在 handoff 创建前后形成;在 ChangeSet 阶段最多展示建议 owner / reviewer / agent,不得写成正式 assignment。 +- `ai_with_technical_review` 必须绑定 reviewer 或返回 warning / blocked,不得把 agent 执行结果直接视为完成。 +- 缺 repo binding、runtime target、PromptPackage、测试命令、权限、回写证据或 reviewer 时,允许创建 draft handoff 或 blocked projection,但不得展示为已开始开发。 + ## 5. ChangeSet 写入正式对象规则 `ChangeItem.objectType` 的提交规则: diff --git a/docs/architecture/RUNTIME_GUARDRAILS.md b/docs/architecture/RUNTIME_GUARDRAILS.md index 8d760a79..cb714c80 100644 --- a/docs/architecture/RUNTIME_GUARDRAILS.md +++ b/docs/architecture/RUNTIME_GUARDRAILS.md @@ -31,7 +31,13 @@ - 飞书回执发送仅允许 `main.py` 的 drain loop 作为消费者,禁止在业务路径即时派发发送任务。 - 所有写接口必须落审计日志(`operation_logs`),至少包含 `trace_id/interface_path/caller`。 - `created_at_beijing` 为计算属性返回,禁止新增数据库物理列。 -- 前端身份边界必须与真实写接口一致:`/space`、真实 `/project-launch`、`/me`、项目主链 standalone 与管理/治理入口必须登录,管理/治理入口需 OWNER/REVIEWER;`/project-launch?demo=1` 或 `?dataSource=demo` 是唯一匿名启动台演示入口,且只能使用本地 mock/demo,不得调用真实写接口。 +- 前端身份边界必须与真实写接口一致:`/space`、真实 `/project-launch`、`/me`、项目主链 standalone 与空间管理入口必须登录;客户空间管理入口看当前 workspace role,平台运营入口看 `platform_context.capabilities`,禁止把客户空间 `OWNER/REVIEWER` 当成 PMFlow 平台管理员;`/project-launch?demo=1` 或 `?dataSource=demo` 是唯一匿名启动台演示入口,且只能使用本地 mock/demo,不得调用真实写接口。 +- 真实 `/project-launch` 的进度、操作流、审查建议、附件引用和计数必须使用当前输入、`pmflow_launch_draft_jobs` 任务状态、`pmflow_launch_drafts` 后端草案、warnings/evidence 派生;禁止在真实模式展示 `mockData.ts` 的 DevWeekly 周报 demo 内容。低信息输入必须在 `POST /pmflow-launch/drafts` 或 `POST /pmflow-launch/draft-jobs` 前置门禁返回 `input_needs_more_info` 422,让用户补充产品目标、目标用户、核心问题或材料,不得生成空草案或把“未提供”字段推进到确认创建页。Project Launch 草案必须按可变现项目启动计划生成,覆盖目标用户/痛点、核心闭环、输入或数据源、AI 处理链路、输出交互、信任确认、安全合规、变现/留存验证与反馈指标;默认目标为 7-10 条 requirements、10-16 条 tasks、4-7 条 risks、3-6 条 decisions,信息不足只能显式进入 warnings,禁止回退成薄功能 demo。真实生成优先走任务化链路:`POST /pmflow-launch/draft-jobs` 创建 job,`GET /pmflow-launch/draft-jobs/{job_id}` 轮询状态,`POST /pmflow-launch/drafts/{draft_id}/revise` 基于当前草案增量修订;“补充一句”必须保存用户真实补充并创建新 revision,旧运行中 job 只能标记 `superseded/cancelled` 或忽略结果,禁止用前端硬编码补充或 UI 暂停动画冒充 AI 可暂停/续写。 +- 账号权限必须分三平面:`users` 只代表登录身份,`tenant_memberships` 代表客户个人/团队 workspace 内角色,`platform_memberships` 代表 PMFlow 内部运营身份。注册用户成为个人空间 `OWNER` 合理,但不自动拥有 `platform.*` capability,不能访问平台供应商、全局路由、route/context 治理、semantic facts 或跨租户运营数据。 +- 产品语义里的空间/Workspace 当前兼容落在 `tenants` 表;手机号是用户登录、自注册和邀请绑定的主身份,`users.phone_e164` 必须全局唯一。每个注册账号必须拥有个人空间,受邀加入团队空间不能剥夺个人空间,退出团队空间后必须自动回到个人空间。团队空间只是 membership,不拥有用户账号本身。 +- 项目可见性必须以后端为准:`GET /projects/`、`GET /projects/{project_id}`、PMFlow OS 项目读接口、SSTC 项目级读接口、模型上下文项目级读接口与 Space legacy `GET /ai-pmo/work-items` 均需按当前用户收口;`OWNER/REVIEWER` 可看租户级团队视图,其他角色只看本人拥有、本人参与或本人拥有的 legacy 工作项。前端隐藏模块只是体验层,不能替代接口过滤。 +- Personal Space 的 `saved_ideas` 是 pre-project idea seed,只允许承接未成型想法、原始输入、来源、准备度、缺失问题、结构化 enrichment 元数据和 Project Launch draft 关联。`POST /space/saved-ideas` 可以尝试 `space_saved_idea_enrichment_v1` 做目标用户/问题/价值假设/验证信号/缺口的结构化完整度判断;该调用使用 `PROMPT_SAVED_IDEA_ENRICHMENT` 并经 Prompt 运营中心 active release 读取与 prompt 归因。问候、测试词或无实质想法输入必须显式判为 `needs_more_info` 和 0 准备度;AI 失败必须显式落 `enrichment_status="ai_failed"` 和 `system_baseline`,不得用模板字段冒充 AI 成功;该接口不得生成草案、创建项目或写项目记忆。`POST /space/saved-ideas/{idea_id}/promote` 只允许生成 `pmflow_launch_drafts` 并回到 Project Launch human review,仍需用户确认后才创建正式项目。团队空间后续扩展只能围绕共享/协作/补充权限扩展该 seed,不得把暂存想法直接升级成 Project Memory、ChangeSet、任务、执行事件或发布信号。 +- Space AI Concierge 的前端产品边界固定为 `/space/ai-concierge/*`,必须在 `API_ROUTE_PROFILE=lean` 下随 core `space_api` 加载;内部可复用 RAG/AdaptiveRAG/session 读模型,但前端不得直接依赖 optional `/rag/*` 路由。AI 管家状态必须分层展示:问答能力、历史读取、真实动作建议各自独立降级;历史读取失败不能阻断新会话提问,也不能把辅助能力错误渲染成当前聊天失败。 - 用户可见错误必须使用产品语义分类,禁止直接展示 Axios/FastAPI 原始错误(如 `Request failed with status code 401`、`Could not validate credentials`、traceback)。401/无 token 必须表达为登录/会话问题;需要恢复输入时只能保存 pending intent 并由用户重新触发写操作,禁止登录后自动重放真实写请求。 ### 3.2 核心业务零兜底 @@ -40,6 +46,8 @@ - 核心业务完成态不得仅按字段非空判定;必须能区分“真实生成 / 人工填写 / 模板占位 / 生成失败”。 - 技术层 fallback 仅允许用于重试、缓存、解析修复、传输兼容等基础设施环节,禁止上浮为用户可见业务结论。 - AI Steward 与其它生成链的成功/失败判定必须优先使用结构校验、字段完整性和证据一致性。 +- PMFlow OS `ChangeReviewPage` 真实模式只能展示 `ProjectTruthState.changeSets / changeItems / evidence` 的后端投影;顶部统计、待确认列表和右侧 AI 副驾评估都必须由真实 ChangeItem/Evidence 派生。没有真实待审项时必须显示空态或待补充,禁止用 demo 的知识库搜索、混合召回、固定风险/建议/计数补位。`accepted / edited / deferred` 只表示候选项已被人工处理但尚未写入 Project Memory;只要 ChangeSet 未 committed 且 ChangeItem 未 committed/skipped,就必须允许继续 accept/edit/defer,真正锁定边界是 ChangeSet commit。 +- PMFlow OS 团队审查与执行边界必须保持单向主链:`TeamLayerPage` 只允许消费 `reviewerRouting / reviewDecision / approvalState / TeamFeed` 做责任路由、角色收件箱、团队动态、审查状态、阻塞解释和跳转,不得直接执行 ChangeSet commit、写 Project Memory 或成为第二套 Change Review;Execution Bridge handoff 只能针对 Project Memory commit 后已进入正式项目对象的 Task,禁止从 `pending_review` ChangeItem、候选 Task、reviewer suggestion 或 team routing item 直接创建正式执行分派。缺 reviewer、repo/runtime、PromptPackage、测试命令、权限或回写证据时必须返回 blocked/warning,禁止冒充已开始开发或已分配成功。 ### 3.3 业务动作归因主链 - 运行时业务动作归因必须优先走: @@ -66,17 +74,26 @@ - `fallback_policy` - `execution_mode` - 开发环境启动必须允许显式关闭重型副作用:`STARTUP_MODEL_PROBE_ENABLED`、`FEISHU_LONG_CONNECTION_ENABLED`、`STARTUP_WORLD_MODEL_WARMUP_ENABLED`、`STARTUP_SCHEDULERS_ENABLED`、`STARTUP_BACKGROUND_WORKERS_ENABLED` 是当前唯一允许的 startup 开关;需要压缩热重载时,应优先通过这些环境变量降载,而不是在代码里硬删启动逻辑或把副作用偷偷移到 import side effect。 -- `backend/app/api/router.py` 的 optional route group 只能通过 `API_ROUTE_PROFILE` 与 `API_OPTIONAL_ROUTE_GROUPS` 控制;默认 `full` 保持现有全量 API,`lean` 只允许作为开发减载配置使用,禁止在运行时代码里基于请求内容或关键词动态裁路由。团队级 AI 运营中心依赖的 `ai_routing` API 与高级路由详情依赖的 `model_routing` API 属于核心路由,必须在 `lean` 下继续加载,避免 `/ai-config/*` 治理页面变成 404。 +- `backend/app/api/router.py` 的 optional route group 只能通过 `API_ROUTE_PROFILE` 与 `API_OPTIONAL_ROUTE_GROUPS` 控制;默认 `full` 保持现有全量 API,`lean` 只允许作为开发减载配置使用,禁止在运行时代码里基于请求内容或关键词动态裁路由。空间级 AI 使用设置依赖的 team policy API 与平台 AI Operations Console 依赖的 `ai_routing / model_routing / llm_engines` API 属于核心路由,必须在 `lean` 下继续加载,避免 `/ai-config/*` 或 `/platform/*` 变成 404。 - 开发预览端口职责必须固定:`5174` 仅保留给源码热更新前端,`docker-compose.yml` 下的静态生产预览前端默认改用 `5175`(`FRONTEND_PROD_PORT` 可覆盖);禁止再让 `5174` 在静态容器和 Vite dev server 之间来回漂移,避免浏览器验证和真实源码脱节。 - 禁止再通过函数名片段、hint 常量或模板词在底层推断 tier、结构化输出或 fallback 策略。 - 所有 LLM 调用禁止静默兜底与 stub 假成功;low 失败时必须优先强制升级到 high 重试并保留低/高两段日志,high 仍失败时才显式报错。 - 用户可见问答链路(`function_name` 含 `answer/reply/qa`)禁止使用 `reasoning_content` 回退,避免思考过程外漏。 - `integrations(type='model_routing')` 的 runtime tier override 不得把 `adaptive_rag_answer_normal` / `adaptive_rag_answer_deep` 这类用户可见 QA 主函数降级到 `low`。 -- 模型默认值与 provider/tier 映射以 `backend/.control_plane/pmflow-routing-rules.json` 为唯一配置源。 -- AI 路由与供应商产品化控制台 v1 只做治理聚合、脱敏展示、团队策略保存和只读诊断:团队策略保存到 `integrations(type='ai_team_policy')`,不得在本阶段改写 `llm_service` runtime 路由、`backend/.control_plane/pmflow-routing-rules.json` 或 provider secret。 -- 用户可见层必须使用产品语义表达 AI 模式与状态,例如“快速处理 / 深度分析 / 项目记忆查询 / 外部实时查询 / 待确认执行”。成员视图不得暴露 `low/high`、provider、model、`function_name`、`api_env`、raw routing meta 或明文 key;负责人视图只看业务场景策略;管理员控制台可在高级详情中查看函数级路线,但默认隐藏 `function_name`。 -- AI 路由诊断允许提示“环境变量 Key 可能覆盖租户 Key”等风险,但只能返回 masked key 和中文风险说明;禁止返回 prompt、raw error、raw provider payload、raw env value 或明文 token。 -- `/ai-config` 用户可见定位为团队级 AI 运营中心,不是项目级管理后台。AI 供应商、团队 AI 策略和路由诊断影响当前团队空间内所有项目;项目级页面只能展示 AI 处理模式、来源/可信状态和待确认状态,禁止提供项目级 provider/model/路由覆盖入口。 +- `backend/.control_plane/pmflow-routing-rules.json` 是 PMFlow 本地路由层 baseline/default rule pack 和冷启动 fallback;平台运行时一旦存在 `integrations(type='ai_capability_slots')` 已发布 active 槽位绑定,`low/high` tier 解析、control-plane rule、函数级 override 和低阶失败升级必须优先消费 active slot 的 primary runtime + fallback chain,禁止继续把 JSON 中的 provider/model 当成唯一实时绑定。`generate_structured_object` 在 `response_contract=structured` 时必须优先消费已发布 active `structured` slot 的 primary/fallback chain;没有 active structured slot 时才回到既有 low/high/control-plane 链路。active structured runtime 选择必须跳过 disabled/deleted runtime 和不满足 `structured_output + json_schema` 的 runtime,并在 routing meta 中记录 `slot_key=structured / slot_version / slot_runtime_source=active_slot / slot_fallback_index / slot_degraded=false`。active structured primary 调用失败后只允许继续尝试同一 active structured slot 的 fallback chain;全部 structured runtime 失败后必须显式失败,禁止再落回模板假成功或无关 low/high 路由。如果 active structured slot 已配置但没有可用 runtime,项目启动、需求结构化、任务拆解、ChangeSet extraction 等保护结构化函数必须显式失败;非保护结构化调用才允许降级到既有路径,并必须记录 `slot_runtime_source=active_slot_degraded` 与 `slot_degraded=true`。已发布 active `long_context` slot 只允许由显式长文档/长线程/跨项目上下文调用消费,即 `routing_meta.slot_key/planned_tier/tier_alias=long_context`;已发布 active `tool` slot 只允许由显式工具调用或 MCP execution planning 消费,即 `routing_meta.slot_key/planned_tier/tier_alias=tool` 或 `execution_mode=local_planner|local_worker`。long_context/tool slot 运行时必须跳过 disabled/deleted runtime 和能力不足 runtime;active slot 已配置但没有可用 runtime 时显式失败,没有 active slot 时回到既有 low/high/control-plane 链路。已发布 active `external_query` slot 只允许在 `route_mode=external` 或等价 external source policy 后由 `external_query_service` 消费,绑定对象是 `primary_provider_id / fallback_provider_ids / verifier_required / source_filtering_required / failure_policy=explicit_fail`,不是普通 LLM engine;internal query、未发布 draft 和缺 route_mode 的调用不得消费该槽。外部查询失败必须显式失败或返回 freshness guard,禁止回退普通 LLM 编造答案。能力槽位草稿只用于平台 AI Ops 编辑、校验和发布预览;运行时禁止读取未发布 draft slots。供应商能力矩阵必须分清声明能力、`capability_probe` 探测证据和后端派生 `effective_capabilities`;探测证据保存在 `integrations(type='llm_engine').config.engines[*].capabilities.capability_probe`,并继续同步兼容 `capability_evidence / capability_probe_status / capability_last_verified_at`。能力探测必须按 registry 逐项执行,`structured_output` 与 `json_schema` 独立验证;`streaming` 只表示 `stream=true` 增量响应的传输/运行特性,不属于编辑弹窗声明能力矩阵,也不得混入“已验证可用”模型能力列表;供应商页声明能力必须完整展示,不得用固定数量截断,任一声明能力缺少 live/provider_contract/operator_verified 证据时,能力状态不得显示为 passed;默认 full 探测可增加 `model_self_report` 低成本自报参考,但 `evidence_strength=model_self_report` 只用于运营提示和冲突辅助,禁止满足 protected slot 发布,供应商页只能在“模型自报参考”独立区域展示,不得混入“探测明细”能力项;默认探测计划只把声明能力纳入阻塞/聚合口径,未声明的可选能力不得展示为“没通过”或导致整体“部分通过”;长上下文供应商探测必须按供应商配置的 `max_context_tokens` 记录声明窗口,禁止把普通 `long_context` 默认硬映射为 32K,`long_context_32k/128k` 只用于显式阈值探测和槽位发布门禁;供应商编辑若改变 provider/model/base URL/API key 或声明能力矩阵,必须清空旧 `capability_probe/capability_evidence/effective_capabilities` 并回到 `untested`,只有名称等展示字段修改可保留证据;长上下文声明窗口未知官方合同时只能记录 `declared_only`,不得冒充 verified evidence;已知官方供应商/模型规格(如 DashScope `qwen-plus`)可作为 `provider_contract` verified evidence,但不写入 AI 调用审计,因为没有真实上游 endpoint 交互;平台操作人可对高成本长上下文执行人工确认,写入 `operator_verified/operator_attestation` 证据、确认窗口、来源、确认人和过期时间,该证据可满足长上下文槽位发布,但必须在 UI 和审计语义中标明“人工确认”,且不得写入 AI 调用审计或伪装成 live/provider endpoint 调用;模型自报是实际上游短调用,应写入 AI 调用审计但不得冒充 verified evidence;旧 runtime 必须由服务层按 provider/model 归一化默认能力和 `capability_probe.status=untested`,但默认/人工标签不得冒充 live probe evidence。publish validation 必须按 `SLOT_REQUIREMENTS` 检查 draft 的 primary/fallback 是否满足槽位要求,高阶槽禁止 local-only/Ollama,结构化槽要求 `structured_output + json_schema` 且具备未过期 live/provider_contract verified evidence,长上下文槽要求 `long_context + max_context_tokens >= 32768` 且具备未过期 live/provider_contract/operator_verified evidence,工具槽要求 `tool_calling` 且具备未过期 live verified evidence;external_query 槽要求至少一个 external provider 可用、MCP adapter 或 bounded live search fallback 可用、verifier 可用、source filtering fail-closed 开启,且 failure policy 必须 explicit_fail;低阶槽缺 probe 只返回 warning。`structured / tool / long_context / external_query` 是正式可配置槽位,`private / multimodal` 仍是不可配置候选槽,只展示候选状态和缺口,不得被 runtime 消费或冒充已启用。 +- `GET /ai-routing/admin-console` 必须返回 `scene_slot_matrix`,把 PMFlow 业务场景解释为 `scene -> scene_contract -> required/current slot -> active runtime -> draft runtime -> scene_health -> release_gate -> impact`,供平台运营判断一次槽位发布会影响哪些能力。每个 scene row 必须包含 `scene_key / scene_label / scene_contract / lifecycle / protected / functions / required_slot / active_slot_runtime / draft_slot_runtime / active_version / draft_dirty / capability_gap / recent_failure_count / scene_health / release_gate / impact_summary`,并兼容旧字段 `current_slot / active_runtime / draft_runtime / recent_failure_rate`;active 场景默认映射为 project_launch/change_review/requirement_structuring/task_breakdown -> structured,assignee_matching -> high,internal_qa -> low/high mixed,external_query -> external_query,feishu_understanding/feishu_reply -> low。`backend/app/services/ai_routing_scene_contracts.py` 是场景合同 registry,active 场景参与健康度和发布门禁;candidate 场景(release_review、retrospective_summary、project_memory_writeback、mcp_execution_planning、contract_sync)只展示合同和预期槽位,不参与 protected release block,不得被运行时消费。`change_review` 是项目主链“发送并提取”的 protected active 场景,绑定 `pmflow_change_review_extraction_v1` 与 `PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION`,输出待确认 ChangeSet/ChangeItems,失败必须 fail-closed。该矩阵只读聚合 active slot 发布版本、draft 预览、能力覆盖、近期失败计数和函数级路由,不写 provider、不写 slot、不写模型路由。场景路由 UI 定位为 Scene Contract & Impact Console,必须展示业务场景合同、当前槽位、active/draft runtime、能力缺口、发布影响、健康度、发布门禁、筛选排序和确定性修复动作;能力槽位详情必须展示受影响场景;供应商/槽位/失败诊断 deep-link 必须聚焦 scene/slot/capability;函数名、规则名和 override 来源不在业务场景路由页展示,函数级检查统一进入隐藏 Route Lab。当前 `sceneContracts.ts` 仅作为后端合同缺失时的前端 fallback,不得调用 LLM 或按关键词推断 runtime 路由。 +- Function Runtime Profile 是业务场景路由下的“生成运行合同”,不是旧函数级模型路由覆盖。配置真源保存到 `integrations(type='ai_function_runtime_profiles')`,默认由 `backend/app/config/ai_function_runtime_profiles.py` seed;平台只允许在业务场景详情里按产品语义展示生成预算、修复预算、temperature、active/draft、真实 smoke、发布门禁和 7d 输出 token/截断观测,不向客户视图展示 raw function name、prompt key 或 provider payload。运行时只能消费已发布 active profile;draft 只能用于平台 AI Ops 编辑、validate、真实 smoke 和发布预览。`pmflow_launch_draft_v1` 与 `pmflow_change_review_extraction_v1` 的 `max_tokens / repair_max_tokens / temperature / response_mode` 必须分别通过 active profile 进入 `PMFlowLaunchService`、`PMFlowChangeReviewService` 和 `routing_meta`,禁止继续以散落常量作为唯一真源;若 profile 服务不可用,只允许使用同文件 default profile 作为冷启动兜底并保留 `profile_source` 审计。 +- `GET /ai-routing/admin-console` 必须返回 `slot_observability`,按 active `low/high/structured/long_context/tool/external_query` 槽位聚合发布后真实调用表现。观测窗口固定为 24h 与 7d,指标包含 `total_calls / failed_calls / failure_rate / fallback_hits / degraded_calls / average_latency_ms / estimated_cost / recent_failure_samples`;`slot_runtime_source=active_slot_degraded` 或 `slot_degraded=true` 计入 degraded,`slot_fallback_index > 0` 计入 fallback 命中。无日志必须返回 `status=empty` 与“暂无运行样本”,禁止伪造成功率、成本或延迟。成本和延迟只展示 `ai_call_logs` 估算来源,不得包装成供应商精确账单或 SLA;该读模型不得改变 publish/rollback/runtime 路由行为。 +- `GET /ai-routing/admin-console` 必须返回 `slot_recommendations`,基于 capability evidence、候选 runtime 与 `slot_observability` 生成确定性运营建议;建议不得调用 LLM,不得自动保存 draft、publish 或 rollback。建议项必须包含 `severity / slot_key / reason / evidence_summary / suggested_action`,可选 `target_engine_id`;缺 verified evidence 建议运行 capability probe,证据过期建议重新 probe,7d failure/fallback/degraded 异常建议补 fallback、评估 fallback 升 primary、修复 active slot 或回滚,low 槽成本/延迟异常建议评估低成本/低延迟候选,structured/tool/long_context 没有候选 runtime 时建议回供应商页补能力或接入新 runtime。证据不足时必须返回空建议并在 UI 展示“暂无建议”,禁止编造优化动作。 +- AI 路由与供应商产品化控制台 v1 属于 PMFlow 平台 AI Operations Console,只做治理聚合、脱敏展示、平台诊断和审计;运行总览合并进 `/platform`,AI 运行层只保留“供应商与凭据 / 能力槽位 / 业务场景路由 / 成本配额缓存 / 失败诊断”明确子菜单:团队策略仍保存到 `integrations(type='ai_team_policy')`,但供应商、全局路由、函数级路由、route/context 治理和 semantic facts 操作必须要求 `platform.*` capability;函数级路由不作为运营侧常规菜单暴露,只能通过隐藏 `/platform/route-lab` 只读诊断页查看。不得在客户空间 `OWNER/REVIEWER` 权限下改写 `llm_service` runtime 路由、`backend/.control_plane/pmflow-routing-rules.json` 或 provider secret。供应商连接不是单选开关,多个连接可同时启用;`default_fallback_engine_id` 仅表示无规则命中时的全局兜底运行时,不得被展示或实现为“唯一启用模型”;能力槽位才负责把启用的供应商连接编排成 PMFlow 可消费的 primary/fallback 能力链,并且必须经过 draft -> validate -> publish -> active 的发布控制后才影响 runtime。 +- 用户可见层必须使用产品语义表达 AI 模式与状态,例如“快速处理 / 深度分析 / 项目记忆查询 / 外部实时查询 / 待确认执行”。成员视图不得暴露 `low/high`、provider、model、`function_name`、`api_env`、raw routing meta 或明文 key;空间负责人只看业务场景策略;平台 AI Ops 的业务场景路由也不展示 `function_name`,函数级路线只允许隐藏 Route Lab 只读诊断。 +- 函数级模型路由覆盖只允许选择已定义槽位别名(当前 `low/high`),禁止直接编辑任意 provider/model/base_url。需求规划、项目需求抽取、人员匹配等保护场景函数不得被覆盖到不满足场景要求的低能力槽;服务端必须在保存 override 前拦截,而不是只靠前端隐藏。 +- AI 路由与供应商运行真源以平台“供应商与凭据”中保存的 provider/model/base_url/api_key/enabled/default_fallback/connection_status/capabilities/capability_probe 与“能力槽位”中保存的 slot primary/fallback 绑定为准;运行路由、函数覆盖路由、连通测试、能力探测、能力槽位和诊断不得使用 `DASHSCOPE_API_KEY / OPENAI_API_KEY` 等环境变量覆盖平台供应商配置,也不得把环境变量配置当成已配置供应商展示。运行调用、low/high tier 选择和低阶失败升级必须跳过 `enabled=false` 的供应商连接;连通测试可以测试停用连接但只更新测试状态,不得自动设为兜底默认;capability probe 只能回写当前 engine config 中的 `capability_probe` evidence/source/status/last_verified/blocking_reasons 及兼容 flat 字段,不得新增 schema 或直接修改 slot active。诊断只能返回 masked key、能力标签、能力来源、last verified、probe status、覆盖缺口、缺证据/证据过期缺口和中文风险说明;禁止返回 prompt、raw error、raw provider payload、raw env value 或明文 token。 +- `ai_call_logs` 是平台 AI 调用审计的运行真源。`llm_service` 必须在真实 provider transport 边界记录上游 `provider_request_payload / provider_response_payload / provider_attempts`,用于平台追溯实际发送给供应商的 model/messages/response_format/agents 等参数、返回 body 与重试链;不得只记录裁剪后的业务 prompt/response 冒充上游出入参。token 口径必须优先读取上游 response payload 中的 `usage.prompt_tokens / usage.completion_tokens / usage.total_tokens`,兼容 `input_tokens / output_tokens`,只有缺失 usage 时才使用长度估算;AI 调用审计列表与详情必须能展示 completion tokens、请求 `max_tokens`、finish_reason 和 Function Runtime Profile 版本,避免把“模型空响应”和“真实返回但解析失败/截断”混成同一类问题。平台审计 API 可返回完整脱敏 provider payload;客户空间、Space、Project Launch、项目级 AI 副驾和普通回答链不得暴露这些字段或原始 headers/密钥。AI 调用失败归因必须区分 `structured_timeout / structured_json_invalid / structured_empty / structured_http_error`,不得统一压成“结构化失败”误导运营排障。 +- AI 场景超时策略归属业务场景合同,不得继续散落为 transport 层写死常量。保护型结构化场景必须通过 `AISceneRuntimePolicyService` 或等价场景策略在 `routing_meta` 显式写入 `scene_timeout_policy_id / input_bucket / input_tokens_estimate / foreground_wait_seconds / provider_timeout_seconds / job_timeout_seconds / scene_fallback_policy / large_input_policy`;底层 transport 只能消费这些显式策略,不得靠 function name 私自推断。Project Launch draft 的 `AISceneRuntimePolicyService` 先按输入规模提供 small/normal/large 默认策略,平台 Function Runtime Profile active 合同可覆盖前台等待、provider/job 超时、最大重试和大输入策略;draft 合同必须在 `/platform/ai-routing-scenes` 保存、smoke 成功并发布后才进入运行时。`provider_timeout_seconds` 控制上游 HTTP client timeout,`job_timeout_seconds` 控制 draft job 外层 `wait_for` 超时失败,`max_retries` 控制 provider 网络异常/可重试 HTTP 状态的 transient retry budget,`large_input_policy=preprocess_then_generate` 在 large 输入桶执行有界材料压缩后再渲染 prompt,`foreground_wait_seconds` 进入 draft job response 供前端等待窗口消费。large 输入应进入预处理/分段结构化方向,而不是无限增加单次模型请求时间。超时必须显式落 `structured_timeout` 或 `draft_job_timeout`,禁止模板兜底冒充草案生成成功。 +- `/ai-config` 用户可见定位为空间级 AI 使用设置,不是 PMFlow 平台管理后台。它只承载 readiness、团队 AI 策略、输入源、知识、MCP 模板使用和额度/连接测试;平台供应商、全局路由、route/context 治理、semantic facts、prompt/eval 与跨租户运营数据必须放在 `/platform/*` 并由 platform capability 保护。项目级页面只能通过 `AI 副驾` 展示副驾状态、来源/可信状态、上下文缺口、待确认和项目流程跳转,禁止提供项目级 provider/model/MCP 模板/底层路由覆盖入口。 +- Project Launch readiness 是平台能力门禁,不是单个用户草案请求的成败灯。`GET /ai-readiness/project-launch` 必须先以平台运行租户供应商、published active `structured` 槽、scene registry、Function Runtime Profile active 合同与结构化能力证据做硬门禁;`pmflow_launch_draft_v1` 调用日志只作为 24h 内健康信号,单次最近失败在已有近期成功且连续失败少于 3 次时只能进入平台 warning,不得关闭用户端生成入口;连续失败达到阈值或缺少近期成功时才允许进入 recovering。`POST /ai-readiness/project-launch/check` 可触发真实结构化 smoke,但不得写 draft/project/task/Project Memory。 +- `/platform/prompt-assets` 是平台 Prompt 运营中心,只允许 `platform.ai_routing.manage` 访问。它可以展示 `ai_prompt_registry`、默认 seed prompt、`prompt_releases`、`prompt_release_checks`、分层 `prompt_golden_cases`、`prompt_replay_runs / prompt_replay_case_results`、prompt-attributed `ai_call_logs` 质量摘要、调用点、风险级别和迁移状态,但不得出现在客户空间 AI 设置、Space、Project Launch 或项目级 AI 副驾。平台 API 允许 `editable_policy=managed_release` 且已配置化的 prompt 创建草稿、激活和回滚;`guarded_release` 只允许创建候选版本、运行 release replay run 和门禁,激活必须通过 variable contract / schema smoke / golden replay;golden replay 无 active golden case、无最新 release replay run 或最新 run 存在 blocking failed case 时必须保持 blocking,禁止伪造成通过。LLM replay 只能通过 `llm_service` + 显式 `routing_meta` 进入本地路由层,禁止在 Prompt 运营中心硬编码 provider/model;case result 必须保留 baseline/candidate AI call log、judge score/label/reason 与风险标记。`priority=blocking` 的 golden case 失败阻塞发布,`important/observation` 失败只能进入 warning/人工复核,不得自动激活。release watch 只能给健康/复核/回滚建议,不得自动回滚。标记为 `runtime_source=inline_discovered` 或 `editable_policy=needs_migration` 的条目只代表已完成清单梳理,运行时尚未改为 active release 读取;运营界面必须明确显示“待迁移”,禁止把它们表达成已经可配置生效。受保护 prompt 的编辑/激活必须继续走 replay/schema/golden case 门禁,不得因为能在清单里看到就绕过发布评审。 ### 3.5 Requirement Plan / Draft / Followup - `requirement_plan` 必须是多轮状态机,不得退回单轮模板问答。 @@ -208,6 +225,7 @@ - prompt registry manifest 校验 - active prompt release - 代码 seed prompt fallback +- Prompt registry 现在同时承载静态 catalog、guarded runtime prompts、semantic fact prompts 与 inline discovered inventory;registry 覆盖并不等于所有调用点已经 release 化。任何新增运行时 prompt 必须先登记 registry,再按风险决定 `managed_release / guarded_release / needs_migration`,不得继续只把大段 prompt 写进业务服务后不留运营索引。 - 回复质量迭代与训练管线的原始反馈入口统一复用 `feedback_center_records / qa_records / ai_call_logs`。 - `query_route_memories` 是独立的路由审计/离线评估样本表:记录 `qa_record_id / route_mode_predicted / route_mode_effective / probe_snapshot / query_profile / feedback_outcome / question_embedding`;v1 只允许沉淀与离线评估,禁止在线自动调阈值或自发改路由策略。 - `QAFeedback` 与 `FeedbackCenterRecord` 写入后必须同步回灌对应 `query_route_memories.feedback_outcome`,便于 route precision/recall 离线评估。 @@ -224,7 +242,7 @@ - `daily-briefing-v2` 与老板晨报当前只做同会话内 `snapshot/live` 同步,不引入 WebSocket 推送。 - 飞书长连接 websocket 在本地/CI 环境默认使用 `certifi` CA bundle 作为信任链。 - 飞书长连接状态分为两层:`last_received_*` 表示 SDK 已收到事件,`last_event_at` 表示 worker 完成处理。 -- LLM 主链按 `CONTROL_PLANE_ROUTING_ENABLED` 走 PMFlow 本地路由层 路由规则;请求超时统一以 `LLM_REQUEST_TIMEOUT` 环境变量为准。 +- LLM 主链按 `CONTROL_PLANE_ROUTING_ENABLED` 走 PMFlow 本地路由层 路由规则;普通请求超时以 `LLM_REQUEST_TIMEOUT` 环境变量为默认,保护型结构化调用以 Function Runtime Profile / 场景策略写入的 `provider_timeout_seconds` 为准。 - 路由配置唯一真源固定为 `backend/.control_plane/pmflow-routing-rules.json`。 - World Model 静态层已接入 Redis 缓存(`wm:static:{tenant_id}:{project_id}`,TTL 600s),写操作后主动 invalidate,动态层不缓存。 - 本地 `ollama` 的聊天/生成模型仍只允许知识库问答链路使用,且必须通过 `llm_engine.settings.enable_local_kb_qa` 显式开启;但 embedding 允许独立回退到本机 `OLLAMA_HOST + OLLAMA_EMBED_MODEL`(默认 `nomic-embed-text:latest`)获取真实向量。 diff --git a/frontend/index.html b/frontend/index.html index 7e084b14..0d61cdb0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,8 +3,9 @@ - - + + + PMFlow diff --git a/frontend/public/brand/pmflow-app-icon.png b/frontend/public/brand/pmflow-app-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3f77b220d694e17b5c501b9d7351efe9eebb1cde GIT binary patch literal 86895 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�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 Z931+*s_bgK=XHP_2D&CXwOWpk{sW+C3!?x4 literal 0 HcmV?d00001 diff --git a/frontend/public/brand/pmflow-logo-lockup.png b/frontend/public/brand/pmflow-logo-lockup.png new file mode 100644 index 0000000000000000000000000000000000000000..77128c6e7bd12440c18b819e6e889156cf7c32a0 GIT binary patch literal 115993 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Ǒm! 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 literal 0 HcmV?d00001 diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico index 243755756454ef000008c3ed9891e111e5c76b3d..6dfd0182fa860de92ba1400a58a6dcfb68775796 100644 GIT binary patch literal 43169 zcmagFQ;;r9tTy_NZLYCBYi!%LZQHhO+qP}nwrAGZ=iC22SEo+ZpNme?sdV>6DtXe? z4FCWEzyXMefd7a9umB7InEsDM@V|OFH~_!~0szp{|F8ZD4FK#x0Ra5`|ErV10|1+- z0D!;$|LTP#|8e5~k^hGX0KfVGK%p1_5Fsxs1_zDxpEm$bLR?t!KmI>EB0~PRJ^=sm z*8l(jPL~iCPt7ZZEiROGGW9$@CtUB z_kc*S(>O4+imsKu&9-`-8(VySrsnN*yEwsz9Smp8;0~UUu08*Y z>(iVOcmc8JyEBxu0(5xITP8to*0#mxTjY*%ViuWHTsX`iJ|Ox)@2JkD9+oAEiO7hK z3%aUb0)pZOAQ6u~azjd}6}b2I>HfY}XBcl=a>M_34Cx`QOHrTr)HHmMXO)dEz{3sO zcGJ@K`_Q#5(F&x+Py|SgKqC?f4sNuE=0jq$#3mGvy&g-Ee!1JADA9OO^5{x^JR z2LVb*56>6eRA6Ly zM(|�whFag=+=%1OI;l`hV;M|0kfGJUyQP0C2ni4d@;V^-A=J?U0@{0J<%jV6G`3fMS;sMGsG-N*XL&`?(^UN|MQdUdhWLT7Pqs#aE z^rH6z5JtttBAS{arO{~#Oir~||FZn z!}L)697w;rOQP*Ac`vYeNl!O%WD28y5Oju6HX_b; z0}IM5!FA@zU`)yCYff4CbtTxiF^Djz;Vd{mmVBnh1pzUI#uz_1J_w(shMc8caxP=2gHckB2yZ$$miWYZ_>Pmp(V8sfON;qnM2s37@3Q9td zc&UkAkXGyukj~pw>BN0W8k;ai17yt2z2RbaXXwyCai5Y#zGhq;Ly*@CI?+Qg<1#n) z#r;wZwm!8;Fvc&IUX_PymQ=n?)>gX})u|L)VwNAb#MG3}wn4nPWFS4v@lbLn86un= z_xu~J(my;$YTlh#GUvi;w^Uzwc2}=}Y>$^*qZZt#n?>yHK5KnAD!Inrv!sbTp}$

% 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 literal 70 qcmZQzU<5-3AgRD$z`(#D2E-ab%m~CxU|Io4GB9v}NetpYNCW^Wy8*rc diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..634423111cc310c9c0ec69d098d574105a33335c GIT binary patch literal 16231 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 literal 0 HcmV?d00001 diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 3f67d55a..f8e9b223 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1,7 +1,3 @@ - - + diff --git a/frontend/src/api/aiCallTraces.ts b/frontend/src/api/aiCallTraces.ts new file mode 100644 index 00000000..af41ade3 --- /dev/null +++ b/frontend/src/api/aiCallTraces.ts @@ -0,0 +1,192 @@ +import { api } from "@/lib/api"; + +type ApiEnvelope = { code: number; message: string; data: T }; + +function unwrap(raw: ApiEnvelope | T): T { + if (raw && typeof raw === "object" && "data" in (raw as Record)) { + return (raw as ApiEnvelope).data; + } + return raw as T; +} + +export interface AICallTraceSummary { + total_calls: number; + success_calls: number; + failed_calls: number; + success_rate: number; + tokens_used: number; + estimated_cost: number; + average_latency_ms?: number | null; + status_counts: Record; + top_providers: Array<{ provider: string; provider_code?: string | null; provider_label?: string | null; count: number }>; +} + +export interface AICallTraceListItem { + id: number; + trace_id?: string | null; + tenant_id: number; + user_id?: number | null; + created_at?: string | null; + function_name: string; + function_label?: string | null; + scene_key?: string | null; + scene_label: string; + status: string; + status_label: string; + provider?: string | null; + provider_code?: string | null; + provider_label?: string | null; + runtime_label?: string | null; + engine_id?: string | null; + engine_name?: string | null; + model?: string | null; + slot_key?: string | null; + slot_label: string; + prompt_key?: string | null; + prompt_release_id?: number | null; + prompt_source?: string | null; + routing_decision?: string | null; + route_source?: string | null; + route_rule_name?: string | null; + tokens_used: number; + prompt_tokens?: number | null; + completion_tokens?: number | null; + total_tokens?: number | null; + request_max_tokens?: number | null; + finish_reason?: string | null; + function_runtime_max_tokens?: number | null; + function_runtime_profile_version?: string | null; + estimated_cost: number; + latency_ms?: number | null; + prompt_preview: string; + response_preview: string; + error_summary?: string | null; + error_diagnosis?: { + code?: string; + label?: string; + summary?: string; + attempt_count?: number; + attempt_errors?: Array<{ status?: string; error_type?: string; error?: string }>; + recommendation?: { + code?: string; + label?: string; + summary?: string; + recommended_actions?: string[]; + current_provider_timeout_seconds?: number | null; + input_bucket?: string; + scene_key?: string; + }; + } | null; +} + +export interface AICallTraceDetail extends AICallTraceListItem { + request: { + prompt_redacted: string; + prompt_chars: number; + provider_request_payload_redacted?: string; + provider_request_payload_chars?: number; + provider_payload_missing_reason?: string | null; + upstream_request_sent?: boolean | null; + }; + response: { + response_redacted: string; + response_chars: number; + looks_truncated: boolean; + provider_response_payload_redacted?: string; + provider_response_payload_chars?: number; + provider_attempts_redacted?: string; + provider_attempts_count?: number; + }; + routing: { + planned_tier?: string | null; + executed_tier?: string | null; + routing_decision?: string | null; + route_source?: string | null; + route_rule_name?: string | null; + routing_config_version?: string | null; + routing_config_updated_at?: string | null; + routing_meta: Record; + }; + probe?: { + probe_run_id?: string | null; + mode?: string | null; + target?: string | null; + target_label?: string | null; + targets?: string[]; + method?: string | null; + status?: string | null; + evidence_strength?: string | null; + failure_code?: string | null; + summary?: Record; + direct_operation?: boolean; + } | null; + audit: { + raw_payload_available: boolean; + raw_payload_policy: string; + }; + runtime_policy?: { + policy_id?: string | null; + policy_version?: string | null; + input_bucket?: string | null; + input_tokens_estimate?: number | null; + foreground_wait_seconds?: number | null; + provider_timeout_seconds?: number | null; + job_timeout_seconds?: number | null; + max_retries?: number | null; + scene_fallback_policy?: string | null; + large_input_policy?: string | null; + timeout_policy_source?: string | null; + timeout_policy_reason?: string | null; + latency_ms?: number | null; + }; +} + +export interface AICallTraceListResponse { + summary: AICallTraceSummary; + items: AICallTraceListItem[]; + page: number; + page_size: number; + total: number; + has_next: boolean; +} + +export interface AICallTraceQuery { + page?: number; + page_size?: number; + status?: string; + provider?: string; + model?: string; + function_name?: string; + scene_key?: string; + slot_key?: string; + prompt_key?: string; + prompt_source?: string; + prompt_release_id?: number; + trace_id?: string; + tenant_id?: string; + keyword?: string; + since_hours?: number; +} + +function toParams(query: AICallTraceQuery): Record { + const params: Record = {}; + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null || value === "") continue; + params[key] = value; + } + return params; +} + +export const aiCallTracesApi = { + async list(query: AICallTraceQuery): Promise { + const { data } = await api.get>("/ai-call-traces", { + params: toParams(query), + }); + return unwrap(data); + }, + + async get(id: string | number): Promise { + const { data } = await api.get>(`/ai-call-traces/${encodeURIComponent(String(id))}`); + return unwrap(data); + }, +}; diff --git a/frontend/src/api/aiReadiness.ts b/frontend/src/api/aiReadiness.ts new file mode 100644 index 00000000..6830d0d5 --- /dev/null +++ b/frontend/src/api/aiReadiness.ts @@ -0,0 +1,75 @@ +import { api } from "@/lib/api"; + +type ApiEnvelope = { code: number; message: string; data: T }; + +function unwrap(raw: ApiEnvelope | T): T { + if (raw && typeof raw === "object" && "data" in (raw as Record)) { + return (raw as ApiEnvelope).data; + } + return raw as T; +} + +export interface AIReadinessApiAction { + label: string; + to: string; +} + +export interface AIReadinessApiBlocker { + code?: string; + key?: string; + label: string; + description?: string; + detail?: string; + status: "missing" | "failed" | "passed" | "warning" | string; + action_label?: string; + action_to?: string; +} + +export interface AIReadinessApiDiagnostic { + cause_code: string; + cause_owner: string; + raw_status: string; + severity: "ok" | "warning" | "critical"; + affected_scenes: string[]; + customer_title: string; + customer_summary: string; + operator_summary: string; + platform_actions: AIReadinessApiAction[]; + checks?: Record; + blockers?: AIReadinessApiBlocker[]; +} + +export interface AIReadinessApiResponse { + scene: string; + status: string; + customer_state?: string; + short_label: string; + title: string; + summary: string; + updated_at_label: string; + can_generate_project_draft: boolean; + blockers: AIReadinessApiBlocker[]; + customer_actions: AIReadinessApiAction[]; + workspace_owner_actions: AIReadinessApiAction[]; + platform_diagnostic?: AIReadinessApiDiagnostic; + platform?: { + severity?: "ok" | "warning" | "critical"; + checks?: Record; + blockers?: AIReadinessApiBlocker[]; + latest_call?: Record | null; + last_checked_at?: string | null; + last_smoke_error?: string | null; + }; +} + +export const aiReadinessApi = { + async getProjectLaunchReadiness(): Promise { + const { data } = await api.get>("/ai-readiness/project-launch"); + return unwrap(data); + }, + + async runProjectLaunchCheck(): Promise { + const { data } = await api.post>("/ai-readiness/project-launch/check", {}); + return unwrap(data); + }, +}; diff --git a/frontend/src/api/aiRouting.ts b/frontend/src/api/aiRouting.ts index 16465425..dd4dc16a 100644 --- a/frontend/src/api/aiRouting.ts +++ b/frontend/src/api/aiRouting.ts @@ -13,8 +13,12 @@ export type AIRoutingStrategy = "quality_first" | "balanced" | "speed_first"; export type AIRoutingSeverity = "ok" | "warning" | "critical"; export interface AIRoutingSlot { - tier: "low" | "high"; + slot_key?: string; + tier: string; label: string; + summary?: string; + configurable?: boolean; + protected?: boolean; provider: string; provider_label: string; model: string; @@ -22,6 +26,168 @@ export interface AIRoutingSlot { configured: boolean; config_source: string; api_key_masked?: string; + primary_engine_id?: string | null; + primary_engine?: AIRoutingSlotEngine | null; + fallback_engine_ids?: string[]; + fallback_engines?: AIRoutingSlotEngine[]; + primary_provider_id?: string | null; + primary_provider?: AIRoutingExternalProvider | null; + fallback_provider_ids?: string[]; + fallback_providers?: AIRoutingExternalProvider[]; + verifier_required?: boolean; + source_filtering_required?: boolean; + external_provider_registry?: AIRoutingExternalProviderRegistry; + candidate_external_providers?: AIRoutingExternalProvider[]; + blocked_external_providers?: AIRoutingExternalProvider[]; + requirements?: AIRoutingSlotRequirement; + capability_coverage?: AIRuntimeCapabilityCoverage; + candidate_engine_count?: number; + candidate_engines?: AIRoutingSlotEngine[]; + blocked_candidate_engine_count?: number; + blocked_candidate_engines?: AIRoutingBlockedSlotEngine[]; + failure_policy?: AIRoutingSlotFailurePolicy; + failure_policy_label?: string; + allow_scene_downgrade?: boolean; + updated_at?: string | null; + updated_by?: number | null; +} + +export type AIRoutingSlotFailurePolicy = "upgrade_to_high" | "try_fallback_then_fail" | "explicit_fail"; + +export interface AIRoutingSlotEngine { + id: string; + name: string; + provider: string; + provider_label: string; + model: string; + base_url: string; + enabled: boolean; + connection_status: string; + api_key_masked: string; + capabilities?: AIRuntimeCapabilities; +} + +export interface AIRoutingBlockedSlotEngine extends AIRoutingSlotEngine { + gap_reasons?: string[]; +} + +export interface AIRoutingExternalProvider { + id: string; + label: string; + kind: string; + summary?: string; + available: boolean; + status: string; +} + +export interface AIRoutingExternalProviderRegistry { + providers: AIRoutingExternalProvider[]; + provider_ids: string[]; + available_provider_count: number; + selected_available_provider_count?: number; + primary_provider_id?: string | null; + fallback_provider_ids?: string[]; + mcp_adapter_available: boolean; + live_search_fallback_available: boolean; + verifier: { + required: boolean; + available: boolean; + status: string; + }; + source_filtering: { + required: boolean; + fail_closed: boolean; + status: string; + }; +} + +export interface AIRuntimeCapabilityProbeEvidence { + ok?: boolean; + status?: "verified" | "failed" | "skipped" | "not_probeable" | "declared_only" | string; + method?: string | null; + evidence_strength?: string | null; + checked_at?: string | null; + summary?: string; + error_summary?: string; + failure_code?: string | null; + request_summary?: string; + response_summary?: string; + latency_ms?: number | null; + tested_tokens?: number | null; + model_self_report?: Record | null; + source?: string | null; + source_type?: string | null; + source_url?: string | null; + source_note?: string | null; + verified_by?: number | string | null; + verified_at?: string | null; + expires_at?: string | null; +} + +export interface AIRuntimeCapabilityProbe { + status?: "untested" | "running" | "passed" | "partial" | "failed" | string; + last_verified_at?: string | null; + verified_capabilities?: Partial>; + evidence?: Record; + source?: "inferred" | "manual" | "probed" | string; +} + +export interface AIRuntimeCapabilities { + structured_output: boolean; + json_schema: boolean; + tool_calling: boolean; + long_context: boolean; + max_context_tokens?: number | null; + vision: boolean; + audio: boolean; + local_only: boolean; + private_deployment: boolean; + external_search_ready: boolean; + avg_latency_ms?: number | null; + cost_tier: "low" | "medium" | "high" | "unknown" | string; + reliability_tier: "standard" | "high" | "local" | "unknown" | string; + capability_source?: "default" | "manual" | "probe" | "mixed" | string; + capability_probe_status?: "untested" | "passed" | "partial" | "failed" | string; + capability_last_verified_at?: string | null; + capability_blocking_reasons?: string[]; + declared_capabilities?: Partial>; + effective_capabilities?: Partial>; + capability_probe?: AIRuntimeCapabilityProbe; + capability_evidence?: Record< + string, + { + status?: "verified" | "failed" | "skipped" | string; + source?: string; + verified_at?: string | null; + message?: string; + latency_ms?: number | null; + method?: string | null; + evidence_strength?: string | null; + failure_code?: string | null; + request_summary?: string; + response_summary?: string; + } + >; +} + +export interface AIRoutingSlotRequirement { + summary: string; + required_flags: string[]; + forbidden_flags: string[]; + any_flags: string[]; + min_context_tokens?: number | null; + labels: string[]; +} + +export interface AIRuntimeCapabilityCoverage { + satisfied: boolean; + missing: string[]; + missing_labels: string[]; + missing_evidence?: string[]; + missing_evidence_labels?: string[]; + stale_evidence?: string[]; + stale_evidence_labels?: string[]; + capabilities?: AIRuntimeCapabilities; } export interface AIRoutingMetricWindow { @@ -47,6 +213,7 @@ export interface AIRoutingOverview { config_updated_at: string; source_of_truth: string; slots: AIRoutingSlot[]; + capability_slots?: AIRoutingSlot[]; metrics: { last_24h: AIRoutingMetricWindow; last_7d: AIRoutingMetricWindow; @@ -88,17 +255,37 @@ export interface AIRoutingProvider { base_url: string; api_key_masked: string; active: boolean; + enabled: boolean; + is_default_fallback: boolean; + connection_status: "available" | "test_failed" | "untested" | string; + last_test_status?: string | null; + last_test_at?: string | null; + last_error_summary?: string | null; status_label: string; updated_at?: string | null; + capabilities?: AIRuntimeCapabilities; + available_actions?: AIRoutingProviderActions; +} + +export interface AIRoutingProviderActions { + enable?: boolean; + disable?: boolean; + edit?: boolean; + test?: boolean; + capability_probe?: boolean; + set_default_fallback?: boolean; + delete?: boolean; } export interface AIRoutingSceneFunction { function_name: string; display_name: string; - tier: "low" | "high"; + tier: "low" | "high" | "structured" | "long_context" | "tool" | "external_query" | string; slot_label: string; source: "default" | "override"; rule_name?: string | null; + protected?: boolean; + runtime_profile?: AIFunctionRuntimeProfileSummary; } export interface AIRoutingSceneRoute { @@ -106,28 +293,321 @@ export interface AIRoutingSceneRoute { label: string; summary: string; primary_slot_label: string; + scene_contract?: AIRoutingSceneBusinessContract; + lifecycle?: string; + scene_family?: string; functions: AIRoutingSceneFunction[]; } +export interface AIRoutingSceneBusinessContract { + scene_key: string; + label: string; + business_goal: string; + input_contract: string; + output_contract: string; + success_criteria: string[]; + failure_policy: string; + confirmation_policy: string; + evidence_policy: string; + protected_reason?: string | null; + expected_slot_key?: string; + expected_slot_label: string; + scene_family?: string; + scene_owner?: string; + lifecycle: "active" | "candidate" | "deprecated" | string; +} + +export interface AIRoutingSceneSlotMatrixSlot { + slot_key: string; + slot_label: string; + required: boolean; + configured: boolean; + runtime_version: string; + runtime_source: string; + primary_engine_id?: string | null; + primary_engine_label: string; + risk_level: AIRoutingSeverity; + risk_label: string; + risk_reasons: string[]; + capability_coverage?: AIRuntimeCapabilityCoverage; +} + +export interface AIRoutingSceneSlotMatrixRow { + scene_key: string; + scene_label?: string; + label: string; + summary: string; + scene_contract?: AIRoutingSceneBusinessContract; + lifecycle?: "active" | "candidate" | "deprecated" | string; + scene_family?: string; + protected: boolean; + required_slot?: string; + current_slot?: string; + current_slot_label?: string; + required_capabilities?: string[]; + active_slot_runtime?: AIRoutingSceneRuntimeSummary; + active_runtime?: AIRoutingSceneRuntimeSummary; + draft_slot_runtime?: AIRoutingSceneRuntimeSummary; + draft_runtime?: AIRoutingSceneRuntimeSummary; + active_version?: string; + draft_dirty?: boolean; + capability_gap?: AIRoutingSceneCapabilityGap; + runtime_version: string; + recent_failure_rate?: number; + recent_failure_count?: number; + recent_failed_calls?: number; + recent_total_calls?: number; + scene_health?: AIRoutingSceneHealth; + release_gate?: AIRoutingSceneReleaseGate; + impact_summary?: string; + primary_slot_label: string; + risk_level: AIRoutingSeverity; + risk_label: string; + slots: AIRoutingSceneSlotMatrixSlot[]; + functions: AIRoutingSceneFunction[]; + function_runtime_profiles?: AIFunctionRuntimeProfileSummary[]; +} + +export interface AIFunctionRuntimeProfileSummary { + function_name: string; + label?: string; + scene_key?: string; + prompt_key?: string; + required_slot?: string; + response_contract?: string; + active_version?: string; + draft_version?: string; + dirty?: boolean; + active: AIFunctionRuntimeProfileRuntime; + draft: AIFunctionRuntimeProfileRuntime; + limits?: { + max_tokens_min?: number; + max_tokens_max?: number; + recommended_min_tokens?: number; + }; + retry_policy?: Record; + health_policy?: Record; + cost_policy?: Record; + draft_smoke?: { + status?: string; + summary?: string; + checked_at?: string | null; + checked_by?: number | null; + }; + observability?: { + window_hours?: number; + total_calls?: number; + failed_calls?: number; + failure_rate?: number; + length_finish_count?: number; + structured_json_invalid_count?: number; + completion_tokens_p50?: number; + completion_tokens_p95?: number; + latest_status?: string; + latest_finish_reason?: string; + latest_call_id?: number; + latest_created_at?: string | null; + }; +} + +export interface AIFunctionRuntimeProfileRuntime { + max_tokens: number; + repair_max_tokens: number; + temperature: number; + foreground_wait_seconds: number; + provider_timeout_seconds: number; + job_timeout_seconds: number; + max_retries: number; + large_input_policy: string; + response_mode: string; + response_format: string; +} + +export interface AIFunctionRuntimeProfilesResponse { + version: string; + active_version: string; + draft_version: string; + published_at?: string | null; + published_by?: number | null; + updated_at?: string | null; + updated_by?: number | null; + dirty: boolean; + validation?: AIRoutingCapabilitySlotValidation; + history?: Array>; + draft_last_smoke?: Record; + active_profiles: Record>; + draft_profiles: Record>; + profiles: Record>; +} + +export interface AIFunctionRuntimeProfileUpdate { + max_tokens?: number; + repair_max_tokens?: number; + temperature?: number; + foreground_wait_seconds?: number; + provider_timeout_seconds?: number; + job_timeout_seconds?: number; + max_retries?: number; + large_input_policy?: "direct_generate" | "preprocess_then_generate"; + response_mode?: string; +} + +export interface AIRoutingSceneHealth { + status: "healthy" | "warning" | "blocked" | "unknown" | string; + score: number; + blockers: string[]; + warnings: string[]; + evidence_summary: string; + last_observed_at?: string | null; +} + +export interface AIRoutingSceneReleaseGate { + publish_blocked: boolean; + blocking_reason?: string | null; + affected_by_draft: boolean; + protected_scene_regression: boolean; + suggested_action: "configure_slot" | "probe_capability" | "add_provider" | "inspect_failures" | "rollback" | "none" | string; +} + +export interface AIRoutingSceneRuntimeSummary { + slot_key: string; + slot_label: string; + version: string; + configured: boolean; + runtime_label: string; + runtime_source: string; + primary_engine_id?: string | null; + primary_provider_id?: string | null; +} + +export interface AIRoutingSceneCapabilityGap { + missing?: string[]; + missing_evidence?: string[]; + stale_evidence?: string[]; + reasons?: string[]; + has_gap?: boolean; + summary?: string; +} + export interface AIRoutingRecentFailure { function_label: string; function_name: string; + scene_key?: string | null; + scene_label?: string | null; status_label: string; planned_slot: string; executed_slot: string; created_at?: string | null; } +export interface AIRoutingSlotObservabilitySample { + function_label: string; + function_name: string; + status_label: string; + slot_runtime_source: "active_slot" | "active_slot_degraded" | string; + slot_fallback_index: number; + created_at?: string | null; +} + +export interface AIRoutingSlotObservabilityWindow { + window_hours: number; + status: "empty" | "sampled" | string; + sample_state_label: string; + total_calls: number; + failed_calls: number; + failure_rate: number; + fallback_hits: number; + fallback_rate: number; + degraded_calls: number; + degraded_rate: number; + active_slot_calls: number; + active_slot_degraded_calls: number; + average_latency_ms?: number | null; + estimated_cost: number; + estimated_cost_source: string; + latency_source: string; + recent_failure_samples: AIRoutingSlotObservabilitySample[]; +} + +export interface AIRoutingSlotObservability { + slot_key: string; + slot_label: string; + active_version: string; + active_runtime: AIRoutingSceneRuntimeSummary; + windows: { + last_24h: AIRoutingSlotObservabilityWindow; + last_7d: AIRoutingSlotObservabilityWindow; + }; +} + +export interface AIRoutingSlotRecommendation { + severity: "critical" | "warning" | "info" | string; + slot_key: string; + slot_label: string; + code: string; + reason: string; + evidence_summary: string; + suggested_action: string; + target_engine_id?: string | null; +} + export interface AIRoutingAdminConsole { overview: AIRoutingOverview; providers: AIRoutingProvider[]; + capability_slots?: AIRoutingSlot[]; + capability_slot_release?: AIRoutingCapabilitySlotsResponse; + function_runtime_profiles?: AIFunctionRuntimeProfilesResponse; scene_routes: AIRoutingSceneRoute[]; + scene_slot_matrix?: AIRoutingSceneSlotMatrixRow[]; + slot_observability?: AIRoutingSlotObservability[]; + slot_recommendations?: AIRoutingSlotRecommendation[]; protected_functions: string[]; recent_failures: AIRoutingRecentFailure[]; diagnostics: AIRoutingDiagnostic[]; audit_hint: string; } +export interface AIRoutingCapabilitySlotsResponse { + version: string; + active_version: string; + draft_version: string; + published_at?: string | null; + published_by?: number | null; + updated_at?: string | null; + updated_by?: number | null; + dirty: boolean; + validation: AIRoutingCapabilitySlotValidation; + history: AIRoutingCapabilitySlotRelease[]; + slots: AIRoutingSlot[]; + active_slots: AIRoutingSlot[]; + draft_slots: AIRoutingSlot[]; +} + +export interface AIRoutingCapabilitySlotRelease { + version: string; + published_at?: string | null; + published_by?: number | null; + summary?: string | null; +} + +export interface AIRoutingCapabilitySlotValidation { + stage: string; + can_publish: boolean; + summary: { + critical: number; + warning: number; + ok: number; + }; + items: AIRoutingDiagnostic[]; +} + +export interface AIRoutingCapabilitySlotUpdate { + primary_engine_id?: string | null; + fallback_engine_ids: string[]; + failure_policy: AIRoutingSlotFailurePolicy; + allow_scene_downgrade: boolean; +} + export interface AIRoutingDiagnosticsRun { checked_at: string; items: AIRoutingDiagnostic[]; @@ -163,4 +643,64 @@ export const aiRoutingApi = { const { data } = await api.post>("/ai-routing/diagnostics/run", {}); return unwrap(data); }, + + async getCapabilitySlots(): Promise { + const { data } = await api.get>("/ai-routing/capability-slots"); + return unwrap(data); + }, + + async updateCapabilitySlot(slotKey: string, payload: AIRoutingCapabilitySlotUpdate): Promise { + const { data } = await api.put>( + `/ai-routing/capability-slots/${slotKey}/draft`, + payload, + ); + return unwrap(data); + }, + + async validateCapabilitySlots(): Promise { + const { data } = await api.post>("/ai-routing/capability-slots/validate", {}); + return unwrap(data); + }, + + async publishCapabilitySlots(summary?: string): Promise { + const { data } = await api.post>("/ai-routing/capability-slots/publish", { + summary, + }); + return unwrap(data); + }, + + async rollbackCapabilitySlots(target_version?: string): Promise { + const { data } = await api.post>("/ai-routing/capability-slots/rollback", { + target_version, + }); + return unwrap(data); + }, + + async getFunctionRuntimeProfiles(): Promise { + const { data } = await api.get>("/ai-routing/function-runtime-profiles"); + return unwrap(data); + }, + + async updateFunctionRuntimeProfile(functionName: string, payload: AIFunctionRuntimeProfileUpdate): Promise { + const { data } = await api.put>( + `/ai-routing/function-runtime-profiles/${encodeURIComponent(functionName)}/draft`, + payload, + ); + return unwrap(data); + }, + + async smokeFunctionRuntimeProfile(functionName: string): Promise { + const { data } = await api.post>( + `/ai-routing/function-runtime-profiles/${encodeURIComponent(functionName)}/smoke`, + {}, + ); + return unwrap(data); + }, + + async publishFunctionRuntimeProfiles(summary?: string): Promise { + const { data } = await api.post>("/ai-routing/function-runtime-profiles/publish", { + summary, + }); + return unwrap(data); + }, }; diff --git a/frontend/src/api/aiSteward.ts b/frontend/src/api/aiSteward.ts index c3ed4c7e..950d2002 100644 --- a/frontend/src/api/aiSteward.ts +++ b/frontend/src/api/aiSteward.ts @@ -161,7 +161,7 @@ export interface DailyBriefingV2Payload { export_title: string; } -export type LLMProvider = "deepseek" | "glm" | "openai" | "ollama"; +export type LLMProvider = "deepseek" | "dashscope" | "openai" | "ollama"; export interface LLMProviderPreset { model: string; @@ -181,6 +181,75 @@ export interface LLMEngineSettings { local_kb_qa_scope: "kb_qa_only"; } +export interface LLMEngineCapabilityProbeEvidence { + ok?: boolean; + status?: "verified" | "failed" | "skipped" | "not_probeable" | "declared_only" | string; + method?: string | null; + evidence_strength?: "live_probe" | "smoke_probe" | "provider_contract" | "declared_only" | "manual" | "inferred" | string | null; + checked_at?: string | null; + summary?: string; + error_summary?: string; + failure_code?: string | null; + request_summary?: string; + response_summary?: string; + latency_ms?: number | null; + tested_tokens?: number | null; + model_self_report?: Record | null; + source?: string | null; + source_type?: string | null; + source_url?: string | null; + source_note?: string | null; + verified_by?: number | string | null; + verified_at?: string | null; + expires_at?: string | null; +} + +export interface LLMEngineCapabilityProbe { + status?: "untested" | "running" | "passed" | "partial" | "failed" | string; + last_verified_at?: string | null; + verified_capabilities?: Partial>; + evidence?: Record; + source?: "inferred" | "manual" | "probed" | string; +} + +export interface LLMEngineCapabilities { + structured_output: boolean; + json_schema: boolean; + tool_calling: boolean; + long_context: boolean; + max_context_tokens?: number | null; + vision: boolean; + audio: boolean; + local_only: boolean; + private_deployment: boolean; + external_search_ready: boolean; + avg_latency_ms?: number | null; + cost_tier: "low" | "medium" | "high" | "unknown" | string; + reliability_tier: "standard" | "high" | "local" | "unknown" | string; + capability_source?: "default" | "manual" | "probe" | "mixed" | string; + capability_probe_status?: "untested" | "passed" | "partial" | "failed" | string; + capability_last_verified_at?: string | null; + capability_blocking_reasons?: string[]; + declared_capabilities?: Partial>; + effective_capabilities?: Partial>; + capability_probe?: LLMEngineCapabilityProbe; + capability_evidence?: Record< + string, + { + status?: "verified" | "failed" | "skipped" | string; + source?: string; + verified_at?: string | null; + message?: string; + latency_ms?: number | null; + method?: string | null; + evidence_strength?: string | null; + failure_code?: string | null; + request_summary?: string; + response_summary?: string; + } + >; +} + export interface LLMEngine { id: string; name: string; @@ -188,12 +257,31 @@ export interface LLMEngine { model: string; base_url: string; api_key_masked: string; + enabled: boolean; + is_default_fallback?: boolean; + connection_status?: string; + last_test_status?: string | null; + last_test_at?: string | null; + last_error_summary?: string | null; updated_at?: string; created_at?: string; + capabilities?: LLMEngineCapabilities; + available_actions?: LLMEngineAvailableActions; +} + +export interface LLMEngineAvailableActions { + enable?: boolean; + disable?: boolean; + edit?: boolean; + test?: boolean; + capability_probe?: boolean; + set_default_fallback?: boolean; + delete?: boolean; } export interface LLMEngineListResponse { active_engine_id: string | null; + default_fallback_engine_id?: string | null; engines: LLMEngine[]; settings: LLMEngineSettings; } @@ -205,6 +293,7 @@ export interface LLMEngineUpsertRequest { model?: string; base_url?: string; set_active?: boolean; + capabilities?: Partial; } export interface LLMEngineConnectionTestResponse { @@ -219,6 +308,19 @@ export interface LLMEngineConnectionTestResponse { response_preview: string; } +export interface LLMEngineCapabilityProbeResponse { + ok: boolean; + engine_id: string; + provider: LLMProvider | string; + model: string; + status: "passed" | "partial" | "failed" | "untested" | string; + probe_run_id?: string; + mode?: "quick" | "full" | "advanced" | string; + targets?: string[]; + results: Record | null }>; + capabilities: LLMEngineCapabilities; +} + export type QAFeedbackRating = "positive" | "negative"; export type QAHelpfulness = "fully" | "partially" | "could_be_better"; export type QAThinkingMode = "normal" | "deep"; @@ -469,6 +571,13 @@ export const aiStewardApi = { }); return unwrap(raw); }, + async askSpaceConcierge(body: QAAskRequest): Promise { + const raw = await apiRequest>("/space/ai-concierge/ask", { + method: "POST", + body: JSON.stringify(body), + }); + return unwrap(raw); + }, async listQASessions(params?: { page?: number; pageSize?: number }): Promise { const query = new URLSearchParams(); query.set("page", String(params?.page ?? 1)); @@ -476,10 +585,21 @@ export const aiStewardApi = { const raw = await apiRequest>(`/rag/qa-sessions?${query.toString()}`); return unwrap(raw); }, + async listSpaceConciergeSessions(params?: { page?: number; pageSize?: number }): Promise { + const query = new URLSearchParams(); + query.set("page", String(params?.page ?? 1)); + query.set("page_size", String(params?.pageSize ?? 20)); + const raw = await apiRequest>(`/space/ai-concierge/sessions?${query.toString()}`); + return unwrap(raw); + }, async getQASession(sessionId: number): Promise { const raw = await apiRequest>(`/rag/qa-sessions/${sessionId}`); return unwrap(raw); }, + async getSpaceConciergeSession(sessionId: number): Promise { + const raw = await apiRequest>(`/space/ai-concierge/sessions/${sessionId}`); + return unwrap(raw); + }, async getQASessionState(sessionId: number): Promise { const raw = await apiRequest>(`/rag/qa-sessions/${sessionId}/state`); return unwrap(raw); @@ -594,6 +714,29 @@ export const aiStewardApi = { ); return unwrap(raw); }, + async setLlmEngineDefaultFallback( + engineId: string, + ): Promise { + const raw = await apiRequest< + ApiEnvelope + >(`/ai-steward/llm-engines/${engineId}/default-fallback`, { + method: "POST", + }); + return unwrap(raw); + }, + async updateLlmEngineEnabled( + engineId: string, + enabled: boolean, + ): Promise { + const raw = await apiRequest>( + `/ai-steward/llm-engines/${engineId}/enabled`, + { + method: "PUT", + body: JSON.stringify({ enabled }), + } + ); + return unwrap(raw); + }, async deleteLlmEngine( engineId: string, ): Promise { @@ -633,6 +776,39 @@ export const aiStewardApi = { ); return unwrap(raw); }, + async probeLlmEngineCapabilities( + engineId: string, + body: { mode?: "quick" | "full" | "advanced"; targets?: string[]; allow_high_cost?: boolean } = { mode: "full" }, + ): Promise { + const raw = await apiRequest>( + `/ai-steward/llm-engines/${engineId}/capability-probe`, + { + method: "POST", + body: JSON.stringify(body), + } + ); + return unwrap(raw); + }, + async attestLlmEngineCapability( + engineId: string, + body: { + capability_key: "long_context"; + tested_tokens: number; + source_type: "official_doc" | "provider_console" | "contract" | "manual_verification"; + source_url?: string | null; + source_note?: string | null; + expires_at?: string | null; + }, + ): Promise { + const raw = await apiRequest>( + `/ai-steward/llm-engines/${engineId}/capability-attest`, + { + method: "POST", + body: JSON.stringify(body), + } + ); + return unwrap(raw); + }, async repairDirtySignalSources(body: SignalSourceRepairRequest): Promise { const raw = await apiRequest>("/ai-steward/signal-sources/repair-dirty", { method: "POST", diff --git a/frontend/src/api/base.ts b/frontend/src/api/base.ts index b5578db7..ca9a17f3 100644 --- a/frontend/src/api/base.ts +++ b/frontend/src/api/base.ts @@ -21,6 +21,22 @@ export interface ApiRequestOptions { headers?: Record } +function getActiveTenantId(): string | null { + try { + const raw = localStorage.getItem("pmflow_user"); + if (!raw) return null; + const user = JSON.parse(raw) as Record; + const direct = user.tenant_id ?? user.tenantId; + if (direct != null && String(direct).trim()) return String(direct).trim(); + const context = user.workspace_context as Record | undefined; + const active = context?.active_workspace as Record | undefined; + const id = active?.id; + return id != null && String(id).trim() ? String(id).trim() : null; + } catch { + return null; + } +} + function extractErrorMessage(payload: unknown, fallback: string): string { if (payload && typeof payload === "object") { const record = payload as Record @@ -43,15 +59,24 @@ function extractErrorMessage(payload: unknown, fallback: string): string { return fallback } +function createApiRequestError(message: string, status: number, payload: unknown): Error { + const error = new Error(message) as Error & { status?: number; payload?: unknown } + error.status = status + error.payload = payload + return error +} + export async function apiRequest(endpoint: string, options: ApiRequestOptions = { method: 'GET' }): Promise { const url = `${API_BASE}${endpoint}` const token = getAuthToken() + const tenantId = getActiveTenantId() const config: RequestInit = { method: options.method, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(tenantId ? { 'X-Tenant-ID': tenantId } : {}), ...options.headers, }, } @@ -65,13 +90,13 @@ export async function apiRequest(endpoint: string, options: ApiRequestOptions const payload = await response.json().catch(() => null) if (!response.ok) { - throw new Error(extractErrorMessage(payload, `HTTP ${response.status}`)) + throw createApiRequestError(extractErrorMessage(payload, `HTTP ${response.status}`), response.status, payload) } if (payload && typeof payload === "object" && "code" in payload) { const code = typeof (payload as Record).code === "number" ? (payload as Record).code : 0 if (code !== 0) { - throw new Error(extractErrorMessage(payload, `HTTP ${response.status}`)) + throw createApiRequestError(extractErrorMessage(payload, `HTTP ${response.status}`), response.status, payload) } } diff --git a/frontend/src/api/pmflowLaunch.ts b/frontend/src/api/pmflowLaunch.ts index 92ca578a..1aa8101e 100644 --- a/frontend/src/api/pmflowLaunch.ts +++ b/frontend/src/api/pmflowLaunch.ts @@ -47,6 +47,36 @@ export interface PMFlowLaunchDraftRequest { source?: string; } +export interface PMFlowLaunchDraftJobRequest extends PMFlowLaunchDraftRequest { + parent_draft_id?: string | null; + revision_note?: string | null; + mode?: "create" | "merge_supplement" | "regenerate"; +} + +export interface PMFlowLaunchDraftJob { + job_id: string; + revision_id: string; + status: "queued" | "running" | "user_paused" | "superseded" | "completed" | "failed" | "cancelled"; + mode: string; + parent_draft_id?: string | null; + superseded_by_job_id?: string | null; + draft_id?: string | null; + draft?: PMFlowLaunchDraft | null; + changed_fields: string[]; + error_code?: string | null; + error_message?: string | null; + started_at?: string | null; + completed_at?: string | null; + expires_at: string; + runtime_policy?: { + foreground_wait_seconds?: number; + provider_timeout_seconds?: number; + job_timeout_seconds?: number; + max_retries?: number; + large_input_policy?: string; + } | null; +} + export interface PMFlowLaunchConfirmResponse { project_id: number; redirect_url: string; @@ -58,7 +88,35 @@ export interface PMFlowLaunchConfirmResponse { } export async function createPMFlowLaunchDraft(payload: PMFlowLaunchDraftRequest): Promise { - const { data } = await api.post>("/pmflow-launch/drafts", payload); + const { data } = await api.post>("/pmflow-launch/drafts", payload, { timeout: 90000 }); + return unwrap(data); +} + +export async function createPMFlowLaunchDraftJob(payload: PMFlowLaunchDraftJobRequest): Promise { + const { data } = await api.post>("/pmflow-launch/draft-jobs", payload, { timeout: 15000 }); + return unwrap(data); +} + +export async function revisePMFlowLaunchDraft(draftId: string, payload: PMFlowLaunchDraftJobRequest): Promise { + const { data } = await api.post>( + `/pmflow-launch/drafts/${encodeURIComponent(draftId)}/revise`, + payload, + { timeout: 15000 } + ); + return unwrap(data); +} + +export async function getPMFlowLaunchDraftJob(jobId: string): Promise { + const { data } = await api.get>(`/pmflow-launch/draft-jobs/${encodeURIComponent(jobId)}`, { timeout: 15000 }); + return unwrap(data); +} + +export async function cancelPMFlowLaunchDraftJob(jobId: string): Promise { + const { data } = await api.post>( + `/pmflow-launch/draft-jobs/${encodeURIComponent(jobId)}/cancel`, + {}, + { timeout: 15000 } + ); return unwrap(data); } @@ -68,7 +126,8 @@ export async function confirmPMFlowLaunchDraft( ): Promise { const { data } = await api.post>( `/pmflow-launch/drafts/${draftId}/confirm`, - payload + payload, + { timeout: 60000 } ); return unwrap(data); } diff --git a/frontend/src/api/pmflowOs.ts b/frontend/src/api/pmflowOs.ts index 5fc7167f..6f18fcc1 100644 --- a/frontend/src/api/pmflowOs.ts +++ b/frontend/src/api/pmflowOs.ts @@ -1,5 +1,9 @@ import type { ChangeItem, + PMFlowAIFeedbackAnswerKind, + PMFlowAIFeedbackEvent, + PMFlowAIFeedbackRating, + PMFlowAIFeedbackTargetType, PMFlowCapacityOverridePayload, PMFlowCapacityOverrideRecord, PMFlowCapacityOverrideSimulation, @@ -171,15 +175,21 @@ export interface PMFlowReleaseSignals { latestValidationRunStatus?: string | null; source?: string | null; changeSetCreated?: boolean; + businessSignalTypes?: string[]; }; summary: { feedbackCount?: number; revenueEventCount?: number; + roiSnapshotCount?: number; + complianceCheckCount?: number; iterationRecommendationCount?: number; + riskSignalCount?: number; latestSignalAt?: string | null; }; feedback: PMFlowReleaseFeedbackIngest[]; revenueEvents: PMFlowReleaseRevenueEvent[]; + roiSnapshots?: PMFlowReleaseROISnapshot[]; + complianceChecks?: PMFlowReleaseComplianceCheck[]; recommendations: PMFlowIterationRecommendation[]; recommendationGroups?: PMFlowIterationRecommendationGroup[]; nextActions: string[]; @@ -214,6 +224,38 @@ export interface PMFlowReleaseRevenueEvent { updatedAt?: string | null; } +export interface PMFlowReleaseROISnapshot { + id: string; + projectId: string; + rangeKey: string; + windowStart?: string | null; + windowEnd?: string | null; + totalRevenueUsd: number; + totalCostUsd: number; + roiRatio?: number | null; + snapshotPayload: Record; + riskLevel: "low" | "medium" | "high" | string; + status: "healthy" | "watch" | "negative" | string; + createdAt?: string | null; + updatedAt?: string | null; +} + +export interface PMFlowReleaseComplianceCheck { + id: string; + projectId: string; + charterId?: string | null; + releaseNodeId?: string | null; + projectModelBindingId?: string | null; + status: string; + score?: number | null; + checklist: Array>; + issues: Array>; + summary: Record; + riskLevel: "low" | "medium" | "high" | string; + createdAt?: string | null; + updatedAt?: string | null; +} + export interface PMFlowIterationRecommendation { id: string; projectId: string; @@ -428,13 +470,166 @@ export interface PMFlowChangeSetDetail { } export interface PMFlowChangeSetCommitResult { - changeSet: Record; + changeSet: { + id?: string; + status?: string; + rawStatus?: string; + committedAt?: string | null; + writebackAt?: string | null; + committedById?: string | null; + committedBy?: string | null; + committedItemIds?: string[]; + skippedItemIds?: string[]; + failedItemIds?: string[]; + [key: string]: unknown; + }; committedItems: Array>; skippedItems: Array>; failedItems: Array>; projectTruthState?: ProjectTruthState | null; } +export interface PMFlowProjectMemoryMetric { + key: string; + label: string; + value: number; +} + +export interface PMFlowProjectMemoryRef { + id?: string | null; + type?: string | null; + sourceType?: string | null; + sourceId?: string | null; + title?: string | null; + summary?: string | null; + displayLabel?: string | null; + label?: string | null; +} + +export interface PMFlowProjectMemoryItem { + id: string; + objectId?: string | null; + objectType: string; + category: string; + title: string; + summary: string; + status: string; + stage?: string | null; + riskLevel?: PMFlowRiskLevel | string | null; + sourceType: string; + sourceId: string; + confidence?: number | null; + tags: string[]; + evidenceRefs: PMFlowProjectMemoryRef[]; + impactRefs: PMFlowProjectMemoryRef[]; + createdAt?: string | null; + updatedAt?: string | null; + metadata: Record; +} + +export interface PMFlowProjectMemoryAsset { + id: string; + assetType: string; + sourceType: string; + sourceId: string; + title: string; + summary: string; + status: string; + displayLabel?: string | null; + linkedItemId?: string | null; + changeSetId?: string | null; + createdAt?: string | null; + updatedAt?: string | null; + metadata: Record; +} + +export interface PMFlowProjectMemoryContextPack { + id: string; + key: string; + title: string; + summary: string; + usage: string; + status: "ready" | "empty" | string; + itemCount: number; + assetCount: number; + sourceItemIds: string[]; + sourceAssetIds: string[]; + updatedAt?: string | null; + warnings: string[]; +} + +export interface PMFlowProjectMemoryWriteback { + id: string; + changeSetId: string; + title: string; + summary: string; + status: string; + committedAt?: string | null; + committedBy?: string | null; + committedById?: string | null; + createdAt?: string | null; + updatedAt?: string | null; + itemCounts: { + total: number; + committed: number; + skipped: number; + pending: number; + }; + items: Array>; + metadata: Record; +} + +export interface PMFlowProjectMemoryOverview { + projectId: string; + projectName: string; + generatedAt: string; + metrics: PMFlowProjectMemoryMetric[]; + facts: PMFlowProjectMemoryItem[]; + requirements: PMFlowProjectMemoryItem[]; + decisions: PMFlowProjectMemoryItem[]; + constraints: PMFlowProjectMemoryItem[]; + evidence: PMFlowProjectMemoryAsset[]; + contextPacks: PMFlowProjectMemoryContextPack[]; + writebacks: PMFlowProjectMemoryWriteback[]; + aiSuggestions: Array>; + warnings: string[]; +} + +export interface PMFlowProjectMemoryItemsResult { + projectId: string; + total: number; + limit: number; + offset: number; + items: PMFlowProjectMemoryItem[]; +} + +export interface PMFlowProjectMemoryItemResult { + projectId: string; + item: PMFlowProjectMemoryItem; +} + +export interface PMFlowProjectMemoryAssetsResult { + projectId: string; + total: number; + limit: number; + offset: number; + assets: PMFlowProjectMemoryAsset[]; +} + +export interface PMFlowProjectMemoryContextPacksResult { + projectId: string; + total: number; + contextPacks: PMFlowProjectMemoryContextPack[]; +} + +export interface PMFlowProjectMemoryWritebacksResult { + projectId: string; + total: number; + limit: number; + offset: number; + writebacks: PMFlowProjectMemoryWriteback[]; +} + export interface PMFlowReviewDecisionRequest { action: PMFlowReviewDecisionAction; reviewer_id?: number; @@ -448,10 +643,91 @@ export interface PMFlowReviewDecisionResult { warnings: string[]; } +export interface PMFlowAIFeedbackPayload { + target_type: PMFlowAIFeedbackTargetType; + target_id: string; + answer_kind: PMFlowAIFeedbackAnswerKind; + rating: PMFlowAIFeedbackRating; + reason_tags?: string[]; + comment?: string | null; + thread_id?: string | null; + message_id?: string | null; + change_set_id?: string | null; + ai_call_log_id?: string | null; + scene_key?: string | null; + prompt_key?: string | null; + prompt_release_id?: number | null; + runtime_profile_id?: string | null; + model_provider?: string | null; + model_name?: string | null; + feedback_metadata?: Record; +} + export async function getPMFlowTruthState(projectId: string): Promise { return get(`/pmflow-os/projects/${projectId}/truth-state`); } +export async function getPMFlowProjectMemoryOverview(projectId: string): Promise { + return get(`/pmflow-os/projects/${projectId}/project-memory/overview`); +} + +export async function getPMFlowProjectMemoryItems( + projectId: string, + params: { category?: string | null; stage?: string | null; sourceType?: string | null; status?: string | null; query?: string | null; limit?: number; offset?: number } = {} +): Promise { + const search = new URLSearchParams(); + if (params.category) search.set("category", params.category); + if (params.stage) search.set("stage", params.stage); + if (params.sourceType) search.set("source_type", params.sourceType); + if (params.status) search.set("status", params.status); + if (params.query) search.set("query", params.query); + if (params.limit) search.set("limit", String(params.limit)); + if (params.offset) search.set("offset", String(params.offset)); + const suffix = search.toString() ? `?${search.toString()}` : ""; + return get(`/pmflow-os/projects/${projectId}/project-memory/items${suffix}`); +} + +export async function getPMFlowProjectMemoryItem(projectId: string, itemId: string): Promise { + return get(`/pmflow-os/projects/${projectId}/project-memory/items/${encodeURIComponent(itemId)}`); +} + +export async function getPMFlowProjectMemoryAssets( + projectId: string, + params: { assetType?: string | null; sourceType?: string | null; status?: string | null; query?: string | null; limit?: number; offset?: number } = {} +): Promise { + const search = new URLSearchParams(); + if (params.assetType) search.set("asset_type", params.assetType); + if (params.sourceType) search.set("source_type", params.sourceType); + if (params.status) search.set("status", params.status); + if (params.query) search.set("query", params.query); + if (params.limit) search.set("limit", String(params.limit)); + if (params.offset) search.set("offset", String(params.offset)); + const suffix = search.toString() ? `?${search.toString()}` : ""; + return get(`/pmflow-os/projects/${projectId}/project-memory/assets${suffix}`); +} + +export async function getPMFlowProjectMemoryContextPacks(projectId: string): Promise { + return get(`/pmflow-os/projects/${projectId}/project-memory/context-packs`); +} + +export async function getPMFlowProjectMemoryWritebacks( + projectId: string, + params: { limit?: number; offset?: number } = {} +): Promise { + const search = new URLSearchParams(); + if (params.limit) search.set("limit", String(params.limit)); + if (params.offset) search.set("offset", String(params.offset)); + const suffix = search.toString() ? `?${search.toString()}` : ""; + return get(`/pmflow-os/projects/${projectId}/project-memory/writebacks${suffix}`); +} + +export async function createPMFlowAIFeedback( + projectId: string, + payload: PMFlowAIFeedbackPayload +): Promise { + return post(`/pmflow-os/projects/${projectId}/ai-feedback`, payload); +} + export async function getPMFlowExecutionSummary(projectId: string): Promise { return get(`/pmflow-os/projects/${projectId}/execution-summary`); } @@ -563,7 +839,7 @@ export async function createPMFlowExtraction( summary?: string; headline?: string; routing_meta?: Record; - proposed_items: PMFlowProposedChangeItem[]; + proposed_items?: PMFlowProposedChangeItem[]; } ): Promise { return post(`/pmflow-os/projects/${projectId}/extractions`, payload); diff --git a/frontend/src/api/promptAssets.ts b/frontend/src/api/promptAssets.ts new file mode 100644 index 00000000..8f7bebfd --- /dev/null +++ b/frontend/src/api/promptAssets.ts @@ -0,0 +1,367 @@ +import { api } from "@/lib/api"; + +type ApiEnvelope = { code: number; message: string; data: T }; + +function unwrap(raw: ApiEnvelope | T): T { + if (raw && typeof raw === "object" && "data" in (raw as Record)) { + return (raw as ApiEnvelope).data; + } + return raw as T; +} + +export interface PromptReleaseSummary { + id: number; + prompt_key: string; + version: number; + status: string; + notes?: string | null; + body_hash: string; + body_preview: string; + body_snapshot?: string; + created_by?: number | null; + activated_by?: number | null; + activated_at?: string | null; + release_gate?: PromptReleaseGateSummary; + latest_checks?: PromptReleaseCheck[]; + latest_replay_run?: PromptReplayRun | null; +} + +export interface PromptReleaseGateSummary { + status: "not_run" | "passed" | "blocked" | string; + summary: string; + blocking_reasons: string[]; + required_checks: string[]; +} + +export interface PromptReleaseCheck { + id: number; + prompt_key: string; + release_id: number; + check_type: string; + status: string; + severity: string; + summary: string; + details_json?: Record; + created_by?: number | null; + created_at?: string | null; + completed_at?: string | null; +} + +export interface PromptReleaseChecksResponse { + release_id: number; + prompt_key: string; + items: PromptReleaseCheck[]; + total: number; + gate: PromptReleaseGateSummary; +} + +export interface PromptReplayCaseResult { + id: number; + run_id: number; + golden_case_id?: number | null; + prompt_key: string; + release_id?: number | null; + baseline_output?: string | null; + candidate_output?: string | null; + status: "passed" | "failed" | "warning" | string; + contract_result_json?: Record; + diff_summary?: string | null; + risk_flags_json?: string[]; + baseline_ai_call_log_id?: number | null; + candidate_ai_call_log_id?: number | null; + judge_score?: number | null; + judge_label?: string | null; + judge_reason?: string | null; + latency_ms?: number | null; + estimated_cost?: number | null; + error_summary?: string | null; + created_at?: string | null; +} + +export interface PromptReplayRun { + id: number; + prompt_key: string; + release_id?: number | null; + baseline_release_id?: number | null; + run_status: "queued" | "running" | "completed" | "failed" | string; + run_mode: string; + case_count: number; + passed_count: number; + failed_count: number; + warning_count: number; + score_delta?: number | null; + summary?: string | null; + risk_flags_json?: string[]; + created_by?: number | null; + started_at?: string | null; + completed_at?: string | null; + created_at?: string | null; + results?: PromptReplayCaseResult[]; +} + +export interface PromptReplayRunsResponse { + prompt_key: string; + release_id: number; + items: PromptReplayRun[]; + total: number; +} + +export interface PromptAssetItem { + prompt_key: string; + module: string; + business_scenario: string; + prompt_family: string; + responsibility_scope: string; + priority: string; + audience: string; + risk_level: string; + runtime_source: string; + editable_policy: string; + input_contract: string; + output_contract: string; + call_sites: string[]; + default_template_available: boolean; + default_template_hash: string; + default_template_preview: string; + default_template_body?: string; + template_variables: string[]; + active_release?: PromptReleaseSummary | null; + latest_release?: PromptReleaseSummary | null; + releases?: PromptReleaseSummary[]; + release_count: number; + draft_release_count: number; + migration_status: string; + quality_summary?: PromptQualitySummary; +} + +export interface PromptQualitySummary { + prompt_key: string; + window: string; + total_calls: number; + failed_calls: number; + failure_rate: number; + fallback_calls: number; + active_release_calls: number; + default_fallback_calls: number; + average_latency_ms?: number | null; + estimated_cost: number; + recent_failures: Array<{ + id: number; + trace_id?: string | null; + status?: string | null; + function_name?: string | null; + prompt_release_id?: number | null; + prompt_source?: string | null; + latency_ms?: number | null; + estimated_cost: number; + created_at?: string | null; + error_summary?: string | null; + }>; + pmflow_feedback?: { + total_feedback: number; + positive_feedback: number; + negative_feedback: number; + negative_rate: number; + reason_tag_counts: Array<{ tag: string; count: number }>; + answer_kind_counts: Array<{ answer_kind: string; count: number }>; + recent_feedback: Array<{ + id: number; + tenant_id?: number; + project_id: number; + target_type: string; + target_id: string; + answer_kind: string; + rating: "positive" | "negative" | string; + reason_tags: string[]; + comment?: string | null; + scene_key?: string | null; + prompt_release_id?: number | null; + runtime_profile_id?: string | null; + optimizer_candidate_id?: number | null; + created_at?: string | null; + }>; + }; + optimizer_candidates?: Array<{ + id: number; + tenant_id?: number; + candidate_type: string; + target_type: string; + target_ref: string; + status: string; + suggestion?: { + summary?: string; + reason_tags?: string[]; + recommended_actions?: string[]; + target_answer_kind?: string; + scene_key?: string | null; + } & Record; + evidence?: Record; + notes?: string | null; + created_at?: string | null; + }>; + release_breakdown: Array<{ + prompt_release_id?: number | null; + prompt_source: string; + total_calls: number; + failed_calls: number; + }>; +} + +export interface PromptGoldenCase { + id: number; + prompt_key: string; + case_name: string; + status: string; + input_payload: Record; + expected_contract: Record; + expected_contains?: string | null; + source: string; + source_ai_call_log_id?: number | null; + case_type?: string; + priority?: string; + scenario_key?: string | null; + tags?: string[]; + last_run_status?: string | null; + last_failed_at?: string | null; + created_by?: number | null; + notes?: string | null; + created_at?: string | null; +} + +export interface PromptGoldenCasesResponse { + prompt_key: string; + items: PromptGoldenCase[]; + total: number; + active_count: number; +} + +export interface PromptAssetsSummary { + total_assets: number; + active_release_count: number; + needs_migration_count: number; + guarded_count: number; + family_counts: Record; + editable_policy_counts: Record; + runtime_source_counts: Record; +} + +export interface PromptAssetsListResponse { + items: PromptAssetItem[]; + total: number; + summary: PromptAssetsSummary; +} + +export interface PromptAssetsQuery { + family?: string; + runtime_source?: string; + editable_policy?: string; + keyword?: string; +} + +export interface PromptReleaseWatchSummary { + prompt_key: string; + release_id: number; + release_version: number; + status: string; + windows: { + "24h": PromptQualitySummary; + "7d": PromptQualitySummary; + }; + structured_invalid_24h: number; + fallback_rate_24h: number; + recommendation: "healthy" | "watch" | "needs_review" | "rollback_suggested" | string; + summary: string; + recent_failures: PromptQualitySummary["recent_failures"]; +} + +function toParams(query: PromptAssetsQuery | Record): Record { + const params: Record = {}; + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null || String(value).trim() === "") continue; + params[key] = typeof value === "number" ? value : String(value).trim(); + } + return params; +} + +export const promptAssetsApi = { + async list(query: PromptAssetsQuery = {}): Promise { + const { data } = await api.get>("/prompt-assets", { + params: toParams(query), + }); + return unwrap(data); + }, + + async get(promptKey: string): Promise { + const { data } = await api.get>(`/prompt-assets/${encodeURIComponent(promptKey)}`); + return unwrap(data); + }, + + async createRelease(promptKey: string, payload: { body_snapshot: string; notes?: string | null }): Promise { + const { data } = await api.post>(`/prompt-assets/${encodeURIComponent(promptKey)}/releases`, payload); + return unwrap(data); + }, + + async activateRelease(promptKey: string, releaseId: number, payload: { reason?: string | null } = {}): Promise { + const { data } = await api.post>(`/prompt-assets/${encodeURIComponent(promptKey)}/releases/${releaseId}/activate`, payload); + return unwrap(data); + }, + + async rollbackRelease(promptKey: string, releaseId: number, payload: { reason?: string | null } = {}): Promise { + const { data } = await api.post>(`/prompt-assets/${encodeURIComponent(promptKey)}/releases/${releaseId}/rollback`, payload); + return unwrap(data); + }, + + async runReleaseChecks(promptKey: string, releaseId: number, payload: { reason?: string | null } = {}): Promise { + const { data } = await api.post>(`/prompt-assets/${encodeURIComponent(promptKey)}/releases/${releaseId}/checks/run`, payload); + return unwrap(data); + }, + + async listReleaseChecks(promptKey: string, releaseId: number): Promise { + const { data } = await api.get>(`/prompt-assets/${encodeURIComponent(promptKey)}/releases/${releaseId}/checks`); + return unwrap(data); + }, + + async createReplayRun(promptKey: string, releaseId: number, payload: { mode?: string; case_status?: string; max_cases?: number; include_llm?: boolean; judge_enabled?: boolean; reason?: string | null } = {}): Promise { + const { data } = await api.post>(`/prompt-assets/${encodeURIComponent(promptKey)}/releases/${releaseId}/replay-runs`, payload); + return unwrap(data); + }, + + async getReleaseWatchSummary(promptKey: string, releaseId: number): Promise { + const { data } = await api.get>(`/prompt-assets/${encodeURIComponent(promptKey)}/releases/${releaseId}/watch-summary`); + return unwrap(data); + }, + + async listReplayRuns(promptKey: string, releaseId: number): Promise { + const { data } = await api.get>(`/prompt-assets/${encodeURIComponent(promptKey)}/releases/${releaseId}/replay-runs`); + return unwrap(data); + }, + + async getReplayRun(promptKey: string, runId: number): Promise { + const { data } = await api.get>(`/prompt-assets/${encodeURIComponent(promptKey)}/replay-runs/${runId}`); + return unwrap(data); + }, + + async getQualitySummary(promptKey: string, query: { window?: "24h" | "7d" | "30d"; status?: string; release_id?: number } = {}): Promise { + const { data } = await api.get>(`/prompt-assets/${encodeURIComponent(promptKey)}/quality-summary`, { + params: toParams(query as Record), + }); + return unwrap(data); + }, + + async listGoldenCases(promptKey: string, status = "active"): Promise { + const { data } = await api.get>(`/prompt-assets/${encodeURIComponent(promptKey)}/golden-cases`, { + params: status ? { status } : undefined, + }); + return unwrap(data); + }, + + async createGoldenCase(promptKey: string, payload: { case_name: string; input_payload: Record; expected_contract?: Record; expected_contains?: string | null; case_type?: string; priority?: string; scenario_key?: string | null; tags?: string[]; notes?: string | null }): Promise { + const { data } = await api.post>(`/prompt-assets/${encodeURIComponent(promptKey)}/golden-cases`, payload); + return unwrap(data); + }, + + async createGoldenCaseFromCall(promptKey: string, aiCallLogId: number, payload: { case_name?: string | null; notes?: string | null } = {}): Promise { + const { data } = await api.post>(`/prompt-assets/${encodeURIComponent(promptKey)}/golden-cases/from-call/${aiCallLogId}`, payload); + return unwrap(data); + }, +}; diff --git a/frontend/src/api/space.ts b/frontend/src/api/space.ts index 618876f3..4d6991ea 100644 --- a/frontend/src/api/space.ts +++ b/frontend/src/api/space.ts @@ -3,9 +3,17 @@ import { getPMFlowExecutionSummary, getPMFlowTruthState, type PMFlowExecutionSummary, + type PMFlowProgressEvent, + type PMFlowRecentDelivery, + type PMFlowReleaseComplianceCheck, + type PMFlowReleaseFeedbackIngest, + type PMFlowReleaseROISnapshot, + type PMFlowReleaseRevenueEvent, } from "./pmflowOs"; import { aiPmoApi, type AIPMOWorkItem } from "./aiPmo"; +import type { PMFlowLaunchDraft } from "./pmflowLaunch"; import { projectsApi, type ProjectSummary } from "./projects"; +import { api } from "@/lib/api"; import type { ChangeItem, MetricSignal, @@ -177,11 +185,86 @@ export interface SpaceDraftItem { export interface SpaceSavedIdea { id: string; title: string; + rawText: string; summary: string; + sourceType: string; + sourceRef?: string | null; + inputMode: "idea" | "import" | "template" | string; + attachments: Array>; + status: "saved" | "enriched" | "needs_more_info" | "draft_ready" | "promoted" | "archived" | string; + statusLabel: string; + aiSummary?: string | null; + missingQuestions: string[]; + readinessScore: number; + enrichmentStatus: "baseline" | "ai_enriched" | "ai_failed" | string; + enrichmentPayload: Record; + enrichmentError?: string | null; + enrichedAt?: string | null; + launchDraftId?: string | null; + promotedProjectId?: number | null; + createdAt: string; updatedAt: string; href: string; } +export interface SpaceSavedIdeaCreateRequest { + raw_text: string; + title?: string | null; + summary?: string | null; + source_type?: "project_launch" | "ai_concierge" | "logout_guard" | "feishu" | "upload" | "manual"; + source_ref?: string | null; + input_mode?: "idea" | "import" | "template"; + attachments?: Array>; +} + +export interface SpaceSavedIdeaUpdateRequest { + title?: string; + raw_text?: string; + summary?: string; + status?: SpaceSavedIdea["status"]; +} + +interface ApiEnvelope { + code: number; + message: string; + data: T; +} + +interface SavedIdeaApiItem { + id: string; + title: string; + raw_text: string; + summary: string; + source_type: string; + source_ref?: string | null; + input_mode: string; + attachments: Array>; + status: string; + status_label: string; + ai_summary?: string | null; + missing_questions: string[]; + readiness_score: number; + enrichment_status?: string; + enrichment_payload?: Record | null; + enrichment_error?: string | null; + enriched_at?: string | null; + launch_draft_id?: string | null; + promoted_project_id?: number | null; + created_at: string; + updated_at: string; + href: string; +} + +interface SavedIdeaListApiResponse { + items: SavedIdeaApiItem[]; +} + +export interface SpaceSavedIdeaPromoteResponse { + idea: SpaceSavedIdea; + draft: PMFlowLaunchDraft; + redirectUrl: string; +} + export interface SpaceLegacyProjectScopeItem { id: string; workItemId: number; @@ -212,6 +295,72 @@ export interface SpaceWorkbench { generatedAt: string; } +function unwrap(raw: ApiEnvelope | T): T { + if (raw && typeof raw === "object" && "data" in (raw as Record)) { + return (raw as ApiEnvelope).data; + } + return raw as T; +} + +function mapSavedIdea(item: SavedIdeaApiItem): SpaceSavedIdea { + return { + id: item.id, + title: item.title, + rawText: item.raw_text, + summary: item.summary, + sourceType: item.source_type, + sourceRef: item.source_ref ?? null, + inputMode: item.input_mode, + attachments: Array.isArray(item.attachments) ? item.attachments : [], + status: item.status, + statusLabel: item.status_label, + aiSummary: item.ai_summary ?? null, + missingQuestions: Array.isArray(item.missing_questions) ? item.missing_questions : [], + readinessScore: Number(item.readiness_score || 0), + enrichmentStatus: item.enrichment_status || "baseline", + enrichmentPayload: item.enrichment_payload && typeof item.enrichment_payload === "object" ? item.enrichment_payload : {}, + enrichmentError: item.enrichment_error ?? null, + enrichedAt: item.enriched_at ?? null, + launchDraftId: item.launch_draft_id ?? null, + promotedProjectId: item.promoted_project_id ?? null, + createdAt: item.created_at, + updatedAt: item.updated_at, + href: item.href, + }; +} + +async function listSavedIdeas(limit = 20): Promise { + const { data } = await api.get>("/space/saved-ideas", { params: { limit } }); + return unwrap(data).items.map(mapSavedIdea); +} + +async function createSavedIdea(payload: SpaceSavedIdeaCreateRequest): Promise { + const { data } = await api.post>("/space/saved-ideas", payload); + return mapSavedIdea(unwrap(data)); +} + +async function updateSavedIdea(id: string, payload: SpaceSavedIdeaUpdateRequest): Promise { + const { data } = await api.patch>(`/space/saved-ideas/${encodeURIComponent(id)}`, payload); + return mapSavedIdea(unwrap(data)); +} + +async function archiveSavedIdea(id: string): Promise { + const { data } = await api.post>(`/space/saved-ideas/${encodeURIComponent(id)}/archive`); + return mapSavedIdea(unwrap(data)); +} + +async function promoteSavedIdea(id: string): Promise { + const { data } = await api.post>( + `/space/saved-ideas/${encodeURIComponent(id)}/promote` + ); + const payload = unwrap(data); + return { + idea: mapSavedIdea(payload.idea), + draft: payload.draft, + redirectUrl: payload.redirect_url, + }; +} + interface ProjectSnapshot { project: ProjectSummary; truth: ProjectTruthState | null; @@ -220,6 +369,24 @@ interface ProjectSnapshot { warnings: string[]; } +const PROJECT_WARNING_MESSAGES: Array<{ code: string; message: string }> = [ + { code: "repo_bindings_empty", message: "还没有连接代码仓库,代码执行类建议暂时不会生成。" }, + { code: "prompt_package_empty", message: "还没有生成执行提示包,AI 执行准备度仍在补齐。" }, + { code: "progress_events_empty", message: "还没有记录执行进展,任务真正推进后这里会自动更新。" }, +]; + +function formatProjectWarning(projectTitle: string, warning: string): string { + const matched = PROJECT_WARNING_MESSAGES.find((item) => warning.includes(item.code)); + if (matched) { + return `${projectTitle}:${matched.message}`; + } + return `${projectTitle}:有一项项目摘要信息暂未同步,稍后重试后会自动更新。`; +} + +function uniqueStrings(items: string[]): string[] { + return Array.from(new Set(items)); +} + const ACTIVE_EXECUTION_STATUSES: Array = ["queued", "pushed", "coding", "pr_open", "testing"]; function stageLabel(stage?: string | null): string { @@ -743,6 +910,129 @@ function deriveFeedbackSignalAttention(snapshot: ProjectSnapshot): SpaceFeedback .filter((item): item is SpaceFeedbackAttentionItem => Boolean(item)); } +function signalRiskSeverity(riskLevel?: string | null): SpaceFeedbackAttentionItem["severity"] { + if (riskLevel === "high") return "high"; + if (riskLevel === "medium") return "medium"; + return "low"; +} + +function releaseFeedbackSummary(item: PMFlowReleaseFeedbackIngest): string { + return item.content || item.title || "发布后反馈已同步,等待在 Release Growth 查看。"; +} + +function deriveFeedbackIngestAttention( + snapshot: ProjectSnapshot, + projectId: string, + projectTitle: string, + item: PMFlowReleaseFeedbackIngest +): SpaceFeedbackAttentionItem { + const metrics = Object.keys(item.metrics || {}); + return { + id: `release-feedback-${projectId}-${item.id}`, + projectId, + projectTitle, + source: "feedback", + sourceLabel: "用户反馈", + title: item.title || "新反馈信号", + summary: releaseFeedbackSummary(item), + value: metrics.length ? `${metrics.length} 个指标` : item.sourceType, + severity: "medium", + updatedAt: item.updatedAt || item.createdAt || snapshot.project.updated_at, + href: releaseGrowthHref(projectId), + }; +} + +function deriveRevenueAttention( + snapshot: ProjectSnapshot, + projectId: string, + projectTitle: string, + item: PMFlowReleaseRevenueEvent +): SpaceFeedbackAttentionItem { + return { + id: `release-revenue-${projectId}-${item.id}`, + projectId, + projectTitle, + source: "revenue", + sourceLabel: "收入信号", + title: "收入信号更新", + summary: `${item.category || "revenue"} · ${item.externalRef || item.sourceType || "manual"}`, + value: `${item.currency || "USD"} ${item.amountUsd}`, + severity: "low", + updatedAt: item.updatedAt || item.occurredAt || item.createdAt || snapshot.project.updated_at, + href: releaseGrowthHref(projectId), + }; +} + +function roiValue(item: PMFlowReleaseROISnapshot): string { + return typeof item.roiRatio === "number" ? `${(item.roiRatio * 100).toFixed(1)}%` : "未计算"; +} + +function deriveROIAttention( + snapshot: ProjectSnapshot, + projectId: string, + projectTitle: string, + item: PMFlowReleaseROISnapshot +): SpaceFeedbackAttentionItem { + const severity = signalRiskSeverity(item.riskLevel); + return { + id: `release-roi-${projectId}-${item.id}`, + projectId, + projectTitle, + source: "roi", + sourceLabel: "ROI 信号", + title: severity === "high" ? "ROI 风险关注" : "ROI 信号更新", + summary: `${item.rangeKey} · 收入 ${item.totalRevenueUsd} / 成本 ${item.totalCostUsd}`, + value: roiValue(item), + severity, + updatedAt: item.updatedAt || item.createdAt || snapshot.project.updated_at, + href: releaseGrowthHref(projectId), + }; +} + +function complianceSummary(item: PMFlowReleaseComplianceCheck): string { + const firstIssue = item.issues?.[0]; + if (firstIssue) { + return String(firstIssue["title"] || firstIssue["summary"] || firstIssue["code"] || "合规问题待处理"); + } + return `状态 ${item.status}${item.score == null ? "" : ` · score ${item.score}`}`; +} + +function deriveComplianceAttention( + snapshot: ProjectSnapshot, + projectId: string, + projectTitle: string, + item: PMFlowReleaseComplianceCheck +): SpaceFeedbackAttentionItem { + const severity = signalRiskSeverity(item.riskLevel); + return { + id: `release-compliance-${projectId}-${item.id}`, + projectId, + projectTitle, + source: "compliance", + sourceLabel: "合规检查", + title: severity === "high" ? "合规阻塞关注" : "合规检查更新", + summary: complianceSummary(item), + value: item.score == null ? item.status : `${item.status} / ${item.score}`, + severity, + updatedAt: item.updatedAt || item.createdAt || snapshot.project.updated_at, + href: releaseGrowthHref(projectId), + }; +} + +function deriveReleaseSignalAttention(snapshot: ProjectSnapshot): SpaceFeedbackAttentionItem[] { + const { project, truth, execution } = snapshot; + const signals = execution?.releaseSignals; + if (!signals || signals.status === "empty") return []; + const projectId = String(project.id); + const projectTitle = project.title || truth?.project.name || `项目 ${projectId}`; + return [ + ...(signals.feedback || []).map((item) => deriveFeedbackIngestAttention(snapshot, projectId, projectTitle, item)), + ...(signals.revenueEvents || []).map((item) => deriveRevenueAttention(snapshot, projectId, projectTitle, item)), + ...(signals.roiSnapshots || []).map((item) => deriveROIAttention(snapshot, projectId, projectTitle, item)), + ...(signals.complianceChecks || []).map((item) => deriveComplianceAttention(snapshot, projectId, projectTitle, item)), + ]; +} + function isIterationChangeItem(item: ChangeItem): boolean { const text = `${item.objectType} ${item.title} ${item.summary} ${item.diff}`.toLowerCase(); return ( @@ -824,7 +1114,13 @@ function deriveIterationDraftAttention(snapshot: ProjectSnapshot): SpaceIteratio } function deriveFeedbackAttention(snapshot: ProjectSnapshot): SpaceFeedbackAttentionItem[] { - return deriveFeedbackSignalAttention(snapshot); + const seen = new Set(); + return [...deriveReleaseSignalAttention(snapshot), ...deriveFeedbackSignalAttention(snapshot)].filter((item) => { + const key = `${item.source}:${item.title}:${item.summary}:${item.value || ""}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); } function deriveDrafts(snapshot: ProjectSnapshot): SpaceDraftItem[] { @@ -1149,6 +1445,65 @@ function deriveStaleExecutionActions(snapshot: ProjectSnapshot): SpaceNextAction })); } +function isExecutionFailureEvent(event: PMFlowProgressEvent): boolean { + return ["failed", "blocked", "test_failed"].includes(String(event.event_type || "").toLowerCase()); +} + +function executionFailureTitle(event: PMFlowProgressEvent): string { + const eventType = String(event.event_type || "").toLowerCase(); + if (eventType === "test_failed") return "测试失败待处理"; + if (eventType === "blocked") return "执行阻塞待处理"; + return "执行结果阻塞"; +} + +function deriveFailedProgressEventActions(snapshot: ProjectSnapshot): SpaceNextActionQueueItem[] { + const { project, truth, execution } = snapshot; + const failedEvents = (execution?.recentProgressEvents || []).filter(isExecutionFailureEvent); + if (!failedEvents.length) return []; + const projectId = String(project.id); + const projectTitle = project.title || truth?.project.name || `项目 ${projectId}`; + return failedEvents.slice(0, 2).map((event) => ({ + id: `next-action-execution-result-${projectId}-${event.id}`, + projectId, + projectTitle, + kind: "execution_attention" as const, + title: executionFailureTitle(event), + summary: event.summary || event.workflow_state || "执行结果显示失败或阻塞,需要回到 Execution Bridge 查看证据并决定下一步。", + categoryLabel: "执行阻塞", + priorityLabel: "高优先级", + priorityRank: 16, + severity: "high" as const, + updatedAt: objectCreatedAt(event, project.updated_at), + href: executionHref(projectId), + })); +} + +function deriveFailedDeliveryActions(snapshot: ProjectSnapshot): SpaceNextActionQueueItem[] { + const { project, truth, execution } = snapshot; + const failedDeliveries = (execution?.recentDeliveries || []).filter((item: PMFlowRecentDelivery) => String(item.status || "").toLowerCase() === "failed"); + if (!failedDeliveries.length) return []; + const projectId = String(project.id); + const projectTitle = project.title || truth?.project.name || `项目 ${projectId}`; + return failedDeliveries.slice(0, 1).map((delivery) => ({ + id: `next-action-execution-delivery-${projectId}-${delivery.id || delivery.delivery_id}`, + projectId, + projectTitle, + kind: "execution_attention" as const, + title: "执行回写失败待处理", + summary: delivery.failure_reason || delivery.repository_full_name || "执行回写存在失败记录,需要回到 Execution Bridge 查看 delivery 证据。", + categoryLabel: "执行阻塞", + priorityLabel: "高优先级", + priorityRank: 16, + severity: "high" as const, + updatedAt: delivery.received_at || project.updated_at, + href: executionHref(projectId), + })); +} + +function deriveExecutionResultActions(snapshot: ProjectSnapshot): SpaceNextActionQueueItem[] { + return [...deriveFailedProgressEventActions(snapshot), ...deriveFailedDeliveryActions(snapshot)]; +} + function buildNextActionQueue({ pendingConfirmations, aiProgress, @@ -1157,6 +1512,7 @@ function buildNextActionQueue({ iterationDraftAttention, reviewAttentionActions, staleExecutionActions, + executionResultActions, legacyActions, }: { pendingConfirmations: SpacePendingConfirmation[]; @@ -1166,6 +1522,7 @@ function buildNextActionQueue({ iterationDraftAttention: SpaceIterationDraftAttentionItem[]; reviewAttentionActions: SpaceNextActionQueueItem[]; staleExecutionActions: SpaceNextActionQueueItem[]; + executionResultActions: SpaceNextActionQueueItem[]; legacyActions: SpaceNextActionQueueItem[]; }): SpaceNextActionQueueItem[] { const pendingActions = pendingConfirmations.map((item): SpaceNextActionQueueItem => { @@ -1179,7 +1536,7 @@ function buildNextActionQueue({ summary: item.summary, categoryLabel: item.source === "approval" ? "审批待确认" : "ChangeSet 待确认", priorityLabel: nextActionPriorityLabel(severity), - priorityRank: severity === "high" ? 10 : severity === "medium" ? 34 : 36, + priorityRank: severity === "high" ? 6 : severity === "medium" ? 10 : 11, severity, updatedAt: item.createdAt, href: item.href, @@ -1242,14 +1599,24 @@ function buildNextActionQueue({ summary: item.summary, categoryLabel: item.sourceLabel, priorityLabel: nextActionPriorityLabel(item.severity), - priorityRank: item.severity === "high" ? 50 : 56, + priorityRank: item.severity === "high" ? 30 : item.severity === "medium" ? 34 : 36, severity: item.severity, updatedAt: item.updatedAt, href: item.href, })); const seen = new Set(); - return [...reviewAttentionActions, ...legacyActions, ...pendingActions, ...releaseBlockedActions, ...staleExecutionActions, ...activeExecutionActions, ...iterationActions, ...feedbackActions] + return [ + ...pendingActions, + ...reviewAttentionActions, + ...legacyActions, + ...executionResultActions, + ...releaseBlockedActions, + ...staleExecutionActions, + ...feedbackActions, + ...activeExecutionActions, + ...iterationActions, + ] .filter((item) => { const key = `${item.kind}:${item.href}:${item.title}`; if (seen.has(key)) return false; @@ -1277,26 +1644,42 @@ async function getProjectSnapshot(project: ProjectSummary): Promise `${project.title}: ${item}`)); - if (execution?.warnings.length) warnings.push(...execution.warnings.map((item) => `${project.title}: ${item}`)); - if (capabilities?.warnings.length) warnings.push(...capabilities.warnings.map((item) => `${project.title}: ${item}`)); - return { project, truth, execution, capabilities, warnings }; + if (projectionWarnings.length) warnings.push(...projectionWarnings.map((item) => formatProjectWarning(project.title, item))); + if (execution?.warnings.length) warnings.push(...execution.warnings.map((item) => formatProjectWarning(project.title, item))); + if (capabilities?.warnings.length) warnings.push(...capabilities.warnings.map((item) => formatProjectWarning(project.title, item))); + return { project, truth, execution, capabilities, warnings: uniqueStrings(warnings) }; } export const spaceApi = { - async getWorkbench(): Promise { + listSavedIdeas, + createSavedIdea, + updateSavedIdea, + archiveSavedIdea, + promoteSavedIdea, + + async getWorkbench(options?: { projectLimit?: number; savedIdeasLimit?: number }): Promise { const summary = await projectsApi.getSummary({ status: "active" }); - const projects = sortByDate(summary.projects).slice(0, 8); + const projectLimit = options?.projectLimit ?? 8; + const savedIdeasLimit = options?.savedIdeasLimit ?? 20; + const projects = sortByDate(summary.projects).slice(0, projectLimit); const projectsById = new Map(projects.map((project) => [String(project.id), project])); - const [snapshots, legacyWorkItemsResult] = await Promise.all([ + const [snapshots, legacyWorkItemsResult, savedIdeasResult] = await Promise.all([ Promise.all(projects.map(getProjectSnapshot)), aiPmoApi .listWorkItems({ limit: 80 }) .then((payload) => ({ payload, warning: null as string | null })) .catch(() => ({ payload: { items: [] as AIPMOWorkItem[] }, - warning: "旧 AI PMO 工作项未同步,个人空间只展示新主链待处理项。", + warning: null as string | null, })), + savedIdeasLimit > 0 + ? listSavedIdeas(savedIdeasLimit) + .then((items) => ({ items, warning: null as string | null })) + .catch(() => ({ + items: [] as SpaceSavedIdea[], + warning: "暂存想法未同步,当前只展示已接入的其它空间摘要。", + })) + : Promise.resolve({ items: [] as SpaceSavedIdea[], warning: null as string | null }), ]); const legacyWorkItems = legacyWorkItemsResult.payload.items || []; const legacyActions = sortByDate(deriveLegacyWorkItemActions(legacyWorkItems, projectsById)).slice(0, 8); @@ -1311,6 +1694,7 @@ export const spaceApi = { const iterationDraftAttention = sortByDate(snapshots.flatMap(deriveIterationDraftAttention)).slice(0, 8); const reviewAttentionActions = snapshots.flatMap(deriveReviewAttentionActions); const staleExecutionActions = sortByDate(snapshots.flatMap(deriveStaleExecutionActions)).slice(0, 4); + const executionResultActions = sortByDate(snapshots.flatMap(deriveExecutionResultActions)).slice(0, 4); return { projects: workbenchProjects.slice(0, 4), nextActionQueue: buildNextActionQueue({ @@ -1321,6 +1705,7 @@ export const spaceApi = { iterationDraftAttention, reviewAttentionActions, staleExecutionActions, + executionResultActions, legacyActions, }), pendingConfirmations, @@ -1330,15 +1715,14 @@ export const spaceApi = { releaseAttention, feedbackAttention, iterationDraftAttention, - savedIdeas: [], + savedIdeas: savedIdeasResult.items.slice(0, 8), drafts: sortByDate([...snapshots.flatMap(deriveDrafts), ...deriveLegacyDrafts(legacyWorkItems, projectsById)]).slice(0, 8), legacyProjectScopeItems, recentProjects: sortByDate(workbenchProjects), - warnings: [ - "暂存想法只读聚合尚未接入后端合同,当前不使用 mock 数据冒充真实内容。", - ...(legacyWorkItemsResult.warning ? [legacyWorkItemsResult.warning] : []), + warnings: uniqueStrings([ + ...(savedIdeasResult.warning ? [savedIdeasResult.warning] : []), ...snapshots.flatMap((snapshot) => snapshot.warnings), - ].slice(0, 8), + ]).slice(0, 8), generatedAt: new Date().toISOString(), }; }, diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index a8cabc29..3884f123 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -23,7 +23,16 @@ export interface User { username: string; name: string; email?: string; + phone_country_code?: string | null; + phone_number?: string | null; + phone_e164?: string | null; + phone_verified_at?: string | null; role: string; + platform_context?: { + has_platform_access?: boolean; + roles?: string[]; + capabilities?: string[]; + } | null; assigned_domains: string[]; workload: number; status: UserStatus; @@ -40,6 +49,8 @@ export interface UserCreateRequest { password: string; name: string; email?: string; + phone_country_code?: string; + phone_number?: string; role: UserRole | UserRole[]; assigned_domains?: string[]; project_ids?: number[]; @@ -48,6 +59,8 @@ export interface UserCreateRequest { export interface UserUpdateRequest { name?: string; email?: string; + phone_country_code?: string; + phone_number?: string; role?: UserRole | UserRole[]; assigned_domains?: string[]; workload?: number; @@ -73,6 +86,14 @@ export interface LoginResponse { user: User; } +export interface PhoneCodeSendResponse { + phone_e164: string; + masked_phone: string; + purpose: string; + expires_in_seconds: number; + dev_code?: string; +} + export function parseUserRoles(role?: UserRole | UserRole[] | string | null): UserRole[] { if (Array.isArray(role)) { const normalized = role @@ -100,6 +121,38 @@ export async function login(credentials: { username: string; password: string }) return data; } +export async function sendPhoneCode(input: { + phone_country_code?: string; + phone_number: string; + purpose?: string; +}): Promise { + const { data } = await api.post("/auth/phone/send-code", { + phone_country_code: input.phone_country_code || "+86", + phone_number: input.phone_number, + purpose: input.purpose || "login", + }); + return data; +} + +export async function verifyPhoneCode(input: { + phone_country_code?: string; + phone_number: string; + code: string; + purpose?: string; + invite_token?: string; + name?: string; +}): Promise { + const { data } = await api.post("/auth/phone/verify", { + phone_country_code: input.phone_country_code || "+86", + phone_number: input.phone_number, + code: input.code, + purpose: input.purpose || "login", + invite_token: input.invite_token, + name: input.name, + }); + return data; +} + /** * Get current user information */ diff --git a/frontend/src/api/workspaces.ts b/frontend/src/api/workspaces.ts new file mode 100644 index 00000000..5d250716 --- /dev/null +++ b/frontend/src/api/workspaces.ts @@ -0,0 +1,77 @@ +import { api } from "@/lib/api"; + +export interface WorkspaceSummary { + id: number; + name: string; + domain?: string | null; + workspace_type: "personal" | "team" | string; + slug?: string | null; + owner_user_id?: number | null; + status: string; + role?: string | null; + membership_status?: string | null; + is_personal_owner?: boolean; +} + +export interface WorkspaceContext { + active_workspace: WorkspaceSummary | null; + workspaces: WorkspaceSummary[]; +} + +export async function getMyWorkspaces(): Promise { + const { data } = await api.get<{ code: number; data: WorkspaceContext }>("/workspaces/me"); + return data.data; +} + +export async function createTeamWorkspace(name: string): Promise { + const { data } = await api.post<{ code: number; data: WorkspaceSummary }>("/workspaces/teams", { name }); + return data.data; +} + +export async function switchWorkspace(tenantId: number): Promise { + const { data } = await api.post<{ code: number; data: WorkspaceSummary }>("/workspaces/switch", { tenant_id: tenantId }); + return data.data; +} + +export async function inviteWorkspaceMember(input: { + phone_country_code?: string; + phone_number: string; + email?: string; + role: string; + project_ids?: number[]; +}): Promise<{ token: string; id: number; email?: string; phone_e164?: string; masked_phone?: string; role: string; project_ids: number[]; status: string; expires_at: string }> { + const { data } = await api.post<{ code: number; data: { token: string; id: number; email?: string; phone_e164?: string; masked_phone?: string; role: string; project_ids: number[]; status: string; expires_at: string } }>( + "/workspaces/invitations", + { + phone_country_code: input.phone_country_code || "+86", + phone_number: input.phone_number, + email: input.email, + role: input.role, + project_ids: input.project_ids || [], + } + ); + return data.data; +} + +export async function acceptWorkspaceInvitation(input: { + token: string; + user?: { + username: string; + password: string; + name: string; + email: string; + }; +}): Promise<{ user: Record; workspace_id: number; role: string }> { + const { data } = await api.post<{ code: number; data: { user: Record; workspace_id: number; role: string } }>( + "/workspaces/invitations/accept", + input.user ? { token: input.token, user: input.user } : { token: input.token } + ); + return data.data; +} + +export async function leaveWorkspace(tenantId: number): Promise<{ left_workspace_id: number; active_workspace_id: number }> { + const { data } = await api.post<{ code: number; data: { left_workspace_id: number; active_workspace_id: number } }>( + `/workspaces/${tenantId}/leave` + ); + return data.data; +} diff --git a/frontend/src/auth/RouteBoundary.tsx b/frontend/src/auth/RouteBoundary.tsx index 9e9b4eed..bd8ef7fc 100644 --- a/frontend/src/auth/RouteBoundary.tsx +++ b/frontend/src/auth/RouteBoundary.tsx @@ -1,6 +1,6 @@ import { Navigate, useLocation } from "react-router-dom"; import { Button } from "@/components/ui/button"; -import { isManagerRole } from "@/lib/rbac"; +import { hasPlatformAccess, hasPlatformCapability, isManagerRole } from "@/lib/rbac"; import { useAuthStore } from "@/stores/auth"; export function isProjectLaunchDemo(search: string): boolean { @@ -27,7 +27,7 @@ export function AuthBoundary({ return children; } -export function ManagerBoundary({ children }: { children: JSX.Element }) { +export function WorkspaceManagerBoundary({ children }: { children: JSX.Element }) { const user = useAuthStore((s) => s.user); const location = useLocation(); if (!user?.id) { @@ -35,20 +35,52 @@ export function ManagerBoundary({ children }: { children: JSX.Element }) { return ; } if (!isManagerRole(user.role)) { - return ; + return ; } return children; } -export function PermissionDeniedSurface() { +export function ManagerBoundary({ children }: { children: JSX.Element }) { + return {children}; +} + +export function PlatformBoundary({ + children, + capability = "platform.admin.access", +}: { + children: JSX.Element; + capability?: string; +}) { + const user = useAuthStore((s) => s.user); + const location = useLocation(); + if (!user?.id) { + const redirect = location.pathname + location.search; + return ; + } + const canAccess = + capability === "platform.admin.access" + ? hasPlatformAccess(user) || hasPlatformCapability(user, capability) + : hasPlatformCapability(user, capability); + if (!canAccess) { + return ; + } + return children; +} + +export function PermissionDeniedSurface({ variant = "workspace" }: { variant?: "workspace" | "platform" }) { const logout = useAuthStore((s) => s.logout); + const isPlatform = variant === "platform"; return (

权限不足
-

当前账号没有执行此操作的权限

+

+ {isPlatform ? "当前账号不是 PMFlow 平台运营账号" : "当前账号没有执行此操作的权限"} +

- 该页面属于团队管理或系统治理能力,需要 OWNER 或 REVIEWER 权限。你可以返回个人空间,或切换到具备权限的账号。 + {isPlatform + ? "该页面属于平台 AI 运营控制面,需要平台侧授权。客户空间 Owner 只能管理自己空间的 AI 使用方式,不能进入全局供应商、路由和语义治理后台。" + : "该页面属于空间管理能力,需要当前空间的 OWNER 或 REVIEWER 权限。你可以返回个人空间,或切换到具备权限的账号。"}

- {!isProjectLaunchSurface && !isPMFlowOsSurface ? : null}
); } diff --git a/frontend/src/components/layout/Sidebar.test.tsx b/frontend/src/components/layout/Sidebar.test.tsx index 1fb58f12..788557e0 100644 --- a/frontend/src/components/layout/Sidebar.test.tsx +++ b/frontend/src/components/layout/Sidebar.test.tsx @@ -12,7 +12,7 @@ vi.mock("@/features/pmflow-os/store", () => ({ usePMFlowOsStore: () => ({ state: { changeItems: [], - project: { stage: "idea" }, + project: { id: "17", stage: "idea" }, }, }), })); @@ -45,7 +45,7 @@ describe("Sidebar product navigation", () => { expect(screen.queryByText("用户管理")).toBeNull(); }); - it("shows team operations and AI operations center for managers", () => { + it("does not expose AI operations center in the project sidebar for managers", () => { useAuthStore.setState({ token: "token", user: { id: "7", username: "owner", role: "OWNER" }, @@ -60,8 +60,30 @@ describe("Sidebar product navigation", () => { expect(screen.queryByText("团队负载")).toBeNull(); expect(screen.queryByText("能力管理")).toBeNull(); expect(screen.queryByText("用户管理")).toBeNull(); - expect(screen.getByText("AI 运营")).toBeInTheDocument(); - expect(screen.getByText("AI 运营中心")).toBeInTheDocument(); + expect(screen.queryByText("AI 运营")).toBeNull(); + expect(screen.queryByText("AI 运营中心")).toBeNull(); expect(screen.queryByText("管理后台")).toBeNull(); }); + + it("keeps projectId when navigating inside the project sidebar", () => { + useAuthStore.setState({ + token: "token", + user: { id: "7", username: "owner", role: "OWNER" }, + }); + + renderWithProviders(, ["/pmflow-os?projectId=17"]); + + expect(screen.getByRole("link", { name: /项目工作台/ })).toHaveAttribute( + "href", + "/work-graph?projectId=17" + ); + expect(screen.getByRole("link", { name: /开发执行/ })).toHaveAttribute( + "href", + "/execution-bridge?projectId=17" + ); + expect(screen.getByRole("link", { name: /项目记忆/ })).toHaveAttribute( + "href", + "/project-memory?projectId=17" + ); + }); }); diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 79746d77..0a6343f4 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -8,15 +8,12 @@ import { Moon, ShieldCheck, Sparkles, - Sliders, Sun, Users, X, } from "lucide-react"; import { cn } from "@/lib/utils"; -import { isManagerRole } from "@/lib/rbac"; import { useTheme } from "@/hooks/useTheme"; -import { useAuthStore } from "@/stores/auth"; import { Button } from "@/components/ui/button"; import { LaunchSidebar } from "@/features/project-launch/components/LaunchSidebar"; import { PMFlowBrandHeader } from "@/features/pmflow-os/components/chrome"; @@ -24,17 +21,13 @@ import { usePMFlowOsStore } from "@/features/pmflow-os/store"; import type { PMFlowStage } from "@/features/pmflow-os/types"; const primaryNavItems = [ - { to: "/command-center", label: "指挥台", icon: Sparkles }, + { to: "/command-center", label: "指挥台", icon: Sparkles, activePaths: ["/pmflow-os", "/command-center", "/structured-extraction"] }, { to: "/work-graph", label: "项目工作台", icon: FolderKanban }, { to: "/change-review", label: "决策与待确认", icon: ShieldCheck }, { to: "/execution-bridge", label: "开发执行", icon: GitBranchPlus }, { to: "/release-growth", label: "发布与增长", icon: LineChart }, { to: "/team-layer", label: "团队层", icon: Users }, - { to: "/project-memory", label: "项目记忆", icon: BookMarked }, -]; - -const aiOpsNavItems = [ - { to: "/ai-config", label: "AI 运营中心", icon: Sliders }, + { to: "/project-memory", label: "项目记忆", icon: BookMarked, activePaths: ["/project-memory", "/memory-writeback-result"] }, ]; interface SidebarProps { @@ -44,12 +37,11 @@ interface SidebarProps { export function Sidebar({ open, onClose }: SidebarProps) { const { theme, toggleTheme } = useTheme(); - const user = useAuthStore((s) => s.user); - const isManager = isManagerRole(user?.role); const location = useLocation(); const isProjectLaunchSurface = location.pathname === "/project-launch"; const { state } = usePMFlowOsStore(); const isPMFlowOsSurface = [ + "/pmflow-os", "/command-center", "/structured-extraction", "/change-review", @@ -59,8 +51,11 @@ export function Sidebar({ open, onClose }: SidebarProps) { "/release-growth", "/team-layer", "/project-memory", - ].includes(location.pathname); + ].some((path) => location.pathname === path || location.pathname.startsWith(`${path}/`)); const pendingCount = state.changeItems.filter((item) => item.status === "pending_review").length; + const currentProjectId = String(state.project.id || "").trim(); + const scopedNavPath = (to: string) => + isPMFlowOsSurface ? withProjectScope(to, location.search, currentProjectId) : to; if (isProjectLaunchSurface) { return ; @@ -71,15 +66,19 @@ export function Sidebar({ open, onClose }: SidebarProps) { label, icon: Icon, badge, + activePaths, }: { to: string; label: string; icon: typeof LayoutDashboard; badge?: string | number; - }) => ( + activePaths?: string[]; + }) => { + const matchesCurrentPath = activePaths?.some((path) => location.pathname === path || location.pathname.startsWith(`${path}/`)) ?? false; + return ( @@ -87,7 +86,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
)} - ); - - const renderSectionLabel = (label: string) => ( -
- {label} -
- ); + ); + }; return (
- {isManager ? ( - <> -
-
- {renderSectionLabel("AI 运营")} - {aiOpsNavItems.map(renderNavItem)} -
- - ) : null} )} @@ -182,6 +167,16 @@ export function Sidebar({ open, onClose }: SidebarProps) { ); } +function withProjectScope(to: string, search: string, stateProjectId?: string) { + const params = new URLSearchParams(search); + if (params.get("demo") === "1" || params.get("dataSource") === "demo") { + return `${to}?demo=1`; + } + const urlProjectId = params.get("projectId") || params.get("project_id"); + const projectId = urlProjectId || (stateProjectId && stateProjectId !== "unselected-project" ? stateProjectId : ""); + return projectId ? `${to}?projectId=${encodeURIComponent(projectId)}` : to; +} + const SIDEBAR_STAGES: Array<{ key: PMFlowStage; label: string; summary: string }> = [ { key: "idea", label: "创意", summary: "问题探索与机会识别" }, { key: "validation", label: "验证", summary: "方案验证与用户反馈" }, diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx index f1e89dad..2fa16c12 100644 --- a/frontend/src/components/layout/TopBar.tsx +++ b/frontend/src/components/layout/TopBar.tsx @@ -35,12 +35,11 @@ const ROOT_LEVEL_PATHS = new Set([ "/release-growth", "/team-layer", "/project-memory", - "/dashboard", - "/workloads", - "/projects", + "/space", + "/space/projects", "/ai-config", "/skills", - "/users", + "/space/settings/members", ]); interface TopBarProps { @@ -65,12 +64,12 @@ function resolveFallbackBackPath(pathname: string) { ) { return "/command-center"; } - if (/^\/projects\/\d+\/(?:board|charter\/\d+)$/.test(pathname)) return "/projects"; - if (/^\/workloads\/\d+$/.test(pathname)) return "/workloads"; + if (/^\/projects\/\d+\/(?:board|charter\/\d+)$/.test(pathname)) return "/space/projects"; + if (/^\/workloads\/\d+$/.test(pathname)) return "/team-layer?view=capacity"; const skillTaskMatch = pathname.match(/^\/users\/(\d+)\/skill-tasks\/\d+$/); - if (skillTaskMatch) return `/users/${skillTaskMatch[1]}/skills`; - if (/^\/users\/\d+\/(?:profile|skills)$/.test(pathname)) return "/users"; - return "/dashboard"; + if (skillTaskMatch) return `/space/settings/members/${skillTaskMatch[1]}`; + if (/^\/users\/\d+\/(?:profile|skills)$/.test(pathname)) return "/space/settings/members"; + return "/space"; } export function TopBar({ onMenuToggle }: TopBarProps) { @@ -177,7 +176,7 @@ export function TopBar({ onMenuToggle }: TopBarProps) {

{user?.email || user?.username}

- + 个人中心 diff --git a/frontend/src/components/login/LoginExperience.tsx b/frontend/src/components/login/LoginExperience.tsx index 4afadcf0..34a769bc 100644 --- a/frontend/src/components/login/LoginExperience.tsx +++ b/frontend/src/components/login/LoginExperience.tsx @@ -9,12 +9,18 @@ export default function LoginExperience({ description = DEFAULT_LOGIN_DESCRIPTION, account, password, + authMode = "phone", + codeSending = false, + codeCooldown = 0, + devCode = null, loading = false, errorTitle, errorDescription, onAccountChange, onPasswordChange, onSubmit, + onSendCode, + onAuthModeChange, onGoogleSignIn = () => {}, onResetPassword = () => {}, onCreateAccount = () => {}, @@ -27,12 +33,18 @@ export default function LoginExperience({ description={description} account={account} password={password} + authMode={authMode} + codeSending={codeSending} + codeCooldown={codeCooldown} + devCode={devCode} loading={loading} errorTitle={errorTitle} errorDescription={errorDescription} onAccountChange={onAccountChange} onPasswordChange={onPasswordChange} onSubmit={onSubmit} + onSendCode={onSendCode} + onAuthModeChange={onAuthModeChange} onGoogleSignIn={onGoogleSignIn} onResetPassword={onResetPassword} onCreateAccount={onCreateAccount} diff --git a/frontend/src/components/login/LoginForm.tsx b/frontend/src/components/login/LoginForm.tsx index 7028c261..e6988ad0 100644 --- a/frontend/src/components/login/LoginForm.tsx +++ b/frontend/src/components/login/LoginForm.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Eye, EyeOff } from "lucide-react"; +import { Eye, EyeOff, Smartphone } from "lucide-react"; import type { LoginExperienceProps } from "./loginTypes"; @@ -34,15 +34,20 @@ export function LoginForm({ description, account, password, + authMode = "phone", + codeSending = false, + codeCooldown = 0, + devCode = null, loading = false, errorTitle, errorDescription, onAccountChange, onPasswordChange, onSubmit, + onSendCode, + onAuthModeChange, onGoogleSignIn, onResetPassword, - onCreateAccount, }: LoginFormProps) { const [showPassword, setShowPassword] = useState(false); const [keepSignedIn, setKeepSignedIn] = useState(true); @@ -57,19 +62,36 @@ export function LoginForm({
+
+ + +
+
onAccountChange(event.target.value)} autoComplete="username" - placeholder="请输入邮箱地址或用户名" + placeholder={authMode === "phone" ? "请输入手机号" : "请输入用户名或手机号"} className="pmflow-auth-input" />
@@ -77,31 +99,48 @@ export function LoginForm({
onPasswordChange(event.target.value)} - autoComplete="current-password" - placeholder="请输入密码" - className="pmflow-auth-input pr-14" + autoComplete={authMode === "phone" ? "one-time-code" : "current-password"} + placeholder={authMode === "phone" ? "请输入 6 位验证码" : "请输入密码"} + className="pmflow-auth-input pr-32" /> - + {authMode === "phone" ? ( + + ) : ( + + )}
+ {authMode === "phone" && devCode ? ( +

+ 本地开发验证码:{devCode} +

+ ) : null}
-
+ {authMode === "password" ? ( +
+
+ ) : ( +
+ + 手机号验证后自动登录;首次使用会创建个人空间,若手机号被邀请,会绑定团队空间。 +
+ )} {errorTitle ? (
@@ -124,24 +169,21 @@ export function LoginForm({ ) : null}
- 或继续使用 + 账号体系
-

- 还没有 PMFlow 账号?{" "} - + 没有账号也可以直接用手机号继续,系统会自动创建个人空间。

diff --git a/frontend/src/components/login/LoginWorkflowVisual.tsx b/frontend/src/components/login/LoginWorkflowVisual.tsx index 44aff60d..169638f1 100644 --- a/frontend/src/components/login/LoginWorkflowVisual.tsx +++ b/frontend/src/components/login/LoginWorkflowVisual.tsx @@ -1,6 +1,7 @@ import type { CSSProperties, MouseEvent as ReactMouseEvent } from "react"; import { useEffect, useMemo, useRef, useState } from "react"; import { Globe, Mail } from "lucide-react"; +import { PMFlowMarkImage } from "@/components/brand/PMFlowLogo"; import { buildTrendPath } from "./loginMath"; import { @@ -112,7 +113,7 @@ export function LoginWorkflowVisual() {
- PMFlow + PMFlow
diff --git a/frontend/src/components/login/loginTypes.ts b/frontend/src/components/login/loginTypes.ts index f4a82ca6..2d843d03 100644 --- a/frontend/src/components/login/loginTypes.ts +++ b/frontend/src/components/login/loginTypes.ts @@ -5,12 +5,18 @@ export type LoginExperienceProps = { description?: ReactNode; account: string; password: string; + authMode?: "phone" | "password"; + codeSending?: boolean; + codeCooldown?: number; + devCode?: string | null; loading?: boolean; errorTitle?: string | null; errorDescription?: string | null; onAccountChange: (value: string) => void; onPasswordChange: (value: string) => void; onSubmit: (event: FormEvent) => void; + onSendCode?: () => void; + onAuthModeChange?: (mode: "phone" | "password") => void; onGoogleSignIn?: () => void; onResetPassword?: () => void; onCreateAccount?: () => void; diff --git a/frontend/src/components/marketing/MarketingFrame.tsx b/frontend/src/components/marketing/MarketingFrame.tsx index 773e345d..2e51282c 100644 --- a/frontend/src/components/marketing/MarketingFrame.tsx +++ b/frontend/src/components/marketing/MarketingFrame.tsx @@ -1,6 +1,7 @@ import type { PropsWithChildren, ReactNode } from "react"; import { ArrowRight } from "lucide-react"; import { Link } from "react-router-dom"; +import { PMFlowMarkImage } from "@/components/brand/PMFlowLogo"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { useAuthStore } from "@/stores/auth"; @@ -111,12 +112,7 @@ export function PublicNav({ ctaLabel, ctaHref }: PublicNavProps) { export function PMFlowMark({ compact = false }: { compact?: boolean }) { return (
-
- - - - -
+
PMFlow
{!compact &&
AI APP OS
} diff --git a/frontend/src/components/sstc/FeedbackSummaryCard.tsx b/frontend/src/components/sstc/FeedbackSummaryCard.tsx index d386849a..8a234000 100644 --- a/frontend/src/components/sstc/FeedbackSummaryCard.tsx +++ b/frontend/src/components/sstc/FeedbackSummaryCard.tsx @@ -95,9 +95,9 @@ export function FeedbackSummaryCard({ projectId, compact = false, tasks = [] }: size="sm" variant="outline" className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50" - onClick={() => navigate(`/ai-config?section=feedback-center&project_id=${numericProjectId}`)} + onClick={() => navigate(`/release-growth?projectId=${numericProjectId}`)} > - 打开反馈中心 + 查看发布与反馈
diff --git a/frontend/src/components/users/UserFormDialog.tsx b/frontend/src/components/users/UserFormDialog.tsx index 929ee7c7..516d31d0 100644 --- a/frontend/src/components/users/UserFormDialog.tsx +++ b/frontend/src/components/users/UserFormDialog.tsx @@ -1,16 +1,14 @@ import { useEffect, useState } from "react"; import { useMutation } from "@tanstack/react-query"; -import { Eye, EyeOff } from "lucide-react"; import { - createUser, parseUserRoles, updateUser, getRoleLabel, type User, type UserRole, - type UserCreateRequest, type UserUpdateRequest, } from "@/api/users"; +import { inviteWorkspaceMember } from "@/api/workspaces"; import { useAuthStore } from "@/stores/auth"; import { Dialog, @@ -26,49 +24,6 @@ import { useToast } from "@/components/ui/toast"; import { getApiErrorMessage } from "@/lib/apiError"; const ROLES: UserRole[] = ["OWNER", "BUILDER", "OPERATOR", "REVIEWER"]; -const MAX_PASSWORD_BYTES = 72; - -function passwordUtf8Bytes(value: string): number { - return new TextEncoder().encode(value).length; -} - -function generateSafePassword(length = 14): string { - const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*"; - const randomValues = new Uint32Array(length); - if (globalThis.crypto?.getRandomValues) { - globalThis.crypto.getRandomValues(randomValues); - } else { - for (let i = 0; i < length; i += 1) { - randomValues[i] = Math.floor(Math.random() * chars.length); - } - } - return Array.from(randomValues, (value) => chars[value % chars.length]).join(""); -} - -function resolvePasswordValidationMessage(passwordTooShort: boolean, passwordTooLong: boolean): string | null { - if (passwordTooShort) return "密码至少需要 6 个字符"; - if (passwordTooLong) return "密码最长支持 72 个 UTF-8 字节,请缩短后重试"; - return null; -} - -function isStalePasswordServerError( - message: string, - options: { - passwordTooShort: boolean; - passwordTooLong: boolean; - }, -): boolean { - const { passwordTooShort, passwordTooLong } = options; - const normalized = String(message || "").toLowerCase(); - if (!normalized) return false; - if ((normalized.includes("72 个 utf-8 字节") || normalized.includes("72 bytes")) && !passwordTooLong) { - return true; - } - if ((normalized.includes("至少需要 6 个字符") || normalized.includes("至少 6 个字符")) && !passwordTooShort) { - return true; - } - return false; -} interface UserFormDialogProps { open: boolean; @@ -88,40 +43,44 @@ export function UserFormDialog({ const currentUser = useAuthStore((s) => s.user); const { toast } = useToast(); const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); + const [phoneNumber, setPhoneNumber] = useState(""); const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [roles, setRoles] = useState(["BUILDER"]); - const [showPassword, setShowPassword] = useState(false); useEffect(() => { if (open) { if (mode === "edit" && user) { setUsername(user.username); - setPassword(""); + setPhoneNumber(user.phone_number || ""); setName(user.name || ""); setEmail(user.email || ""); const normalized = parseUserRoles(user.role); setRoles(normalized.length ? normalized : ["BUILDER"]); } else { setUsername(""); - setPassword(""); + setPhoneNumber(""); setName(""); setEmail(""); setRoles(["BUILDER"]); } - setShowPassword(false); } }, [open, mode, user]); const createMutation = useMutation({ - mutationFn: (data: UserCreateRequest) => createUser(data), - onSuccess: () => { + mutationFn: (data: { phone_number: string; email?: string; role: UserRole | UserRole[] }) => + inviteWorkspaceMember({ + phone_country_code: "+86", + phone_number: data.phone_number, + email: data.email, + role: Array.isArray(data.role) ? data.role[0] : data.role, + }), + onSuccess: (res) => { onSuccess(); onOpenChange(false); - toast({ title: "用户创建成功", variant: "success" }); + toast({ title: `邀请已生成:${res.masked_phone || res.phone_e164 || "成员"}`, variant: "success" }); }, - onError: (error) => toast({ title: `用户创建失败:${getApiErrorMessage(error)}`, variant: "error" }), + onError: (error) => toast({ title: `邀请失败:${getApiErrorMessage(error)}`, variant: "error" }), }); const updateMutation = useMutation({ @@ -149,37 +108,16 @@ export function UserFormDialog({ const isPending = createMutation.isPending || updateMutation.isPending; const error = createMutation.error || updateMutation.error; const canChangeRole = parseUserRoles(currentUser?.role).includes("OWNER"); - const passwordBytes = passwordUtf8Bytes(password); - const passwordTooLong = passwordBytes > MAX_PASSWORD_BYTES; - const passwordTooShort = mode === "create" && password.trim().length > 0 && password.trim().length < 6; - const passwordValidationMessage = - mode === "create" ? resolvePasswordValidationMessage(passwordTooShort, passwordTooLong) : null; const serverErrorMessage = error - ? getApiErrorMessage(error, mode === "create" ? "用户创建失败" : "用户更新失败") + ? getApiErrorMessage(error, mode === "create" ? "邀请失败" : "用户更新失败") : ""; - const visibleErrorMessage = - mode === "create" && - isStalePasswordServerError(serverErrorMessage, { - passwordTooShort, - passwordTooLong, - }) - ? "" - : serverErrorMessage; + const visibleErrorMessage = serverErrorMessage; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (mode === "create") { - if (!password.trim()) return; - if (passwordTooShort) { - toast({ title: "密码至少需要 6 个字符", variant: "error" }); - return; - } - if (passwordTooLong) { - toast({ title: "密码最长支持 72 个 UTF-8 字节,请缩短后重试", variant: "error" }); - return; - } - if (!username.trim()) { - toast({ title: "请输入用户名", variant: "error" }); + if (!phoneNumber.trim()) { + toast({ title: "请输入手机号", variant: "error" }); return; } if (roles.length === 0) { @@ -187,11 +125,9 @@ export function UserFormDialog({ return; } createMutation.mutate({ - username: username.trim(), - password: password.trim(), - name: name.trim() || username.trim(), + phone_number: phoneNumber.trim(), email: email.trim() || undefined, - role: roles, + role: roles[0], }); } else if (user) { if (canChangeRole && roles.length === 0) { @@ -218,84 +154,41 @@ export function UserFormDialog({ - {mode === "create" ? "添加用户" : "编辑用户"} + {mode === "create" ? "邀请成员" : "编辑用户"} {mode === "create" - ? "创建新用户账号" + ? "通过手机号邀请成员;对方验证手机号后会自动注册或绑定已有账号。" : "修改用户基本信息"}
- + { - setUsername(e.target.value); + if (mode === "create") setPhoneNumber(e.target.value); + else setUsername(e.target.value); clearMutationErrors(); }} - placeholder="请输入用户名" + placeholder={mode === "create" ? "请输入 11 位手机号" : "请输入用户名"} required disabled={mode === "edit"} />
- {mode === "create" && ( + {mode === "edit" && (
-
- - -
-
- { - setPassword(e.target.value); - clearMutationErrors(); - }} - className="pr-10" - placeholder="••••••••" - required - /> - -
-

- {passwordValidationMessage || `至少 6 个字符,最长 72 个 UTF-8 字节。当前 ${passwordBytes}/72 bytes`} -

- {!passwordValidationMessage && ( -

推荐直接使用系统生成的 14 位 ASCII 密码,不会触发长度边界。

- )} + + { + setName(e.target.value); + clearMutationErrors(); + }} + placeholder="显示名称" + />
)} -
- - { - setName(e.target.value); - clearMutationErrors(); - }} - placeholder="显示名称" - /> -
( ))}
-

可多选,至少选择一个角色

+

+ {mode === "create" ? "邀请成员时先授予一个空间角色,后续可在成员列表调整。" : "可多选,至少选择一个角色"} +

)} {visibleErrorMessage && ( @@ -346,9 +243,9 @@ export function UserFormDialog({ diff --git a/frontend/src/features/ai-readiness/components.tsx b/frontend/src/features/ai-readiness/components.tsx new file mode 100644 index 00000000..53967099 --- /dev/null +++ b/frontend/src/features/ai-readiness/components.tsx @@ -0,0 +1,243 @@ +import { AlertCircle, CheckCircle2, ChevronRight, CircleDashed, Sparkles } from "lucide-react"; +import { Link } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { AIReadiness, AIReadinessBlockerStatus, AIReadinessOperatorDiagnostic } from "./types"; + +export function AIReadinessPill({ readiness }: { readiness: AIReadiness }) { + const tone = readinessTone(readiness.status); + const target = readiness.canManage ? readiness.primaryAction?.to || "/ai-config?tab=setup" : readiness.primaryAction?.to || "/space"; + return ( + + + {readiness.shortLabel} + + + ); +} + +export function AIReadinessCard({ + readiness, + compact = false, + onSaveIdea, + showWorkspaceActions = true, + showOperatorEntry = true, +}: { + readiness: AIReadiness; + compact?: boolean; + onSaveIdea?: () => void; + showWorkspaceActions?: boolean; + showOperatorEntry?: boolean; +}) { + const tone = readinessTone(readiness.status); + const visibleWorkspaceActions = showWorkspaceActions ? readiness.workspaceOwnerActions : []; + return ( +
+
+ + {readiness.canGenerateProjectDraft ? : } + +
+
+

{readiness.title}

+ {readiness.updatedAtLabel} +
+

{readiness.summary}

+
+
+ +
+ {readiness.blockers.map((item) => ( +
+ +
+
+
{item.label}
+ {item.actionLabel && item.actionTo ? ( + + {item.actionLabel} + + ) : null} +
+
{item.description}
+
+
+ ))} +
+ +
+ {readiness.customerActions[0] ? ( + + ) : null} + {onSaveIdea ? ( + + ) : readiness.customerActions[1] ? ( + + ) : null} + {visibleWorkspaceActions.map((action) => ( + + ))} + {showOperatorEntry && readiness.platformDiagnostic ? ( + + ) : null} +
+
+ ); +} + +export function AIReadinessOperatorDiagnosticCard({ + diagnostic, + compact = false, +}: { + diagnostic: AIReadinessOperatorDiagnostic; + compact?: boolean; +}) { + const severityClass = + diagnostic.severity === "ok" + ? "border-emerald-200 bg-emerald-50 text-emerald-800" + : diagnostic.severity === "warning" + ? "border-amber-200 bg-amber-50 text-amber-900" + : "border-rose-200 bg-rose-50 text-rose-900"; + return ( +
+
+
+
平台诊断
+

客户视图与真实原因

+

{diagnostic.operatorSummary}

+
+ {diagnostic.severity} +
+
+
+
客户会看到
+
{diagnostic.customerTitle}
+
{diagnostic.customerSummary}
+
+
+
诊断字段
+
+ cause: {diagnostic.causeCode} +
+ owner: {diagnostic.causeOwner} +
+ raw: {diagnostic.rawStatus} +
+
+
+
+ {diagnostic.affectedScenes.map((scene) => ( + + {scene} + + ))} +
+ {diagnostic.checks ? ( +
+ {Object.entries(diagnostic.checks).map(([key, value]) => ( +
+
{key}
+
{value}
+
+ ))} +
+ ) : null} + {diagnostic.blockers?.length ? ( +
+ {diagnostic.blockers.slice(0, 3).map((item) => ( +
+
{item.label}
+
{item.description}
+
+ ))} +
+ ) : null} +
+ {diagnostic.platformActions.map((action) => ( + + ))} +
+
+ ); +} + +function BlockerIcon({ status }: { status: AIReadinessBlockerStatus }) { + const className = + status === "passed" + ? "bg-emerald-50 text-emerald-600" + : status === "failed" + ? "bg-rose-50 text-rose-600" + : status === "warning" + ? "bg-amber-50 text-amber-600" + : "bg-slate-100 text-slate-500"; + const Icon = status === "passed" ? CheckCircle2 : status === "missing" ? CircleDashed : AlertCircle; + return ( + + + + ); +} + +function readinessTone(status: AIReadiness["status"]) { + if (status === "ready") { + return { + pillClass: "border-emerald-200 bg-emerald-50 text-emerald-700 hover:bg-emerald-100", + dotClass: "bg-emerald-500", + cardClass: "border-emerald-200", + iconClass: "bg-emerald-50 text-emerald-600", + badgeClass: "bg-emerald-50 text-emerald-700", + }; + } + if (status === "failed" || status === "quota_exhausted" || status === "blocked" || status === "degraded") { + return { + pillClass: "border-rose-200 bg-rose-50 text-rose-700 hover:bg-rose-100", + dotClass: "bg-rose-500", + cardClass: "border-rose-200", + iconClass: "bg-rose-50 text-rose-600", + badgeClass: "bg-rose-50 text-rose-700", + }; + } + if (status === "partial" || status === "recovering") { + return { + pillClass: "border-amber-200 bg-amber-50 text-amber-700 hover:bg-amber-100", + dotClass: "bg-amber-500", + cardClass: "border-amber-200", + iconClass: "bg-amber-50 text-amber-600", + badgeClass: "bg-amber-50 text-amber-700", + }; + } + if (status === "permission_required") { + return { + pillClass: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50", + dotClass: "bg-slate-400", + cardClass: "border-slate-200", + iconClass: "bg-slate-100 text-slate-500", + badgeClass: "bg-slate-100 text-slate-500", + }; + } + return { + pillClass: "border-[#cfe0ff] bg-[#eef4ff] text-[#2463EB] hover:bg-[#e4eeff]", + dotClass: "bg-[#2463EB]", + cardClass: "border-[#cfe0ff]", + iconClass: "bg-[#eef4ff] text-[#2463EB]", + badgeClass: "bg-[#eef4ff] text-[#2463EB]", + }; +} diff --git a/frontend/src/features/ai-readiness/readiness.ts b/frontend/src/features/ai-readiness/readiness.ts new file mode 100644 index 00000000..797cbf9b --- /dev/null +++ b/frontend/src/features/ai-readiness/readiness.ts @@ -0,0 +1,394 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { aiReadinessApi, type AIReadinessApiResponse } from "@/api/aiReadiness"; +import { getAuthToken } from "@/lib/api"; +import { canAccessPlatformAI } from "@/lib/rbac"; +import { useAuthStore } from "@/stores/auth"; +import type { User } from "@/stores/auth"; +import type { + AIReadiness, + AIReadinessAction, + AIReadinessBlocker, + AIReadinessCauseOwner, + AIReadinessCustomerState, + AIReadinessOperatorDiagnostic, + AIReadinessStatus, +} from "./types"; + +const STORAGE_KEY = "pmflow_ai_readiness_status"; +const STORAGE_EVENT = "pmflow-ai-readiness-change"; +const LOCAL_OVERRIDE_ENABLED = + import.meta.env.VITE_AI_READINESS_LOCAL_OVERRIDE === "true" || import.meta.env.MODE === "test"; + +const RECORD_IDEA_ACTION: AIReadinessAction = { label: "记录项目想法", to: "/project-launch" }; +const VIEW_PROJECTS_ACTION: AIReadinessAction = { label: "查看所有项目", to: "/space/projects" }; +const VIEW_SPACE_STATUS_ACTION: AIReadinessAction = { label: "查看空间 AI 状态", to: "/ai-config?tab=setup" }; +const TEAM_POLICY_ACTION: AIReadinessAction = { label: "查看团队策略", to: "/ai-config/team-ai-policy" }; +const TOOL_CONNECTION_ACTION: AIReadinessAction = { label: "配置工具连接", to: "/ai-config?tab=configure" }; + +function normalizeStoredStatus(value: string | null): AIReadinessStatus { + if ( + value === "blocked" || + value === "degraded" || + value === "recovering" || + value === "unknown" || + value === "not_configured" || + value === "partial" || + value === "ready" || + value === "failed" || + value === "quota_exhausted" + ) { + return value; + } + return "not_configured"; +} + +function normalizeApiStatus(value: unknown): AIReadinessStatus { + const raw = String(value || "").trim(); + if ( + raw === "blocked" || + raw === "degraded" || + raw === "recovering" || + raw === "unknown" || + raw === "not_configured" || + raw === "partial" || + raw === "ready" || + raw === "failed" || + raw === "quota_exhausted" || + raw === "permission_required" + ) { + return raw; + } + return "unknown"; +} + +export function canManageAISetup(role?: string | string[] | null): boolean { + const raw = Array.isArray(role) ? role.join(",") : String(role || ""); + const normalized = raw.toUpperCase(); + return ( + normalized.includes("OWNER") || + normalized.includes("ADMIN") || + normalized.includes("REVIEWER") + ); +} + +function readStatus(): AIReadinessStatus { + if (typeof window === "undefined") return "not_configured"; + return normalizeStoredStatus(window.localStorage.getItem(STORAGE_KEY)); +} + +export function setStoredAIReadinessStatus(status: AIReadinessStatus) { + if (status === "permission_required") return; + window.localStorage.setItem(STORAGE_KEY, status); + window.dispatchEvent(new CustomEvent(STORAGE_EVENT)); +} + +function customerStateForStatus(status: AIReadinessStatus): AIReadinessCustomerState { + if (status === "ready") return "ai_collaboration_ready"; + if (status === "partial" || status === "recovering") return "service_recovering"; + if (status === "failed" || status === "quota_exhausted" || status === "blocked" || status === "degraded") { + return "ai_collaboration_degraded"; + } + if (status === "permission_required") return "workspace_action_required"; + return "ai_draft_temporarily_unavailable"; +} + +function customerCopy(status: AIReadinessStatus) { + if (status === "ready") { + return { + shortLabel: "AI 协作可用", + title: "AI 协作可用", + summary: "PMFlow 可以生成项目草案、提取需求与风险,并把结果交回主流程确认。", + updatedAtLabel: "可用", + }; + } + if (status === "partial" || status === "recovering") { + return { + shortLabel: "AI 协作恢复中", + title: "AI 草案生成正在恢复", + summary: "平台能力正在恢复验证中。你可以先记录想法,系统确认稳定后会开放草案生成。", + updatedAtLabel: "恢复中", + }; + } + if (status === "failed" || status === "quota_exhausted" || status === "blocked" || status === "degraded") { + return { + shortLabel: "AI 协作暂不可用", + title: "AI 项目草案暂不可用", + summary: + "当前空间暂时不能生成 AI 项目草案。你仍可以推进已有项目、处理待确认事项,或先记录新的项目想法。", + updatedAtLabel: "暂不可用", + }; + } + if (status === "permission_required") { + return { + shortLabel: "AI 协作暂不可用", + title: "AI 项目草案暂不可用", + summary: "当前账号可以查看项目与待确认事项,也可以先记录项目想法;AI 可用状态由空间负责人处理。", + updatedAtLabel: "只读", + }; + } + return { + shortLabel: "AI 协作暂不可用", + title: "AI 项目草案暂不可用", + summary: + "当前空间暂时不能使用 AI 生成项目草案。你仍可以记录想法、查看项目和处理待确认事项;系统不会用空草案或模板草案冒充 AI 结果。", + updatedAtLabel: "暂不可用", + }; +} + +function customerBlockers(status: AIReadinessStatus, canManage: boolean): AIReadinessBlocker[] { + if (status === "ready") { + return [ + { + key: "draft_generation", + label: "项目草案生成", + status: "passed", + description: "可以把项目想法生成可确认的结构化草案。", + }, + { + key: "project_copilot", + label: "项目 AI 副驾", + status: "passed", + description: "项目内 AI 副驾可围绕当前上下文解释、建议并发起待确认动作。", + }, + { + key: "safe_confirmation", + label: "确认保护", + status: "passed", + description: "生成结果仍进入项目主流程确认,不会直接写入正式项目状态。", + }, + ]; + } + + return [ + { + key: "draft_generation", + label: "当前影响", + status: status === "recovering" || status === "partial" ? "warning" : "failed", + description: status === "recovering" || status === "partial" ? "AI 草案生成正在恢复验证。" : "暂时不能生成 AI 项目草案。", + }, + { + key: "existing_work", + label: "仍可继续", + status: "passed", + description: "记录想法、处理待确认、查看项目和推进已有项目不受影响。", + }, + { + key: "safe_fallback", + label: "保护规则", + status: "warning", + description: canManage + ? "空间负责人可以查看空间 AI 状态;平台能力未恢复前,不会开放 AI 草案生成。" + : "PMFlow 不会用空草案或模板内容冒充 AI 结果。", + }, + ]; +} + +function diagnosticForStatus(status: AIReadinessStatus): AIReadinessOperatorDiagnostic | undefined { + const customer = customerCopy(status); + const platformActions: AIReadinessAction[] = [ + { label: "查看供应商与凭据", to: "/platform/ai-routing-providers" }, + { label: "查看失败诊断", to: "/platform/ai-routing-failures" }, + ]; + const base = { + rawStatus: status, + affectedScenes: ["项目草案生成", "项目 AI 副驾", "空间 AI 管家"], + customerTitle: customer.title, + customerSummary: customer.summary, + platformActions, + }; + + if (status === "ready") { + return { + ...base, + causeCode: "ready", + causeOwner: "pmflow_platform", + severity: "ok", + operatorSummary: "客户侧 AI 协作可用,项目草案生成与项目副驾入口可以开放。", + }; + } + if (status === "partial" || status === "recovering") { + return { + ...base, + causeCode: "route_or_readiness_incomplete", + causeOwner: "pmflow_platform", + severity: "warning", + operatorSummary: "平台侧 AI 可用性检查未完成。需要核对供应商槽位、场景路由和项目生成保护策略。", + }; + } + if (status === "failed" || status === "blocked" || status === "degraded") { + return { + ...base, + causeCode: "ai_generation_chain_failed", + causeOwner: "pmflow_platform", + severity: "critical", + operatorSummary: "平台侧 AI 生成链路失败。需要查看最近失败、供应商状态、结构化输出和路由诊断。", + }; + } + if (status === "quota_exhausted") { + return { + ...base, + causeCode: "platform_quota_or_budget_guard", + causeOwner: "pmflow_platform", + severity: "critical", + operatorSummary: "平台侧 AI 调用额度或预算保护触发。需要核对受影响空间、限流策略和恢复窗口。", + }; + } + if (status === "permission_required") { + return { + ...base, + causeCode: "viewer_not_workspace_manager", + causeOwner: "workspace_admin", + severity: "warning", + operatorSummary: "当前用户不是空间管理角色。客户侧只展示业务影响,不展示配置或诊断入口。", + affectedScenes: ["项目草案生成入口", "空间 AI 状态入口"], + platformActions: [{ label: "查看客户视图", to: "/space" }], + }; + } + return { + ...base, + causeCode: "provider_or_route_not_opened", + causeOwner: "pmflow_platform", + severity: "critical", + operatorSummary: "平台侧尚未开放 AI 项目草案能力。需要处理供应商、场景路由、保护函数和默认槽位。", + }; +} + +function workspaceOwnerActions(status: AIReadinessStatus): AIReadinessAction[] { + if (status === "ready") return [VIEW_SPACE_STATUS_ACTION, TEAM_POLICY_ACTION]; + return [VIEW_SPACE_STATUS_ACTION, TOOL_CONNECTION_ACTION, TEAM_POLICY_ACTION]; +} + +function customerActions(status: AIReadinessStatus): AIReadinessAction[] { + if (status === "ready") return [{ label: "新建项目", to: "/project-launch" }]; + return [RECORD_IDEA_ACTION, VIEW_PROJECTS_ACTION]; +} + +function buildReadiness(status: AIReadinessStatus, user: User | null): AIReadiness { + const canManage = canManageAISetup(user?.role); + const canOperatePlatformAI = canAccessPlatformAI(user); + const copy = customerCopy(status); + const actions = customerActions(status); + const ownerActions = canManage ? workspaceOwnerActions(status) : []; + const diagnostic = canOperatePlatformAI ? diagnosticForStatus(status) : undefined; + + return { + status, + customerState: customerStateForStatus(status), + shortLabel: copy.shortLabel, + title: copy.title, + summary: copy.summary, + blockers: customerBlockers(status, canManage), + customerActions: actions, + workspaceOwnerActions: ownerActions, + platformDiagnostic: diagnostic, + primaryAction: actions[0], + secondaryAction: actions[1] || ownerActions[0], + canGenerateProjectDraft: status === "ready", + canManage, + canOperatePlatformAI, + updatedAtLabel: copy.updatedAtLabel, + }; +} + +function normalizeBlocker(item: Record): AIReadinessBlocker { + return { + key: String(item.key || item.code || item.label || "readiness_blocker"), + label: String(item.label || "AI 可用性检查"), + status: item.status === "passed" || item.status === "failed" || item.status === "warning" || item.status === "missing" + ? item.status + : "warning", + description: String(item.description || item.detail || ""), + actionLabel: item.actionLabel ? String(item.actionLabel) : item.action_label ? String(item.action_label) : undefined, + actionTo: item.actionTo ? String(item.actionTo) : item.action_to ? String(item.action_to) : undefined, + }; +} + +function buildReadinessFromApi(payload: AIReadinessApiResponse, user: User | null): AIReadiness { + const status = normalizeApiStatus(payload.status); + const canManage = canManageAISetup(user?.role); + const canOperatePlatformAI = canAccessPlatformAI(user); + const actions = (payload.customer_actions || []).map((item) => ({ label: item.label, to: item.to })); + const ownerActions = canManage + ? (payload.workspace_owner_actions || []).map((item) => ({ label: item.label, to: item.to })) + : []; + const platformDiagnostic = payload.platform_diagnostic && canOperatePlatformAI + ? { + causeCode: payload.platform_diagnostic.cause_code, + causeOwner: String(payload.platform_diagnostic.cause_owner || "unknown") as AIReadinessCauseOwner, + rawStatus: normalizeApiStatus(payload.platform_diagnostic.raw_status), + severity: payload.platform_diagnostic.severity, + affectedScenes: payload.platform_diagnostic.affected_scenes || [], + customerTitle: payload.platform_diagnostic.customer_title, + customerSummary: payload.platform_diagnostic.customer_summary, + operatorSummary: payload.platform_diagnostic.operator_summary, + platformActions: payload.platform_diagnostic.platform_actions || [], + checks: payload.platform_diagnostic.checks, + blockers: (payload.platform_diagnostic.blockers || []).map((item) => normalizeBlocker(item as unknown as Record)), + } + : undefined; + const blockers = (payload.blockers || []).map((item) => normalizeBlocker(item as unknown as Record)); + return { + status, + customerState: (payload.customer_state as AIReadiness["customerState"]) || customerStateForStatus(status), + shortLabel: payload.short_label || customerCopy(status).shortLabel, + title: payload.title || customerCopy(status).title, + summary: payload.summary || customerCopy(status).summary, + blockers: blockers.length ? blockers : customerBlockers(status, canManage), + customerActions: actions.length ? actions : customerActions(status), + workspaceOwnerActions: ownerActions.length ? ownerActions : canManage ? workspaceOwnerActions(status) : [], + platformDiagnostic, + primaryAction: actions[0], + secondaryAction: actions[1] || ownerActions[0], + canGenerateProjectDraft: Boolean(payload.can_generate_project_draft), + canManage, + canOperatePlatformAI, + updatedAtLabel: payload.updated_at_label || customerCopy(status).updatedAtLabel, + }; +} + +export function useAIReadiness() { + const user = useAuthStore((state) => state.user); + const [overrideStatus, setOverrideStatus] = useState(() => readStatus()); + const token = getAuthToken(); + + useEffect(() => { + if (!LOCAL_OVERRIDE_ENABLED) return; + const sync = () => setOverrideStatus(readStatus()); + window.addEventListener("storage", sync); + window.addEventListener(STORAGE_EVENT, sync); + return () => { + window.removeEventListener("storage", sync); + window.removeEventListener(STORAGE_EVENT, sync); + }; + }, []); + + const query = useQuery({ + queryKey: ["ai-readiness", "project-launch"], + queryFn: () => aiReadinessApi.getProjectLaunchReadiness(), + enabled: Boolean(token) && !LOCAL_OVERRIDE_ENABLED, + retry: 1, + staleTime: 30_000, + }); + + const setReadinessStatus = useCallback((nextStatus: AIReadinessStatus) => { + if (!LOCAL_OVERRIDE_ENABLED) return; + setStoredAIReadinessStatus(nextStatus); + setOverrideStatus(readStatus()); + }, []); + + const readiness = useMemo(() => { + if (LOCAL_OVERRIDE_ENABLED) return buildReadiness(overrideStatus, user); + if (query.data) return buildReadinessFromApi(query.data, user); + if (query.isError) return buildReadiness("unknown", user); + return buildReadiness("recovering", user); + }, [overrideStatus, query.data, query.isError, user]); + + return { + readiness, + rawStatus: readiness.status, + setReadinessStatus, + isLoading: query.isLoading, + refetch: query.refetch, + }; +} diff --git a/frontend/src/features/ai-readiness/types.ts b/frontend/src/features/ai-readiness/types.ts new file mode 100644 index 00000000..8244d9ca --- /dev/null +++ b/frontend/src/features/ai-readiness/types.ts @@ -0,0 +1,66 @@ +export type AIReadinessStatus = + | "blocked" + | "degraded" + | "recovering" + | "unknown" + | "not_configured" + | "partial" + | "ready" + | "failed" + | "quota_exhausted" + | "permission_required"; + +export type AIReadinessBlockerStatus = "missing" | "failed" | "passed" | "warning"; +export type AIReadinessCauseOwner = "pmflow_platform" | "workspace_admin" | "user_action" | "unknown"; +export type AIReadinessCustomerState = + | "ai_collaboration_ready" + | "ai_draft_temporarily_unavailable" + | "ai_collaboration_degraded" + | "workspace_action_required" + | "service_recovering"; + +export interface AIReadinessAction { + label: string; + to: string; +} + +export interface AIReadinessBlocker { + key: string; + label: string; + status: AIReadinessBlockerStatus; + description: string; + actionLabel?: string; + actionTo?: string; +} + +export interface AIReadinessOperatorDiagnostic { + causeCode: string; + causeOwner: AIReadinessCauseOwner; + rawStatus: AIReadinessStatus; + severity: "ok" | "warning" | "critical"; + affectedScenes: string[]; + customerTitle: string; + customerSummary: string; + operatorSummary: string; + platformActions: AIReadinessAction[]; + checks?: Record; + blockers?: AIReadinessBlocker[]; +} + +export interface AIReadiness { + status: AIReadinessStatus; + customerState: AIReadinessCustomerState; + shortLabel: string; + title: string; + summary: string; + blockers: AIReadinessBlocker[]; + customerActions: AIReadinessAction[]; + workspaceOwnerActions: AIReadinessAction[]; + platformDiagnostic?: AIReadinessOperatorDiagnostic; + primaryAction?: AIReadinessAction; + secondaryAction?: AIReadinessAction; + canGenerateProjectDraft: boolean; + canManage: boolean; + canOperatePlatformAI: boolean; + updatedAtLabel: string; +} diff --git a/frontend/src/features/personal-space/PersonalSpacePage.test.tsx b/frontend/src/features/personal-space/PersonalSpacePage.test.tsx index b3015d47..69a3aca1 100644 --- a/frontend/src/features/personal-space/PersonalSpacePage.test.tsx +++ b/frontend/src/features/personal-space/PersonalSpacePage.test.tsx @@ -1,15 +1,20 @@ -import { screen } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import PersonalSpacePage from "./PersonalSpacePage"; import { aiPmoApi } from "@/api/aiPmo"; +import { aiStewardApi } from "@/api/aiSteward"; import { getPMFlowExecutionCapabilities, getPMFlowExecutionSummary, getPMFlowTruthState } from "@/api/pmflowOs"; import { projectsApi } from "@/api/projects"; import { spaceApi, type SpaceWorkbench } from "@/api/space"; +import { useAuthStore } from "@/stores/auth"; import { renderWithProviders } from "@/test/renderWithProviders"; vi.mock("@/api/space", () => ({ spaceApi: { getWorkbench: vi.fn(), + archiveSavedIdea: vi.fn(), + promoteSavedIdea: vi.fn(), + updateSavedIdea: vi.fn(), }, })); @@ -27,6 +32,17 @@ vi.mock("@/api/aiPmo", () => ({ }, })); +vi.mock("@/api/aiSteward", () => ({ + aiStewardApi: { + askRag: vi.fn(), + listQASessions: vi.fn(), + getQASession: vi.fn(), + askSpaceConcierge: vi.fn(), + listSpaceConciergeSessions: vi.fn(), + getSpaceConciergeSession: vi.fn(), + }, +})); + vi.mock("@/api/pmflowOs", () => ({ getPMFlowTruthState: vi.fn(), getPMFlowExecutionSummary: vi.fn(), @@ -55,14 +71,59 @@ const emptyWorkbench: SpaceWorkbench = { drafts: [], legacyProjectScopeItems: [], recentProjects: [], - warnings: ["暂存想法只读聚合尚未接入后端合同,当前不使用 mock 数据冒充真实内容。"], + warnings: [], generatedAt: "2026-04-26T08:00:00.000Z", }; +const savedIdea = { + id: "idea-1", + title: "现在试试暂存", + rawText: "现在试试暂存", + summary: "现在试试暂存", + sourceType: "project_launch", + sourceRef: null, + inputMode: "idea", + attachments: [], + status: "saved", + statusLabel: "信息不足", + aiSummary: null, + missingQuestions: ["目标用户是谁?", "要解决的核心问题是什么?"], + readinessScore: 0.16, + enrichmentStatus: "baseline", + enrichmentPayload: { source: "system_baseline" }, + enrichmentError: null, + enrichedAt: null, + launchDraftId: null, + promotedProjectId: null, + createdAt: "2026-04-26T08:00:00.000Z", + updatedAt: "2026-04-26T08:00:00.000Z", + href: "/space#saved-ideas", +} satisfies SpaceWorkbench["savedIdeas"][number]; + describe("PersonalSpacePage", () => { + afterEach(() => { + cleanup(); + }); + beforeEach(() => { vi.clearAllMocks(); + localStorage.removeItem("pmflow_space_ai_concierge_position"); + localStorage.removeItem("pmflow_ai_readiness_status"); + useAuthStore.setState({ + token: "test-token", + user: { id: "7", username: "owner", role: "OWNER" }, + }); vi.mocked(aiPmoApi.listWorkItems).mockResolvedValue({ items: [] }); + vi.mocked(aiStewardApi.listSpaceConciergeSessions).mockResolvedValue({ items: [], page: 1, total_pages: 1 }); + vi.mocked(aiStewardApi.getSpaceConciergeSession).mockResolvedValue({ + session: { id: 1, title: "历史会话", message_count: 0 }, + messages: [], + }); + vi.mocked(aiStewardApi.askSpaceConcierge).mockResolvedValue({ + answer: "可以先处理待确认事项。", + reply_text: "可以先处理待确认事项。", + sources: [{ source: "space", label: "个人空间", score: 1 }], + }); }); it("renders the read-only Space workbench sections without demo saved ideas", async () => { @@ -87,8 +148,155 @@ describe("PersonalSpacePage", () => { expect(screen.getAllByText("草案箱")).not.toHaveLength(0); expect(screen.getAllByText("最近项目")).not.toHaveLength(0); expect(screen.getAllByText("暂无暂存想法")).not.toHaveLength(0); - expect(screen.getByText("暂存想法只读聚合尚未接入后端合同,当前不使用 mock 数据冒充真实内容。")).toBeInTheDocument(); + expect(screen.getAllByText("在新项目启动台、AI 管家或导入材料时,可以先把未成型想法保存到这里。").length).toBeGreaterThan(0); expect(screen.queryByText("AI 周报助手")).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: "空间首页" })).toHaveAttribute("href", "/space"); + expect(screen.getByRole("link", { name: "暂存想法" })).toHaveAttribute("href", "/space/ideas"); + expect(screen.getByRole("link", { name: "草案箱" })).toHaveAttribute("href", "/space/drafts"); + expect(screen.queryByRole("link", { name: "最近项目" })).toBeNull(); + expect(screen.queryByRole("link", { name: "待我确认" })).toBeNull(); + expect(screen.queryByRole("link", { name: "AI 正在推进" })).toBeNull(); + expect(screen.getByRole("button", { name: "打开空间 AI 管家" })).toBeInTheDocument(); + expect(screen.getAllByText("AI 项目草案暂不可用").length).toBeGreaterThan(0); + expect(screen.getAllByText("查看空间 AI 状态").length).toBeGreaterThan(0); + expect(screen.queryByText("AI 尚未就绪")).toBeNull(); + expect(screen.queryByText("启用 AI 能力")).toBeNull(); + expect(screen.queryByText("AI 服务")).toBeNull(); + expect(screen.queryByText("使用策略")).toBeNull(); + }); + + it("lets the Space AI concierge button be dragged without opening the drawer", async () => { + vi.mocked(spaceApi.getWorkbench).mockResolvedValue(emptyWorkbench); + localStorage.setItem("pmflow_space_ai_concierge_position", JSON.stringify({ x: 100, y: 100 })); + + renderWithProviders(, ["/space"]); + + const buttons = await screen.findAllByRole("button", { name: "打开空间 AI 管家" }); + const button = buttons[buttons.length - 1]; + await waitFor(() => expect(button).toHaveStyle({ left: "100px", top: "100px" })); + fireEvent.pointerDown(button, { button: 0, pointerId: 1, clientX: 100, clientY: 100 }); + fireEvent.pointerMove(button, { pointerId: 1, clientX: 180, clientY: 160 }); + fireEvent.pointerUp(button, { pointerId: 1, clientX: 180, clientY: 160 }); + fireEvent.click(button); + + expect(screen.queryByRole("dialog")).toBeNull(); + await waitFor(() => expect(localStorage.getItem("pmflow_space_ai_concierge_position")).not.toBeNull()); + expect(button).toHaveStyle({ left: "180px", top: "160px" }); + }); + + it("lets Space AI concierge metrics jump to the matching workbench sections", async () => { + vi.mocked(spaceApi.getWorkbench).mockResolvedValue({ + ...emptyWorkbench, + nextActionQueue: [ + { + id: "next-action-1", + projectId: "17", + projectTitle: "真实项目", + kind: "pending_confirmation", + title: "高风险需求变更待确认", + summary: "高风险 ChangeSet 需要先确认。", + categoryLabel: "ChangeSet 待确认", + priorityLabel: "高优先级", + priorityRank: 10, + severity: "high", + updatedAt: "2026-04-26T08:00:00.000Z", + href: "/change-review?projectId=17&changeSetId=93", + }, + ], + pendingConfirmations: [ + { + id: "change-91", + projectId: "17", + projectTitle: "真实项目", + title: "确认需求变更", + summary: "等待在主流程确认变更。", + source: "changeset", + stage: "需求", + riskLevel: "medium", + createdAt: "2026-04-26T08:00:00.000Z", + href: "/change-review?projectId=17&changeSetId=91", + }, + ], + aiProgress: [ + { + id: "execution-17", + projectId: "17", + projectTitle: "真实项目", + title: "AI 正在推进执行链路", + summary: "正在聚合执行进度与回写状态。", + status: "coding", + progress: 52, + updatedAt: "2026-04-26T08:00:00.000Z", + href: "/execution-bridge?projectId=17", + }, + ], + }); + const scrollIntoView = vi.fn(); + const focus = vi.fn(); + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { configurable: true, value: scrollIntoView }); + Object.defineProperty(HTMLElement.prototype, "focus", { configurable: true, value: focus }); + + renderWithProviders(, ["/space"]); + + const openButtons = await screen.findAllByRole("button", { name: "打开空间 AI 管家" }); + fireEvent.click(openButtons[openButtons.length - 1]); + fireEvent.click(await screen.findByRole("button", { name: "跳转到下一步" })); + + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + await waitFor(() => expect(window.location.hash).toBe("#next-actions")); + expect(scrollIntoView).toHaveBeenCalled(); + expect(focus).toHaveBeenCalled(); + + fireEvent.click(openButtons[openButtons.length - 1]); + fireEvent.click(await screen.findByRole("button", { name: "跳转到待确认" })); + + await waitFor(() => expect(window.location.hash).toBe("#pending-confirmations")); + + fireEvent.click(openButtons[openButtons.length - 1]); + fireEvent.click(await screen.findByRole("button", { name: "跳转到执行中" })); + + await waitFor(() => expect(window.location.hash).toBe("#ai-progress")); + }); + + it("keeps AI concierge history failure out of the chat input error slot", async () => { + vi.mocked(spaceApi.getWorkbench).mockResolvedValue(emptyWorkbench); + vi.mocked(aiStewardApi.listSpaceConciergeSessions).mockRejectedValueOnce(Object.assign(new Error("Not Found"), { status: 404 })); + + renderWithProviders(, ["/space"]); + + const buttons = await screen.findAllByRole("button", { name: "打开空间 AI 管家" }); + fireEvent.click(buttons[buttons.length - 1]); + + expect(await screen.findByText("历史记录暂不可用,当前仍可新建对话。")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("问空间里的项目、下一步、待确认、草案和 AI 能力状态...")).toBeInTheDocument(); + expect(screen.queryByText("无法读取 AI 管家历史,请稍后重试。")).toBeNull(); + }); + + it("shows history-specific empty state when AI concierge history cannot be loaded", async () => { + vi.mocked(spaceApi.getWorkbench).mockResolvedValue(emptyWorkbench); + vi.mocked(aiStewardApi.listSpaceConciergeSessions).mockRejectedValueOnce(Object.assign(new Error("Not Found"), { status: 404 })); + + renderWithProviders(, ["/space?view=ai-history"]); + + expect(await screen.findByText("历史记录暂不可用")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "重新读取" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "开始新对话" })).toBeInTheDocument(); + }); + + it("shows chat error only when AI concierge answer fails", async () => { + vi.mocked(spaceApi.getWorkbench).mockResolvedValue(emptyWorkbench); + vi.mocked(aiStewardApi.askSpaceConcierge).mockRejectedValueOnce(Object.assign(new Error("upstream failed"), { status: 500 })); + + renderWithProviders(, ["/space"]); + + const buttons = await screen.findAllByRole("button", { name: "打开空间 AI 管家" }); + fireEvent.click(buttons[buttons.length - 1]); + const input = await screen.findByPlaceholderText("问空间里的项目、下一步、待确认、草案和 AI 能力状态..."); + fireEvent.change(input, { target: { value: "我今天应该先处理什么?" } }); + fireEvent.click(screen.getByRole("button", { name: "发送给空间 AI 管家" })); + + expect(await screen.findByText("AI 管家暂时无法回答。请检查后端服务或稍后重试。")).toBeInTheDocument(); + expect(screen.queryByText("历史记录暂不可用,当前仍可新建对话。")).toBeNull(); }); it("keeps workbench actions as navigation-only links into the main flows", async () => { @@ -439,6 +647,331 @@ describe("PersonalSpacePage", () => { expect(screen.queryByRole("button", { name: /生成草案|写入 ChangeSet|创建下一轮/ })).not.toBeInTheDocument(); }); + it("opens saved idea details with continue editing, draft generation, and archive actions", async () => { + vi.mocked(spaceApi.getWorkbench).mockResolvedValue({ + ...emptyWorkbench, + savedIdeas: [savedIdea], + }); + vi.mocked(spaceApi.updateSavedIdea).mockResolvedValue({ + ...savedIdea, + title: "更完整的想法", + rawText: "补充后的原始输入", + summary: "补充后的摘要", + }); + + renderWithProviders(, ["/space"]); + + fireEvent.click((await screen.findAllByText("现在试试暂存"))[0]); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /继续完善/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "AI 草案暂不可用" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "归档" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /继续完善/ })); + fireEvent.change(screen.getByPlaceholderText("给这个想法起个名字"), { target: { value: "更完整的想法" } }); + fireEvent.change(screen.getByPlaceholderText("继续补充目标用户、核心问题、信号或约束"), { target: { value: "补充后的原始输入" } }); + fireEvent.click(screen.getByRole("button", { name: "保存完善" })); + + await waitFor(() => { + expect(spaceApi.updateSavedIdea).toHaveBeenCalledWith("idea-1", { + title: "更完整的想法", + raw_text: "补充后的原始输入", + summary: "现在试试暂存", + }); + }); + fireEvent.click(screen.getByRole("button", { name: "关闭" })); + }); + + it("does not keep a failed draft-generation banner after closing and reopening a saved idea", async () => { + localStorage.setItem("pmflow_ai_readiness_status", "ready"); + vi.mocked(spaceApi.getWorkbench).mockResolvedValue({ + ...emptyWorkbench, + savedIdeas: [savedIdea], + }); + vi.mocked(spaceApi.promoteSavedIdea).mockRejectedValue({ + response: { status: 500, data: { message: "AI provider unavailable" } }, + }); + + renderWithProviders(, ["/space"]); + + fireEvent.click((await screen.findAllByText("现在试试暂存"))[0]); + fireEvent.click(await screen.findByRole("button", { name: "生成项目草案" })); + expect(await screen.findByText("AI 生成服务暂时不可用。你的输入已保留,系统已记录问题。")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "关闭" })); + fireEvent.click(screen.getAllByText("现在试试暂存")[0]); + + expect(screen.queryByText("AI 生成服务暂时不可用。你的输入已保留,系统已记录问题。")).toBeNull(); + }); + + it("consumes main-flow extraction, execution result, and release business signals as read-only next actions", async () => { + vi.mocked(projectsApi.getSummary).mockResolvedValue({ + projects: [ + { + id: 17, + title: "真实项目", + description: "真实项目摘要", + status: "active", + progress: 62, + created_at: "2026-04-26T07:00:00.000Z", + updated_at: "2026-04-26T08:00:00.000Z", + }, + ], + } as never); + vi.mocked(getPMFlowTruthState).mockResolvedValue({ + loopStage: "extraction_ready", + lastWritebackAt: "2026-04-26T08:00:00.000Z", + project: { + id: "17", + name: "真实项目", + goal: "真实项目摘要", + stage: "development", + threadId: "thread-17", + }, + thread: { + id: "thread-17", + title: "真实项目", + projectId: "17", + objective: "继续推进", + extractionStatus: "ready", + messageIds: ["m-1"], + }, + messages: [], + changeSets: [ + { + id: "cs-ai", + threadId: "thread-17", + summary: "AI 结构化提取生成的变更待确认。", + headline: "AI 结构化提取", + createdAt: "2026-04-26T08:00:00.000Z", + status: "open", + itemIds: ["ci-ai"], + }, + ], + changeItems: [ + { + id: "ci-ai", + changeSetId: "cs-ai", + group: "new_object", + objectType: "Requirement", + objectId: "req-ai", + title: "确认 AI 结构化变更", + summary: "AI 从项目线程抽取了待确认需求。", + diff: "新增需求", + confidence: 0.86, + riskLevel: "high", + status: "pending_review", + evidenceIds: ["ev-ai"], + }, + ], + evidence: [], + requirements: [], + decisions: [], + tasks: [], + risks: [], + modelDecisions: [], + testRuns: [], + releases: [], + metricSignals: [], + approvals: [], + feed: [], + teamInbox: { product: [], engineering: [], qa: [], growth: [] }, + activeChangeSetId: "cs-ai", + selectedTaskId: null, + execution: { + taskId: null, + status: "synced", + prompt: "", + commitLog: [], + prStatus: "", + testSummary: "", + writebackStatus: "", + }, + } as never); + vi.mocked(getPMFlowExecutionSummary).mockResolvedValue({ + projectId: "17", + execution: { + taskId: "task-1", + status: "failed", + prompt: "", + commitLog: [], + prStatus: "", + testSummary: "pytest failed", + writebackStatus: "", + }, + releaseGate: { + status: "blocked", + title: "发布门禁阻塞", + summary: "执行结果失败,不能发布。", + blockingReasons: ["测试失败: pytest failed"], + nextActions: ["回到 Execution Bridge 处理失败。"], + checks: [{ key: "tests", label: "测试", status: "failed", detail: "pytest failed" }], + evidence: [{ id: "ev-test", type: "test_failed", summary: "pytest failed", occurredAt: "2026-04-26T08:01:00.000Z" }], + updatedAt: "2026-04-26T08:01:00.000Z", + }, + releaseSignals: { + status: "active", + relationship: { + releaseGateStatus: "blocked", + source: "pmflow_os_release_growth", + changeSetCreated: false, + businessSignalTypes: ["feedback", "revenue", "roi", "compliance"], + }, + summary: { + feedbackCount: 1, + revenueEventCount: 1, + roiSnapshotCount: 1, + complianceCheckCount: 1, + iterationRecommendationCount: 1, + riskSignalCount: 2, + latestSignalAt: "2026-04-26T08:04:00.000Z", + }, + feedback: [ + { + id: "fb-1", + projectId: "17", + sourceType: "manual", + title: "新反馈信号", + content: "用户反馈集中在 onboarding 卡顿。", + metrics: { count: 12 }, + samples: [], + status: "active", + createdAt: "2026-04-26T08:04:00.000Z", + updatedAt: "2026-04-26T08:04:00.000Z", + }, + ], + revenueEvents: [ + { + id: "rev-1", + projectId: "17", + sourceType: "manual", + category: "subscription", + currency: "USD", + amountUsd: 1200, + externalRef: "rev_001", + eventPayload: {}, + occurredAt: "2026-04-26T08:03:30.000Z", + createdAt: "2026-04-26T08:03:30.000Z", + updatedAt: "2026-04-26T08:03:30.000Z", + }, + ], + roiSnapshots: [ + { + id: "roi-1", + projectId: "17", + rangeKey: "monthly", + totalRevenueUsd: 1200, + totalCostUsd: 1800, + roiRatio: -0.3333, + snapshotPayload: {}, + riskLevel: "high", + status: "negative", + createdAt: "2026-04-26T08:03:00.000Z", + updatedAt: "2026-04-26T08:03:00.000Z", + }, + ], + complianceChecks: [ + { + id: "comp-1", + projectId: "17", + status: "blocked", + score: 58, + checklist: [{ key: "privacy", status: "failed" }], + issues: [{ title: "缺少隐私授权记录", severity: "high" }], + summary: {}, + riskLevel: "high", + createdAt: "2026-04-26T08:02:30.000Z", + updatedAt: "2026-04-26T08:02:30.000Z", + }, + ], + recommendations: [ + { + id: "rec-1", + projectId: "17", + status: "draft", + priority: "medium", + recommendationSummary: { + title: "下一轮迭代建议待确认", + summary: "基于发布后反馈生成下一轮优化建议。", + }, + proposedCharterPatch: {}, + proposedTasks: [], + changeSetCreated: false, + createdAt: "2026-04-26T08:02:00.000Z", + updatedAt: "2026-04-26T08:02:00.000Z", + }, + ], + recommendationGroups: [], + nextActions: [], + warnings: [], + }, + devSync: {}, + repoBindings: [], + activeDevTasks: [], + recentProgressEvents: [ + { + id: "progress-failed", + event_type: "failed", + source: "codex", + summary: "执行失败:pytest 未通过。", + occurred_at: "2026-04-26T08:03:00.000Z", + }, + ], + recentDeliveries: [ + { + id: "delivery-failed", + delivery_id: "delivery-1", + event_type: "workflow_run", + status: "failed", + failure_reason: "GitHub delivery failed", + repository_full_name: "pmflow/app", + received_at: "2026-04-26T08:02:00.000Z", + }, + ], + warnings: [], + } as never); + vi.mocked(getPMFlowExecutionCapabilities).mockResolvedValue({ + projectId: "17", + executionModes: [], + personalReadiness: { + status: "empty", + title: "暂无执行准备度", + summary: "暂无真实执行能力快照。", + toolReadiness: [], + waitingForUser: { + pendingConfirmations: 1, + pendingReviews: 0, + waitingAcceptance: 0, + }, + blockers: [], + recommendedNextStep: null, + targetHref: "/execution-bridge?projectId=17", + }, + teamSignals: [], + warnings: [], + generatedAt: "2026-04-26T08:00:00.000Z", + } as never); + + const { spaceApi: actualSpaceApi } = await vi.importActual("@/api/space"); + const workbench = await actualSpaceApi.getWorkbench(); + + expect(workbench.nextActionQueue.slice(0, 4).map((item) => item.title)).toEqual([ + "确认 AI 结构化变更", + "执行结果阻塞", + "执行回写失败待处理", + "发布门禁阻塞", + ]); + expect(workbench.nextActionQueue.map((item) => item.title)).toEqual( + expect.arrayContaining(["新反馈信号", "收入信号更新", "ROI 风险关注", "合规阻塞关注", "下一轮迭代建议待确认"]) + ); + expect(workbench.nextActionQueue.every((item) => ["/change-review", "/execution-bridge", "/release-growth", "/pmflow-os", "/team-layer", "/space"].some((prefix) => item.href.startsWith(prefix)))).toBe(true); + expect(workbench.feedbackAttention.map((item) => item.source)).toEqual(["feedback", "revenue", "roi", "compliance"]); + expect(workbench.iterationDraftAttention[0]).toMatchObject({ + title: "下一轮迭代建议待确认", + href: "/release-growth?projectId=17", + }); + }); + it("aggregates reviewer routing into the next action queue as navigation-only review attention", async () => { vi.mocked(projectsApi.getSummary).mockResolvedValue({ projects: [ diff --git a/frontend/src/features/personal-space/PersonalSpacePage.tsx b/frontend/src/features/personal-space/PersonalSpacePage.tsx index f37c4fc5..9b130056 100644 --- a/frontend/src/features/personal-space/PersonalSpacePage.tsx +++ b/frontend/src/features/personal-space/PersonalSpacePage.tsx @@ -1,4 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertCircle, ArrowRight, @@ -12,21 +12,31 @@ import { Loader2, MessageCircle, MoreHorizontal, + PencilLine, Plus, RefreshCw, Search, ShieldCheck, Sparkles, UserCheck, + X, } from "lucide-react"; -import { useState, type ReactNode } from "react"; -import { Link } from "react-router-dom"; +import { useEffect, useState, type ReactNode } from "react"; +import { Link, useLocation, useNavigate } from "react-router-dom"; import { aiPmoApi } from "@/api/aiPmo"; import { projectsApi } from "@/api/projects"; import { Button } from "@/components/ui/button"; +import { AIReadinessCard } from "@/features/ai-readiness/components"; +import { useAIReadiness } from "@/features/ai-readiness/readiness"; import { MobileBottomNav } from "@/features/pmflow-os/components/MobileBottomNav"; import { PMFlowBrand } from "@/features/pmflow-os/components/chrome"; -import { SpaceSidebar, SpaceTopBar } from "@/features/personal-space/components/SpaceChrome"; +import { SpaceAIConcierge } from "@/features/personal-space/components/SpaceAIConcierge"; +import { + SPACE_WORKBENCH_QUERY_KEY, + SPACE_WORKBENCH_REFRESH_MS, + SpaceSidebar, + SpaceTopBar, +} from "@/features/personal-space/components/SpaceChrome"; import { spaceApi, type SpaceAIProgressItem, @@ -41,31 +51,85 @@ import { type SpacePendingConfirmation, type SpaceReleaseAttentionItem, type SpaceSavedIdea, + type SpaceSavedIdeaUpdateRequest, type SpaceWorkbenchProject, } from "@/api/space"; import { getApiErrorMessage } from "@/lib/apiError"; +import { savePendingIntent, type ProjectLaunchDraftPendingPayload } from "@/lib/pendingIntent"; import { cn } from "@/lib/utils"; -const LIVE_REFRESH_MS = 30_000; - export default function PersonalSpacePage() { + const location = useLocation(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { readiness } = useAIReadiness(); + const [selectedIdeaId, setSelectedIdeaId] = useState(null); const workbenchQuery = useQuery({ - queryKey: ["space", "workbench"], + queryKey: SPACE_WORKBENCH_QUERY_KEY, queryFn: () => spaceApi.getWorkbench(), - refetchInterval: LIVE_REFRESH_MS, + refetchInterval: SPACE_WORKBENCH_REFRESH_MS, refetchOnWindowFocus: true, retry: 1, }); const data = workbenchQuery.data; - const sidebarMetrics = { - recentProjects: data?.recentProjects.length ?? 0, - pendingConfirmations: data?.pendingConfirmations.length ?? 0, - aiProgress: data?.aiProgress.length ?? 0, - nextActions: data?.nextActionQueue.length ?? 0, - savedIdeas: data?.savedIdeas.length ?? 0, - drafts: data?.drafts.length ?? 0, + const selectedIdea = data?.savedIdeas.find((idea) => idea.id === selectedIdeaId) ?? null; + const resetIdeaMutations = () => { + archiveIdeaMutation.reset(); + promoteIdeaMutation.reset(); + updateIdeaMutation.reset(); }; + const selectIdea = (id: string) => { + resetIdeaMutations(); + setSelectedIdeaId(id); + }; + const closeIdeaPanel = () => { + resetIdeaMutations(); + setSelectedIdeaId(null); + }; + + const archiveIdeaMutation = useMutation({ + mutationFn: (id: string) => spaceApi.archiveSavedIdea(id), + onSuccess: () => { + setSelectedIdeaId(null); + void queryClient.invalidateQueries({ queryKey: SPACE_WORKBENCH_QUERY_KEY }); + }, + }); + + const updateIdeaMutation = useMutation({ + mutationFn: ({ id, payload }: { id: string; payload: SpaceSavedIdeaUpdateRequest }) => spaceApi.updateSavedIdea(id, payload), + onSuccess: (updatedIdea) => { + setSelectedIdeaId(updatedIdea.id); + void queryClient.invalidateQueries({ queryKey: SPACE_WORKBENCH_QUERY_KEY }); + }, + }); + + const promoteIdeaMutation = useMutation({ + mutationFn: (id: string) => spaceApi.promoteSavedIdea(id), + onSuccess: (payload) => { + const mode = payload.idea.inputMode === "import" || payload.idea.inputMode === "template" ? payload.idea.inputMode : "idea"; + savePendingIntent({ + intentType: "project_launch_draft", + route: "/project-launch", + payload: { + text: payload.idea.rawText, + mode, + draftId: payload.draft.draft_id, + rawDraft: payload.draft as unknown as Record, + }, + }); + void queryClient.invalidateQueries({ queryKey: SPACE_WORKBENCH_QUERY_KEY }); + navigate(payload.redirectUrl); + }, + }); + + useEffect(() => { + if (!location.hash) return; + window.requestAnimationFrame(() => { + const target = document.getElementById(location.hash.slice(1)); + target?.scrollIntoView({ block: "start", behavior: "smooth" }); + }); + }, [data, location.hash]); return (
@@ -75,7 +139,8 @@ export default function PersonalSpacePage() { isLoading={workbenchQuery.isLoading} isFetching={workbenchQuery.isFetching} onRetry={() => void workbenchQuery.refetch()} - sidebarMetrics={sidebarMetrics} + readiness={readiness} + onSelectIdea={selectIdea} /> void workbenchQuery.refetch()} + readiness={readiness} + onSelectIdea={selectIdea} /> + archiveIdeaMutation.mutate(id)} + onPromote={(id) => { + promoteIdeaMutation.reset(); + if (!readiness.canGenerateProjectDraft) return; + promoteIdeaMutation.mutate(id); + }} + onUpdate={(id, payload) => updateIdeaMutation.mutate({ id, payload })} + archiveError={archiveIdeaMutation.error} + promoteError={promoteIdeaMutation.error} + updateError={updateIdeaMutation.error} + isArchiving={archiveIdeaMutation.isPending} + isPromoting={promoteIdeaMutation.isPending} + isUpdating={updateIdeaMutation.isPending} + /> +
); } @@ -94,18 +180,20 @@ function DesktopSpace({ isLoading, isFetching, onRetry, - sidebarMetrics, + readiness, + onSelectIdea, }: { data: Awaited> | undefined; error: unknown; isLoading: boolean; isFetching: boolean; onRetry: () => void; - sidebarMetrics: { recentProjects: number; pendingConfirmations: number; aiProgress: number; nextActions: number; savedIdeas: number; drafts: number }; + readiness: ReturnType["readiness"]; + onSelectIdea: (id: string) => void; }) { return (
- +
@@ -127,11 +215,12 @@ function DesktopSpace({ {!isLoading && !error && data ? (
+ {!readiness.canGenerateProjectDraft ? : null} - +
{data.projects.length ? ( data.projects.map((project) => ) @@ -140,7 +229,7 @@ function DesktopSpace({ )}
-
+
- +
+
+ +
{isLoading ? ( @@ -327,26 +424,26 @@ function MobileSpace({
- +
{data.savedIdeas.length ? ( - data.savedIdeas.slice(0, 4).map((idea) => ) + data.savedIdeas.slice(0, 3).map((idea) => ) ) : ( - + )}
- +
- {data.drafts.length ? data.drafts.slice(0, 4).map((draft) => ) : } + {data.drafts.length ? data.drafts.slice(0, 3).map((draft) => ) : }
- +
{data.recentProjects.length ? ( - data.recentProjects.slice(0, 5).map((project) => ) + data.recentProjects.slice(0, 3).map((project) => ) ) : ( )} @@ -413,7 +510,7 @@ function WorkbenchHeader({ function NextActionQueuePanel({ items }: { items: SpaceNextActionQueueItem[] }) { return ( -
+
+
{items.length ? ( @@ -934,24 +1031,30 @@ function IterationDraftAttentionRow({ item }: { item: SpaceIterationDraftAttenti ); } -function SavedIdeasBox({ ideas }: { ideas: SpaceSavedIdea[] }) { +function SavedIdeasBox({ ideas, onSelectIdea }: { ideas: SpaceSavedIdea[]; onSelectIdea: (id: string) => void }) { + const preview = ideas.slice(0, 3); return ( -
- +
+
{ideas.length ? ( - ideas.slice(0, 5).map((idea) => ) + preview.map((idea) => ) ) : ( - + )} + {ideas.length > preview.length ? : null}
); } -function SavedIdeaRow({ idea, compact }: { idea: SpaceSavedIdea; compact?: boolean }) { +function SavedIdeaRow({ idea, compact, onSelectIdea }: { idea: SpaceSavedIdea; compact?: boolean; onSelectIdea: (id: string) => void }) { return ( - + ); } -function DraftBox({ drafts }: { drafts: SpaceDraftItem[] }) { +function SavedIdeaDetailPanel({ + idea, + canGenerateDraft, + onClose, + onArchive, + onPromote, + onUpdate, + archiveError, + promoteError, + updateError, + isArchiving, + isPromoting, + isUpdating, +}: { + idea: SpaceSavedIdea | null; + canGenerateDraft: boolean; + onClose: () => void; + onArchive: (id: string) => void; + onPromote: (id: string) => void; + onUpdate: (id: string, payload: SpaceSavedIdeaUpdateRequest) => void; + archiveError: unknown; + promoteError: unknown; + updateError: unknown; + isArchiving: boolean; + isPromoting: boolean; + isUpdating: boolean; +}) { + const [isEditing, setIsEditing] = useState(false); + const [draftTitle, setDraftTitle] = useState(""); + const [draftSummary, setDraftSummary] = useState(""); + const [draftRawText, setDraftRawText] = useState(""); + + useEffect(() => { + setIsEditing(false); + setDraftTitle(idea?.title ?? ""); + setDraftSummary(idea?.summary ?? ""); + setDraftRawText(idea?.rawText ?? ""); + }, [idea?.id, idea?.rawText, idea?.summary, idea?.title]); + + if (!idea) return null; + const canPromote = idea.status !== "archived" && idea.status !== "promoted"; + const canEdit = idea.status !== "archived" && idea.status !== "promoted"; + const error = archiveError || promoteError || updateError; + const saveDisabled = isUpdating || !draftRawText.trim() || !draftTitle.trim(); + const enrichmentLabel = savedIdeaEnrichmentLabel(idea.enrichmentStatus); + const enrichmentDescription = savedIdeaEnrichmentDescription(idea); + const enrichmentFields = savedIdeaEnrichmentFields(idea); + const handleSave = () => { + if (saveDisabled) return; + onUpdate(idea.id, { + title: draftTitle.trim(), + raw_text: draftRawText.trim(), + summary: draftSummary.trim(), + }); + }; + return ( -
- +
+ +
+ +
+
+

想法摘要

+ {isEditing ? ( +