chore(pmflow): integrate current workspace changes

This commit is contained in:
Albert 2026-05-05 23:13:35 +08:00
parent 169bda214f
commit 37056f8fac
275 changed files with 51035 additions and 3211 deletions

View File

@ -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

View File

@ -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,动态层不缓存

View File

@ -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=<id>` 读取真实后端 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=<id>`;旧 `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=<id>&changeSetId=<id>`、已有 `/team-layer?projectId=<id>`、`/execution-bridge?projectId=<id>`、`/release-growth?projectId=<id>` 或复用旧工作项决策 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=<id>`、否则迁到 `/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=<id>`;“补充一句”通过真实输入弹层创建 `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=<id>&changeSetId=<id>`、已有 `/team-layer?projectId=<id>`、`/execution-bridge?projectId=<id>`、`/release-growth?projectId=<id>` 或复用旧工作项决策 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=<id>` 调用 `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=<id>` 调用 `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`。

View File

@ -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、后端是否稍后成功生成草案,并提供“恢复最近草案”入口,用于定位前端超时/代理断开但后端实际成功的情况;当前先记录,不实现。

View File

@ -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",

View File

@ -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)

View File

@ -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")

View File

@ -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)

View File

@ -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")

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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")

View File

@ -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

View File

@ -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")

View File

@ -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)

View File

@ -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")

View File

@ -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")

View File

@ -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")

View File

@ -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)

View File

@ -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),
)
)
)

View File

@ -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)
)

View File

@ -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)),
)
)
),
)

View File

@ -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)

View File

@ -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)

View File

@ -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},
)

View File

@ -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,

View File

@ -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,

View File

@ -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})

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,
*,

View File

@ -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)

View File

@ -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"}

View File

@ -23,4 +23,3 @@ class LinkSignalRequest(BaseModel):
class ProjectMembersAssignRequest(BaseModel):
member_user_ids: list[int] = Field(default_factory=list)

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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",

View File

@ -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(

View File

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

View File

@ -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)

View File

@ -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))

View File

@ -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))

View File

@ -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)

View File

@ -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),
}
)

View File

@ -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)

View File

@ -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)

View File

@ -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,

View File

@ -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:

View File

@ -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)

View File

@ -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))

View File

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

View File

@ -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

View File

@ -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]] = {

View File

@ -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",

View File

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

View File

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

View File

@ -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",

View File

@ -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",

View File

@ -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")

View File

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

View File

@ -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)

View File

@ -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)

View File

@ -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")

View File

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

View File

@ -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):

View File

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

View File

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

View File

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

View File

@ -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")

View File

@ -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])

View File

@ -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")

View File

@ -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,

View File

@ -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",

View File

@ -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

View File

@ -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",

View File

@ -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)

View File

@ -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

View File

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

View File

@ -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,

View File

@ -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(

View File

@ -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

View File

@ -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,

View File

@ -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"(?<!\d)(1[3-9]\d{9})(?!\d)"),
re.compile(r"([A-Za-z0-9._%+-]{2,})@([A-Za-z0-9.-]+\.[A-Za-z]{2,})"),
)
SCENE_LABELS = {key: str(value.get("label") or key) for key, value in AI_SCENE_REGISTRY.items()}
SCENE_BY_FUNCTION = {
str(function_name): scene_key
for scene_key, scene in AI_SCENE_REGISTRY.items()
for function_name in (scene.get("function_names") or [])
}
class AICallTraceService:
def __init__(self, db: AsyncSession) -> 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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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)),
}

View File

@ -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,
}

View File

@ -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(

View File

@ -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

View File

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

View File

@ -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,

View File

@ -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",
},
}

View File

@ -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,
)

Some files were not shown because too many files have changed in this diff Show More