chore(pmflow): integrate current workspace changes
This commit is contained in:
parent
169bda214f
commit
37056f8fac
|
|
@ -16,8 +16,8 @@ PMFLOW_EXPECTED_DB_DIALECT=postgresql
|
||||||
|
|
||||||
# Backend Runtime
|
# Backend Runtime
|
||||||
CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174
|
CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174
|
||||||
LLM_PROVIDER=glm
|
LLM_PROVIDER=dashscope
|
||||||
LLM_FALLBACK_PROVIDER=glm
|
LLM_FALLBACK_PROVIDER=dashscope
|
||||||
CONTROL_PLANE_ROUTING_ENABLED=true
|
CONTROL_PLANE_ROUTING_ENABLED=true
|
||||||
CONTROL_PLANE_ROUTING_RULES_PATH=backend/.control_plane/pmflow-routing-rules.json
|
CONTROL_PLANE_ROUTING_RULES_PATH=backend/.control_plane/pmflow-routing-rules.json
|
||||||
CONTROL_PLANE_AGENTS_DEFAULT_TIMEOUT_SECONDS=120
|
CONTROL_PLANE_AGENTS_DEFAULT_TIMEOUT_SECONDS=120
|
||||||
|
|
|
||||||
11
AGENTS.md
11
AGENTS.md
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
你是一位资深的全栈工程师,精通 Python 3.11+、FastAPI、SQLAlchemy 2.0、LangChain、React 18、TypeScript、shadcn/ui、Tailwind CSS。
|
你是一位资深的全栈工程师,精通 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/` 封装层
|
- 禁止在前端组件内直接调用 `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。
|
- 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`
|
- 前端状态管理:服务端数据用 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 只能恢复输入,禁止登录后自动重放真实写请求。
|
- 用户可见错误必须使用产品语义分类,禁止直接展示 Axios/FastAPI 原始错误(如 `Request failed with status code 401`、`Could not validate credentials`、traceback);401/无 token 必须表达为登录/会话问题,pending intent 只能恢复输入,禁止登录后自动重放真实写请求。
|
||||||
- 用户可见核心业务内容零兜底:AI 失败、证据不足、结构化失败时必须显式失败或标记待确认,禁止用默认句、模板句、stub 冒充成功。
|
- 用户可见核心业务内容零兜底: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_*`、关键词仓或散落正则拍板。
|
- 业务语义、问答/执行归因与前台动作拍板必须优先走“对象/上下文绑定 -> `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。
|
- `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 策略。
|
- `llm_service` 运行时必须显式传入 `routing_meta`;禁止再通过函数名片段、hint 常量或模板词在底层推断 tier、结构化输出或 fallback 策略。
|
||||||
|
|
@ -136,7 +141,7 @@ frontend/ # React 前端
|
||||||
## 11. 已知 BOUNDARY(不要幻构行为)
|
## 11. 已知 BOUNDARY(不要幻构行为)
|
||||||
|
|
||||||
- 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用
|
- 飞书回调字段可能缺失文件名/大小,仅保证 `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)
|
- World Model 静态层已接入 Redis 缓存(key: `wm:static:{tenant_id}:{project_id}`,TTL 600s)
|
||||||
- 写操作后主动 invalidate,动态层不缓存
|
- 写操作后主动 invalidate,动态层不缓存
|
||||||
|
|
||||||
|
|
|
||||||
61
CLAUDE.md
61
CLAUDE.md
|
|
@ -75,14 +75,22 @@ backend/
|
||||||
- `backend/app/config/ai_prompts.py`(`PROMPT_RAG_QUERY_SEMANTICS` 现在明确要求 internal domain 问答把 `resource_role / object_binding` 打出来;模型配置示例绑定 `config:model_routing`,MCP 配置示例绑定 `config:mcp_template_center`,功能/文档问法则分别绑定 `resource_role=feature/doc`)
|
- `backend/app/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/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/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/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/mcp_config/`(MCP 模板接入中心:catalog、bundle 持久化、后端直连 runtime probe / Microsoft Learn 检索桥接、Local Bridge 接入说明与本机确认状态)
|
||||||
- `backend/app/services/semantic_fact_service/`(semantic facts 读模型、八类业务对象统一抽取/失活、ORM 提交钩子入队、治理看板查询)
|
- `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/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/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/api/model_routing.py`(AI 运营中心“高级路由详情”兼容入口,`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/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/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)
|
- `backend/app/services/signal/`(信号接入、编排、planner、repair)
|
||||||
|
|
@ -95,6 +103,7 @@ backend/
|
||||||
```text
|
```text
|
||||||
frontend/
|
frontend/
|
||||||
├── e2e/ # Playwright 真实浏览器 E2E 用例
|
├── e2e/ # Playwright 真实浏览器 E2E 用例
|
||||||
|
├── public/ # 静态资源;`brand/` 是 PMFlow Logo / favicon 源资产
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── api/ # 接口封装
|
│ ├── api/ # 接口封装
|
||||||
│ ├── features/ # 新产品域与原型状态机(如 PMFlow OS / 个人空间 / 新项目启动台)
|
│ ├── features/ # 新产品域与原型状态机(如 PMFlow OS / 个人空间 / 新项目启动台)
|
||||||
|
|
@ -110,35 +119,43 @@ frontend/
|
||||||
└── vite.config.ts
|
└── 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/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/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/apiError.ts`、`frontend/src/lib/pendingIntent.ts`(前端身份边界、产品化 API 错误分类与登录续接 pending intent 真源;用户可见 UI 禁止直接展示 Axios/FastAPI 原始 401/403/5xx 文案)
|
- `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/`(个人空间首页;通过 `frontend/src/api/space.ts` 只读聚合项目摘要、PMFlow OS truth-state / execution-summary / execution-capabilities,并兼容消费旧 AI PMO `waiting_manual / draft_pending_confirm` 工作项,承载执行准备度、继续推进、待确认、AI 正在推进、草案箱、缺项目归属补全和最近项目入口)
|
- `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/confirm API 创建项目,只有显式 `?demo=1` 或 `?dataSource=demo` 才启用本地 mock 演示状态机)
|
- `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/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` 才启用本地演示状态机)
|
- `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_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 真实创建链;`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_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 已实现)
|
- `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/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/components/ai-steward/`(AI 问答共享展示组件与旧问答浮窗兼容代码;当前产品壳不再全局挂载 `FloatingQAChat`,空间页由 `frontend/src/features/personal-space/components/SpaceAIConcierge.tsx` 承接入口,并复用 `QAMarkdownContent` 等展示能力)
|
||||||
- `frontend/src/pages/ai-config/AIConfigPage.tsx` + `frontend/src/pages/ai-config/constants.ts`(团队级 AI 运营中心;`/ai-config` 技术路由保留,用户可见命名为“AI 运营中心”,首页按策略 / 连接 / 治理 / 高级分组组织入口)
|
- `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-routing/` + `frontend/src/api/aiRouting.ts`(AI 运营中心 -> 团队 AI 策略与 AI 路由/供应商;负责人看业务策略,管理员看供应商连接、快速/深度槽位、诊断和高级详情,成员无独立入口)
|
- `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-config/mcp-config/`(AI 运营中心 -> MCP 模板接入中心)
|
- `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/query-route-governance/`(AI 运营中心 -> route/context 治理页)
|
- `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/semantic-facts-board/`(AI 运营中心 -> semantic facts 治理页)
|
- `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/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/api/queryRouteGovernance.ts`(route/context 治理页前端 API)
|
||||||
- `frontend/src/pages/scheduler-center/`(Mounted AI Console)
|
- `frontend/src/pages/scheduler-center/`(Mounted AI Console)
|
||||||
- `frontend/src/components/sstc/`(SSTC 工作流组件)
|
- `frontend/src/components/sstc/`(SSTC 工作流组件)
|
||||||
- `frontend/src/pages/user-skills/components/UserSkillsRadarPanel.tsx` 与 `frontend/src/pages/workload-detail/components/WorkloadTrendChart.tsx`(图表按页面内二级 chunk 拆分后的入口)
|
- `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`。
|
- 页面目录化、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/图表页则把重对话框与图表从同步入口拆出。
|
- 顶层路由已改为 `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/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/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/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` 仅做薄入口。
|
- `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/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 动态补齐。
|
- `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/` 是当前唯一登录真源。
|
- 旧登录实现 `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` 可用。
|
- 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用。
|
||||||
- 真实飞书网页 E2E 依赖本机已登录的 Chrome/Feishu 会话,结果校验统一走数据库探针。
|
- 真实飞书网页 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,动态层不缓存。
|
- World Model 静态层已接入 Redis 缓存(`wm:static:{tenant_id}:{project_id}`,TTL 600s),写操作后主动 invalidate,动态层不缓存。
|
||||||
- `signal_sources` 仅作为历史创建结果区的兼容展示字段;项目看板与 SSTC 信号阶段统一只读正式 `Signal`。
|
- `signal_sources` 仅作为历史创建结果区的兼容展示字段;项目看板与 SSTC 信号阶段统一只读正式 `Signal`。
|
||||||
|
|
||||||
|
|
|
||||||
2
USER.md
2
USER.md
|
|
@ -28,6 +28,7 @@
|
||||||
- 当前产品原型与首版体验默认面向个人开发者与 `1-10` 人小团队,采用 `solo-first, team-ready` 方向;不要按 `10-50` 人重型团队工具去设计。
|
- 当前产品原型与首版体验默认面向个人开发者与 `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` 主链推进。
|
- 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 维护的项目真相层,通过异步审查和人机混编执行协作”;不要退回传统多人维护任务表、开会同步、手动填状态的项目管理范式。
|
- 团队方向已拍板为“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` 和代码索引,不得从头重想方案。
|
- 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 或泛后台风格。
|
- 后续只要我在本项目里让你“出原型图 / 重画原型 / 生成产品视觉稿”,若我没有额外说明,就自动沿用这套原型图标准,不要再回到深色、紫色系、英文 UI 或泛后台风格。
|
||||||
|
|
@ -129,3 +130,4 @@
|
||||||
- `backend/tests/test_training_pipeline_service.py` 目前仍有历史 SQLite 测试夹具下的 `JSONB` 建表失败问题,这不是本轮改名引入的,需要单独清理测试基座。
|
- `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 警告。
|
- `backend/app/schemas/recipe.py` 与 `backend/app/schemas/skill.py` 仍有 Pydantic V2 的 class-based config deprecation 警告。
|
||||||
- 当前运行环境里的 `lark_oapi` / `websockets` 仍有上游 deprecation 警告,后续需要统一升级或兼容处理。
|
- 当前运行环境里的 `lark_oapi` / `websockets` 仍有上游 deprecation 警告,后续需要统一升级或兼容处理。
|
||||||
|
- Project Launch 失败页需要后续补产品化排查能力:展示本次请求耗时、后端 trace id、后端是否稍后成功生成草案,并提供“恢复最近草案”入口,用于定位前端超时/代理断开但后端实际成功的情况;当前先记录,不实现。
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
"model": "deepseek-chat",
|
"model": "deepseek-chat",
|
||||||
"api_env": "DEEPSEEK_API_KEY"
|
"api_env": "DEEPSEEK_API_KEY"
|
||||||
},
|
},
|
||||||
"glm": {
|
"dashscope": {
|
||||||
"name": "Qwen (DashScope)",
|
"name": "Qwen (DashScope)",
|
||||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
"model": "qwen-plus",
|
"model": "qwen-plus",
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
},
|
},
|
||||||
"tier_defaults": {
|
"tier_defaults": {
|
||||||
"low": {
|
"low": {
|
||||||
"provider": "glm",
|
"provider": "dashscope",
|
||||||
"model": "qwen-plus",
|
"model": "qwen-plus",
|
||||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
"api_env": "DASHSCOPE_API_KEY"
|
"api_env": "DASHSCOPE_API_KEY"
|
||||||
|
|
@ -59,7 +59,7 @@
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"priority": 310,
|
"priority": 310,
|
||||||
"tier": "low",
|
"tier": "low",
|
||||||
"provider": "glm",
|
"provider": "dashscope",
|
||||||
"model": "qwen-plus",
|
"model": "qwen-plus",
|
||||||
"api_env": "DASHSCOPE_API_KEY",
|
"api_env": "DASHSCOPE_API_KEY",
|
||||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
|
|
@ -80,7 +80,7 @@
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"priority": 300,
|
"priority": 300,
|
||||||
"tier": "low",
|
"tier": "low",
|
||||||
"provider": "glm",
|
"provider": "dashscope",
|
||||||
"model": "qwen-plus",
|
"model": "qwen-plus",
|
||||||
"api_env": "DASHSCOPE_API_KEY",
|
"api_env": "DASHSCOPE_API_KEY",
|
||||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
|
|
@ -150,7 +150,7 @@
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"priority": 50,
|
"priority": 50,
|
||||||
"tier": "low",
|
"tier": "low",
|
||||||
"provider": "glm",
|
"provider": "dashscope",
|
||||||
"model": "qwen-plus",
|
"model": "qwen-plus",
|
||||||
"api_env": "DASHSCOPE_API_KEY",
|
"api_env": "DASHSCOPE_API_KEY",
|
||||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .shared import *
|
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_filters import _build_latest_decision_projection, _should_hide_work_item_from_inbox, _safe_int
|
||||||
from .helpers_inbox_payload import (
|
from .helpers_inbox_payload import (
|
||||||
_build_closure_payload_overrides,
|
_build_closure_payload_overrides,
|
||||||
|
|
@ -11,6 +12,7 @@ from .helpers_inbox_payload import (
|
||||||
from .schemas import PMOProcessTextRequest
|
from .schemas import PMOProcessTextRequest
|
||||||
from ...services.ai_pmo.project_operating_context_builder import ProjectOperatingContextBuilder
|
from ...services.ai_pmo.project_operating_context_builder import ProjectOperatingContextBuilder
|
||||||
|
|
||||||
|
|
||||||
@router.post("/process-text")
|
@router.post("/process-text")
|
||||||
async def process_text(
|
async def process_text(
|
||||||
request: PMOProcessTextRequest,
|
request: PMOProcessTextRequest,
|
||||||
|
|
@ -52,12 +54,12 @@ async def list_work_items(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
):
|
):
|
||||||
_ = current_user
|
|
||||||
stmt = (
|
stmt = (
|
||||||
select(WorkItem, IngestEvent, Project)
|
select(WorkItem, IngestEvent, Project)
|
||||||
.join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id)
|
.join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id)
|
||||||
.outerjoin(Project, and_(Project.tenant_id == tenant_id, Project.id == WorkItem.project_id))
|
.outerjoin(Project, and_(Project.tenant_id == tenant_id, Project.id == WorkItem.project_id))
|
||||||
.where(WorkItem.tenant_id == tenant_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())
|
.order_by(WorkItem.updated_at.desc(), WorkItem.id.desc())
|
||||||
)
|
)
|
||||||
if state:
|
if state:
|
||||||
|
|
@ -103,6 +105,7 @@ async def get_work_item(
|
||||||
and_(
|
and_(
|
||||||
WorkItem.tenant_id == tenant_id,
|
WorkItem.tenant_id == tenant_id,
|
||||||
WorkItem.id == int(work_item_id),
|
WorkItem.id == int(work_item_id),
|
||||||
|
_visible_work_item_filter(current_user=current_user, tenant_id=tenant_id),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
|
from .shared import _visible_work_item_filter
|
||||||
from .helpers_resume import _queue_resume_work_item, _reject_work_item
|
from .helpers_resume import _queue_resume_work_item, _reject_work_item
|
||||||
from .schemas import PMODraftDecisionRequest, PMOResumeWorkItemRequest, PMOWorkItemDecisionRequest
|
from .schemas import PMODraftDecisionRequest, PMOResumeWorkItemRequest, PMOWorkItemDecisionRequest
|
||||||
from ...services.ai_pmo.draft_confirm_handler import DraftConfirmHandler
|
from ...services.ai_pmo.draft_confirm_handler import DraftConfirmHandler
|
||||||
|
|
@ -19,6 +20,7 @@ async def resume_work_item(
|
||||||
and_(
|
and_(
|
||||||
WorkItem.tenant_id == tenant_id,
|
WorkItem.tenant_id == tenant_id,
|
||||||
WorkItem.id == int(work_item_id),
|
WorkItem.id == int(work_item_id),
|
||||||
|
_visible_work_item_filter(current_user=current_user, tenant_id=tenant_id),
|
||||||
)
|
)
|
||||||
).limit(1)
|
).limit(1)
|
||||||
)
|
)
|
||||||
|
|
@ -61,6 +63,7 @@ async def decide_work_item(
|
||||||
and_(
|
and_(
|
||||||
WorkItem.tenant_id == tenant_id,
|
WorkItem.tenant_id == tenant_id,
|
||||||
WorkItem.id == int(work_item_id),
|
WorkItem.id == int(work_item_id),
|
||||||
|
_visible_work_item_filter(current_user=current_user, tenant_id=tenant_id),
|
||||||
)
|
)
|
||||||
).limit(1)
|
).limit(1)
|
||||||
)
|
)
|
||||||
|
|
@ -165,6 +168,7 @@ async def decide_draft_work_item(
|
||||||
and_(
|
and_(
|
||||||
WorkItem.tenant_id == tenant_id,
|
WorkItem.tenant_id == tenant_id,
|
||||||
WorkItem.id == int(work_item_id),
|
WorkItem.id == int(work_item_id),
|
||||||
|
_visible_work_item_filter(current_user=current_user, tenant_id=tenant_id),
|
||||||
)
|
)
|
||||||
).limit(1)
|
).limit(1)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from typing import Any, Literal, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import and_, select
|
from sqlalchemy import and_, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ...core.logging import get_logger
|
from ...core.logging import get_logger
|
||||||
|
|
@ -14,10 +14,40 @@ from ...core.response import success_response
|
||||||
from ...core.database import AsyncSessionLocal, get_db
|
from ...core.database import AsyncSessionLocal, get_db
|
||||||
from ...dependencies.auth import get_current_user
|
from ...dependencies.auth import get_current_user
|
||||||
from ...dependencies.tenant import get_tenant_id
|
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_cadence import AIPMOCadenceService
|
||||||
from ...services.ai_pmo.ai_pmo_kernel import AIPMOKernelService
|
from ...services.ai_pmo.ai_pmo_kernel import AIPMOKernelService
|
||||||
|
|
||||||
router = APIRouter(prefix="/ai-pmo", tags=["AI PMO"])
|
router = APIRouter(prefix="/ai-pmo", tags=["AI PMO"])
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
FRONT_NOISE_INTENTS = {"query.read_only", "query.answer"}
|
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)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -12,11 +12,15 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from ..core.database import get_db
|
from ..core.database import get_db
|
||||||
from ..core.response import error_response, success_response
|
from ..core.response import error_response, success_response
|
||||||
from ..dependencies.auth import get_current_user
|
from ..dependencies.auth import get_current_user
|
||||||
|
from ..dependencies.permissions import require_platform_ai_ops
|
||||||
from ..dependencies.tenant import get_tenant_id
|
from ..dependencies.tenant import get_tenant_id
|
||||||
from ..models import User
|
from ..models import User
|
||||||
|
from ..services.ai_function_runtime_profile_service import AIFunctionRuntimeProfileService
|
||||||
from ..services.ai_routing_governance_service import AIRoutingGovernanceService
|
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.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"])
|
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)
|
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:
|
def _forbidden(message: str) -> JSONResponse:
|
||||||
return JSONResponse(status_code=403, content=error_response(403, message))
|
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))
|
return AIRoutingGovernanceService(db, tenant_id=int(tenant_id))
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_service(db: AsyncSession) -> AIFunctionRuntimeProfileService:
|
||||||
|
return AIFunctionRuntimeProfileService(db)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/overview")
|
@router.get("/overview")
|
||||||
async def get_ai_routing_overview(
|
async def get_ai_routing_overview(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
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()
|
data = await _service(db, tenant_id).get_overview()
|
||||||
return success_response(data)
|
return success_response(data)
|
||||||
|
|
||||||
|
|
@ -104,21 +141,304 @@ async def update_ai_team_policy(
|
||||||
async def get_ai_routing_admin_console(
|
async def get_ai_routing_admin_console(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
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()
|
data = await _service(db, tenant_id).get_admin_console()
|
||||||
return success_response(data)
|
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")
|
@router.post("/diagnostics/run")
|
||||||
async def run_ai_routing_diagnostics(
|
async def run_ai_routing_diagnostics(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
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()
|
data = await _service(db, tenant_id).run_diagnostics()
|
||||||
return success_response(data)
|
return success_response(data)
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,23 @@
|
||||||
"""认证 API"""
|
"""认证 API"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import or_
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
from ..core.database import get_db
|
from ..core.database import get_db
|
||||||
from ..models import User, UserStatus
|
from ..models import User, UserStatus
|
||||||
from ..schemas import UserLoginResponse
|
from ..schemas import PhoneCodeSendRequest, PhoneCodeVerifyRequest, UserLoginResponse, UserRegisterRequest
|
||||||
from ..services.auth import AuthService
|
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"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
@ -20,7 +29,7 @@ async def login(
|
||||||
):
|
):
|
||||||
"""用户登录,返回 JWT"""
|
"""用户登录,返回 JWT"""
|
||||||
result = await db.execute(
|
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()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
@ -39,10 +48,76 @@ async def login(
|
||||||
detail="User is disabled",
|
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})
|
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(
|
return UserLoginResponse(
|
||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
token_type="bearer",
|
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},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..core.response import success_response
|
from ..core.response import success_response
|
||||||
from ..core.database import get_db
|
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 ..dependencies.tenant import get_tenant_id
|
||||||
from ..models import User
|
from ..models import User
|
||||||
from ..services.auth import has_any_role
|
|
||||||
from ..services.llm_service import llm_service
|
from ..services.llm_service import llm_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/ai-steward/llm-engines", tags=["AI Steward"])
|
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):
|
class LLMEngineCreateRequest(BaseModel):
|
||||||
name: str = Field(..., min_length=1, max_length=80)
|
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)
|
api_key: Optional[str] = Field(default="", max_length=500)
|
||||||
model: Optional[str] = Field(default=None, max_length=120)
|
model: Optional[str] = Field(default=None, max_length=120)
|
||||||
base_url: Optional[str] = Field(default=None, max_length=255)
|
base_url: Optional[str] = Field(default=None, max_length=255)
|
||||||
set_active: bool = True
|
set_active: bool = True
|
||||||
|
capabilities: dict[str, object] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class LLMEngineUpdateRequest(BaseModel):
|
class LLMEngineUpdateRequest(BaseModel):
|
||||||
name: Optional[str] = Field(default=None, min_length=1, max_length=80)
|
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)
|
api_key: Optional[str] = Field(default=None, max_length=500)
|
||||||
model: Optional[str] = Field(default=None, max_length=120)
|
model: Optional[str] = Field(default=None, max_length=120)
|
||||||
base_url: Optional[str] = Field(default=None, max_length=255)
|
base_url: Optional[str] = Field(default=None, max_length=255)
|
||||||
|
capabilities: Optional[dict[str, object]] = None
|
||||||
|
|
||||||
|
|
||||||
class LLMEngineTestRequest(BaseModel):
|
class LLMEngineTestRequest(BaseModel):
|
||||||
prompt: Optional[str] = Field(default=None, max_length=1000)
|
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):
|
class LLMEngineSettingsUpdateRequest(BaseModel):
|
||||||
enable_local_kb_qa: bool = False
|
enable_local_kb_qa: bool = False
|
||||||
|
|
||||||
|
|
||||||
def _require_manager(user: User) -> None:
|
class LLMEngineEnabledUpdateRequest(BaseModel):
|
||||||
if not has_any_role(str(user.role or ""), ["OWNER", "REVIEWER"]):
|
enabled: bool = True
|
||||||
raise HTTPException(status_code=403, detail="仅管理员可操作 LLM 引擎配置")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_llm_engines(
|
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),
|
db: AsyncSession = Depends(get_db),
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
):
|
):
|
||||||
|
|
@ -63,11 +103,10 @@ async def list_llm_engines(
|
||||||
@router.post("")
|
@router.post("")
|
||||||
async def create_llm_engine(
|
async def create_llm_engine(
|
||||||
body: LLMEngineCreateRequest,
|
body: LLMEngineCreateRequest,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(require_platform_ai_ops),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
):
|
):
|
||||||
_require_manager(current_user)
|
|
||||||
try:
|
try:
|
||||||
engine = await llm_service.create_engine(
|
engine = await llm_service.create_engine(
|
||||||
db,
|
db,
|
||||||
|
|
@ -78,6 +117,7 @@ async def create_llm_engine(
|
||||||
model=body.model,
|
model=body.model,
|
||||||
base_url=body.base_url,
|
base_url=body.base_url,
|
||||||
set_active=body.set_active,
|
set_active=body.set_active,
|
||||||
|
capabilities=body.capabilities,
|
||||||
)
|
)
|
||||||
all_items = await llm_service.list_engines(db, tenant_id=tenant_id)
|
all_items = await llm_service.list_engines(db, tenant_id=tenant_id)
|
||||||
return success_response({"engine": engine, **all_items})
|
return success_response({"engine": engine, **all_items})
|
||||||
|
|
@ -88,11 +128,10 @@ async def create_llm_engine(
|
||||||
@router.put("/settings")
|
@router.put("/settings")
|
||||||
async def update_llm_engine_settings(
|
async def update_llm_engine_settings(
|
||||||
body: LLMEngineSettingsUpdateRequest,
|
body: LLMEngineSettingsUpdateRequest,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(require_platform_ai_ops),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
):
|
):
|
||||||
_require_manager(current_user)
|
|
||||||
try:
|
try:
|
||||||
settings = await llm_service.update_engine_settings(
|
settings = await llm_service.update_engine_settings(
|
||||||
db,
|
db,
|
||||||
|
|
@ -109,11 +148,10 @@ async def update_llm_engine_settings(
|
||||||
async def update_llm_engine(
|
async def update_llm_engine(
|
||||||
engine_id: str,
|
engine_id: str,
|
||||||
body: LLMEngineUpdateRequest,
|
body: LLMEngineUpdateRequest,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(require_platform_ai_ops),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
):
|
):
|
||||||
_require_manager(current_user)
|
|
||||||
try:
|
try:
|
||||||
engine = await llm_service.update_engine(
|
engine = await llm_service.update_engine(
|
||||||
db,
|
db,
|
||||||
|
|
@ -124,6 +162,7 @@ async def update_llm_engine(
|
||||||
api_key=body.api_key,
|
api_key=body.api_key,
|
||||||
model=body.model,
|
model=body.model,
|
||||||
base_url=body.base_url,
|
base_url=body.base_url,
|
||||||
|
capabilities=body.capabilities,
|
||||||
)
|
)
|
||||||
all_items = await llm_service.list_engines(db, tenant_id=tenant_id)
|
all_items = await llm_service.list_engines(db, tenant_id=tenant_id)
|
||||||
return success_response({"engine": engine, **all_items})
|
return success_response({"engine": engine, **all_items})
|
||||||
|
|
@ -134,13 +173,12 @@ async def update_llm_engine(
|
||||||
@router.post("/{engine_id}/activate")
|
@router.post("/{engine_id}/activate")
|
||||||
async def activate_llm_engine(
|
async def activate_llm_engine(
|
||||||
engine_id: str,
|
engine_id: str,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(require_platform_ai_ops),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
):
|
):
|
||||||
_require_manager(current_user)
|
|
||||||
try:
|
try:
|
||||||
result = await llm_service.activate_engine(
|
result = await llm_service.set_default_fallback_engine(
|
||||||
db,
|
db,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
engine_id=engine_id,
|
engine_id=engine_id,
|
||||||
|
|
@ -151,20 +189,60 @@ async def activate_llm_engine(
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
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")
|
@router.post("/test-active")
|
||||||
async def test_active_llm_engine(
|
async def test_active_llm_engine(
|
||||||
body: LLMEngineTestRequest,
|
body: LLMEngineTestRequest,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(require_platform_ai_ops),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
):
|
):
|
||||||
_require_manager(current_user)
|
|
||||||
try:
|
try:
|
||||||
data = await llm_service.test_engine_connection(
|
data = await llm_service.test_engine_connection(
|
||||||
db,
|
db,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
engine_id=None,
|
engine_id=None,
|
||||||
prompt=body.prompt,
|
prompt=body.prompt,
|
||||||
|
user_id=int(current_user.id),
|
||||||
)
|
)
|
||||||
return success_response(data)
|
return success_response(data)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
|
@ -175,31 +253,81 @@ async def test_active_llm_engine(
|
||||||
async def test_llm_engine(
|
async def test_llm_engine(
|
||||||
engine_id: str,
|
engine_id: str,
|
||||||
body: LLMEngineTestRequest,
|
body: LLMEngineTestRequest,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(require_platform_ai_ops),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
):
|
):
|
||||||
_require_manager(current_user)
|
|
||||||
try:
|
try:
|
||||||
data = await llm_service.test_engine_connection(
|
data = await llm_service.test_engine_connection(
|
||||||
db,
|
db,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
engine_id=engine_id,
|
engine_id=engine_id,
|
||||||
prompt=body.prompt,
|
prompt=body.prompt,
|
||||||
|
user_id=int(current_user.id),
|
||||||
)
|
)
|
||||||
return success_response(data)
|
return success_response(data)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from 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}")
|
@router.delete("/{engine_id}")
|
||||||
async def delete_llm_engine(
|
async def delete_llm_engine(
|
||||||
engine_id: str,
|
engine_id: str,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(require_platform_ai_ops),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
):
|
):
|
||||||
_require_manager(current_user)
|
|
||||||
try:
|
try:
|
||||||
result = await llm_service.delete_engine(
|
result = await llm_service.delete_engine(
|
||||||
db,
|
db,
|
||||||
|
|
|
||||||
|
|
@ -20,19 +20,19 @@ def _build_provider_payload() -> dict[str, Any]:
|
||||||
low_tier = dict(tier_defaults.get("low") or {})
|
low_tier = dict(tier_defaults.get("low") or {})
|
||||||
high_tier = dict(tier_defaults.get("high") or {})
|
high_tier = dict(tier_defaults.get("high") or {})
|
||||||
openai_defaults = dict(provider_defaults.get("openai") 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 {})
|
deepseek_defaults = dict(provider_defaults.get("deepseek") or {})
|
||||||
ollama_defaults = dict(provider_defaults.get("ollama") 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()
|
high_model = str(high_tier.get("model") or openai_defaults.get("model") or "GLM-5").strip()
|
||||||
return {
|
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,
|
"high": high_model,
|
||||||
"base_url": str(high_tier.get("base_url") or openai_defaults.get("base_url") or "https://api.edgefn.net/v1").strip(),
|
"base_url": str(high_tier.get("base_url") or openai_defaults.get("base_url") or "https://api.edgefn.net/v1").strip(),
|
||||||
"providers": {
|
"providers": {
|
||||||
"glm": {
|
"dashscope": {
|
||||||
"model": str(glm_defaults.get("model") or "qwen-plus").strip(),
|
"model": str(dashscope_defaults.get("model") or "qwen-plus").strip(),
|
||||||
"base_url": str(glm_defaults.get("base_url") or "https://dashscope.aliyuncs.com/compatible-mode/v1").strip(),
|
"base_url": str(dashscope_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(),
|
"api_env": str(dashscope_defaults.get("api_env") or "DASHSCOPE_API_KEY").strip(),
|
||||||
},
|
},
|
||||||
"openai": {
|
"openai": {
|
||||||
"model": high_model,
|
"model": high_model,
|
||||||
|
|
|
||||||
|
|
@ -34,12 +34,13 @@ async def analyze_cost_optimization(
|
||||||
@router.get("/context/projects/{project_id}/truth-nodes")
|
@router.get("/context/projects/{project_id}/truth-nodes")
|
||||||
async def list_project_truth_nodes(
|
async def list_project_truth_nodes(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
|
permission_user: User = Depends(require_project_member_or_manager),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""获取项目上下文中的宪章列表,用于前端选择器。"""
|
"""获取项目上下文中的宪章列表,用于前端选择器。"""
|
||||||
_ = current_user
|
_ = (current_user, permission_user)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(TruthNode).where(
|
select(TruthNode).where(
|
||||||
and_(
|
and_(
|
||||||
|
|
@ -66,12 +67,13 @@ async def list_project_truth_nodes(
|
||||||
@router.get("/context/projects/{project_id}/experiments")
|
@router.get("/context/projects/{project_id}/experiments")
|
||||||
async def list_project_experiments(
|
async def list_project_experiments(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
|
permission_user: User = Depends(require_project_member_or_manager),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""获取项目上下文中的实验列表(基于 linked_context_nodes 关联)。"""
|
"""获取项目上下文中的实验列表(基于 linked_context_nodes 关联)。"""
|
||||||
_ = current_user
|
_ = (current_user, permission_user)
|
||||||
truth_ids_result = await db.execute(
|
truth_ids_result = await db.execute(
|
||||||
select(TruthNode.id).where(
|
select(TruthNode.id).where(
|
||||||
and_(
|
and_(
|
||||||
|
|
@ -128,4 +130,3 @@ async def list_project_experiments(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return success_response(data={"items": items})
|
return success_response(data={"items": items})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ...core.response import ErrorCode, error_response, success_response
|
from ...core.response import ErrorCode, error_response, success_response
|
||||||
from ...core.database import get_db
|
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 ...models import Experiment, TruthNode, User
|
||||||
from ...schemas.model_capability import (
|
from ...schemas.model_capability import (
|
||||||
CostOptimizationAnalyzeRequest,
|
CostOptimizationAnalyzeRequest,
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..core.response import error_response, success_response
|
from ..core.response import error_response, success_response
|
||||||
from ..core.database import get_db
|
from ..core.database import get_db
|
||||||
from ..dependencies import get_current_user
|
from ..dependencies import require_platform_ai_ops
|
||||||
from ..models import User
|
from ..models import User
|
||||||
from ..services.auth import has_any_role
|
from ..services.model.model_routing import ModelRoutingService, PLATFORM_RUNTIME_TENANT_ID
|
||||||
from ..services.model.model_routing import ModelRoutingService
|
|
||||||
from ..services.operation_log_service import log_operation
|
from ..services.operation_log_service import log_operation
|
||||||
|
|
||||||
router = APIRouter(prefix="/model-routing", tags=["model_routing"])
|
router = APIRouter(prefix="/model-routing", tags=["model_routing"])
|
||||||
|
|
@ -27,9 +26,9 @@ class ModelRoutingUpdateRequest(BaseModel):
|
||||||
@router.get("/functions")
|
@router.get("/functions")
|
||||||
async def list_model_routing(
|
async def list_model_routing(
|
||||||
db: AsyncSession = Depends(get_db),
|
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)
|
items = await ModelRoutingService.list_effective_routing(db, tenant_id=tenant_id)
|
||||||
return success_response({"items": items})
|
return success_response({"items": items})
|
||||||
|
|
||||||
|
|
@ -38,9 +37,9 @@ async def list_model_routing(
|
||||||
async def get_model_routing(
|
async def get_model_routing(
|
||||||
function_name: str,
|
function_name: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
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(
|
data = await ModelRoutingService.get_effective_routing(
|
||||||
db,
|
db,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
|
|
@ -54,12 +53,9 @@ async def update_model_routing(
|
||||||
function_name: str,
|
function_name: str,
|
||||||
body: ModelRoutingUpdateRequest,
|
body: ModelRoutingUpdateRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
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"]):
|
tenant_id = PLATFORM_RUNTIME_TENANT_ID
|
||||||
return JSONResponse(status_code=403, content=error_response(403, "仅管理员可修改模型路由配置"))
|
|
||||||
|
|
||||||
tenant_id = int(current_user.tenant_id)
|
|
||||||
before = await ModelRoutingService.get_effective_routing(
|
before = await ModelRoutingService.get_effective_routing(
|
||||||
db,
|
db,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,27 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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 ...core.response import error_response, success_response
|
||||||
from ...dependencies import get_current_user
|
from ...dependencies import get_current_user
|
||||||
from ...models import 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
|
from ...services.pmflow_launch_service import PMFlowLaunchError, PMFlowLaunchService
|
||||||
|
|
||||||
router = APIRouter(prefix="/pmflow-launch", tags=["pmflow-launch"])
|
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")
|
@router.post("/drafts")
|
||||||
async def create_pmflow_launch_draft(
|
async def create_pmflow_launch_draft(
|
||||||
body: PMFlowLaunchDraftRequest,
|
body: PMFlowLaunchDraftRequest,
|
||||||
|
|
@ -28,6 +36,73 @@ async def create_pmflow_launch_draft(
|
||||||
return success_response(payload.model_dump(mode="json"))
|
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")
|
@router.post("/drafts/{draft_id}/confirm")
|
||||||
async def confirm_pmflow_launch_draft(
|
async def confirm_pmflow_launch_draft(
|
||||||
draft_id: str,
|
draft_id: str,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from ...core.response import error_response, success_response
|
||||||
from ...dependencies import get_current_user
|
from ...dependencies import get_current_user
|
||||||
from ...models import Project, User, UserProject
|
from ...models import Project, User, UserProject
|
||||||
from ...schemas.pmflow_os import (
|
from ...schemas.pmflow_os import (
|
||||||
|
PMFlowAIFeedbackCreateRequest,
|
||||||
PMFlowCapacityOverridePayload,
|
PMFlowCapacityOverridePayload,
|
||||||
PMFlowChangeItemDeferRequest,
|
PMFlowChangeItemDeferRequest,
|
||||||
PMFlowChangeItemEditRequest,
|
PMFlowChangeItemEditRequest,
|
||||||
|
|
@ -29,6 +30,7 @@ from ...schemas.pmflow_os import (
|
||||||
)
|
)
|
||||||
from ...services.auth import has_any_role
|
from ...services.auth import has_any_role
|
||||||
from ...services.adaptive_capacity_service import AdaptiveCapacityService
|
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_change_review_service import PMFlowChangeReviewError, PMFlowChangeReviewService
|
||||||
from ...services.pmflow_os_projection_service import PMFlowOSProjectionService
|
from ...services.pmflow_os_projection_service import PMFlowOSProjectionService
|
||||||
from ...services.team_execution_capability_service import TeamExecutionCapabilityService
|
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"))
|
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")
|
@router.post("/projects/{project_id}/threads/{thread_id}/messages")
|
||||||
async def create_pmflow_os_thread_message(
|
async def create_pmflow_os_thread_message(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
|
from ...services.auth import has_any_role
|
||||||
|
|
||||||
def _as_dict(value: object) -> dict[str, Any]:
|
def _as_dict(value: object) -> dict[str, Any]:
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
|
|
@ -123,6 +124,46 @@ async def _get_project_or_404(
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
return project
|
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(
|
async def _cleanup_project_dependencies(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
*,
|
*,
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .shared import *
|
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("/")
|
@router.get("/")
|
||||||
async def list_projects(
|
async def list_projects(
|
||||||
summary: Optional[int] = Query(default=0),
|
summary: Optional[int] = Query(default=0),
|
||||||
status: Optional[str] = Query(default=None),
|
status: Optional[str] = Query(default=None),
|
||||||
assigned_role: 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),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
tenant_id = int(current_user.tenant_id)
|
||||||
query = select(Project).where(Project.tenant_id == tenant_id)
|
query = select(Project).where(Project.tenant_id == tenant_id)
|
||||||
|
query = query.where(_visible_project_filter(current_user=current_user))
|
||||||
if status:
|
if status:
|
||||||
query = query.where(Project.status == status)
|
query = query.where(Project.status == status)
|
||||||
if assigned_role:
|
if assigned_role:
|
||||||
|
|
@ -35,9 +37,8 @@ async def list_projects(
|
||||||
@router.get("/{project_id}")
|
@router.get("/{project_id}")
|
||||||
async def get_project(
|
async def get_project(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
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)
|
return _serialize_project_row(project)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 .helpers import _cleanup_project_dependencies, _fetch_project_member_user_ids, _get_project_or_404, _serialize_project_row
|
||||||
from .schemas import ProjectCreateRequest, ProjectMembersAssignRequest, ProjectUpdateRequest
|
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("/")
|
@router.post("/")
|
||||||
async def create_project(
|
async def create_project(
|
||||||
body: ProjectCreateRequest,
|
body: ProjectCreateRequest,
|
||||||
|
|
@ -14,6 +32,9 @@ async def create_project(
|
||||||
title = compact_title(body.title, max_len=24)
|
title = compact_title(body.title, max_len=24)
|
||||||
if not title:
|
if not title:
|
||||||
raise HTTPException(status_code=400, detail="title required")
|
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:
|
try:
|
||||||
member_user_ids = await resolve_project_member_ids(
|
member_user_ids = await resolve_project_member_ids(
|
||||||
db,
|
db,
|
||||||
|
|
@ -214,4 +235,3 @@ async def delete_project(
|
||||||
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
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)
|
await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=project_id)
|
||||||
return {"message": "Project deleted"}
|
return {"message": "Project deleted"}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,4 +23,3 @@ class LinkSignalRequest(BaseModel):
|
||||||
|
|
||||||
class ProjectMembersAssignRequest(BaseModel):
|
class ProjectMembersAssignRequest(BaseModel):
|
||||||
member_user_ids: list[int] = Field(default_factory=list)
|
member_user_ids: list[int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from typing import Any, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel, Field
|
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.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -5,7 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from .helpers import _resolve_tenant_id
|
from .helpers import _resolve_tenant_id
|
||||||
from .shared import *
|
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
|
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(
|
async def get_query_route_governance_summary(
|
||||||
project_id: int | None = Query(default=None),
|
project_id: int | None = Query(default=None),
|
||||||
window_days: 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),
|
db: AsyncSession = Depends(get_db),
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
):
|
):
|
||||||
|
|
@ -40,7 +40,7 @@ async def list_query_route_governance_records(
|
||||||
search: str | None = Query(default=None),
|
search: str | None = Query(default=None),
|
||||||
limit: int = Query(default=20, ge=1, le=100),
|
limit: int = Query(default=20, ge=1, le=100),
|
||||||
offset: int = Query(default=0, ge=0),
|
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),
|
db: AsyncSession = Depends(get_db),
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
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(
|
async def get_query_route_governance_detail(
|
||||||
record_id: int,
|
record_id: int,
|
||||||
project_id: int | None = Query(default=None),
|
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),
|
db: AsyncSession = Depends(get_db),
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
):
|
):
|
||||||
|
|
@ -79,4 +79,3 @@ async def get_query_route_governance_detail(
|
||||||
if data is None:
|
if data is None:
|
||||||
raise HTTPException(status_code=404, detail="query route record not found")
|
raise HTTPException(status_code=404, detail="query route record not found")
|
||||||
return success_response(data)
|
return success_response(data)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ CORE_ROUTE_MODULES = (
|
||||||
"tasks",
|
"tasks",
|
||||||
"webhooks",
|
"webhooks",
|
||||||
"auth",
|
"auth",
|
||||||
|
"workspaces_api",
|
||||||
"users_api",
|
"users_api",
|
||||||
"endpoints.skill",
|
"endpoints.skill",
|
||||||
"workloads",
|
"workloads",
|
||||||
|
|
@ -32,9 +33,14 @@ CORE_ROUTE_MODULES = (
|
||||||
"task_briefs",
|
"task_briefs",
|
||||||
"pmflow_os_api",
|
"pmflow_os_api",
|
||||||
"pmflow_launch_api",
|
"pmflow_launch_api",
|
||||||
|
"space_api",
|
||||||
"legacy_ai_steward_api",
|
"legacy_ai_steward_api",
|
||||||
"account_settings_api",
|
"account_settings_api",
|
||||||
|
"ai_readiness_api",
|
||||||
|
"ai_call_traces_api",
|
||||||
|
"prompt_assets_api",
|
||||||
"ai_routing",
|
"ai_routing",
|
||||||
|
"llm_engines",
|
||||||
"model_routing",
|
"model_routing",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -43,7 +49,6 @@ OPTIONAL_ROUTE_GROUPS = {
|
||||||
"knowledge": ("rag_api", "knowledge_graph", "semantic_facts_api", "ai_pmo_api"),
|
"knowledge": ("rag_api", "knowledge_graph", "semantic_facts_api", "ai_pmo_api"),
|
||||||
"ai_control": (
|
"ai_control": (
|
||||||
"ai_steward_api",
|
"ai_steward_api",
|
||||||
"llm_engines",
|
|
||||||
"llm_providers",
|
"llm_providers",
|
||||||
"ai_logs_api",
|
"ai_logs_api",
|
||||||
"prompt_profiles",
|
"prompt_profiles",
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ...core.database import get_db
|
from ...core.database import get_db
|
||||||
from ...core.response import error_response, success_response
|
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 ...models import User
|
||||||
from ...services.semantic_fact_service import SemanticFactService
|
from ...services.semantic_fact_service import SemanticFactService
|
||||||
|
|
||||||
|
|
@ -25,7 +25,7 @@ class SemanticFactsBackfillRequest(BaseModel):
|
||||||
@router.get("/admin/overview")
|
@router.get("/admin/overview")
|
||||||
async def get_semantic_facts_overview(
|
async def get_semantic_facts_overview(
|
||||||
db: AsyncSession = Depends(get_db),
|
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))
|
service = SemanticFactService(db, tenant_id=int(current_user.tenant_id))
|
||||||
return success_response(await service.get_admin_overview())
|
return success_response(await service.get_admin_overview())
|
||||||
|
|
@ -37,7 +37,7 @@ async def list_semantic_fact_objects(
|
||||||
q: str | None = Query(default=None),
|
q: str | None = Query(default=None),
|
||||||
status: str | None = Query(default=None),
|
status: str | None = Query(default=None),
|
||||||
db: AsyncSession = Depends(get_db),
|
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))
|
service = SemanticFactService(db, tenant_id=int(current_user.tenant_id))
|
||||||
items = await service.list_admin_objects(
|
items = await service.list_admin_objects(
|
||||||
|
|
@ -53,7 +53,7 @@ async def get_semantic_fact_object_detail(
|
||||||
entity_type: str,
|
entity_type: str,
|
||||||
entity_id: int,
|
entity_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
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))
|
service = SemanticFactService(db, tenant_id=int(current_user.tenant_id))
|
||||||
try:
|
try:
|
||||||
|
|
@ -68,7 +68,7 @@ async def reextract_semantic_fact_object(
|
||||||
entity_type: str,
|
entity_type: str,
|
||||||
entity_id: int,
|
entity_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
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))
|
service = SemanticFactService(db, tenant_id=int(current_user.tenant_id))
|
||||||
row = await service.enqueue_entity_event(
|
row = await service.enqueue_entity_event(
|
||||||
|
|
@ -92,7 +92,7 @@ async def reextract_semantic_fact_object(
|
||||||
async def enqueue_semantic_fact_backfill(
|
async def enqueue_semantic_fact_backfill(
|
||||||
body: SemanticFactsBackfillRequest,
|
body: SemanticFactsBackfillRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
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))
|
service = SemanticFactService(db, tenant_id=int(current_user.tenant_id))
|
||||||
result = await service.enqueue_backfill(
|
result = await service.enqueue_backfill(
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -14,9 +14,11 @@ router = APIRouter(tags=["sstc"])
|
||||||
async def get_charter_detail(
|
async def get_charter_detail(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
charter_id: str,
|
charter_id: str,
|
||||||
|
permission_user: User = Depends(require_project_member_or_manager),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
_ = permission_user
|
||||||
pid = _parse_project_id(project_id)
|
pid = _parse_project_id(project_id)
|
||||||
cid = _parse_node_id(charter_id, "charter_id")
|
cid = _parse_node_id(charter_id, "charter_id")
|
||||||
tenant_id = int(current_user.tenant_id)
|
tenant_id = int(current_user.tenant_id)
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,11 @@ class CharterCreateRequest(BaseModel):
|
||||||
@router.get("/projects/{project_id}/charters")
|
@router.get("/projects/{project_id}/charters")
|
||||||
async def list_charters(
|
async def list_charters(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
|
permission_user: User = Depends(require_project_member_or_manager),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
_ = permission_user
|
||||||
pid = _parse_project_id(project_id)
|
pid = _parse_project_id(project_id)
|
||||||
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
|
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
|
||||||
await _ensure_project(svc, pid)
|
await _ensure_project(svc, pid)
|
||||||
|
|
@ -143,4 +145,3 @@ async def create_charter(
|
||||||
content=_as_dict(charter.content),
|
content=_as_dict(charter.content),
|
||||||
)
|
)
|
||||||
return success_response(_build_charter_summary_payload(charter))
|
return success_response(_build_charter_summary_payload(charter))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,11 @@ router = APIRouter(tags=["sstc"])
|
||||||
@router.get("/projects/{project_id}/sstc/overview")
|
@router.get("/projects/{project_id}/sstc/overview")
|
||||||
async def get_board_overview(
|
async def get_board_overview(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
|
permission_user: User = Depends(require_project_member_or_manager),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
_ = permission_user
|
||||||
pid = _parse_project_id(project_id)
|
pid = _parse_project_id(project_id)
|
||||||
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
|
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
|
||||||
await _ensure_project(svc, pid)
|
await _ensure_project(svc, pid)
|
||||||
|
|
@ -31,9 +33,11 @@ async def get_board_overview(
|
||||||
@router.get("/projects/{project_id}/sstc/stages")
|
@router.get("/projects/{project_id}/sstc/stages")
|
||||||
async def get_stages(
|
async def get_stages(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
|
permission_user: User = Depends(require_project_member_or_manager),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
_ = permission_user
|
||||||
pid = _parse_project_id(project_id)
|
pid = _parse_project_id(project_id)
|
||||||
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
|
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
|
||||||
await _ensure_project(svc, pid)
|
await _ensure_project(svc, pid)
|
||||||
|
|
@ -42,9 +46,11 @@ async def get_stages(
|
||||||
@router.get("/projects/{project_id}/sstc/risks")
|
@router.get("/projects/{project_id}/sstc/risks")
|
||||||
async def get_risks(
|
async def get_risks(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
|
permission_user: User = Depends(require_project_member_or_manager),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
_ = permission_user
|
||||||
pid = _parse_project_id(project_id)
|
pid = _parse_project_id(project_id)
|
||||||
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
|
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
|
||||||
await _ensure_project(svc, pid)
|
await _ensure_project(svc, pid)
|
||||||
|
|
@ -54,9 +60,11 @@ async def get_risks(
|
||||||
async def get_governance_snapshot(
|
async def get_governance_snapshot(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
charter_id: Optional[int] = Query(default=None),
|
charter_id: Optional[int] = Query(default=None),
|
||||||
|
permission_user: User = Depends(require_project_member_or_manager),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
_ = permission_user
|
||||||
pid = _parse_project_id(project_id)
|
pid = _parse_project_id(project_id)
|
||||||
tenant_id = int(current_user.tenant_id)
|
tenant_id = int(current_user.tenant_id)
|
||||||
svc = SSTCService(db, tenant_id=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")
|
@router.get("/projects/{project_id}/sstc-chain")
|
||||||
async def get_sstc_chain(
|
async def get_sstc_chain(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
|
permission_user: User = Depends(require_project_member_or_manager),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
_ = permission_user
|
||||||
pid = _parse_project_id(project_id)
|
pid = _parse_project_id(project_id)
|
||||||
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
|
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
|
||||||
await _ensure_project(svc, pid)
|
await _ensure_project(svc, pid)
|
||||||
return success_response(await svc.build_chain(pid))
|
return success_response(await svc.build_chain(pid))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,11 @@ router = APIRouter(tags=["sstc"])
|
||||||
@router.get("/projects/{project_id}/releases")
|
@router.get("/projects/{project_id}/releases")
|
||||||
async def list_releases(
|
async def list_releases(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
|
permission_user: User = Depends(require_project_member_or_manager),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
_ = permission_user
|
||||||
pid = _parse_project_id(project_id)
|
pid = _parse_project_id(project_id)
|
||||||
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
|
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
|
||||||
await _ensure_project(svc, pid)
|
await _ensure_project(svc, pid)
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,11 @@ router = APIRouter(tags=["sstc"])
|
||||||
@router.get("/projects/{project_id}/reviews")
|
@router.get("/projects/{project_id}/reviews")
|
||||||
async def list_reviews(
|
async def list_reviews(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
|
permission_user: User = Depends(require_project_member_or_manager),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
_ = permission_user
|
||||||
pid = _parse_project_id(project_id)
|
pid = _parse_project_id(project_id)
|
||||||
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
|
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
|
||||||
await _ensure_project(svc, pid)
|
await _ensure_project(svc, pid)
|
||||||
|
|
@ -101,4 +103,3 @@ async def create_review(
|
||||||
"content": _as_dict(review_node.content),
|
"content": _as_dict(review_node.content),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,11 @@ async def list_signals(
|
||||||
created_to: Optional[str] = Query(default=None, description="创建时间结束(北京时间 YYYY-MM-DD)"),
|
created_to: Optional[str] = Query(default=None, description="创建时间结束(北京时间 YYYY-MM-DD)"),
|
||||||
limit: int = Query(default=10, ge=1, le=100),
|
limit: int = Query(default=10, ge=1, le=100),
|
||||||
offset: int = Query(default=0, ge=0),
|
offset: int = Query(default=0, ge=0),
|
||||||
|
permission_user: User = Depends(require_project_member_or_manager),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
_ = permission_user
|
||||||
pid = _parse_project_id(project_id)
|
pid = _parse_project_id(project_id)
|
||||||
tenant_id = int(current_user.tenant_id)
|
tenant_id = int(current_user.tenant_id)
|
||||||
svc = SSTCService(db, tenant_id=tenant_id)
|
svc = SSTCService(db, tenant_id=tenant_id)
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,11 @@ async def get_task_board(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
assignee_id: Optional[int] = Query(default=None),
|
assignee_id: Optional[int] = Query(default=None),
|
||||||
priority: Optional[str] = Query(default=None),
|
priority: Optional[str] = Query(default=None),
|
||||||
|
permission_user: User = Depends(require_project_member_or_manager),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
_ = permission_user
|
||||||
pid = _parse_project_id(project_id)
|
pid = _parse_project_id(project_id)
|
||||||
tenant_id = int(current_user.tenant_id)
|
tenant_id = int(current_user.tenant_id)
|
||||||
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
|
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ from .helpers_roles import _normalize_role_input, _pack_roles
|
||||||
|
|
||||||
def _translate_user_integrity_error(exc: IntegrityError) -> str:
|
def _translate_user_integrity_error(exc: IntegrityError) -> str:
|
||||||
message = str(getattr(exc, "orig", exc)).lower()
|
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:
|
if "users.username" in message or "username" in message:
|
||||||
return "用户名已存在"
|
return "用户名已存在"
|
||||||
if "users.email" in message or "email" in message:
|
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():
|
if existing_email.scalar_one_or_none():
|
||||||
raise ValueError("邮箱已存在")
|
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(
|
user = User(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
username=username,
|
username=username,
|
||||||
password_hash=AuthService.hash_password(user_data.password),
|
password_hash=AuthService.hash_password(user_data.password),
|
||||||
name=(user_data.name or username).strip(),
|
name=(user_data.name or username).strip(),
|
||||||
email=(user_data.email or f"{username}@placeholder.local").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),
|
role=_pack_roles(user_data.role),
|
||||||
assigned_domains=user_data.assigned_domains or [],
|
assigned_domains=user_data.assigned_domains or [],
|
||||||
workload=0,
|
workload=0,
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,14 @@ async def update_user(
|
||||||
user.name = user_data.name.strip()
|
user.name = user_data.name.strip()
|
||||||
if user_data.email is not None:
|
if user_data.email is not None:
|
||||||
user.email = user_data.email.strip()
|
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:
|
if user_data.assigned_domains is not None:
|
||||||
user.assigned_domains = user_data.assigned_domains
|
user.assigned_domains = user_data.assigned_domains
|
||||||
if user_data.workload is not None:
|
if user_data.workload is not None:
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,20 @@ from __future__ import annotations
|
||||||
from .shared import *
|
from .shared import *
|
||||||
from .helpers_roles import _build_role_filter, _has_any_role, _normalize_role_input, _parse_int_list
|
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 .helpers_user import _collect_user_enrichment, _serialize_user
|
||||||
|
from ...services.platform_access_service import get_platform_context
|
||||||
|
|
||||||
@router.get("/me", response_model=UserResponse)
|
@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("/")
|
@router.get("/")
|
||||||
async def list_users(
|
async def list_users(
|
||||||
|
|
@ -47,6 +56,8 @@ async def list_users(
|
||||||
User.username.ilike(q),
|
User.username.ilike(q),
|
||||||
User.name.ilike(q),
|
User.name.ilike(q),
|
||||||
User.email.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])
|
workloads, skills, projects = await _collect_user_enrichment(db, tenant_id, [user])
|
||||||
return _serialize_user(user, workloads, skills, projects)
|
return _serialize_user(user, workloads, skills, projects)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -120,11 +120,17 @@ def _entry(
|
||||||
responsibility_scope: str | None = None,
|
responsibility_scope: str | None = None,
|
||||||
priority: str | None = None,
|
priority: str | None = None,
|
||||||
audience: str | None = None,
|
audience: str | None = None,
|
||||||
|
runtime_source: str = "catalog",
|
||||||
|
editable_policy: str = "managed_release",
|
||||||
|
risk_level: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
resolved_family = _normalize_family(prompt_family or _derive_prompt_family(prompt_key, business_scenario))
|
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_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_priority = str(priority or "").strip().lower() or _derive_priority(resolved_family)
|
||||||
resolved_audience = str(audience or "").strip() or _derive_audience(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 {
|
return {
|
||||||
"prompt_key": prompt_key,
|
"prompt_key": prompt_key,
|
||||||
|
|
@ -137,6 +143,9 @@ def _entry(
|
||||||
"responsibility_scope": resolved_scope,
|
"responsibility_scope": resolved_scope,
|
||||||
"priority": resolved_priority,
|
"priority": resolved_priority,
|
||||||
"audience": resolved_audience,
|
"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,
|
output_contract=QA_OUTPUT,
|
||||||
call_sites=FEISHU_REPLY_CALL_SITES,
|
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(
|
_entry(
|
||||||
"PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION",
|
"PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION",
|
||||||
module="part_01",
|
module="part_01",
|
||||||
|
|
@ -583,6 +608,215 @@ def _scene_entries() -> tuple[dict[str, Any], ...]:
|
||||||
output_contract="输出仅基于外部证据的对比回答。",
|
output_contract="输出仅基于外部证据的对比回答。",
|
||||||
call_sites=("backend/app/services/external_query_service/presenter.py",),
|
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_03_entries(),
|
||||||
*_part_04_entries(),
|
*_part_04_entries(),
|
||||||
*_scene_entries(),
|
*_scene_entries(),
|
||||||
|
*_runtime_guardrail_entries(),
|
||||||
|
*_semantic_fact_entries(),
|
||||||
|
*_inline_discovered_entries(),
|
||||||
)
|
)
|
||||||
|
|
||||||
PROMPT_REGISTRY_MAP: dict[str, dict[str, Any]] = {
|
PROMPT_REGISTRY_MAP: dict[str, dict[str, Any]] = {
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,9 @@ from .ai_prompts_catalog import (
|
||||||
PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA,
|
PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA,
|
||||||
PROMPT_EXTERNAL_TREND_QA,
|
PROMPT_EXTERNAL_TREND_QA,
|
||||||
PROMPT_INDUSTRY_TREND_QA,
|
PROMPT_INDUSTRY_TREND_QA,
|
||||||
|
PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION,
|
||||||
PROMPT_PMFLOW_LAUNCH_DRAFT,
|
PROMPT_PMFLOW_LAUNCH_DRAFT,
|
||||||
|
PROMPT_SAVED_IDEA_ENRICHMENT,
|
||||||
)
|
)
|
||||||
from .ai_prompt_registry import (
|
from .ai_prompt_registry import (
|
||||||
PROMPT_REGISTRY,
|
PROMPT_REGISTRY,
|
||||||
|
|
@ -1013,6 +1015,9 @@ __all__ = [
|
||||||
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA",
|
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA",
|
||||||
"PROMPT_EXTERNAL_TREND_QA",
|
"PROMPT_EXTERNAL_TREND_QA",
|
||||||
"PROMPT_INDUSTRY_TREND_QA",
|
"PROMPT_INDUSTRY_TREND_QA",
|
||||||
|
"PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION",
|
||||||
|
"PROMPT_PMFLOW_LAUNCH_DRAFT",
|
||||||
|
"PROMPT_SAVED_IDEA_ENRICHMENT",
|
||||||
"PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT",
|
"PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT",
|
||||||
"PROMPT_RAG_QUERY_SEMANTICS",
|
"PROMPT_RAG_QUERY_SEMANTICS",
|
||||||
"PROMPT_RAG_FOLLOWUP_ANCHOR_SEMANTICS",
|
"PROMPT_RAG_FOLLOWUP_ANCHOR_SEMANTICS",
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,9 @@ from .part_scenes_06 import (
|
||||||
PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA,
|
PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA,
|
||||||
PROMPT_EXTERNAL_TREND_QA,
|
PROMPT_EXTERNAL_TREND_QA,
|
||||||
PROMPT_INDUSTRY_TREND_QA,
|
PROMPT_INDUSTRY_TREND_QA,
|
||||||
|
PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION,
|
||||||
PROMPT_PMFLOW_LAUNCH_DRAFT,
|
PROMPT_PMFLOW_LAUNCH_DRAFT,
|
||||||
|
PROMPT_SAVED_IDEA_ENRICHMENT,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|
@ -74,5 +76,7 @@ __all__ = [
|
||||||
"PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA",
|
"PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA",
|
||||||
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET",
|
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET",
|
||||||
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA",
|
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA",
|
||||||
|
"PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION",
|
||||||
"PROMPT_PMFLOW_LAUNCH_DRAFT",
|
"PROMPT_PMFLOW_LAUNCH_DRAFT",
|
||||||
|
"PROMPT_SAVED_IDEA_ENRICHMENT",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from __future__ import annotations
|
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(
|
PROMPT_EXTERNAL_TREND_QA = _compose_prompt(
|
||||||
"你是 PMFlow 的市场情报助手。",
|
"你是 PMFlow 的市场情报助手。",
|
||||||
|
|
@ -25,7 +25,7 @@ PROMPT_EXTERNAL_TREND_QA = _compose_prompt(
|
||||||
项目上下文:{project_context}
|
项目上下文:{project_context}
|
||||||
外部搜索结果:{external_results}
|
外部搜索结果:{external_results}
|
||||||
""".strip(),
|
""".strip(),
|
||||||
TEXT_OUTPUT_GUARDRAIL,
|
JSON_OUTPUT_GUARDRAIL,
|
||||||
)
|
)
|
||||||
|
|
||||||
PROMPT_INDUSTRY_TREND_QA = _compose_prompt(
|
PROMPT_INDUSTRY_TREND_QA = _compose_prompt(
|
||||||
|
|
@ -203,7 +203,7 @@ JSON schema:
|
||||||
PROMPT_PMFLOW_LAUNCH_DRAFT = _compose_prompt(
|
PROMPT_PMFLOW_LAUNCH_DRAFT = _compose_prompt(
|
||||||
"你是 PMFlow 的新项目启动草案生成器。",
|
"你是 PMFlow 的新项目启动草案生成器。",
|
||||||
"""
|
"""
|
||||||
用户输入了一个 AI 产品创意。你只能生成“待用户确认”的项目草案,不能声称项目已经创建。
|
用户输入了一个 AI 产品创意。你只能生成“待用户确认”的项目草案,不能声称项目已经创建、已确认、已写入项目记忆或已开始执行。
|
||||||
|
|
||||||
输出必须是 JSON 对象,不要 Markdown,不要解释。字段:
|
输出必须是 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 条。
|
1. requirements 目标 7-10 条,tasks 目标 10-16 条,risks 目标 4-7 条,decisions 目标 3-6 条;输入信息不足时允许低于目标,但必须在 warnings 写明缺口。
|
||||||
2. 所有对象必须来自用户输入的合理结构化推断;信息不足时写入 warnings,不要编造具体事实。
|
2. 草案要像“可变现项目的待确认启动计划”,不是只列 3 个功能点。requirements 必须覆盖:目标用户/痛点、核心使用闭环、输入或数据来源、AI/模型处理链路、输出物与交互体验、信任/人工确认、安全合规或权限、变现/定价/留存验证、埋点或反馈指标。
|
||||||
3. confidence 用 0-1 数值。
|
3. requirements 每项必须有 title、summary、acceptance_criteria;acceptance_criteria 至少 2 条,表达可验收结果,不能只写“完成”“支持”这类空泛描述。
|
||||||
4. AI 生成内容只能是待确认草案,不要使用“已确认”“已写入”等表述。
|
4. tasks 必须拆成可执行的首批工作包,而不是一句话待办;必须覆盖 product、engineering、qa 三类,至少包含:用户/场景验证、信息架构或交互、关键接口/数据流、AI 输出质量验证、权限/隐私边界、变现或增长验证、埋点/反馈闭环。priority 只能是 low、medium、high、urgent;requirement_index 必须指向 requirements 的数组下标,不能越界。
|
||||||
5. evidence.excerpt 必须引用或概括用户输入中的证据。
|
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}
|
{input_text}
|
||||||
|
|
||||||
附件摘要:
|
附件摘要:
|
||||||
{attachments_json}
|
{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(),
|
""".strip(),
|
||||||
TEXT_OUTPUT_GUARDRAIL,
|
TEXT_OUTPUT_GUARDRAIL,
|
||||||
)
|
)
|
||||||
|
|
@ -245,5 +342,7 @@ __all__ = [
|
||||||
"PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA",
|
"PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA",
|
||||||
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET",
|
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET",
|
||||||
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA",
|
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA",
|
||||||
|
"PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION",
|
||||||
"PROMPT_PMFLOW_LAUNCH_DRAFT",
|
"PROMPT_PMFLOW_LAUNCH_DRAFT",
|
||||||
|
"PROMPT_SAVED_IDEA_ENRICHMENT",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ async def probe_model_connectivity_on_startup() -> None:
|
||||||
|
|
||||||
low_runtime = build_tier_runtime("low")
|
low_runtime = build_tier_runtime("low")
|
||||||
high_runtime = build_tier_runtime("high")
|
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"
|
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_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"
|
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),
|
error=str(resolve_exc),
|
||||||
)
|
)
|
||||||
if not isinstance(runtime, dict) or not runtime:
|
if not isinstance(runtime, dict) or not runtime:
|
||||||
runtime = llm_service._env_provider_runtime(provider)
|
raise ValueError("未解析到已保存的供应商配置")
|
||||||
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 "")
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
"startup_model_probe_runtime_invalid",
|
"startup_model_probe_runtime_invalid",
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,16 @@ FastAPI 依赖注入
|
||||||
from .auth import (
|
from .auth import (
|
||||||
oauth2_scheme,
|
oauth2_scheme,
|
||||||
get_current_user,
|
get_current_user,
|
||||||
|
get_optional_current_user,
|
||||||
get_current_active_user,
|
get_current_active_user,
|
||||||
require_roles,
|
require_roles,
|
||||||
)
|
)
|
||||||
from .permissions import (
|
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_manager_user,
|
||||||
require_project_member_or_manager,
|
require_project_member_or_manager,
|
||||||
require_self_or_manager,
|
require_self_or_manager,
|
||||||
|
|
@ -22,8 +28,14 @@ from .tenant import get_tenant_id
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"oauth2_scheme",
|
"oauth2_scheme",
|
||||||
"get_current_user",
|
"get_current_user",
|
||||||
|
"get_optional_current_user",
|
||||||
"get_current_active_user",
|
"get_current_active_user",
|
||||||
"require_roles",
|
"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_manager_user",
|
||||||
"require_project_member_or_manager",
|
"require_project_member_or_manager",
|
||||||
"require_self_or_manager",
|
"require_self_or_manager",
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,9 @@ from sqlalchemy import and_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..core.database import get_db
|
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.auth import MANAGER_ROLES, parse_role_values
|
||||||
|
from ..services.platform_access_service import has_platform_capability
|
||||||
from .auth import get_current_user
|
from .auth import get_current_user
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -58,15 +59,44 @@ async def require_project_member_or_manager(
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail="Invalid project_id") from exc
|
raise HTTPException(status_code=400, detail="Invalid project_id") from exc
|
||||||
|
|
||||||
membership = await db.execute(
|
access = await db.execute(
|
||||||
select(UserProject.user_id).where(
|
select(Project.id)
|
||||||
|
.outerjoin(
|
||||||
|
UserProject,
|
||||||
and_(
|
and_(
|
||||||
UserProject.tenant_id == int(current_user.tenant_id),
|
UserProject.tenant_id == Project.tenant_id,
|
||||||
UserProject.project_id == pid,
|
UserProject.project_id == Project.id,
|
||||||
UserProject.user_id == int(current_user.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")
|
raise HTTPException(status_code=403, detail="Project membership required")
|
||||||
return current_user
|
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")
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
from .base import Base, BaseNoTenant, BaseNoCreatedAt
|
from .base import Base, BaseNoTenant, BaseNoCreatedAt
|
||||||
from .enums import UserRole, UserStatus
|
from .enums import UserRole, UserStatus
|
||||||
from .tenant import Tenant
|
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 import User
|
||||||
from .user_project import UserProject
|
from .user_project import UserProject
|
||||||
from .skill import SkillTag
|
from .skill import SkillTag
|
||||||
|
|
@ -50,7 +54,7 @@ from .query_route_memory import QueryRouteMemory
|
||||||
from .semantic_fact import SemanticFact
|
from .semantic_fact import SemanticFact
|
||||||
from .semantic_fact_extraction_run import SemanticFactExtractionRun
|
from .semantic_fact_extraction_run import SemanticFactExtractionRun
|
||||||
from .feedback_center import FeedbackCenterRecord, FeedbackCenterRecordRevision
|
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 (
|
from .training_pipeline import (
|
||||||
TrainingDataset,
|
TrainingDataset,
|
||||||
TrainingDatasetItem,
|
TrainingDatasetItem,
|
||||||
|
|
@ -108,8 +112,10 @@ from .ai_project_control import (
|
||||||
ComplianceCheckRun,
|
ComplianceCheckRun,
|
||||||
EvidencePack,
|
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_change_review import PMFlowChangeSet, PMFlowChangeItem, PMFlowApproval, PMFlowEvidenceLink
|
||||||
|
from .pmflow_ai_feedback import PMFlowAIFeedbackEvent
|
||||||
|
from .saved_idea import SavedIdea
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
|
|
@ -118,6 +124,9 @@ __all__ = [
|
||||||
"UserRole",
|
"UserRole",
|
||||||
"UserStatus",
|
"UserStatus",
|
||||||
"Tenant",
|
"Tenant",
|
||||||
|
"TenantMembership",
|
||||||
|
"TenantInvitation",
|
||||||
|
"PlatformMembership",
|
||||||
"User",
|
"User",
|
||||||
"UserProject",
|
"UserProject",
|
||||||
"SkillTag",
|
"SkillTag",
|
||||||
|
|
@ -170,6 +179,10 @@ __all__ = [
|
||||||
"JudgeRun",
|
"JudgeRun",
|
||||||
"OptimizerCandidate",
|
"OptimizerCandidate",
|
||||||
"PromptRelease",
|
"PromptRelease",
|
||||||
|
"PromptReleaseCheck",
|
||||||
|
"PromptGoldenCase",
|
||||||
|
"PromptReplayRun",
|
||||||
|
"PromptReplayCaseResult",
|
||||||
"TrainingDataset",
|
"TrainingDataset",
|
||||||
"TrainingDatasetItem",
|
"TrainingDatasetItem",
|
||||||
"TrainingJob",
|
"TrainingJob",
|
||||||
|
|
@ -222,10 +235,13 @@ __all__ = [
|
||||||
"ComplianceCheckRun",
|
"ComplianceCheckRun",
|
||||||
"EvidencePack",
|
"EvidencePack",
|
||||||
"PMFlowLaunchDraft",
|
"PMFlowLaunchDraft",
|
||||||
|
"PMFlowLaunchDraftJob",
|
||||||
"PMFlowThread",
|
"PMFlowThread",
|
||||||
"PMFlowThreadMessage",
|
"PMFlowThreadMessage",
|
||||||
"PMFlowChangeSet",
|
"PMFlowChangeSet",
|
||||||
"PMFlowChangeItem",
|
"PMFlowChangeItem",
|
||||||
"PMFlowApproval",
|
"PMFlowApproval",
|
||||||
"PMFlowEvidenceLink",
|
"PMFlowEvidenceLink",
|
||||||
|
"PMFlowAIFeedbackEvent",
|
||||||
|
"SavedIdea",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from .base import BaseNoTenant
|
from .base import BaseNoTenant
|
||||||
|
|
@ -32,6 +32,17 @@ class AICallLog(BaseLog):
|
||||||
tokens_used: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
tokens_used: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
cost: Mapped[Optional[float]] = mapped_column(DECIMAL(10, 6), nullable=True)
|
cost: Mapped[Optional[float]] = mapped_column(DECIMAL(10, 6), nullable=True)
|
||||||
status: Mapped[str] = mapped_column(VARCHAR(50), nullable=False)
|
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)
|
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)
|
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)
|
routing_decision: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -30,6 +30,7 @@ class PMFlowChangeSet(Base):
|
||||||
created_by: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id"), nullable=False, index=True)
|
created_by: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
reviewed_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)
|
reviewed_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)
|
||||||
committed_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):
|
class PMFlowChangeItem(Base):
|
||||||
|
|
|
||||||
|
|
@ -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):
|
class PMFlowThread(Base):
|
||||||
__tablename__ = "pmflow_threads"
|
__tablename__ = "pmflow_threads"
|
||||||
|
|
||||||
|
|
@ -72,4 +103,4 @@ class PMFlowThreadMessage(Base):
|
||||||
status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="sent", index=True)
|
status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="sent", index=True)
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["PMFlowLaunchDraft", "PMFlowThread", "PMFlowThreadMessage"]
|
__all__ = ["PMFlowLaunchDraft", "PMFlowLaunchDraftJob", "PMFlowThread", "PMFlowThreadMessage"]
|
||||||
|
|
|
||||||
|
|
@ -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__ = [
|
__all__ = [
|
||||||
"QualityAsset",
|
"QualityAsset",
|
||||||
"QualityAssetPair",
|
"QualityAssetPair",
|
||||||
"JudgeRun",
|
"JudgeRun",
|
||||||
"OptimizerCandidate",
|
"OptimizerCandidate",
|
||||||
"PromptRelease",
|
"PromptRelease",
|
||||||
|
"PromptReleaseCheck",
|
||||||
|
"PromptGoldenCase",
|
||||||
|
"PromptReplayRun",
|
||||||
|
"PromptReplayCaseResult",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
"""租户模型"""
|
"""空间模型。
|
||||||
|
|
||||||
|
数据库沿用 tenants 表名;产品语义为 Workspace。
|
||||||
|
"""
|
||||||
from .base import BaseNoTenant
|
from .base import BaseNoTenant
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from sqlalchemy import BigInteger, Integer, VARCHAR
|
from sqlalchemy import BigInteger, Integer, VARCHAR
|
||||||
|
|
@ -11,3 +14,7 @@ class Tenant(BaseNoTenant):
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
name: Mapped[str] = mapped_column(VARCHAR(255), nullable=False)
|
name: Mapped[str] = mapped_column(VARCHAR(255), nullable=False)
|
||||||
domain: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True)
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
@ -5,7 +5,7 @@ from .base import Base
|
||||||
from .enums import UserRole, UserStatus
|
from .enums import UserRole, UserStatus
|
||||||
from .types import JSONB
|
from .types import JSONB
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
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
|
from typing import List, Optional, TYPE_CHECKING, Any
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -20,6 +20,12 @@ class User(Base):
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
username: Mapped[str] = mapped_column(VARCHAR(100), nullable=False, unique=True)
|
username: Mapped[str] = mapped_column(VARCHAR(100), nullable=False, unique=True)
|
||||||
email: Mapped[str] = mapped_column(VARCHAR(255), 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)
|
password_hash: Mapped[str] = mapped_column(VARCHAR(255), nullable=False)
|
||||||
name: Mapped[str] = mapped_column(VARCHAR(100), nullable=False)
|
name: Mapped[str] = mapped_column(VARCHAR(100), nullable=False)
|
||||||
role: Mapped[str] = mapped_column(VARCHAR(50), nullable=False)
|
role: Mapped[str] = mapped_column(VARCHAR(50), nullable=False)
|
||||||
|
|
@ -43,9 +49,16 @@ class User(Base):
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": str(self.id),
|
"id": str(self.id),
|
||||||
|
"tenant_id": str(self.tenant_id),
|
||||||
"username": self.username,
|
"username": self.username,
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
"email": self.email,
|
"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,
|
"role": self.role,
|
||||||
"assigned_domains": self.assigned_domains or [],
|
"assigned_domains": self.assigned_domains or [],
|
||||||
"workload": self.workload,
|
"workload": self.workload,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ from .auth import (
|
||||||
UserUpdate,
|
UserUpdate,
|
||||||
UserResponse,
|
UserResponse,
|
||||||
UserLoginResponse,
|
UserLoginResponse,
|
||||||
|
PhoneCodeSendRequest,
|
||||||
|
PhoneCodeVerifyRequest,
|
||||||
|
UserRegisterRequest,
|
||||||
UserResetPasswordRequest,
|
UserResetPasswordRequest,
|
||||||
UserStatusPatchRequest,
|
UserStatusPatchRequest,
|
||||||
)
|
)
|
||||||
|
|
@ -80,6 +83,9 @@ __all__ = [
|
||||||
"UserUpdate",
|
"UserUpdate",
|
||||||
"UserResponse",
|
"UserResponse",
|
||||||
"UserLoginResponse",
|
"UserLoginResponse",
|
||||||
|
"PhoneCodeSendRequest",
|
||||||
|
"PhoneCodeVerifyRequest",
|
||||||
|
"UserRegisterRequest",
|
||||||
"UserResetPasswordRequest",
|
"UserResetPasswordRequest",
|
||||||
"UserStatusPatchRequest",
|
"UserStatusPatchRequest",
|
||||||
"SignalCreate",
|
"SignalCreate",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,34 @@ class UserLoginResponse(BaseModel):
|
||||||
user: dict
|
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):
|
class UserCreate(BaseModel):
|
||||||
"""创建用户请求"""
|
"""创建用户请求"""
|
||||||
|
|
||||||
|
|
@ -22,6 +50,8 @@ class UserCreate(BaseModel):
|
||||||
password: str
|
password: str
|
||||||
name: str
|
name: str
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
|
phone_country_code: Optional[str] = None
|
||||||
|
phone_number: Optional[str] = None
|
||||||
role: Union[UserRole, List[UserRole], str]
|
role: Union[UserRole, List[UserRole], str]
|
||||||
assigned_domains: Optional[List[str]] = []
|
assigned_domains: Optional[List[str]] = []
|
||||||
project_ids: Optional[List[int]] = []
|
project_ids: Optional[List[int]] = []
|
||||||
|
|
@ -32,6 +62,8 @@ class UserUpdate(BaseModel):
|
||||||
|
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
email: 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
|
role: Optional[Union[UserRole, List[UserRole], str]] = None
|
||||||
assigned_domains: Optional[List[str]] = None
|
assigned_domains: Optional[List[str]] = None
|
||||||
workload: Optional[int] = None
|
workload: Optional[int] = None
|
||||||
|
|
@ -59,6 +91,10 @@ class UserResponse(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
name: str
|
name: str
|
||||||
email: Optional[str] = None
|
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
|
role: str
|
||||||
assigned_domains: List[str]
|
assigned_domains: List[str]
|
||||||
workload: int
|
workload: int
|
||||||
|
|
@ -67,5 +103,6 @@ class UserResponse(BaseModel):
|
||||||
workload_ratio: Optional[float] = None
|
workload_ratio: Optional[float] = None
|
||||||
top_skills: List[str] = []
|
top_skills: List[str] = []
|
||||||
project_ids: List[int] = []
|
project_ids: List[int] = []
|
||||||
|
platform_context: Optional[dict] = None
|
||||||
created_at: str
|
created_at: str
|
||||||
updated_at: str
|
updated_at: str
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,12 @@ class PMFlowLaunchDraftRequest(BaseModel):
|
||||||
source: str = "web"
|
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):
|
class PMFlowLaunchRequirement(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
summary: str = ""
|
summary: str = ""
|
||||||
|
|
@ -75,6 +81,32 @@ class PMFlowLaunchDraftResponse(PMFlowLaunchDraftPayload):
|
||||||
expires_at: str
|
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):
|
class PMFlowLaunchConfirmRequest(BaseModel):
|
||||||
edited_draft: dict[str, Any] = Field(default_factory=dict)
|
edited_draft: dict[str, Any] = Field(default_factory=dict)
|
||||||
confirm: bool = True
|
confirm: bool = True
|
||||||
|
|
@ -95,6 +127,9 @@ __all__ = [
|
||||||
"PMFlowLaunchConfirmRequest",
|
"PMFlowLaunchConfirmRequest",
|
||||||
"PMFlowLaunchConfirmResponse",
|
"PMFlowLaunchConfirmResponse",
|
||||||
"PMFlowLaunchDecision",
|
"PMFlowLaunchDecision",
|
||||||
|
"PMFlowLaunchDraftJobRequest",
|
||||||
|
"PMFlowLaunchDraftJobResponse",
|
||||||
|
"PMFlowLaunchDraftJobRuntimePolicy",
|
||||||
"PMFlowLaunchDraftPayload",
|
"PMFlowLaunchDraftPayload",
|
||||||
"PMFlowLaunchDraftRequest",
|
"PMFlowLaunchDraftRequest",
|
||||||
"PMFlowLaunchDraftResponse",
|
"PMFlowLaunchDraftResponse",
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,9 @@ PMFlowReviewerConfidence = Literal["low", "medium", "high"]
|
||||||
PMFlowFallbackReviewerRole = Literal["OWNER", "REVIEWER", "MEMBER"]
|
PMFlowFallbackReviewerRole = Literal["OWNER", "REVIEWER", "MEMBER"]
|
||||||
PMFlowReviewDecisionAction = Literal["request_review", "approve", "request_changes", "reject"]
|
PMFlowReviewDecisionAction = Literal["request_review", "approve", "request_changes", "reject"]
|
||||||
PMFlowApprovalState = Literal["not_requested", "requested", "approved", "changes_requested", "rejected"]
|
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[
|
PMFlowExecutionCapabilityMode = Literal[
|
||||||
"ai_direct",
|
"ai_direct",
|
||||||
"ai_with_user_acceptance",
|
"ai_with_user_acceptance",
|
||||||
|
|
@ -93,6 +96,50 @@ class PMFlowThreadMessageResponse(BaseModel):
|
||||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
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):
|
class PMFlowProposedChangeItem(BaseModel):
|
||||||
group: PMFlowChangeGroup
|
group: PMFlowChangeGroup
|
||||||
object_type: PMFlowObjectType
|
object_type: PMFlowObjectType
|
||||||
|
|
@ -113,7 +160,7 @@ class PMFlowExtractionCreateRequest(BaseModel):
|
||||||
headline: Optional[str] = None
|
headline: Optional[str] = None
|
||||||
routing_meta: dict[str, Any] = Field(default_factory=dict)
|
routing_meta: dict[str, Any] = Field(default_factory=dict)
|
||||||
llm_call_id: Optional[str] = None
|
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):
|
class PMFlowChangeItemEditRequest(BaseModel):
|
||||||
|
|
@ -377,6 +424,8 @@ class PMFlowProjectMemory(BaseModel):
|
||||||
class ProjectTruthStateProjection(BaseModel):
|
class ProjectTruthStateProjection(BaseModel):
|
||||||
loopStage: ProjectLoopStage = "command_idle"
|
loopStage: ProjectLoopStage = "command_idle"
|
||||||
lastWritebackAt: Optional[str] = None
|
lastWritebackAt: Optional[str] = None
|
||||||
|
lastWritebackBy: Optional[str] = None
|
||||||
|
lastWritebackById: Optional[str] = None
|
||||||
project: PMFlowProjectSummary
|
project: PMFlowProjectSummary
|
||||||
thread: PMFlowThread
|
thread: PMFlowThread
|
||||||
messages: list[dict[str, Any]] = Field(default_factory=list)
|
messages: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|
@ -392,6 +441,7 @@ class ProjectTruthStateProjection(BaseModel):
|
||||||
releases: list[PMFlowRelease] = Field(default_factory=list)
|
releases: list[PMFlowRelease] = Field(default_factory=list)
|
||||||
metricSignals: list[PMFlowMetricSignal] = Field(default_factory=list)
|
metricSignals: list[PMFlowMetricSignal] = Field(default_factory=list)
|
||||||
approvals: list[dict[str, Any]] = 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)
|
feed: list[PMFlowFeedEvent] = Field(default_factory=list)
|
||||||
teamInbox: PMFlowTeamInbox = Field(default_factory=PMFlowTeamInbox)
|
teamInbox: PMFlowTeamInbox = Field(default_factory=PMFlowTeamInbox)
|
||||||
reviewerRouting: PMFlowReviewerRoutingProjection = Field(default_factory=PMFlowReviewerRoutingProjection)
|
reviewerRouting: PMFlowReviewerRoutingProjection = Field(default_factory=PMFlowReviewerRoutingProjection)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -1099,19 +1099,19 @@ class AdaptiveRAGAnswerMainlineMixin:
|
||||||
|
|
||||||
is_snapshot_direct_question = str((scene_semantics or {}).get("snapshot_focus") or "none").strip().lower() != "none"
|
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:
|
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,
|
clean_q=effective_q,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
query_semantics=query_semantics,
|
query_semantics=query_semantics,
|
||||||
internal_context_packet=internal_context_packet,
|
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(
|
return await self._persist_fast_answer(
|
||||||
question=clean_q,
|
question=clean_q,
|
||||||
asked_by=asked_by,
|
asked_by=asked_by,
|
||||||
asked_via=asked_via,
|
asked_via=asked_via,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
fast_result=glm_semantic_result,
|
fast_result=fast_semantic_result,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
external_session_id=context_session_id,
|
external_session_id=context_session_id,
|
||||||
turn_index=turn_index,
|
turn_index=turn_index,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
"""Adaptive RAG mixin module."""
|
"""Adaptive RAG mixin module."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
from .shared_utils import _env_bool
|
from .shared_utils import _env_bool
|
||||||
from .internal_query_context import internal_context_target_resolution, update_internal_query_context_packet
|
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:
|
class AdaptiveRAGAnswerRetrievalMixin:
|
||||||
_ENV_ENABLE_FIT_BRIEF_EXTERNAL_RECOMMENDATION = "PMFLOW_ENABLE_FIT_BRIEF_EXTERNAL_RECOMMENDATION"
|
_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]:
|
async def _execute_external_mcp_search(self, plan) -> dict[str, Any]:
|
||||||
from ..external_query_service.schemas import ExternalAdapterResult, ExternalQueryPlan
|
from ..external_query_service.schemas import ExternalAdapterResult, ExternalQueryPlan
|
||||||
|
|
@ -80,6 +165,7 @@ class AdaptiveRAGAnswerRetrievalMixin:
|
||||||
result_limit: int = 3,
|
result_limit: int = 3,
|
||||||
max_pages: int = 3,
|
max_pages: int = 3,
|
||||||
) -> tuple[list[RetrievedItem], dict[str, Any]]:
|
) -> 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(
|
live_search_ready = all(
|
||||||
callable(item)
|
callable(item)
|
||||||
for item in (
|
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
|
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 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)
|
legacy_retrieval = getattr(self, "_retrieve_live_external_context", None)
|
||||||
if callable(legacy_retrieval):
|
if callable(legacy_retrieval):
|
||||||
rows, meta = await legacy_retrieval(
|
rows, meta = await legacy_retrieval(
|
||||||
|
|
@ -120,25 +231,30 @@ class AdaptiveRAGAnswerRetrievalMixin:
|
||||||
tenant_id=getattr(self, "tenant_id", 1),
|
tenant_id=getattr(self, "tenant_id", 1),
|
||||||
query_builder=getattr(self, "_build_live_external_search_queries", None),
|
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(
|
registry = ExternalProviderRegistry(
|
||||||
adapters=[
|
adapters=self._order_external_query_adapters(adapters=adapters, binding=active_external_slot),
|
||||||
MCPExternalAdapter(
|
provider_statuses=ExternalProviderRegistry.default_provider_statuses(
|
||||||
search_callable=getattr(self, "_execute_external_mcp_search", None),
|
mcp_available=mcp_ready,
|
||||||
fetch_text_callable=getattr(self, "_fetch_url_text", None),
|
live_search_available=live_search_ready,
|
||||||
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,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
execution = await ExternalQueryGateway(
|
execution = await ExternalQueryGateway(
|
||||||
planner=planner,
|
planner=planner,
|
||||||
|
|
@ -151,7 +267,7 @@ class AdaptiveRAGAnswerRetrievalMixin:
|
||||||
context_packet = execution.to_context_packet(question=question).model_dump(mode="json")
|
context_packet = execution.to_context_packet(question=question).model_dump(mode="json")
|
||||||
verifier = dict(execution.verifier or {})
|
verifier = dict(execution.verifier or {})
|
||||||
if not bool(verifier.get("ok")):
|
if not bool(verifier.get("ok")):
|
||||||
return [], {
|
blocked_meta = {
|
||||||
"mode": "external_query_gateway_verifier_blocked",
|
"mode": "external_query_gateway_verifier_blocked",
|
||||||
"adapter": execution.adapter_name,
|
"adapter": execution.adapter_name,
|
||||||
"verifier": verifier,
|
"verifier": verifier,
|
||||||
|
|
@ -179,6 +295,17 @@ class AdaptiveRAGAnswerRetrievalMixin:
|
||||||
"graph_timeout": False,
|
"graph_timeout": False,
|
||||||
"external_context_packet": context_packet,
|
"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] = []
|
rows: list[RetrievedItem] = []
|
||||||
for item in list(execution.evidence or [])[: max(1, int(result_limit))]:
|
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
|
return rows, meta
|
||||||
|
|
||||||
async def _maybe_fetch_live_external_freshness_context(
|
async def _maybe_fetch_live_external_freshness_context(
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ class AdaptiveRAGFastAnswerSemanticMixinP1:
|
||||||
lines = [f"{idx}. {point}" for idx, point in enumerate(attention_points, start=1)]
|
lines = [f"{idx}. {point}" for idx, point in enumerate(attention_points, start=1)]
|
||||||
return f"{base_answer}\n\n建议关注:\n" + "\n".join(lines)
|
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,
|
self,
|
||||||
*,
|
*,
|
||||||
clean_q: str,
|
clean_q: str,
|
||||||
|
|
@ -160,7 +160,7 @@ class AdaptiveRAGFastAnswerSemanticMixinP1:
|
||||||
"source": "db",
|
"source": "db",
|
||||||
"label": "Project database",
|
"label": "Project database",
|
||||||
"score": 0.99,
|
"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,
|
"db_query_ms": db_query_ms,
|
||||||
|
|
@ -342,13 +342,13 @@ class AdaptiveRAGFastAnswerSemanticMixinP1:
|
||||||
"table": "projects",
|
"table": "projects",
|
||||||
"matched_project_ids": matched_ids,
|
"matched_project_ids": matched_ids,
|
||||||
"project_index_count": len(project_index),
|
"project_index_count": len(project_index),
|
||||||
"retrieval_mode": "glm_semantic",
|
"retrieval_mode": "fast_project_semantic",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"db_query_ms": db_query_ms,
|
"db_query_ms": db_query_ms,
|
||||||
"response_time_ms": response_ms,
|
"response_time_ms": response_ms,
|
||||||
"prompt_variant_id": "glm_semantic",
|
"prompt_variant_id": "fast_project_semantic",
|
||||||
}
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -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"}:
|
if planned_entity_type in {"project", "task", "signal", "feature", "config", "user", "doc", "document", "requirement", "release", "review"}:
|
||||||
return planned_entity_type
|
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(
|
business_frame = _coalesce_business_frame_from_runtime(
|
||||||
query_semantics=semantics,
|
query_semantics=semantics,
|
||||||
operation_contract=operation_contract,
|
operation_contract=operation_contract,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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
|
|
@ -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)),
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -57,8 +57,8 @@ class AIServiceRuntimeCoreMixin:
|
||||||
}
|
}
|
||||||
|
|
||||||
provider = str(os.getenv("LLM_PROVIDER", "openai") or "openai").strip().lower()
|
provider = str(os.getenv("LLM_PROVIDER", "openai") or "openai").strip().lower()
|
||||||
if provider == "glm":
|
if provider == "dashscope":
|
||||||
defaults = dict(PROVIDER_DEFAULTS.get("glm", {}))
|
defaults = dict(PROVIDER_DEFAULTS.get("dashscope", {}))
|
||||||
return {
|
return {
|
||||||
"api_key": str(
|
"api_key": str(
|
||||||
api_key
|
api_key
|
||||||
|
|
@ -68,7 +68,7 @@ class AIServiceRuntimeCoreMixin:
|
||||||
"model": str(
|
"model": str(
|
||||||
model
|
model
|
||||||
or defaults.get("model")
|
or defaults.get("model")
|
||||||
or DEFAULT_GLM_MODEL
|
or DEFAULT_DASHSCOPE_LOW_MODEL
|
||||||
or ""
|
or ""
|
||||||
).strip(),
|
).strip(),
|
||||||
"base_url": str(
|
"base_url": str(
|
||||||
|
|
|
||||||
|
|
@ -52,9 +52,9 @@ DEFAULT_OPENAI_MODEL = str(
|
||||||
or _TIER_DEFAULTS.get("high", {}).get("model")
|
or _TIER_DEFAULTS.get("high", {}).get("model")
|
||||||
or "GLM-5"
|
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_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("glm", {}).get("model") or "qwen-plus")
|
DEFAULT_DASHSCOPE_MODEL = str(_PROVIDER_DEFAULTS.get("dashscope", {}).get("model") or "qwen-plus")
|
||||||
DEFAULT_GLM_MODEL = DEFAULT_DASHSCOPE_MODEL
|
DEFAULT_DASHSCOPE_LOW_MODEL = DEFAULT_DASHSCOPE_MODEL
|
||||||
|
|
||||||
from ...config.ai_prompts import build_default_role_prompt_bundle
|
from ...config.ai_prompts import build_default_role_prompt_bundle
|
||||||
from ...core.database import AsyncSessionLocal
|
from ...core.database import AsyncSessionLocal
|
||||||
|
|
|
||||||
|
|
@ -237,14 +237,14 @@ def can_assign_tasks(user_role: str) -> bool:
|
||||||
|
|
||||||
def can_view_ai_policy(user_role: str) -> bool:
|
def can_view_ai_policy(user_role: str) -> bool:
|
||||||
"""AI policy pages are visible to governance roles only."""
|
"""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:
|
def can_operate_ai_routing(user_role: str) -> bool:
|
||||||
"""AI routing diagnostics/tests are available to reviewer-level operators."""
|
"""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:
|
def can_approve_ai_routing_change(user_role: str) -> bool:
|
||||||
"""High-risk AI routing/policy changes require owner approval in v1."""
|
"""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"])
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ from ...config.ai_prompts import (
|
||||||
PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA,
|
PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA,
|
||||||
PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA,
|
PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA,
|
||||||
)
|
)
|
||||||
from ..prompt_release import PromptReleaseService
|
from ..prompt_runtime_service import PromptRuntimeRenderResult, PromptRuntimeService
|
||||||
from .schemas import (
|
from .schemas import (
|
||||||
ExternalContextPacket,
|
ExternalContextPacket,
|
||||||
SignalSourceRecommendationCandidate,
|
SignalSourceRecommendationCandidate,
|
||||||
|
|
@ -392,7 +392,7 @@ class ExternalAnswerPresenter:
|
||||||
signal_source_objectives = ";".join(
|
signal_source_objectives = ";".join(
|
||||||
[str(item).strip() for item in list(packet.plan.signal_source_objectives or []) if str(item).strip()][:4]
|
[str(item).strip() for item in list(packet.plan.signal_source_objectives or []) if str(item).strip()][:4]
|
||||||
) or "无"
|
) or "无"
|
||||||
prompt = await self._render_prompt(
|
prompt_result = await self._render_prompt_result(
|
||||||
prompt_key="PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET",
|
prompt_key="PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET",
|
||||||
fallback_template=PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET,
|
fallback_template=PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET,
|
||||||
question=str(question or "").strip(),
|
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 "无",
|
uncertainties=";".join([str(item).strip() for item in list(packet.uncertainties or []) if str(item).strip()]) or "无",
|
||||||
source_summaries=self._build_source_summaries(packet),
|
source_summaries=self._build_source_summaries(packet),
|
||||||
)
|
)
|
||||||
|
prompt = prompt_result.prompt
|
||||||
try:
|
try:
|
||||||
structured = await generator(
|
structured = await generator(
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
|
|
@ -419,6 +420,7 @@ class ExternalAnswerPresenter:
|
||||||
"response_contract": "structured",
|
"response_contract": "structured",
|
||||||
"fallback_policy": "fast_local",
|
"fallback_policy": "fast_local",
|
||||||
"execution_mode": "query",
|
"execution_mode": "query",
|
||||||
|
**prompt_result.routing_meta(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
payload = getattr(structured, "payload", None)
|
payload = getattr(structured, "payload", None)
|
||||||
|
|
@ -512,9 +514,28 @@ class ExternalAnswerPresenter:
|
||||||
fallback_template: str,
|
fallback_template: str,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> str:
|
) -> 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"):
|
if not hasattr(self.db, "execute"):
|
||||||
return str(fallback_template).format(**kwargs).strip()
|
return PromptRuntimeRenderResult(
|
||||||
return await PromptReleaseService(self.db).render_prompt(
|
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,
|
tenant_id=self.tenant_id,
|
||||||
prompt_key=prompt_key,
|
prompt_key=prompt_key,
|
||||||
fallback_template=fallback_template,
|
fallback_template=fallback_template,
|
||||||
|
|
@ -567,7 +588,7 @@ class ExternalAnswerPresenter:
|
||||||
|
|
||||||
contract = self._normalize_contract(packet.plan.answer_contract)
|
contract = self._normalize_contract(packet.plan.answer_contract)
|
||||||
prompt_key, fallback_template, prompt_variant_id = self._PROMPT_CONFIG[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,
|
prompt_key=prompt_key,
|
||||||
fallback_template=fallback_template,
|
fallback_template=fallback_template,
|
||||||
question=str(question or "").strip(),
|
question=str(question or "").strip(),
|
||||||
|
|
@ -577,6 +598,7 @@ class ExternalAnswerPresenter:
|
||||||
time_range=str(packet.plan.time_range_hint or "").strip() or "未指定",
|
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 "无",
|
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"
|
deep_thinking = str(thinking_mode or "").strip().lower() == "deep"
|
||||||
if deep_thinking:
|
if deep_thinking:
|
||||||
|
|
@ -600,7 +622,10 @@ class ExternalAnswerPresenter:
|
||||||
"fallback_policy": "low_to_high",
|
"fallback_policy": "low_to_high",
|
||||||
"execution_mode": "query",
|
"execution_mode": "query",
|
||||||
"routing_decision": "force_low_first",
|
"routing_decision": "force_low_first",
|
||||||
|
**prompt_result.routing_meta(),
|
||||||
}
|
}
|
||||||
|
else:
|
||||||
|
routing_meta = prompt_result.routing_meta()
|
||||||
answer = await self.llm_service.generate(
|
answer = await self.llm_service.generate(
|
||||||
prompt,
|
prompt,
|
||||||
db=self.db,
|
db=self.db,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,119 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from typing import Any
|
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:
|
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._adapters = list(adapters or [])
|
||||||
|
self._provider_statuses = list(provider_statuses or [])
|
||||||
|
|
||||||
def iter_adapters(self) -> list[Any]:
|
def iter_adapters(self) -> list[Any]:
|
||||||
return list(self._adapters)
|
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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,26 @@ class LLMEmptyContentError(ValueError):
|
||||||
|
|
||||||
|
|
||||||
class LLMChatOpenAIMixin:
|
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
|
@staticmethod
|
||||||
def _is_provider_billing_blocked_http_error(*, status_code: int, body_preview: str) -> bool:
|
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}:
|
if int(status_code or 0) not in {400, 401, 402, 403}:
|
||||||
|
|
@ -79,6 +99,9 @@ class LLMChatOpenAIMixin:
|
||||||
http_status_retry_attempt = 0
|
http_status_retry_attempt = 0
|
||||||
network_retry_budget = 1
|
network_retry_budget = 1
|
||||||
network_retry_attempt = 0
|
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(
|
empty_chat_retry_budget = self._empty_chat_retry_budget(
|
||||||
function_name=function_label,
|
function_name=function_label,
|
||||||
current_max_tokens=current_max_tokens,
|
current_max_tokens=current_max_tokens,
|
||||||
|
|
@ -100,18 +123,42 @@ class LLMChatOpenAIMixin:
|
||||||
"timeoutSeconds": int(control_plane_agents_timeout_seconds),
|
"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:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=request_timeout, trust_env=False) as client:
|
async with httpx.AsyncClient(timeout=request_timeout, trust_env=False) as client:
|
||||||
resp = await client.post(endpoint, headers=headers, json=payload)
|
resp = await client.post(endpoint, headers=headers, json=payload)
|
||||||
except httpx.RequestError as exc:
|
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)
|
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(
|
_runtime_log(
|
||||||
"LLM",
|
"LLM",
|
||||||
(
|
(
|
||||||
"网络异常重试:"
|
"网络异常重试:"
|
||||||
f"function={function_label}, "
|
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__}"
|
f"error={type(exc).__name__}"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -124,11 +171,19 @@ class LLMChatOpenAIMixin:
|
||||||
type(exc).__name__,
|
type(exc).__name__,
|
||||||
str(exc)[:260],
|
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
|
network_retry_attempt += 1
|
||||||
await asyncio.sleep(retry_delay)
|
await asyncio.sleep(retry_delay)
|
||||||
continue
|
continue
|
||||||
raise
|
raise self._attach_provider_audit_to_exception(
|
||||||
|
exc,
|
||||||
|
request_payload=payload,
|
||||||
|
response_payload=None,
|
||||||
|
attempts=provider_attempts,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
if is_control_plane_gateway_request:
|
if is_control_plane_gateway_request:
|
||||||
elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0
|
elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0
|
||||||
|
|
@ -141,6 +196,19 @@ class LLMChatOpenAIMixin:
|
||||||
)
|
)
|
||||||
if resp.status_code >= 400:
|
if resp.status_code >= 400:
|
||||||
body_preview = str(resp.text or "").replace("\n", " ").strip()[:260]
|
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:
|
if is_control_plane_gateway_request:
|
||||||
_runtime_log(
|
_runtime_log(
|
||||||
"PMFlow 本地路由层调用",
|
"PMFlow 本地路由层调用",
|
||||||
|
|
@ -154,10 +222,17 @@ class LLMChatOpenAIMixin:
|
||||||
status_code=status_code,
|
status_code=status_code,
|
||||||
body_preview=body_preview,
|
body_preview=body_preview,
|
||||||
):
|
):
|
||||||
raise ValueError(f"provider_billing_blocked:{provider_name}:{body_preview}")
|
exc = ValueError(f"provider_billing_blocked:{provider_name}:{body_preview}")
|
||||||
can_retry_http_status = (
|
raise self._attach_provider_audit_to_exception(
|
||||||
http_status_retry_budget > 0
|
exc,
|
||||||
and self._is_retryable_http_status(status_code)
|
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:
|
if can_retry_http_status:
|
||||||
retry_delay = self._http_retry_delay_seconds(
|
retry_delay = self._http_retry_delay_seconds(
|
||||||
|
|
@ -165,16 +240,24 @@ class LLMChatOpenAIMixin:
|
||||||
attempt=http_status_retry_attempt,
|
attempt=http_status_retry_attempt,
|
||||||
retry_after_header=resp.headers.get("Retry-After"),
|
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(
|
_runtime_log(
|
||||||
"LLM",
|
"LLM",
|
||||||
(
|
(
|
||||||
"HTTP状态重试:"
|
"HTTP状态重试:"
|
||||||
f"function={function_label}, "
|
f"function={function_label}, "
|
||||||
f"status={status_code}, retry_in={retry_delay:.2f}s, "
|
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
|
http_status_retry_attempt += 1
|
||||||
await asyncio.sleep(retry_delay)
|
await asyncio.sleep(retry_delay)
|
||||||
continue
|
continue
|
||||||
|
|
@ -190,11 +273,49 @@ class LLMChatOpenAIMixin:
|
||||||
)
|
)
|
||||||
use_json_response_format = False
|
use_json_response_format = False
|
||||||
continue
|
continue
|
||||||
resp.raise_for_status()
|
try:
|
||||||
data = resp.json()
|
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 []
|
choices = data.get("choices") or []
|
||||||
if not choices:
|
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 {}
|
first_choice = choices[0] if isinstance(choices[0], dict) else {}
|
||||||
message = first_choice.get("message") if isinstance(first_choice, dict) else {}
|
message = first_choice.get("message") if isinstance(first_choice, dict) else {}
|
||||||
text = self._extract_text_from_chat_response(
|
text = self._extract_text_from_chat_response(
|
||||||
|
|
@ -281,7 +402,12 @@ class LLMChatOpenAIMixin:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
continue
|
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):
|
if _is_control_plane_timeout_message(text):
|
||||||
elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0
|
elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0
|
||||||
_runtime_log(
|
_runtime_log(
|
||||||
|
|
@ -309,4 +435,9 @@ class LLMChatOpenAIMixin:
|
||||||
f"function={function_label} elapsed_ms={elapsed_ms:.0f} text_len={len(text)}"
|
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
Loading…
Reference in New Issue