Compare commits

...

88 Commits

Author SHA1 Message Date
Albert d48b6bfc36 feat: add PMFlow AI context runtime 2026-05-19 12:30:13 +08:00
Albert f8925956d8 feat: update PMFlow agent runtime 2026-05-17 19:58:40 +08:00
Albert ad93c8c31e Merge branch 'main' of http://8.134.102.66:13000/linyuhang/PMFlow into codex/main-flow-d4-7-execution-queue-cleanup 2026-05-15 23:06:17 +08:00
Albert 3ace44a324 feat: harden PMFlow AI operating runtime 2026-05-15 23:04:29 +08:00
Albert 6a3eac64a3 feat(pmflow-os): streamline execution queue workspace 2026-05-06 01:13:32 +08:00
Albert f33073d1a2 feat(pmflow-os): add execution scope board 2026-05-06 00:55:22 +08:00
Albert d3ef506c97 feat(pmflow-os): redesign execution run workspace 2026-05-06 00:30:55 +08:00
Albert 42944928a1 feat(pmflow-os): connect execution capability gate 2026-05-06 00:03:36 +08:00
Albert edf18cb15f test: align ai routing governance audit assertions 2026-05-05 23:24:12 +08:00
Albert d1c4b71376 test: lock ai routing governance boundaries 2026-05-05 23:21:50 +08:00
Albert f48a4d0381 feat: add execution result writeback protocol 2026-05-05 23:19:29 +08:00
Albert 37056f8fac chore(pmflow): integrate current workspace changes 2026-05-05 23:13:35 +08:00
Albert 169bda214f chore: integrate team feed capacity routing cleanup 2026-04-27 14:33:56 +08:00
Albert da74902c88 feat(team): add review activity feed 2026-04-26 22:35:44 +08:00
Albert 9de48aae3b docs(pmflow): close p8 integration ledger 2026-04-26 22:24:47 +08:00
Albert 2943e046e9 merge account p8 review decision preference
# Conflicts:
#	CLAUDE.md
#	docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md
#	docs/architecture/ARCHITECTURE_INVENTORY.md
#	docs/architecture/CODE_REFERENCE_INDEX.md
2026-04-26 22:21:52 +08:00
Albert 0d62b8c0c1 merge space p7 review attention queue
# Conflicts:
#	docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md
#	docs/architecture/ARCHITECTURE_INVENTORY.md
2026-04-26 22:20:34 +08:00
Albert 61def5280b merge main flow p8 review decision loop 2026-04-26 22:19:38 +08:00
Albert 4a687f1e36 feat(pmflow-os): close review decision loop 2026-04-26 22:18:03 +08:00
Albert 0bd9c1ddf7 feat(space): surface review attention queue 2026-04-26 22:10:50 +08:00
Albert fc888cbb8d feat(account): add review decision reminder preferences 2026-04-26 22:07:10 +08:00
Albert e8bb125608 docs(pmflow): rebaseline next plan to p8 2026-04-26 21:53:27 +08:00
Albert 37bf7e9b93 merge team review p1 reviewer routing on p7
# Conflicts:
#	CLAUDE.md
#	backend/tests/test_pmflow_os_projection_api.py
#	docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md
#	docs/architecture/ARCHITECTURE_INVENTORY.md
#	docs/architecture/CODE_REFERENCE_INDEX.md
2026-04-26 21:38:11 +08:00
Albert e65a8b1a18 chore(pmflow): integrate big module p7 2026-04-26 15:18:29 +08:00
Albert 77fa288a81 merge account p7 notification intensity
# Conflicts:
#	docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md
2026-04-26 15:16:32 +08:00
Albert 4a74eea75d merge space p6 personal next action queue
# Conflicts:
#	CLAUDE.md
#	docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md
#	docs/architecture/ARCHITECTURE_INVENTORY.md
#	docs/architecture/CODE_REFERENCE_INDEX.md
2026-04-26 15:16:12 +08:00
Albert 8d60b18214 merge iteration loop p2 recommendation quality
# Conflicts:
#	docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md
2026-04-26 15:14:27 +08:00
Albert 86c6ec79d0 merge shell p7 rebaseline 2026-04-26 15:13:54 +08:00
Albert 5b2add2924 feat(team): finalize reviewer routing contract 2026-04-26 15:12:48 +08:00
Albert 3609bfe687 feat(team): add reviewer routing projection 2026-04-26 15:05:14 +08:00
Albert 3f17f1d52d feat(pmflow-os): batch iteration recommendation drafts 2026-04-26 14:53:18 +08:00
Albert 6bfc3795ae feat(account): add notification intensity contract 2026-04-26 14:50:33 +08:00
Albert 98e2c759aa feat(space): add personal next action queue 2026-04-26 14:46:03 +08:00
Albert bfd35a3320 docs(pmflow): rebaseline next plan to p7 2026-04-26 14:38:18 +08:00
Albert e4edfac782 chore(pmflow): integrate big module p6 2026-04-26 14:26:00 +08:00
Albert 96b045fc66 merge account p6 iteration review preferences 2026-04-26 14:22:48 +08:00
Albert 785dacb75f merge space p5 iteration draft attention 2026-04-26 14:22:45 +08:00
Albert 69a04f4d3f merge iteration loop p1 recommendation changesets 2026-04-26 14:22:42 +08:00
Albert 8cc0cd9e78 feat(account): map iteration review reminders 2026-04-26 14:22:32 +08:00
Albert ea8f9d082e feat(pmflow-os): draft changesets from iteration recommendations 2026-04-26 14:20:48 +08:00
Albert 5db6f71f68 feat(space): surface iteration draft attention 2026-04-26 14:14:13 +08:00
Albert 8594d8eef7 chore(pmflow): integrate big module p5 2026-04-26 14:04:29 +08:00
Albert 5fd4e6e890 merge account p5 feedback notification preferences 2026-04-26 14:01:03 +08:00
Albert 675485ad0f merge space p4 feedback attention 2026-04-26 14:01:01 +08:00
Albert caf5c12271 merge release feedback p1 2026-04-26 14:00:58 +08:00
Albert 66ec4c7d1c feat(account): define post-release notification preferences 2026-04-26 14:00:47 +08:00
Albert 1d213baf4b feat(pmflow-os): ingest release feedback signals 2026-04-26 13:59:09 +08:00
Albert 702545957e feat(space): surface post-release feedback attention 2026-04-26 13:54:59 +08:00
Albert d2c92a980f chore(pmflow): integrate big module p4 2026-04-26 13:47:42 +08:00
Albert e1ecba1f39 merge release growth p2 notification preference
# Conflicts:
#	docs/architecture/CODE_REFERENCE_INDEX.md
2026-04-26 13:44:48 +08:00
Albert 13d4b2562d merge release validation p1
# Conflicts:
#	docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md
#	docs/architecture/ARCHITECTURE_INVENTORY.md
#	docs/architecture/CODE_REFERENCE_INDEX.md
2026-04-26 13:44:09 +08:00
Albert 32af4226f2 feat(pmflow-os): respect release notification preferences 2026-04-26 13:24:43 +08:00
Albert fe7807bbd1 feat(account): define release notification consumption 2026-04-26 13:19:49 +08:00
Albert 875b9292e9 feat(pmflow-os): record release validation runs 2026-04-26 12:53:43 +08:00
Albert b9b4b5a306 feat(space): surface release validation attention 2026-04-26 12:48:30 +08:00
Albert 199be13681 chore(pmflow): integrate big module p3 2026-04-26 12:41:04 +08:00
Albert 5a86007e4c feat(account): map notification preferences to surfaces 2026-04-26 12:39:28 +08:00
Albert 9ba9876811 feat(space): surface release attention read model 2026-04-26 12:36:01 +08:00
Albert e90adbc2eb feat(pmflow-os): gate releases on execution evidence 2026-04-26 11:24:55 +08:00
Albert 8f1edbf423 chore(pmflow): integrate big module p2 2026-04-26 10:57:33 +08:00
Albert 95892d25ec feat(account): add account settings sync contract 2026-04-26 10:56:07 +08:00
Albert 6c0f6dac53 feat(space): route workbench execution items 2026-04-26 10:56:01 +08:00
Albert a7008bb3cc feat(pmflow-os): add execution progress writeback 2026-04-26 10:51:14 +08:00
Albert 2956906f19 feat(pmflow-os): consume execution handoffs 2026-04-26 10:45:06 +08:00
Albert 4b2193e828 chore(pmflow): integrate big module lane workbench 2026-04-26 10:24:01 +08:00
Albert f95bd15ff3 feat(pmflow-os): add execution handoff contract 2026-04-26 10:20:01 +08:00
Albert d5e76d67d2 chore: remove confirmed stale workspace artifacts 2026-04-26 09:22:11 +08:00
Albert 1d34e32788 feat: wire PMFlow changeset commit frontend 2026-04-26 09:19:35 +08:00
Albert b87951935a feat: implement PMFlow changeset commit backend 2026-04-26 09:14:56 +08:00
Albert f0708fa02b docs: lock C2 changeset commit contract 2026-04-26 09:09:46 +08:00
Albert e04ea4d1ee chore: checkpoint current PMFlow workspace 2026-04-26 09:07:16 +08:00
Albert b52428f79e docs: codify PMFlow product delivery governance 2026-04-26 09:06:55 +08:00
Yuuuu 7f9571c3fa chore: snapshot current code before refactor 2026-03-10 15:18:23 +08:00
Yuuuu 16f30bc2c6 feat: complete PMFlow implementation 2026-02-27 08:56:49 +08:00
Yuuuu b0a8c6edb9 fix: 修复 AI 配置 API 启动问题
- 添加 pathlib.Path 导入
- 修复 OpenAICompatibleClient 延迟初始化
- 后端现在可以正常启动并响应配置请求
2026-02-21 00:16:12 +08:00
Sens fd94057e2b feat: 交互式输入数据库配置 2026-02-21 00:03:42 +08:00
Sens b125424e8f feat: 默认使用1Panel PostgreSQL数据库 2026-02-21 00:02:10 +08:00
Yuuuu 319a422362 feat: 添加 LLM 服务按需启动功能
新增功能:
- 后端新增 /ai/start API,支持启动本地 Ollama
- 后端改进 /ai/status API,返回提供商类型
- 前端看板页面添加启动/停止控制按钮
- 提供商感知 UI:云端 API 无需启动,本地 Ollama 可按需控制

用户体验:
- 云端模式:显示"云端服务已就绪",直接可分析
- 本地模式:显示"启动 Ollama"按钮,按需启动节省内存
- 分析完成后:仅本地模式自动停止 Ollama

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 00:01:43 +08:00
Sens e6ff0d5e54 fix: 支持1Panel外部PostgreSQL数据库 2026-02-20 23:54:42 +08:00
Yuuuu 3376c224e3 refactor: 重构设置页面为 Linear.app 风格
设计改进:
- 左侧窄边栏 (200px) + 小图标 (18px)
- 主内容区单列居中 (最大宽度 720px)
- Segmented Control 风格的供应商选择
- 统一表单控件规格 (36px 高度, 6px 圆角)
- 深靛蓝 (#4f46e5) 主色调
- 极简专业的视觉风格

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:41:30 +08:00
Sens 0e252281cd fix: 添加PostgreSQL启动超时处理 2026-02-20 23:18:10 +08:00
Sens 809ff9428c fix: 修复Docker仓库问题,添加阿里云镜像源 2026-02-20 23:04:28 +08:00
Yuuuu 5194bf8c99 feat: 添加一键启动脚本
新增文件:
- 启动.command: macOS 双击启动脚本
- 启动说明.md: 启动使用说明

使用方式:
1. 双击 "启动.command" 即可启动前后端
2. 双击 "stop.sh" 即可停止服务

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:01:33 +08:00
Sens de57318632 fix: 修复部署脚本处理Docker仓库访问失败的问题 2026-02-20 22:59:10 +08:00
Sens 656cefa668 feat: 添加腾讯云部署脚本和文档 2026-02-20 22:54:01 +08:00
Yuuuu 689cf94d4b feat: 前端添加 LLM 配置设置页面
新增功能:
- 前端设置页面可切换 LLM 提供商
- 支持 DeepSeek, OpenAI, Qwen, Ollama
- API 密钥配置界面
- 配置保存到 .env 文件

后端 API:
- GET /ai/config - 获取当前配置
- POST /ai/config - 更新配置

使用方式:
1. 打开前端 "⚙️ 设置" 页面
2. 选择 LLM 提供商
3. 输入 API 密钥(云端)或设置地址(本地)
4. 保存配置并重启后端

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 22:48:38 +08:00
Yuuuu 7364c143fb feat: 支持多种 LLM 提供商 (云端 API + 本地 Ollama)
新增功能:
- 支持云端 API: DeepSeek, OpenAI, Qwen
- 支持本地 Ollama: llama3:8b, nomic-embed-text
- 统一 LLM 客户端,自动切换提供商

配置方式:
- LLM_PROVIDER=deepseek (云端, 推荐)
- LLM_PROVIDER=openai (云端)
- LLM_PROVIDER=qwen (阿里云)
- LLM_PROVIDER=ollama (本地)

优势:
- 云端 API: 电脑不发热,响应速度快
- 本地 Ollama: 完全免费,数据隐私

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 22:37:31 +08:00
Yuuuu 0981acb06c Initial commit: Neural Sync - AI-native project management platform
Features:
- Signal monitoring with AI strategic analysis
- Experiment management with score tracking
- Context knowledge nodes with RAG
- Multi-model voting for high-impact signals
- Ollama integration for local LLM

Tech stack:
- Backend: FastAPI + SQLAlchemy + PostgreSQL
- Frontend: Vue 3 + Naive UI + TypeScript
- AI: Ollama (llama3, nomic-embed-text)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 18:06:39 +08:00
806 changed files with 266523 additions and 7674 deletions

View File

@ -11,12 +11,13 @@ DB_PORT=5435
REDIS_PORT=6379
BACKEND_PORT=8000
FRONTEND_PORT=5174
FRONTEND_PROD_PORT=5175
PMFLOW_EXPECTED_DB_DIALECT=postgresql
# Backend Runtime
CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174
LLM_PROVIDER=glm
LLM_FALLBACK_PROVIDER=glm
LLM_PROVIDER=dashscope
LLM_FALLBACK_PROVIDER=dashscope
CONTROL_PLANE_ROUTING_ENABLED=true
CONTROL_PLANE_ROUTING_RULES_PATH=backend/.control_plane/pmflow-routing-rules.json
CONTROL_PLANE_AGENTS_DEFAULT_TIMEOUT_SECONDS=120
@ -29,6 +30,7 @@ LLM_REQUEST_TIMEOUT=90
# 模型默认值统一由 backend/.control_plane/pmflow-routing-rules.json 管理,不再依赖 LLM_ROUTING_* / *_MODEL 环境变量
DASHSCOPE_API_KEY=
OPENAI_API_KEY=
ZHIPU_API_KEY=
DEEPSEEK_API_KEY=
OLLAMA_HOST=http://host.docker.internal:11434
OLLAMA_BASE_URL=http://host.docker.internal:11434
@ -43,3 +45,14 @@ RAG_DEEP_ANSWER_MAX_TOKENS=1400
# Feishu long connection retry policy (1s -> 2s -> 4s, max 3 attempts)
FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS=1
FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS=3
# GitHub App platform connection.
# SaaS/hosted PMFlow configures this once. End users only install the app and select repositories in GitHub.
GITHUB_APP_ID=
GITHUB_APP_SLUG=
GITHUB_APP_INSTALL_URL=
GITHUB_APP_CALLBACK_URL=
GITHUB_APP_WEBHOOK_SECRET=
# Prefer a secret-mounted PEM file in production. Host path: ./secrets/github-app-private-key.pem
GITHUB_APP_PRIVATE_KEY_PATH=/app/secrets/github-app-private-key.pem
GITHUB_APP_PRIVATE_KEY=

13
.gitignore vendored
View File

@ -68,6 +68,19 @@ Pixelle-Video/
.env
.env.*
!.env.example
secrets/
# Playwright browser profiles
frontend/.playwright-profiles/
.playwright-cli/
frontend/.playwright-cli/
frontend/output/
output/
.tmp-feishu-*.png
task_instance_*.xlsx
yesterday_tasks_*.csv
yesterday_tasks_*.xlsx
工单数据_*.xlsx
工单全字段报表_*.xlsx
tmp_short_confirm_redesign_summary.html
ai.rtf

View File

@ -0,0 +1,74 @@
{
"version": "v2",
"updated_at": "2026-03-05T00:00:00Z",
"source_of_truth": "openclaw",
"description": "PMFlow 三引擎统一路由:所有主链路统一经 OpenClaw OpenAI-Compatible 网关。",
"rules": [
{
"name": "engine_low_execute_glm_flash",
"enabled": true,
"priority": 300,
"tier": "low",
"provider": "openai",
"model": "zai/glm-4-flash",
"match_any": [
"feishu_level_classification",
"feishu_signal_extraction",
"rag_intent_classification",
"classify_intent",
"feishu_humanized_reply",
"extract_context_insights"
]
},
{
"name": "engine_high_reasoning_minimax",
"enabled": true,
"priority": 220,
"tier": "high",
"provider": "openai",
"model": "minimax/MiniMax-M2.5",
"match_any": [
"adaptive_rag_answer",
"feishu_project_prefill",
"feishu_task_orchestration",
"generate_task_breakdown",
"generate_charter_from_signal",
"decompose_tasks_from_charter",
"recommend_assignees_for_task",
"evaluate_model_requirement",
"calculate_alignment_score",
"detect_cognitive_conflicts",
"rag_db_project_semantic_retrieval",
"workload_optimize_prompt",
"analyze_cost_roi"
]
},
{
"name": "engine_high_extended_reasoning",
"enabled": true,
"priority": 200,
"tier": "high",
"provider": "openai",
"model": "minimax/MiniMax-M2.5",
"match_any": [
"analyze_food_calories",
"feishu_new_project_brief",
"feishu_project_signal_classification",
"sstc_ai_breakdown",
"sstc_charter_localize_zh",
"sstc_charter_prefill",
"skill_score_update",
"test_engine_connection"
]
},
{
"name": "engine_safety_fallback",
"enabled": true,
"priority": 50,
"tier": "safety",
"provider": "openai",
"model": "deepseek/deepseek-chat",
"match_all": true
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

View File

@ -2,7 +2,7 @@
你是一位资深的全栈工程师,精通 Python 3.11+、FastAPI、SQLAlchemy 2.0、LangChain、React 18、TypeScript、shadcn/ui、Tailwind CSS。
你正在为 **PMFlow** 开发代码:这是一款 AI 原生、人员能力驱动的项目管理操作系统,专为 10-50 人 AI 开发团队打造。
你正在为 **PMFlow** 开发代码:这是一款 AI 原生、人员能力驱动的项目执行操作系统,首版面向个人开发者、一人公司、半技术产品创作者与 1-10 人 AI 产品小团队,采用 `solo-first, team-ready` 方向。
---
@ -97,7 +97,33 @@ frontend/ # React 前端
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
- MCP 模板接入中心只允许保存模板参数、命令预览、runtime probe 与“本机已执行”确认状态;后端 `backend_direct` 模板可由 PMFlow backend 直接校验/调用(包括远程 `streamable_http` MCP 与内建 `backend_adapter` runtime),本地 `stdio` 模板只能走 PMFlow Local Bridge 或兼容 MCP host;禁止从服务端直接写宿主机 `~/.codex/config.toml`、禁止替客户执行本机命令,GitHub 类模板只允许保存 env var 名称或占位符,不得落明文 token。
- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState`
- 前端身份边界必须与真实写接口一致:`/space`、真实 `/project-launch`、`/me`、项目主链 standalone 与空间管理/平台运营入口必须登录;客户空间管理入口看当前 workspace role,平台运营入口看 `platform_context.capabilities`,禁止把客户空间 `OWNER/REVIEWER` 当成 PMFlow 平台管理员;`/project-launch?demo=1` 或 `?dataSource=demo` 是唯一匿名启动台演示入口,且只能使用本地 mock/demo,不得调用真实写接口。
- 真实 `/project-launch` 的 AI 草案生成优先走任务化链路:`POST /pmflow-launch/draft-jobs` 创建生成任务,前端轮询 `GET /pmflow-launch/draft-jobs/{job_id}`;“补充一句”必须提交用户真实补充并生成新 revision,旧运行中 job 只能标记为 `superseded/cancelled` 或忽略其结果,禁止用前端硬编码补充、UI 暂停动画或旧同步请求冒充可暂停/续写的 AI 调用。
- Project Launch、需求结构化、任务拆解等保护型结构化 AI 场景的超时必须来自显式场景运行策略并写入 `routing_meta`,包括 `scene_timeout_policy_id / input_bucket / provider_timeout_seconds / job_timeout_seconds / large_input_policy`;底层 transport 只消费该合同,不得靠散落常量或函数名私自决定。`structured_timeout` 必须作为模型超时单独归因和展示,禁止与 JSON 无效、空响应混成笼统“结构化失败”。
- Project Launch 草案生成与项目主链“发送并提取”变更抽取的 `max_tokens / repair_max_tokens / temperature / response_mode / foreground_wait_seconds / provider_timeout_seconds / job_timeout_seconds / max_retries / large_input_policy` 必须来自 Function Runtime Profile active 发布版本;draft 只能在平台 AI Ops 的业务场景路由里编辑、校验、真实 smoke 和发布,运行时不得读取未发布 draft,也不得把散落常量作为唯一真源。`provider_timeout_seconds` 必须控制上游 HTTP client timeout,`job_timeout_seconds` 必须控制外层任务/调用超时失败,`max_retries` 必须控制 provider transient retry budget,`large_input_policy=preprocess_then_generate` 必须在 large 输入桶进入有界材料压缩后再渲染 prompt,`foreground_wait_seconds` 必须返回给前端作为前台等待窗口。
- 用户可见错误必须使用产品语义分类,禁止直接展示 Axios/FastAPI 原始错误(如 `Request failed with status code 401`、`Could not validate credentials`、traceback);401/无 token 必须表达为登录/会话问题,pending intent 只能恢复输入,禁止登录后自动重放真实写请求。
- 用户可见核心业务内容零兜底:AI 失败、证据不足、结构化失败时必须显式失败或标记待确认,禁止用默认句、模板句、stub 冒充成功。
- PMFlow AI 用户侧入口必须遵守“双脑结构”:Space AI 是跨项目注意力、想法孵化、待确认聚合、AI 进行中监控和入口路由的 Operating Partner;项目级 AI 副驾是绑定 ProjectTruthState、ChangeSet、Evidence、ExecutionContext 与 ReleaseSignals 的上下文操作副驾。禁止把两者做成无对象锚点的泛聊天;Space AI 不得直接写 Project Memory、提交 ChangeSet 或发起 Execution Bridge handoff;项目副驾不得做跨项目经营排序、平台 provider/model/prompt 诊断或绕过确认链。
- Space AI Concierge / 用户侧 PMFlow AI 必须把用户原始问题与结构化 `surface_context` 分开传递,只回答 PMFlow 产品/操作知识、当前 tenant/project/space 状态、下一步指导与带来源的外部公开信息;系统代码、数据库表、prompt key、trace、函数名、模型供应商、内部路由、技术架构与架构实现必须被 `PMFlowAIDisclosureState -> protected_internal_refusal.v1 -> AnswerVerifier` 主链阻断或清洗,返回 payload 禁止暴露 raw source label、tool trace 或底层异常,内部诊断只能走显式 `/platform/*` 入口。
- `/space/pmflow-ai` 与兼容 `/space/ai-concierge/ask` 的用户侧主控必须先进入 `PMFlowAgentRuntimeService.run_space_turn()`,该服务只负责创建 durable `pmflow_agent_runs` 并委托 `PMFlowAIAgentRunService` 执行 `LLM Step Planner -> typed AgentToolRegistry tool -> Observation Ledger -> State Critic -> Terminal Answer State -> PMFlowAIAgentAnswerRuntime`。`pmflow_agent_runtime/loop.py`、legacy `AnswerArbitrator/NaturalAnswerComposer`、旧 Turn Runtime 和 CustomerAnswerPresentationService 只能作为 compatibility/tool/renderer 资产,禁止恢复为产品主控或最终答案来源。
- 旧 `PMFlowAIConversationOrchestrator / SpaceAIExecutionContract / SpaceAITaskGraphService / pmflow_ai_runtime_kernel` 已从 Space 用户侧代码中删除;`PROMPT_PMFLOW_AI_ROUTE_PLANNER` 仅保留为 legacy/deprecated 资产,不得恢复为主控,也不得再拍板 `source_strategy / answer_contract / allowed_executors / answer_obligations` 或分派 analyzer 早返回。
- `/space/ai-concierge/ask` 的 API 层只加载 context、结构化 `interaction_submission`、持久化和返回,不得绕过 Agent OS 主控。`interaction_submission` 只能折叠为 raw question、answers 和 supplement;`intent_hint / evidence_mode / primary_subject` 旧推导字段已删除,禁止从按钮、选项、question id 或关键词恢复。`route_plan / answer_contract / source_strategy / allowed_executors` 在 Space 用户侧只能作为兼容 payload 或离线解释字段,禁止再决定 action、来源、追问或最终 answer shape。
- 项目查找、项目状态、风险、容量、MVP 等能力不再靠问句关键词或固定 `answer_contract` 进入专用链路;必须先由 TurnState 判断目标、对象锚点和证据缺口,再调用 `PMFlowAIEvidenceToolRegistry` 的白名单只读 evidence tool。普通项目清单/项目数量等 surface 可验证事实也只能作为 `inspect_surface_context` 工具结果进入 TurnState。Packet 6 后工具层只返回事实、来源、置信度、目标相关性和剩余缺口,不得生成 implication、judgement 或最终答案;`PMFlowAIEvidenceAbsorber` 是唯一把 evidence 转成 `fact / source / implication / relevance_to_goal / confidence`、`remaining_gaps` 和 `JudgementState` 的层;`CustomerAnswerPresentationService` 只消费 final TurnState/CustomerEvidencePacket 的 `answer_shape` 做 renderer,不得再按 quick intent、旧 route contract、packet 名称或 answer_type 场景枚举重判展示形态。旧项目查找/通用合成/展示模板早返回服务不得恢复。
- 当前项目 `status_answer / execution_guidance` 必须先经过项目执行态覆盖门禁:`inspect_project_status` 必须读取 PMFlow OS truth-state 与 execution-summary,并同步输出 `ProjectOperatingEvidencePacket`,覆盖指挥台、项目工作台、决策与待确认、开发执行、测试质量、发布增长、团队层、项目记忆、风险阻塞和最近动态这些 PMFlow OS 全流程 facet;Packet R 后这些 facet 必须由独立 reader 消费对应 PMFlow OS 投影,禁止退回单函数拼字段或只读项目 `active`、项目数量、可见卡片状态生成推进建议。Packet S 后 `PMFlowAIEvidenceCoverageGate` 只能消费 `TurnState.evidence_state.structured_evidence.project_operating_evidence_packet(s)` 的 facet coverage profile,禁止再通过中文 finding 文案、关键词或固定短句判断 coverage。覆盖不足时必须先触发低风险只读 probe,不得直接合成最终答案或把系统缺口转成用户追问。Packet T 后最终合成必须生成并消费 `project_operating_answer_verification.v1`:`safe_next_actions` 才能进入正文/customer packet/canvas 的可推进动作,`blocked_actions` 只能进入不可直接做边界,`missing_or_partial_facets` 必须进入证据边界;禁止让模型或展示层绕过该 verification profile 自由生成完整推进方案。
- `/space/ai-concierge/ask` 的 ActionSelector 只能根据 `PMFlowAITurnState.evidence_state.evidence_gaps`、`sufficiency_state` 和白名单只读 action 选择下一步;禁止按自然语言关键词、`looks_like_*`、业务枚举、旧 `answer_contract`、旧 `task_type` 或 executor 列表做运行时拍板。项目查找、项目状态、外部查询、MVP、风险、容量等能力只能通过 `PMFlowAIEvidenceToolRegistry` 包装成 evidence tool,不能早返回抢主控。
- 项目状态、风险、容量和 MVP 工具仍必须遵守只读边界:只返回证据、依据、缺口和建议,不写 Project Memory、不提交 ChangeSet、不写 review decision、不发起执行、不发布、不自动派任务、不写 capacity override;对象范围由 TurnState 的 explicit/current page/reply/card/active workspace strong focus 决定,visible context 只能作为候选或证据范围。
- 新增任何用户可见 PMFlow 能力时,如果用户会问“这个能力现在怎么样 / 下一步怎么办 / 能不能让 AI 处理 / 风险是什么”,必须优先同步加入 Agent OS typed tool 合同、planner/critic 行为、observation/artifact/verification 投影、Terminal Answer State/Answer Runtime、runtime golden behavior case、回归测试和文档;兼容 Turn Runtime 只作为旧链路适配。不得只新增 `goal_kind / answer_contract / analysis_task / business_frame / evidence_plan`、executor 枚举、关键词路由或 specialized analyzer;没有专用 tool 时也必须通过通用 evidence tool 返回有边界的判断、依据、缺口和下一步。
- 用户侧 PMFlow AI 的回答生成和最终返回都必须经过 `Answer Runtime -> Answer Quality Gate -> CustomerAnswerPresentationService -> CustomerEvidencePacket -> Customer Payload Gate` 合同:最终合成前先形成 `PMFlowAIAnswerObjective / PMFlowAIAnswerPlan / PMFlowAIAnswerDraft` 与客户安全 `answer_runtime_packet`,再按 `strategic_judgement / status_answer / clarification / execution_guidance / fact_list / composite_answer / general_answer` 这些表达形态渲染。Renderer 只能作为 draft source,不做目标识别、来源选择、对象绑定、追问仲裁或 answer_type 重判;静态产品合同和保护拒答合同只能提供 `required_claims / customer_safe_capabilities / source_summary / limitations`,不得保存整段终局回答或要求全文相等;Answer Quality Gate 是唯一答案质量出口,必须把空 draft、内部运行时术语泄漏、计划语句回显、静态合同材料缺失写成 `PMFlowAIAnswerQualityFinding`,并对重写结果二次复检,仍失败时 fail-closed,禁止恢复 `_sanitize_state_answer` 这类 renderer 局部兜底;项目运行态 verification 等后处理约束必须先合入 draft 再过 gate,写入 `answer / customer_evidence_packet / answer_canvas` 的文本也必须先客户安全投影,禁止后处理绕过 gate;`CustomerAnswerPresentationService` 只能清洗、裁剪和承接 runtime 已生成的自然答案,不得在 `answer_canvas.direct_answer` 或 packet fallback 中二次生成 `结论/判断依据/综合判断/可执行建议/依据` 这类固定段落或 answer_type 前缀;Customer Payload Gate 必须移除 raw `answer_runtime_packet / pmflow_ai_turn_state / evidence_findings / route_plan / trace / tool_trace / debug`,历史归档也必须使用同一份 sanitized customer payload;Composer/Verifier 只能消费 final TurnState、Answer Runtime packet 和 customer-safe packet 的 `display_label/display_summary/business_values`,禁止把 `System Snapshot / User Snapshot / Project database / raw JSON / 字段名 / trace / prompt key` 直接喂给用户答案表达层,也禁止靠新增场景关键词或固定模板解决回答重复问题。
- 用户侧 PMFlow AI 的终局表达材料必须先经过 `PMFlowAITerminalAnswerState`:所有可说内容进入 `Claim Ledger`,所有不可说/不可确认/不可执行边界进入 `Boundary Ledger`,所有尝试进入 `Attempt Ledger`,表达策略进入 `AnswerIntentPlan`。Verifier 的 `reason/internal_diagnostic` 只能作为内部诊断,不得进入用户 answer、limitations、CustomerEvidencePacket 或客户可见 terminal state;客户可见只允许消费 `customer_safe_summary/customer_safe_effect`。`AnswerIntentPlan` 只能从 Claim/Boundary Ledger 选择 lead claim/boundary,并输出 `answer_moves / composition_guidance / anti_template_rules / detail_level / structure_preference` 这类表达策略;它不得生成最终正文、不得按业务场景或 failure_type 选择固定 presenter、不得读用户问句关键词做路由,也不得强制固定“判断依据/证据边界/下一步”三段式。最终自然表达必须经通用 terminal customer answer composer 和 critic:composer 只消费 terminal ledgers + AnswerIntentPlan,critic 只检查空答、内部运行时术语、固定标题模板、重复句和 lead claim/boundary 丢失,不得新增场景化 Customer Failure Presenter。Quality fail-closed 与 state fallback 也必须重建干净 terminal state 再表达,禁止复用被失败 draft 污染的 `answer_draft_claim`,禁止返回固定“质量检查未通过/旧链路模板/项目运行证据不足”句。禁止把所有 blocked/fail-closed 默认解释成项目运行证据不足,禁止按 failure_type 写固定 presenter,禁止从 raw runtime step 或 verifier reason 直接生成用户正文。
- 用户侧 PMFlow AI 的 verifier failure 必须输出 `verification_delta` 并声明 `retry_would_change_state`;runtime 只有在 retry 会改变 TurnState 或能执行新的只读 probe/read 时才允许继续重试。`next_action_hint=synthesize_answer` 不得单独制造重复合成循环,Reviser 也不得在 retry=false 时创建 system action gap。没有 state delta 的失败必须进入 terminal-state blocked payload,由 Claim/Boundary Ledger 提供客户安全材料。
- 用户侧 PMFlow AI 回复必须同时返回客户安全的 `customer_interaction_context_packet` 与 `answer_process_packet`:前者只暴露 `visible_objects/current_focus_object` 这类业务对象锚点,用于“这个项目/刚才那个/它”的续问归位;后者只描述本轮真实执行过的产品化步骤,供前端逐步展示,并可带 `duration_ms / total_duration_ms` 说明真实耗时归因。禁止前端用固定五步、固定 active index 或后台 trace 冒充思考过程,禁止在这些 packet 中暴露表名、字段名、prompt key、函数名、trace 或模型供应商。
- Packet U/V 后,展示层不得再根据 `answer_type / route_display / verification.ok` 自行生成“识别问题、读取证据、表达判断、检查展示”等固定过程步骤;`CustomerAnswerPresentationService.build_answer_process_packet()` 只能保留显式传入的 runtime/process steps,Space 前端只展示后端返回的真实 steps。来源展示也必须 fail-closed:后端不得在无来源时补 `PMFlow 上下文`,前端不得在 `sources` 为空时补 `个人空间/PMFlow 上下文`。
- Space AI 多轮续问对象归位必须以 Session Object Ledger 为真源:每轮只从真实返回 payload 的客户安全对象生成 `current_focus_object / last_shown_objects / recent_objects`,写入 `qa_session_states.context_capsules`;下一轮只有弱指代且 TurnState 目标需要单对象 inspection/execution 时,才允许用 ledger 绑定 `primary_object(binding_strength=session_object_ledger)`。禁止用 transcript 词面、项目名关键词、唯一可见项目或前端按钮文案恢复对象;有可执行系统证据缺口时必须先低风险 read/probe,不能把系统缺口 label 当成用户追问。
- Session Object Ledger 必须受 `object_binding_decision` 和 topic switch gate 约束:当 TurnState 判断本轮是新项目、新想法、新方向、无对象或拒绝上一轮焦点时,必须挂起旧 `current_focus_object`,禁止用 ledger 触发 `inspect_project_status / inspect_internal_projects`。工具证据进入 `known_facts` 前必须经过目标相关性过滤;无关项目证据只能进入缺口/审计,不得作为新想法判断依据。用户答案出口必须清除 `evidence_findings / TurnState / problem_frame` 等内部运行时术语。
- Turn Runtime 不得因为 `can_answer_now=true` 跳过未执行的系统读证据动作;只要 TurnState 仍有可执行的非用户 `system_evidence_gap/freshness_gap`,必须先执行对应 read/probe,再合成或显式说明剩余缺口。新项目/新想法/开放方向必须用 `PMFlowAIThreadWorkspace` + `ResearchWorkspace.collected_inputs` 持久化线程 slot,后续轮次合并 `PMFlowAITurnDelta` 继续推进,禁止靠 transcript、关键词、按钮文案或场景枚举恢复上下文。
- 开放方向/新想法在 `can_answer_with_assumptions=true` 且无强项目绑定时,最终回答必须消费 TurnState 与 workspace slot 给出受控初判、切口、风险、缺口和下一步,禁止因没有 external findings 直接退成“证据不足不能判断”。需要用户补充时,`interaction_request` 必须批量消费 workspace pending questions 与 user-actionable gaps 生成 stepper,能一次问完的关键缺口不得拆成多轮单点模板追问。
- PMFlow ChangeSet 写入边界:`accepted / edited / deferred` 只表示候选项已被人工处理但尚未写入 Project Memory;只要 ChangeSet 未 committed 且 ChangeItem 未 committed/skipped,就必须允许继续 accept/edit/defer。真正锁定边界是用户明确执行 ChangeSet commit / 确认写入项目记忆之后。
- PMFlow 审查与执行边界:团队层只做责任路由、角色收件箱、团队动态、审查状态、阻塞解释和跳转,不直接执行 ChangeSet commit 或写 Project Memory;开发执行只能针对 Project Memory commit 后形成的正式 Task 发起 Execution Bridge handoff,禁止从 `pending_review` ChangeItem 或候选 Task 直接分派人/agent 执行。
- 业务语义、问答/执行归因与前台动作拍板必须优先走“对象/上下文绑定 -> `SemanticContextPacket` -> `SemanticDecision` -> gatekeeper”主链,禁止在 `backend/app` 运行时代码里恢复 `looks_like_*`、关键词仓或散落正则拍板。
- `query.answer` 路由与外部查询规划不得在运行时代码里维护 `_INTERNAL_ROUTE_OBJECTS / _EXTERNAL_ROUTE_OBJECTS / _SEMANTIC_QUERY_OBJECT_TYPES` 一类对象词表,也不得新增 `*_probe_is_strong / *_route_probe / query_kind(weather/news/company/market/model)` 这类二次语义拍板。`query_profile / source_policy_hint / SemanticDecision` 只能作为上游结构化输入;若证据不足,只能显式返回 `uncertain/clarify`,禁止在执行层靠对象集合、`subject_focus`、`needs_freshness`、`referenced_objects` 再次裁决 internal vs external。
- `llm_service` 运行时必须显式传入 `routing_meta`;禁止再通过函数名片段、hint 常量或模板词在底层推断 tier、结构化输出或 fallback 策略。
@ -111,7 +137,9 @@ frontend/ # React 前端
- 外部 freshness 问答的 follow-up 若锚定上一轮“未接入实时外部数据/无法确认最新结论”的 QA 回复,默认继续留在 `query.answer` 聊天链并先尝试实时外部检索;只有用户显式点明系统/项目/配置等内部对象并提出接入动作时,才允许落入内部接入/入库流程。
- non-plan `query.answer` 必须先经过 `Query Route Gateway` 产出 `route_mode / decision_origin / reply_contract / probe_snapshot`,再进入 AdaptiveRAG;回复正文必须遵守显式来源合同(`内部知识库查询:` / `外部实时查询:`),`uncertain` 只允许 `external-first` 或澄清,禁止默认混合答案。
- `route_mode=external` 后不得再直接把 DuckDuckGo HTML 抓取当主执行层;必须统一进入 `External Query Planner -> External Query Gateway -> External Provider Registry -> MCP adapter 优先 -> bounded fallback live search -> external answer verifier` 主链。external 来源过滤必须 fail-closed,禁止把 `Feishu history / doc / pmflow` 这类内部来源混回 `外部实时查询` 的来源摘要。
- `freshness_required=true` 的行业/市场/趋势类外部查询必须执行近年证据门禁:planner 要前置当前年/上一年查询,live search 不能第一条旧结果即停止,gateway verifier 必须拒绝只有过期年份材料的证据包,禁止把 2024 白皮书类旧资料冒充 2026 当前趋势。
- `query.answer` 的客户可见回复必须再经过 `Answer Presentation Policy`:客户视图只允许看到中文化 `display_label/display_summary`,禁止直接暴露 `raw_source_label`、`Project database / Semantic facts / Knowledge Graph / Feishu history` 等机器来源或内部术语。prompt context 也只能喂展示字段,不能再把原始英文来源标签直接喂给模型。
- `PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT / PROMPT_CUSTOMER_ANSWER_COMPOSER / PROMPT_CUSTOMER_ANSWER_VERIFIER / PROMPT_RAG_QUERY_SEMANTICS / PROMPT_RAG_FOLLOWUP_ANCHOR_SEMANTICS / PROMPT_RAG_PENDING_CLARIFICATION_SELECTION_SEMANTICS` 必须运行时读取 Prompt 运营中心 active release,并在 `ai_call_logs.routing_meta` 写入 `prompt_key / prompt_release_id / prompt_source`;禁止恢复为直接硬编码模板调用且无 prompt 归因。`PROMPT_PMFLOW_AI_ROUTE_PLANNER` 只允许作为 legacy/deprecated prompt key 占位,不得在 Space 用户侧运行时调用。
- internal `query.answer` 一旦命中 `route_mode=internal`,必须再走“`Operation Contract -> Target Resolver -> Query DSL Plan -> 白名单受控执行器 -> semantic facts 优先(可用时)-> bounded fallback retrieval -> answer verifier`”链路;禁止让 LLM 自由写 SQL、自由决定 join/表名,或在没有证据时直接给内部结论。
- `MountedDraftWorkspace.requirement_card` 是 requirement-plan 唯一业务真源;assistant prose、topic facts、run 文案只可审计,不可反推业务字段。
- `project_create.create / followup.confirm` 的任务拆解是从属子步骤:运行时必须先 low-first 结构化生成,必要时升级 high;仍失败则显式记为 `pending`,禁止冒充成功。
@ -134,7 +162,7 @@ frontend/ # React 前端
## 11. 已知 BOUNDARY(不要幻构行为)
- 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用
- `agents.defaults.timeoutSeconds` 在 PMFlow 本地路由层 网关行为未验证,当前以 `LLM_REQUEST_TIMEOUT` 环境变量为准
- `agents.defaults.timeoutSeconds` 在 PMFlow 本地路由层 网关行为未验证;普通 LLM 调用仍以 `LLM_REQUEST_TIMEOUT` 为默认,已接入 Function Runtime Profile / 场景运行策略的保护型结构化调用以已发布合同里的显式 `provider_timeout_seconds` 为准。
- World Model 静态层已接入 Redis 缓存(key: `wm:static:{tenant_id}:{project_id}`,TTL 600s)
- 写操作后主动 invalidate,动态层不缓存
@ -204,6 +232,45 @@ frontend/ # React 前端
- 发生 context 不足时 → 停止并输出已完成部分
声明 `[CONTEXT: LIMIT]`,不要截断输出
## 17. Codex 并行开发治理
- 多个 Codex 线程并行推进时,可以按 `Main Flow / Space / Account Settings / Shell Integration` 等大模块 lane 长期并行,但实际执行单位必须是 `Packet`,不得只以模糊“大模块”直接开工。
- 每个 Packet 开始前必须有任务包,至少声明 `packet_id / module_lane / owner_thread / lane_owner / integration_owner / base_ref / read_scope / write_scope / forbidden_paths / shared_files / cross_lane_inputs / cross_lane_outputs / depends_on / merge_after / verification / docs_to_sync`。
- 没有 `write_scope` 的任务不允许并行;`write_scope` 重叠的 Packet 必须串行。
- 每轮并行最多一个 `contract / migration / router owner`;其他线程只能消费该 owner 已定义的稳定接口。
- 大模块并行时,`Shell / Integration owner` 负责共享路由、导航、Provider、共享 API base、共享文档和最终合并;模块线程只能写自己的 owned files。
- `Space` 线程只做个人空间聚合、草案箱、待确认摘要、AI 进行中摘要和跳转,不直接写 Project Memory、ChangeSet commit 或 Execution Bridge 回写。
- `Account Settings` 线程只做个人资料、偏好、通知、账户壳和设置入口,不直接改项目主链、权限核心、AI 路由或 Space 聚合。
- `Main Flow` 线程继续推进 PMFlow OS 主链,但不得顺手重做 Space 或个人中心。
- 并行线程默认使用独立分支 `codex/<packet_id>`,并优先使用独立 git worktree;不得在同一脏工作区里让多个线程直接写同一批文件。
- 一个 Packet 对应一个短生命周期分支;Packet 完成、验证通过、文档同步完成后必须形成可审查 commit。复杂 Packet 可在同一分支内做多个逻辑完整 checkpoint commit,但禁止提交半成品、失败验证或越界改动。
- Codex 完成 Packet 后必须自行执行 commit gate 判定:满足 `auto_commit` 条件时应自行提交;命中 `hold_no_commit` 时不得提交并报告阻塞;命中 `ask_before_commit` 时先说明事实再等用户拍板。具体判定以 `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` 的 `Packet commit 协议` 为准。
- 不允许在旧 Packet 分支上继续做新 Packet;新 Packet 必须从最新 integration base 或明确依赖分支重新开 `codex/<new_packet_id>`。
- 合并前必须检查 `git diff --name-only`,确认没有写出 `write_scope`;越界改动必须拆回对应 Packet 或交给 integration owner。
- 合并顺序固定为:Contract / migration → Backend service/API → Frontend API/store → UI/page → Docs/test/integration。
- 每批并行开发必须指定一个 integration owner,负责共享文件合并、最终验证、文档同步和进度台账回写。
共享高冲突区默认同一时间只能一个 Packet 写:
- `backend/app/api/router.py`
- `backend/app/models/__init__.py`
- `backend/app/schemas/*.py` 中的跨域 schema
- `backend/alembic/versions/*`
- `frontend/src/router/index.tsx`
- `frontend/src/main.tsx`
- `frontend/src/api/base.ts`
- `frontend/src/lib/api.ts`
- `frontend/src/features/pmflow-os/components/PMFlowOsFrame.tsx`
- `frontend/src/features/pmflow-os/components/MobileBottomNav.tsx`
- `frontend/src/features/pmflow-os/store.tsx`
- `CLAUDE.md`
- `docs/architecture/CODE_REFERENCE_INDEX.md`
- `docs/architecture/ARCHITECTURE_INVENTORY.md`
- `docs/architecture/API_CONTRACTS.md`
- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md`
若必须多人改共享文件,先由 integration owner 建立接口占位或合同,再让其它线程只改自己 owned 文件,最后由 integration owner 统一合并共享文件。
## 安全约束(必须严格遵守)

123
CLAUDE.md

File diff suppressed because one or more lines are too long

48
USER.md
View File

@ -14,6 +14,24 @@
- 回答要直接、具体、贴当前对象,优先给证据链、调用链和真实落点;不要写成模板化汇报。
- 不确定就明确说不确定,不要假装完整。
- 已拍板方案不要反复推荐替代品;配置和偏好尽量收敛,别分散。
- 前端默认视觉标准:品牌主色固定为 `#2463EB`;浅色界面默认以白色 + `#2463EB` 为主,不再回到泛紫、泛青的 AI 套板。
- 前端默认交互和样式参考 `Linear` 的克制层级与节奏;所有用户可见内容默认使用中文,除非我明确要求保留英文。
- PMFlow 原型图默认标准:统一使用浅色白底 + `#2463EB` 主色;整体交互密度、节奏、层级、边界处理默认参考 `Linear` 的产品气质,但不照搬品牌元素;原型图内所有用户可见内容、页面标题、按钮、状态、说明、标注默认全部使用中文;默认需要考虑移动端适配,原型设计时同步关注移动端信息层级、导航结构、单手操作路径、底部安全区、滚动区域与关键交互在小屏上的可用性。
- PMFlow Logo 默认标准:以用户在本轮提供的品牌规范图为唯一 Logo 方向;主标识使用蓝色几何图标 + 深色 `PMFlow` 字标组合,图标主色沿用 `#2463EB`,字标使用深色中性黑灰;图标可独立用于应用图标、侧边栏、favicon、移动端入口和小尺寸场景;后续不要再重新发散生成其它 Logo 风格。
- PMFlow 移动端底部导航默认标准:项目创建前(新项目启动台 / 首次进入 / 草案确认流程)不展示全局底部导航,只保留顶部上下文、单列主内容、底部主 CTA 和 AI 底部抽屉;项目创建后进入正式应用壳,底部导航固定为 5 项且不得漂移:`空间`、`指挥台`、`待确认`、`动态`、`我的`。`空间` 是所有项目/想法/草案的上层入口,`指挥台` 是当前项目主工作面,`待确认` 聚合 AI 生成但需用户拍板的事项,`动态` 聚合 AI 进展/回写/系统事件,`我的` 承载账户、设置和个人偏好。具体项目内的 `工作台 / 开发执行 / 发布增长 / 项目记忆` 不进入底部导航,默认通过项目内二级入口、分段切换或更多菜单进入。
- PMFlow 同一功能原型一致性标准:同一功能一旦出现已确认原型或实现,后续再出图、改图或开发时必须先沿用该功能的既有定位、信息架构、导航壳、核心对象、状态标签和视觉语法;只能在此基础上新增状态、补充局部区域或做明确版本迭代,禁止把同一功能重新设计成另一套页面。若确实需要重构,必须先说明“旧版是什么、新版改什么、迁移影响是什么”,并得到明确确认。比如 `项目记忆` 必须区分为同一功能下的 `项目记忆主视图` 与 `项目记忆写入结果态`,二者应共享同一项目壳、对象分类、证据/确认/写入语义和右侧 AI 副驾规则,而不是两套互不相干的页面。
- PMFlow 高保真原型默认不能按“桌面原型画布宽度”锁死主内容区。桌面保真不等于固定画布:必须同时考虑普通桌面、超宽屏和小屏三档。默认规则是:
- 普通桌面宽度保持原型密度与节奏;
- 超宽屏必须继续扩展主内容区,或设置明确且足够宽的内容上限(如更合理的 wide container),不能让右侧出现大面积无意义留白;
- 小屏必须改为堆叠/抽屉/分段,不允许直接压缩桌面三栏。
- 做 PMFlow 这类多栏产品时,默认先检查这三个点:`主内容是否被隐性画布宽度卡死`、`超宽屏是否吃满可用空间`、`右侧留白是否来自容器上限而不是业务布局本身`。
- 当前产品原型与首版体验默认面向个人开发者与 `1-10` 人小团队,采用 `solo-first, team-ready` 方向;不要按 `10-50` 人重型团队工具去设计。
- PMFlow 产品蓝图真源已沉淀到 `docs/PMFLOW_PRODUCT_BLUEPRINT.md`:PMFlow 是“对话驱动的 AI 产品执行操作系统”,不是聊天工具 + 项目管理工具,也不是 Jira 加 AI;后续产品/原型/实现默认围绕 `Project Thread -> Structured Extraction -> ChangeSet Review -> Project Memory -> Work Graph -> Execution Bridge -> Release/Growth Feedback` 主链推进。
- 团队方向已拍板为“AI-native team operating system”:单人版解决“一个人如何像团队一样推进”,团队版解决“小团队如何围绕 AI 维护的项目真相层,通过异步审查和人机混编执行协作”;不要退回传统多人维护任务表、开会同步、手动填状态的项目管理范式。
- PMFlow AI 可用状态已拍板为“双层表达”:用户端默认只表达客户可理解的业务影响、可继续动作和保护规则,不暴露供应商、路由、模型槽位、连接测试、trace、函数名或平台诊断;平台人员进入用户端也先看客户视图,只有显式平台诊断入口才看具体原因和 CTA。`/platform/*` 是平台 AI Ops 控制面,默认展示供应商、路由、诊断、模型槽位、失败样本和对应处理动作。
- PMFlow 产品推进执行标准已沉淀到 `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md`:后续每次 PMFlow 产品推进任务完成后,Codex 默认要自己更新该文档的进度台账;新增/修改入口、API、Schema、边界时同步更新 `CLAUDE.md`、`ARCHITECTURE_INVENTORY.md`、`CODE_REFERENCE_INDEX.md`、`API_CONTRACTS.md` 或 `RUNTIME_GUARDRAILS.md`。新开线程继续产品推进时,必须先读 `USER.md`、产品蓝图、执行标准、`CLAUDE.md` 和代码索引,不得从头重想方案。
- 原型图与首版视觉交付默认只做浅色主题,不要求同时输出暗色模式;暗色模式属于后续产品实现能力,不作为当前原型标准。
- 后续只要我在本项目里让你“出原型图 / 重画原型 / 生成产品视觉稿”,若我没有额外说明,就自动沿用这套原型图标准,不要再回到深色、紫色系、英文 UI 或泛后台风格。
### 学习与更新方式
@ -64,6 +82,7 @@
- AI 路由内部主名:`control_plane`
- 路由规则真源:`backend/.control_plane/pmflow-routing-rules.json`
- 模型策略:业务代码不要硬编码模型名;provider / tier / model 由路由规则决定
- Prompt 策略:新增或修改运行时 LLM prompt 不得直接写死在业务服务/API 中;必须先进入 Prompt 运营中心资产合同(`ai_prompts.py` 默认模板 + `ai_prompt_registry.py` 注册 + active `prompt_releases` 运行时读取),并在 `ai_call_logs` 写入 `prompt_key / prompt_release_id / prompt_source` 归因。历史 `needs_migration` 存量只能作为待迁移清单,不得作为新增写法。
- 历史路由命名已废弃,不再把旧名字当现行架构,也不再建议回退
- 以上栈默认不再主动建议替代方案,除非用户明确要求比较或迁移
@ -77,6 +96,34 @@
- 遇到链路问题先补 prompt、关键词、正则,而不是先查 routing、context、session、gatekeeper。
- 用 fallback、默认句、stub、模板话术冒充成功。
- 把本该直答的问题拖进过深推理、官样文章或汇报体输出。
- 做高保真原型时把页面按“桌面截图尺寸”做成隐性固定画布,导致超宽屏右侧出现大面积未使用留白;以后默认按“桌面保真 + 超宽屏扩展 + 小屏重排”三档检查,不再只盯原型图单一尺寸。
## 前端快回路与线程治理
### 前端任务分级
- `L1 微调类`:文案、字重、边框、间距、颜色、单元素位置。默认只允许 focused live 页确认或快验证,不默认跑 full build。
- `L2 视觉逻辑类`:响应式、跨屏对齐、布局算法、曲线路径、动画节奏。允许 `build + 多视口截图`,但不夹带无关文案和样式修补。
- `L3 结构治理类`:拆组件、拆路由、拆包、改 compose、改启动链路。必须单独线程处理,不和单页 comment 修复混做。
### 线程是否继续的判断规则
- 同一对象、同一路径、同一验证面,优先留在原线程直接做完。
- 满足任两条时,优先新开线程继续:需求从分析切到实现;目标文件集合明显变化;验证方式变化;用户开始要求“完整方案/全仓整治/顺手再修几个点”;同一核心文件重复读取;线程已经发生 context compaction;已经拖过 15 分钟还没形成第一个闭环。
- 当前改动已经能独立闭环时,不顺手带 unrelated fix;尤其不要把 `L1 微调` 混进 `L2 视觉逻辑` 或 `L3 结构治理`。
### 强制止损阈值
- 同一线程出现 `context_compacted` 后,如果任务仍未闭环,优先建议切新线程;出现两次 compaction,不再继续混合任务。
- 从用户发起到首次实际代码编辑超过 `8` 分钟,必须先说明卡点,不继续闷头探索。
- 同一核心文件读取超过 `2` 次,必须先总结当前判断,再决定是否继续读。
- 同一轮工具调用超过 `20` 次但仍没有形成首个有效闭环,必须收口范围或切线程。
### 前端验证预算
- `L1 微调类`:优先 `verify:login-fast` 或 focused screenshot,不默认跑 `npm run build`。
- `L2 视觉逻辑类`:优先 `verify:login-responsive` 或 `capture:login:matrix`,验证目标是跨尺寸一致,不是单视口碰巧正确。
- `L3 结构治理类`:独立线程里跑 `typecheck + build + focused smoke`,不拿页面 comment 回合承载。
## 当前知道但暂不处理的项目问题
@ -84,3 +131,4 @@
- `backend/tests/test_training_pipeline_service.py` 目前仍有历史 SQLite 测试夹具下的 `JSONB` 建表失败问题,这不是本轮改名引入的,需要单独清理测试基座。
- `backend/app/schemas/recipe.py` 与 `backend/app/schemas/skill.py` 仍有 Pydantic V2 的 class-based config deprecation 警告。
- 当前运行环境里的 `lark_oapi` / `websockets` 仍有上游 deprecation 警告,后续需要统一升级或兼容处理。
- Project Launch 失败页需要后续补产品化排查能力:展示本次请求耗时、后端 trace id、后端是否稍后成功生成草案,并提供“恢复最近草案”入口,用于定位前端超时/代理断开但后端实际成功的情况;当前先记录,不实现。

View File

@ -10,7 +10,7 @@
"model": "deepseek-chat",
"api_env": "DEEPSEEK_API_KEY"
},
"glm": {
"dashscope": {
"name": "Qwen (DashScope)",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"model": "qwen-plus",
@ -22,6 +22,12 @@
"model": "GLM-5",
"api_env": "OPENAI_API_KEY"
},
"zhipu": {
"name": "Zhipu BigModel",
"base_url": "https://open.bigmodel.cn/api/paas/v4",
"model": "glm-5.1",
"api_env": "ZHIPU_API_KEY"
},
"ollama": {
"name": "Ollama",
"base_url": "http://127.0.0.1:11434",
@ -31,7 +37,7 @@
},
"tier_defaults": {
"low": {
"provider": "glm",
"provider": "dashscope",
"model": "qwen-plus",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"api_env": "DASHSCOPE_API_KEY"
@ -54,12 +60,23 @@
"api_env": "OPENAI_API_KEY",
"function_exact": "classify_intent_complex"
},
{
"name": "engine_low_pmflow_ai_operating_mission",
"enabled": true,
"priority": 312,
"tier": "low",
"provider": "dashscope",
"model": "qwen-plus",
"api_env": "DASHSCOPE_API_KEY",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"function_exact": "pmflow_ai_operating_mission"
},
{
"name": "engine_low_semantic_context_exact",
"enabled": true,
"priority": 310,
"tier": "low",
"provider": "glm",
"provider": "dashscope",
"model": "qwen-plus",
"api_env": "DASHSCOPE_API_KEY",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
@ -80,7 +97,7 @@
"enabled": true,
"priority": 300,
"tier": "low",
"provider": "glm",
"provider": "dashscope",
"model": "qwen-plus",
"api_env": "DASHSCOPE_API_KEY",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
@ -150,7 +167,7 @@
"enabled": true,
"priority": 50,
"tier": "low",
"provider": "glm",
"provider": "dashscope",
"model": "qwen-plus",
"api_env": "DASHSCOPE_API_KEY",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",

96
backend/.env.example Normal file
View File

@ -0,0 +1,96 @@
# Database Configuration
DATABASE_URL=postgresql+asyncpg://pmflow:pmflow_pass@db:5432/pmflow
# 可选:启动方言守卫(postgresql / sqlite)。配置后若不一致将拒绝启动。
PMFLOW_EXPECTED_DB_DIALECT=postgresql
# For PostgreSQL with pgvector:
# DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5435/neural_sync
# For local SQLite quick-start:
# DATABASE_URL=sqlite+aiosqlite:///./neural_sync.db
# Redis Configuration
REDIS_URL=redis://:pmflow_redis_pass@redis:6379/0
# Knowledge Graph Incremental Scheduler
KG_INCREMENTAL_INTERVAL_MINUTES=5
KG_INCREMENTAL_DELTA_LIMIT=1000
KG_INCREMENTAL_EVENT_BATCH=200
KG_CODE_SCAN_INTERVAL_HOURS=6
# ========================================
# LLM / OpenClaw Control Plane
# ========================================
# 单一控制面:PMFlow 仅调用 OpenClaw 网关(OpenAI-Compatible)
LLM_PROVIDER=openai
OPENAI_BASE_URL=http://openclaw:18789/v1
OPENAI_API_KEY=<OPENCLAW_GATEWAY_TOKEN>
# OpenClaw 函数路由
OPENCLAW_ROUTING_ENABLED=true
OPENCLAW_ROUTING_RULES_PATH=/app/backend/.openclaw/pmflow-routing-rules.json
# OpenClaw agent 默认超时(秒):用于注入 chat/completions -> agents.defaults.timeoutSeconds
OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS=120
# 关闭 PMFlow 内部 mixed routing,优先以 OpenClaw 规则为准
LLM_MIXED_ROUTING_ENABLED=false
LLM_ROUTING_DEFAULT_TIER=active
LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true
# low/high/safety tier 默认模型(由网关解释)
LLM_ROUTING_LOW_PROVIDER=openai
LLM_ROUTING_LOW_MODEL=zai/glm-4-flash
LLM_ROUTING_HIGH_PROVIDER=openai
LLM_ROUTING_HIGH_MODEL=minimax/MiniMax-M2.5
LLM_ROUTING_SAFETY_PROVIDER=openai
LLM_ROUTING_SAFETY_MODEL=deepseek/deepseek-chat
# 应急开关:仅在网关不可用时允许直连 provider(默认关闭)
LLM_DIRECT_EMERGENCY_ENABLED=false
LLM_FALLBACK_PROVIDER=ollama
# Adaptive RAG 深入思考模式(前端开关=deep 时生效)
RAG_DEEP_RETRIEVAL_STEPS=5
RAG_DEEP_VECTOR_TOP_K=12
RAG_DEEP_CONTEXT_BLOCKS=16
RAG_DEEP_ANSWER_MAX_TOKENS=1400
# --- Emergency Direct Provider Keys (optional) ---
GLM_API_KEY=
GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4
GLM_MODEL=glm-4-flash
DEEPSEEK_API_KEY=
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
# --- Ollama 本地配置(应急本地兜底) ---
OLLAMA_HOST=http://127.0.0.1:11434
OLLAMA_CHAT_MODEL=llama3:8b
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
OLLAMA_BASE_URL=http://127.0.0.1:11434
OLLAMA_MODEL=llama3:latest
# ========================================
# Multi-Model Voting Configuration (仅 Ollama 支持)
# For high-impact signals (impact_score >= VOTING_THRESHOLD), multiple models will vote
# Available models: llama3, llama3.2, qwen2.5, mistral, deepseek-coder, etc.
VOTING_MODELS=llama3,qwen2.5,mistral
VOTING_THRESHOLD=0.8
# Business Context for AI Strategic Analysis
# Customize this to match your actual product/team
# This context is used by the AI to evaluate external signals
BUSINESS_CONTEXT=Pixelle-Video: An AI-powered automated short video generation platform. Core features: topic-to-video automation (script generation → AI images/videos → TTS → BGM → final video). Tech stack: Python, Streamlit, FastAPI, ComfyUI workflows. AI models: FLUX/Qwen/SDXL for images, WAN 2.1/2.2 for videos, Edge TTS/Index TTS for voice. Supports multiple pipelines: standard generation, custom assets, digital human, image-to-video, action transfer.
# Plane PM Tool Integration (optional, simulation mode if not set)
PLANE_API_URL=https://api.plane.so/api/v1
PLANE_API_KEY=
PLANE_WORKSPACE_SLUG=
PLANE_PROJECT_ID=
# Lark/Feishu Bot Integration (optional)
LARK_APP_ID=
LARK_APP_SECRET=
LARK_VERIFICATION_TOKEN=
# 飞书长连接重试策略(指数退避 1s -> 2s -> 4s,最多 3 次)
FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS=1
FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS=3

View File

@ -0,0 +1,74 @@
{
"version": "v2",
"updated_at": "2026-03-05T00:00:00Z",
"source_of_truth": "openclaw",
"description": "PMFlow 三引擎统一路由:所有主链路统一经 OpenClaw OpenAI-Compatible 网关。",
"rules": [
{
"name": "engine_low_execute_glm_flash",
"enabled": true,
"priority": 300,
"tier": "low",
"provider": "openai",
"model": "zai/glm-4-flash",
"match_any": [
"feishu_level_classification",
"feishu_signal_extraction",
"rag_intent_classification",
"classify_intent",
"feishu_humanized_reply",
"extract_context_insights"
]
},
{
"name": "engine_high_reasoning_minimax",
"enabled": true,
"priority": 220,
"tier": "high",
"provider": "openai",
"model": "minimax/MiniMax-M2.5",
"match_any": [
"adaptive_rag_answer",
"feishu_project_prefill",
"feishu_task_orchestration",
"generate_task_breakdown",
"generate_charter_from_signal",
"decompose_tasks_from_charter",
"recommend_assignees_for_task",
"evaluate_model_requirement",
"calculate_alignment_score",
"detect_cognitive_conflicts",
"rag_db_project_semantic_retrieval",
"workload_optimize_prompt",
"analyze_cost_roi"
]
},
{
"name": "engine_high_extended_reasoning",
"enabled": true,
"priority": 200,
"tier": "high",
"provider": "openai",
"model": "minimax/MiniMax-M2.5",
"match_any": [
"analyze_food_calories",
"feishu_new_project_brief",
"feishu_project_signal_classification",
"sstc_ai_breakdown",
"sstc_charter_localize_zh",
"sstc_charter_prefill",
"skill_score_update",
"test_engine_connection"
]
},
{
"name": "engine_safety_fallback",
"enabled": true,
"priority": 50,
"tier": "safety",
"provider": "openai",
"model": "deepseek/deepseek-chat",
"match_all": true
}
]
}

View File

@ -0,0 +1,496 @@
"""add ai project control tables
Revision ID: 20260421_add_ai_project_control_tables
Revises: 20260415_add_semantic_facts
Create Date: 2026-04-21
"""
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 = "20260421_add_ai_project_control_tables"
down_revision = "20260415_add_semantic_facts"
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 _seed_model_catalog(bind) -> None:
table = sa.table(
"model_catalog_items",
sa.column("tenant_id", sa.BigInteger()),
sa.column("provider", sa.String()),
sa.column("model_key", sa.String()),
sa.column("display_name", sa.String()),
sa.column("version", sa.String()),
sa.column("modalities", _json_type(bind)),
sa.column("capability_tags", _json_type(bind)),
sa.column("strengths", _json_type(bind)),
sa.column("limitations", _json_type(bind)),
sa.column("compliance_status", sa.String()),
sa.column("filing_status", sa.String()),
sa.column("input_cost_per_mtok", sa.Numeric(12, 4)),
sa.column("output_cost_per_mtok", sa.Numeric(12, 4)),
sa.column("official_docs_url", sa.Text()),
sa.column("is_active", sa.Boolean()),
)
op.bulk_insert(
table,
[
{
"tenant_id": 1,
"provider": "阿里云",
"model_key": "qwen-plus",
"display_name": "通义千问 Plus",
"version": "latest",
"modalities": ["text", "image"],
"capability_tags": ["general", "rag", "reasoning", "agent", "code"],
"strengths": ["中文通用能力强", "RAG 适配稳定", "工具调用成熟"],
"limitations": ["不适合视频生成"],
"compliance_status": "ready",
"filing_status": "filed",
"input_cost_per_mtok": 0.8,
"output_cost_per_mtok": 2.0,
"official_docs_url": "https://help.aliyun.com/zh/model-studio/",
"is_active": True,
},
{
"tenant_id": 1,
"provider": "火山引擎",
"model_key": "doubao-pro",
"display_name": "豆包 Pro",
"version": "latest",
"modalities": ["text", "image"],
"capability_tags": ["general", "rag", "reasoning", "multimodal"],
"strengths": ["长文本稳", "中文体验好", "成本适中"],
"limitations": ["代码生成弱于专用模型"],
"compliance_status": "ready",
"filing_status": "filed",
"input_cost_per_mtok": 0.8,
"output_cost_per_mtok": 2.0,
"official_docs_url": "https://www.volcengine.com/product/doubao",
"is_active": True,
},
{
"tenant_id": 1,
"provider": "百度智能云",
"model_key": "ernie-4.0-turbo",
"display_name": "文心一言 4.0 Turbo",
"version": "latest",
"modalities": ["text", "image"],
"capability_tags": ["general", "rag", "reasoning", "multimodal"],
"strengths": ["企业落地常见", "多模态均衡"],
"limitations": ["综合成本偏高"],
"compliance_status": "ready",
"filing_status": "filed",
"input_cost_per_mtok": 1.2,
"output_cost_per_mtok": 3.2,
"official_docs_url": "https://cloud.baidu.com/product/wenxinworkshop",
"is_active": True,
},
{
"tenant_id": 1,
"provider": "DeepSeek",
"model_key": "deepseek-chat",
"display_name": "DeepSeek Chat",
"version": "latest",
"modalities": ["text"],
"capability_tags": ["general", "reasoning", "code", "agent"],
"strengths": ["代码与推理性价比高", "适合技术团队"],
"limitations": ["需要留意合规和交付边界"],
"compliance_status": "review_required",
"filing_status": "unknown",
"input_cost_per_mtok": 0.27,
"output_cost_per_mtok": 1.1,
"official_docs_url": "https://platform.deepseek.com/",
"is_active": True,
},
{
"tenant_id": 1,
"provider": "Moonshot AI",
"model_key": "kimi-k2",
"display_name": "Kimi K2",
"version": "latest",
"modalities": ["text"],
"capability_tags": ["general", "long_context", "rag", "reasoning"],
"strengths": ["长上下文强", "资料理解稳定"],
"limitations": ["对多模态场景支持有限"],
"compliance_status": "ready",
"filing_status": "filed",
"input_cost_per_mtok": 1.0,
"output_cost_per_mtok": 3.0,
"official_docs_url": "https://platform.moonshot.cn/",
"is_active": True,
},
],
)
def upgrade() -> None:
bind = op.get_bind()
json_type = _json_type(bind)
if not _table_exists(bind, "model_catalog_items"):
op.create_table(
"model_catalog_items",
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("provider", sa.String(length=120), nullable=False),
sa.Column("model_key", sa.String(length=120), nullable=False),
sa.Column("display_name", sa.String(length=255), nullable=False),
sa.Column("version", sa.String(length=120), nullable=False),
sa.Column("modalities", json_type, nullable=False),
sa.Column("capability_tags", json_type, nullable=False),
sa.Column("strengths", json_type, nullable=False),
sa.Column("limitations", json_type, nullable=False),
sa.Column("compliance_status", sa.String(length=50), nullable=False, server_default="review_required"),
sa.Column("filing_status", sa.String(length=50), nullable=False, server_default="unknown"),
sa.Column("input_cost_per_mtok", sa.DECIMAL(12, 4), nullable=True),
sa.Column("output_cost_per_mtok", sa.DECIMAL(12, 4), nullable=True),
sa.Column("official_docs_url", sa.Text(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.UniqueConstraint(
"tenant_id",
"provider",
"model_key",
"version",
name="uq_model_catalog_items_tenant_provider_key_version",
),
)
op.create_index("ix_model_catalog_items_tenant_id", "model_catalog_items", ["tenant_id"], unique=False)
op.create_index("ix_model_catalog_items_provider", "model_catalog_items", ["provider"], unique=False)
op.create_index("ix_model_catalog_items_model_key", "model_catalog_items", ["model_key"], unique=False)
op.create_index("ix_model_catalog_items_is_active", "model_catalog_items", ["is_active"], unique=False)
_seed_model_catalog(bind)
table_specs = {
"project_model_bindings": [
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("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
sa.Column("model_catalog_item_id", sa.BigInteger(), sa.ForeignKey("model_catalog_items.id"), nullable=False),
sa.Column("selected_by_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
sa.Column("selection_reason", json_type, nullable=False),
sa.Column("estimated_daily_tokens", sa.Integer(), nullable=False, server_default="0"),
sa.Column("estimated_monthly_cost_usd", sa.DECIMAL(12, 2), nullable=True),
sa.Column("active_from", sa.TIMESTAMP(), nullable=True),
sa.Column("archived_at", sa.TIMESTAMP(), nullable=True),
],
"model_recommendation_snapshots": [
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("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
sa.Column("recommendation_scope", sa.String(length=32), nullable=False, server_default="charter"),
sa.Column("snapshot_input", json_type, nullable=False),
sa.Column("recommendations", json_type, nullable=False),
sa.Column("top_model_catalog_item_id", sa.BigInteger(), sa.ForeignKey("model_catalog_items.id"), nullable=True),
sa.Column("generated_by", sa.String(length=32), nullable=False, server_default="rule_engine"),
],
"model_usage_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("project_model_binding_id", sa.BigInteger(), sa.ForeignKey("project_model_bindings.id"), nullable=True),
sa.Column("source_type", sa.String(length=32), nullable=False, server_default="manual"),
sa.Column("event_key", sa.String(length=255), nullable=True),
sa.Column("input_tokens", sa.Integer(), nullable=False, server_default="0"),
sa.Column("output_tokens", sa.Integer(), nullable=False, server_default="0"),
sa.Column("cost_usd", sa.DECIMAL(12, 4), nullable=False, server_default="0"),
sa.Column("event_payload", json_type, nullable=False),
sa.Column("occurred_at", sa.TIMESTAMP(), nullable=True),
sa.UniqueConstraint("tenant_id", "event_key", name="uq_model_usage_events_tenant_event_key"),
],
"model_eval_runs": [
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("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
sa.Column("project_model_binding_id", sa.BigInteger(), sa.ForeignKey("project_model_bindings.id"), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False, server_default="pending"),
sa.Column("success_criteria", json_type, nullable=False),
sa.Column("test_cases", json_type, nullable=False),
sa.Column("summary", json_type, nullable=False),
sa.Column("score", sa.DECIMAL(5, 2), nullable=True),
],
"prompt_packages": [
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("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=False),
sa.Column("task_id", sa.BigInteger(), sa.ForeignKey("tasks.id"), nullable=True),
sa.Column("project_model_binding_id", sa.BigInteger(), sa.ForeignKey("project_model_bindings.id"), nullable=True),
sa.Column("package_kind", sa.String(length=32), nullable=False, server_default="development"),
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
sa.Column("package_text", sa.Text(), nullable=False),
sa.Column("package_payload", json_type, nullable=False),
sa.Column("generated_from", json_type, nullable=False),
],
"dev_workspace_bindings": [
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("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("workspace_key", sa.String(length=255), nullable=False),
sa.Column("repo_root", sa.Text(), nullable=True),
sa.Column("editor_type", sa.String(length=32), nullable=False, server_default="vscode"),
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
sa.Column("last_seen_at", sa.TIMESTAMP(), nullable=True),
sa.UniqueConstraint("tenant_id", "workspace_key", name="uq_dev_workspace_bindings_tenant_workspace_key"),
],
"repo_bindings": [
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("provider", sa.String(length=32), nullable=False, server_default="github"),
sa.Column("external_repo_id", sa.String(length=255), nullable=True),
sa.Column("owner", sa.String(length=255), nullable=False),
sa.Column("repo_name", sa.String(length=255), nullable=False),
sa.Column("default_branch", sa.String(length=255), nullable=False, server_default="main"),
sa.Column("installation_id", sa.String(length=255), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
sa.Column("webhook_secret_hint", sa.String(length=255), nullable=True),
sa.UniqueConstraint("tenant_id", "provider", "owner", "repo_name", name="uq_repo_bindings_tenant_provider_repo"),
],
"task_code_links": [
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("task_id", sa.BigInteger(), sa.ForeignKey("tasks.id"), nullable=False),
sa.Column("repo_binding_id", sa.BigInteger(), sa.ForeignKey("repo_bindings.id"), nullable=False),
sa.Column("branch_name", sa.String(length=255), nullable=True),
sa.Column("pr_number", sa.Integer(), nullable=True),
sa.Column("last_commit_sha", sa.String(length=255), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False, server_default="linked"),
sa.UniqueConstraint(
"tenant_id",
"task_id",
"repo_binding_id",
"branch_name",
name="uq_task_code_links_tenant_task_repo_branch",
),
],
"progress_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("task_id", sa.BigInteger(), sa.ForeignKey("tasks.id"), nullable=False),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("repo_binding_id", sa.BigInteger(), sa.ForeignKey("repo_bindings.id"), nullable=True),
sa.Column("workspace_binding_id", sa.BigInteger(), sa.ForeignKey("dev_workspace_bindings.id"), nullable=True),
sa.Column("event_type", sa.String(length=32), nullable=False),
sa.Column("source", sa.String(length=32), nullable=False, server_default="manual"),
sa.Column("derived_task_status", sa.String(length=32), nullable=True),
sa.Column("summary", sa.Text(), nullable=True),
sa.Column("payload", json_type, nullable=False),
sa.Column("occurred_at", sa.TIMESTAMP(), nullable=True),
],
"dev_sessions": [
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("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("workspace_binding_id", sa.BigInteger(), sa.ForeignKey("dev_workspace_bindings.id"), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
sa.Column("started_at", sa.TIMESTAMP(), nullable=True),
sa.Column("heartbeat_at", sa.TIMESTAMP(), nullable=True),
sa.Column("ended_at", sa.TIMESTAMP(), nullable=True),
sa.Column("session_payload", json_type, nullable=False),
],
"release_validation_runs": [
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("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
sa.Column("release_node_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
sa.Column("project_model_binding_id", sa.BigInteger(), sa.ForeignKey("project_model_bindings.id"), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False, server_default="pending"),
sa.Column("score", sa.DECIMAL(5, 2), nullable=True),
sa.Column("key_metrics", json_type, nullable=False),
sa.Column("risk_items", json_type, nullable=False),
sa.Column("rollback_plan", json_type, nullable=False),
sa.Column("validation_summary", json_type, nullable=False),
],
"feedback_ingests": [
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("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
sa.Column("ingested_by_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("source_type", sa.String(length=32), nullable=False, server_default="manual"),
sa.Column("title", sa.String(length=255), nullable=True),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("metrics", json_type, nullable=False),
sa.Column("samples", json_type, nullable=False),
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
],
"iteration_recommendations": [
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("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
sa.Column("feedback_ingest_id", sa.BigInteger(), sa.ForeignKey("feedback_ingests.id"), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False, server_default="draft"),
sa.Column("priority", sa.String(length=32), nullable=False, server_default="medium"),
sa.Column("recommendation_summary", json_type, nullable=False),
sa.Column("proposed_charter_patch", json_type, nullable=False),
sa.Column("proposed_tasks", json_type, nullable=False),
],
"revenue_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("source_type", sa.String(length=32), nullable=False, server_default="manual"),
sa.Column("currency", sa.String(length=16), nullable=False, server_default="USD"),
sa.Column("amount_usd", sa.DECIMAL(12, 2), nullable=False, server_default="0"),
sa.Column("external_ref", sa.String(length=255), nullable=True),
sa.Column("event_payload", json_type, nullable=False),
sa.Column("occurred_at", sa.TIMESTAMP(), nullable=True),
],
"roi_snapshots": [
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("range_key", sa.String(length=32), nullable=False, server_default="monthly"),
sa.Column("window_start", sa.TIMESTAMP(), nullable=True),
sa.Column("window_end", sa.TIMESTAMP(), nullable=True),
sa.Column("total_revenue_usd", sa.DECIMAL(12, 2), nullable=False, server_default="0"),
sa.Column("total_cost_usd", sa.DECIMAL(12, 2), nullable=False, server_default="0"),
sa.Column("roi_ratio", sa.DECIMAL(12, 4), nullable=True),
sa.Column("snapshot_payload", json_type, nullable=False),
],
"compliance_check_runs": [
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("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
sa.Column("release_node_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
sa.Column("project_model_binding_id", sa.BigInteger(), sa.ForeignKey("project_model_bindings.id"), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False, server_default="pending"),
sa.Column("score", sa.DECIMAL(5, 2), nullable=True),
sa.Column("checklist", json_type, nullable=False),
sa.Column("issues", json_type, nullable=False),
sa.Column("summary", json_type, nullable=False),
],
"evidence_packs": [
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("compliance_check_run_id", sa.BigInteger(), sa.ForeignKey("compliance_check_runs.id"), nullable=True),
sa.Column("package_kind", sa.String(length=32), nullable=False, server_default="compliance_release"),
sa.Column("status", sa.String(length=32), nullable=False, server_default="draft"),
sa.Column("content", json_type, nullable=False),
sa.Column("export_payload", json_type, nullable=False),
],
}
for table_name, columns in table_specs.items():
if _table_exists(bind, table_name):
continue
op.create_table(table_name, *columns)
op.create_index(f"ix_{table_name}_tenant_id", table_name, ["tenant_id"], unique=False)
extra_indexes = {
"project_model_bindings": ["project_id", "charter_id", "model_catalog_item_id", "status"],
"model_recommendation_snapshots": ["project_id", "charter_id"],
"model_usage_events": ["project_id", "project_model_binding_id", "source_type", "occurred_at"],
"model_eval_runs": ["project_id", "charter_id", "project_model_binding_id", "status"],
"prompt_packages": ["project_id", "charter_id", "task_id", "project_model_binding_id", "status"],
"dev_workspace_bindings": ["project_id", "user_id", "status"],
"repo_bindings": ["project_id", "provider", "status"],
"task_code_links": ["task_id", "repo_binding_id", "pr_number", "status"],
"progress_events": ["task_id", "project_id", "user_id", "repo_binding_id", "workspace_binding_id", "event_type", "source", "occurred_at"],
"dev_sessions": ["project_id", "user_id", "workspace_binding_id", "status"],
"release_validation_runs": ["project_id", "charter_id", "release_node_id", "project_model_binding_id", "status"],
"feedback_ingests": ["project_id", "charter_id", "ingested_by_user_id", "source_type", "status"],
"iteration_recommendations": ["project_id", "charter_id", "feedback_ingest_id", "status", "priority"],
"revenue_events": ["project_id", "source_type", "external_ref", "occurred_at"],
"roi_snapshots": ["project_id", "range_key"],
"compliance_check_runs": ["project_id", "charter_id", "release_node_id", "project_model_binding_id", "status"],
"evidence_packs": ["project_id", "compliance_check_run_id", "package_kind", "status"],
}
for table_name, columns in extra_indexes.items():
for column_name in columns:
op.create_index(f"ix_{table_name}_{column_name}", table_name, [column_name], unique=False)
def downgrade() -> None:
bind = op.get_bind()
tables = [
"evidence_packs",
"compliance_check_runs",
"roi_snapshots",
"revenue_events",
"iteration_recommendations",
"feedback_ingests",
"release_validation_runs",
"dev_sessions",
"progress_events",
"task_code_links",
"repo_bindings",
"dev_workspace_bindings",
"prompt_packages",
"model_eval_runs",
"model_usage_events",
"model_recommendation_snapshots",
"project_model_bindings",
"model_catalog_items",
]
for table_name in tables:
if not _table_exists(bind, table_name):
continue
for index in sa.inspect(bind).get_indexes(table_name):
op.drop_index(index["name"], table_name=table_name)
op.drop_table(table_name)

View File

@ -0,0 +1,174 @@
"""finalize v1 productization
Revision ID: 20260421_finalize_v1_productization
Revises: 20260421_add_ai_project_control_tables
Create Date: 2026-04-21
"""
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 = "20260421_finalize_v1_productization"
down_revision = "20260421_add_ai_project_control_tables"
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.get("name") == column_name for column in sa.inspect(bind).get_columns(table_name))
def upgrade() -> None:
bind = op.get_bind()
json_type = _json_type(bind)
if _table_exists(bind, "users") and _column_exists(bind, "users", "role"):
op.execute("UPDATE users SET role = 'OWNER' WHERE UPPER(role) = 'BOSS'")
op.execute("UPDATE users SET role = 'REVIEWER' WHERE UPPER(role) = 'ADMIN'")
op.execute("UPDATE users SET role = 'BUILDER' WHERE UPPER(role) IN ('AI', 'FE', 'BE')")
if _table_exists(bind, "revenue_events") and not _column_exists(bind, "revenue_events", "category"):
op.add_column(
"revenue_events",
sa.Column("category", sa.String(length=32), nullable=False, server_default="other"),
)
op.create_index("ix_revenue_events_category", "revenue_events", ["category"], unique=False)
if _table_exists(bind, "repo_bindings") and not _column_exists(bind, "repo_bindings", "installation_account_login"):
op.add_column(
"repo_bindings",
sa.Column("installation_account_login", sa.String(length=255), nullable=True),
)
if _table_exists(bind, "progress_events") and not _column_exists(bind, "progress_events", "delivery_id"):
op.add_column("progress_events", sa.Column("delivery_id", sa.String(length=255), nullable=True))
op.create_index("ix_progress_events_delivery_id", "progress_events", ["delivery_id"], unique=False)
if _table_exists(bind, "progress_events") and not _column_exists(bind, "progress_events", "event_source_id"):
op.add_column("progress_events", sa.Column("event_source_id", sa.String(length=255), nullable=True))
op.create_index("ix_progress_events_event_source_id", "progress_events", ["event_source_id"], unique=False)
if not _table_exists(bind, "github_app_installations"):
op.create_table(
"github_app_installations",
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("installation_id", sa.String(length=255), nullable=False),
sa.Column("account_login", sa.String(length=255), nullable=False),
sa.Column("account_type", sa.String(length=64), nullable=True),
sa.Column("app_slug", sa.String(length=255), nullable=True),
sa.Column("target_type", sa.String(length=64), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
sa.Column("permissions", json_type, nullable=False),
sa.Column("events", json_type, nullable=False),
sa.Column("metadata", json_type, nullable=False),
sa.Column("last_synced_at", sa.TIMESTAMP(), nullable=True),
sa.UniqueConstraint("tenant_id", "installation_id", name="uq_github_app_installations_tenant_installation"),
)
op.create_index("ix_github_app_installations_tenant_id", "github_app_installations", ["tenant_id"], unique=False)
op.create_index("ix_github_app_installations_installation_id", "github_app_installations", ["installation_id"], unique=False)
op.create_index("ix_github_app_installations_account_login", "github_app_installations", ["account_login"], unique=False)
op.create_index("ix_github_app_installations_status", "github_app_installations", ["status"], unique=False)
if not _table_exists(bind, "github_app_repositories"):
op.create_table(
"github_app_repositories",
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("installation_id", sa.String(length=255), nullable=False),
sa.Column("external_repo_id", sa.String(length=255), nullable=False),
sa.Column("owner", sa.String(length=255), nullable=False),
sa.Column("repo_name", sa.String(length=255), nullable=False),
sa.Column("full_name", sa.String(length=255), nullable=False),
sa.Column("default_branch", sa.String(length=255), nullable=True),
sa.Column("is_private", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("permissions", json_type, nullable=False),
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
sa.Column("last_synced_at", sa.TIMESTAMP(), nullable=True),
sa.UniqueConstraint(
"tenant_id",
"installation_id",
"external_repo_id",
name="uq_github_app_repositories_repo",
),
)
op.create_index("ix_github_app_repositories_tenant_id", "github_app_repositories", ["tenant_id"], unique=False)
op.create_index("ix_github_app_repositories_installation_id", "github_app_repositories", ["installation_id"], unique=False)
op.create_index("ix_github_app_repositories_external_repo_id", "github_app_repositories", ["external_repo_id"], unique=False)
op.create_index("ix_github_app_repositories_owner", "github_app_repositories", ["owner"], unique=False)
op.create_index("ix_github_app_repositories_repo_name", "github_app_repositories", ["repo_name"], unique=False)
op.create_index("ix_github_app_repositories_full_name", "github_app_repositories", ["full_name"], unique=False)
op.create_index("ix_github_app_repositories_status", "github_app_repositories", ["status"], unique=False)
if not _table_exists(bind, "github_webhook_deliveries"):
op.create_table(
"github_webhook_deliveries",
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("delivery_id", sa.String(length=255), nullable=False),
sa.Column("event_type", sa.String(length=64), nullable=False),
sa.Column("repo_binding_id", sa.BigInteger(), sa.ForeignKey("repo_bindings.id"), nullable=True),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=True),
sa.Column("installation_id", sa.String(length=255), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False, server_default="received"),
sa.Column("failure_reason", sa.Text(), nullable=True),
sa.Column("payload", json_type, nullable=False),
sa.Column("received_at", sa.TIMESTAMP(), nullable=True),
sa.UniqueConstraint("tenant_id", "delivery_id", name="uq_github_webhook_deliveries_tenant_delivery"),
)
op.create_index("ix_github_webhook_deliveries_tenant_id", "github_webhook_deliveries", ["tenant_id"], unique=False)
op.create_index("ix_github_webhook_deliveries_delivery_id", "github_webhook_deliveries", ["delivery_id"], unique=False)
op.create_index("ix_github_webhook_deliveries_event_type", "github_webhook_deliveries", ["event_type"], unique=False)
op.create_index("ix_github_webhook_deliveries_repo_binding_id", "github_webhook_deliveries", ["repo_binding_id"], unique=False)
op.create_index("ix_github_webhook_deliveries_project_id", "github_webhook_deliveries", ["project_id"], unique=False)
op.create_index("ix_github_webhook_deliveries_installation_id", "github_webhook_deliveries", ["installation_id"], unique=False)
op.create_index("ix_github_webhook_deliveries_status", "github_webhook_deliveries", ["status"], unique=False)
op.create_index("ix_github_webhook_deliveries_received_at", "github_webhook_deliveries", ["received_at"], unique=False)
def downgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "github_webhook_deliveries"):
op.drop_table("github_webhook_deliveries")
if _table_exists(bind, "github_app_repositories"):
op.drop_table("github_app_repositories")
if _table_exists(bind, "github_app_installations"):
op.drop_table("github_app_installations")
if _table_exists(bind, "progress_events") and _column_exists(bind, "progress_events", "event_source_id"):
op.drop_index("ix_progress_events_event_source_id", table_name="progress_events")
op.drop_column("progress_events", "event_source_id")
if _table_exists(bind, "progress_events") and _column_exists(bind, "progress_events", "delivery_id"):
op.drop_index("ix_progress_events_delivery_id", table_name="progress_events")
op.drop_column("progress_events", "delivery_id")
if _table_exists(bind, "repo_bindings") and _column_exists(bind, "repo_bindings", "installation_account_login"):
op.drop_column("repo_bindings", "installation_account_login")
if _table_exists(bind, "revenue_events") and _column_exists(bind, "revenue_events", "category"):
op.drop_index("ix_revenue_events_category", table_name="revenue_events")
op.drop_column("revenue_events", "category")

View File

@ -0,0 +1,150 @@
"""add pmflow launch tables
Revision ID: 20260425_add_pmflow_launch_tables
Revises: 20260421_finalize_v1_productization
Create Date: 2026-04-25
"""
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 = "20260425_add_pmflow_launch_tables"
down_revision = "20260421_finalize_v1_productization"
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()
json_type = _json_type(bind)
if not _table_exists(bind, "pmflow_launch_drafts"):
op.create_table(
"pmflow_launch_drafts",
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("draft_id", sa.String(length=64), nullable=False),
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("draft_payload", json_type, nullable=False),
sa.Column("edited_payload", json_type, nullable=True),
sa.Column("status", sa.String(length=32), nullable=False, server_default="draft"),
sa.Column("confidence", sa.Float(), nullable=True),
sa.Column("warnings", json_type, nullable=False),
sa.Column("confirmed_project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=True),
sa.Column("expires_at", sa.TIMESTAMP(), nullable=False),
sa.Column("confirmed_at", sa.TIMESTAMP(), nullable=True),
sa.Column("confirm_request_hash", sa.String(length=128), nullable=True),
sa.UniqueConstraint("tenant_id", "draft_id", name="uq_pmflow_launch_drafts_tenant_draft_id"),
)
op.create_index("ix_pmflow_launch_drafts_tenant_id", "pmflow_launch_drafts", ["tenant_id"], unique=False)
op.create_index("ix_pmflow_launch_drafts_user_id", "pmflow_launch_drafts", ["user_id"], unique=False)
op.create_index("ix_pmflow_launch_drafts_draft_id", "pmflow_launch_drafts", ["draft_id"], unique=False)
op.create_index("ix_pmflow_launch_drafts_status", "pmflow_launch_drafts", ["status"], unique=False)
op.create_index(
"ix_pmflow_launch_drafts_confirmed_project_id",
"pmflow_launch_drafts",
["confirmed_project_id"],
unique=False,
)
op.create_index("ix_pmflow_launch_drafts_expires_at", "pmflow_launch_drafts", ["expires_at"], unique=False)
if not _table_exists(bind, "pmflow_threads"):
op.create_table(
"pmflow_threads",
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("launch_draft_id", sa.BigInteger(), sa.ForeignKey("pmflow_launch_drafts.id"), nullable=True),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("objective", sa.Text(), nullable=False),
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
sa.Column("source", sa.String(length=32), nullable=False, server_default="project_launch"),
sa.UniqueConstraint("tenant_id", "project_id", "source", name="uq_pmflow_threads_tenant_project_source"),
)
op.create_index("ix_pmflow_threads_tenant_id", "pmflow_threads", ["tenant_id"], unique=False)
op.create_index("ix_pmflow_threads_project_id", "pmflow_threads", ["project_id"], unique=False)
op.create_index("ix_pmflow_threads_launch_draft_id", "pmflow_threads", ["launch_draft_id"], unique=False)
op.create_index("ix_pmflow_threads_status", "pmflow_threads", ["status"], unique=False)
op.create_index("ix_pmflow_threads_source", "pmflow_threads", ["source"], unique=False)
if not _table_exists(bind, "pmflow_thread_messages"):
op.create_table(
"pmflow_thread_messages",
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("thread_id", sa.BigInteger(), sa.ForeignKey("pmflow_threads.id"), nullable=False),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
sa.Column("launch_draft_id", sa.BigInteger(), sa.ForeignKey("pmflow_launch_drafts.id"), nullable=True),
sa.Column("role", sa.String(length=32), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("source", sa.String(length=32), nullable=False, server_default="project_launch"),
sa.Column("attachments", json_type, nullable=False),
sa.Column("message_metadata", json_type, nullable=False),
sa.Column("status", sa.String(length=32), nullable=False, server_default="sent"),
)
op.create_index("ix_pmflow_thread_messages_tenant_id", "pmflow_thread_messages", ["tenant_id"], unique=False)
op.create_index("ix_pmflow_thread_messages_thread_id", "pmflow_thread_messages", ["thread_id"], unique=False)
op.create_index("ix_pmflow_thread_messages_project_id", "pmflow_thread_messages", ["project_id"], unique=False)
op.create_index(
"ix_pmflow_thread_messages_launch_draft_id",
"pmflow_thread_messages",
["launch_draft_id"],
unique=False,
)
op.create_index("ix_pmflow_thread_messages_status", "pmflow_thread_messages", ["status"], unique=False)
def downgrade() -> None:
bind = op.get_bind()
for index_name, table_name in (
("ix_pmflow_thread_messages_status", "pmflow_thread_messages"),
("ix_pmflow_thread_messages_launch_draft_id", "pmflow_thread_messages"),
("ix_pmflow_thread_messages_project_id", "pmflow_thread_messages"),
("ix_pmflow_thread_messages_thread_id", "pmflow_thread_messages"),
("ix_pmflow_thread_messages_tenant_id", "pmflow_thread_messages"),
("ix_pmflow_threads_source", "pmflow_threads"),
("ix_pmflow_threads_status", "pmflow_threads"),
("ix_pmflow_threads_launch_draft_id", "pmflow_threads"),
("ix_pmflow_threads_project_id", "pmflow_threads"),
("ix_pmflow_threads_tenant_id", "pmflow_threads"),
("ix_pmflow_launch_drafts_expires_at", "pmflow_launch_drafts"),
("ix_pmflow_launch_drafts_confirmed_project_id", "pmflow_launch_drafts"),
("ix_pmflow_launch_drafts_status", "pmflow_launch_drafts"),
("ix_pmflow_launch_drafts_draft_id", "pmflow_launch_drafts"),
("ix_pmflow_launch_drafts_user_id", "pmflow_launch_drafts"),
("ix_pmflow_launch_drafts_tenant_id", "pmflow_launch_drafts"),
):
if _table_exists(bind, table_name):
try:
op.drop_index(index_name, table_name=table_name)
except Exception:
pass
for table_name in ("pmflow_thread_messages", "pmflow_threads", "pmflow_launch_drafts"):
if _table_exists(bind, table_name):
op.drop_table(table_name)

View File

@ -0,0 +1,173 @@
"""add pmflow change review tables
Revision ID: 20260426_add_pmflow_change_review_tables
Revises: 20260425_add_pmflow_launch_tables
Create Date: 2026-04-26
"""
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 = "20260426_add_pmflow_change_review_tables"
down_revision = "20260425_add_pmflow_launch_tables"
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()
json_type = _json_type(bind)
if not _table_exists(bind, "pmflow_change_sets"):
op.create_table(
"pmflow_change_sets",
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=False),
sa.Column("source_message_id", sa.BigInteger(), sa.ForeignKey("pmflow_thread_messages.id"), nullable=False),
sa.Column("status", sa.String(length=32), nullable=False, server_default="open"),
sa.Column("summary", sa.Text(), nullable=False),
sa.Column("headline", sa.String(length=255), nullable=False),
sa.Column("extraction_scope", sa.String(length=64), nullable=False, server_default="latest_message"),
sa.Column("routing_meta", json_type, nullable=False),
sa.Column("llm_call_id", sa.String(length=128), nullable=True),
sa.Column("created_by", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("reviewed_at", sa.TIMESTAMP(), nullable=True),
sa.Column("committed_at", sa.TIMESTAMP(), nullable=True),
)
op.create_index("ix_pmflow_change_sets_tenant_id", "pmflow_change_sets", ["tenant_id"], unique=False)
op.create_index("ix_pmflow_change_sets_project_id", "pmflow_change_sets", ["project_id"], unique=False)
op.create_index("ix_pmflow_change_sets_thread_id", "pmflow_change_sets", ["thread_id"], unique=False)
op.create_index("ix_pmflow_change_sets_source_message_id", "pmflow_change_sets", ["source_message_id"], unique=False)
op.create_index("ix_pmflow_change_sets_status", "pmflow_change_sets", ["status"], unique=False)
op.create_index("ix_pmflow_change_sets_created_by", "pmflow_change_sets", ["created_by"], unique=False)
if not _table_exists(bind, "pmflow_change_items"):
op.create_table(
"pmflow_change_items",
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("change_set_id", sa.BigInteger(), sa.ForeignKey("pmflow_change_sets.id"), nullable=False),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
sa.Column("group", sa.String(length=32), nullable=False),
sa.Column("object_type", sa.String(length=64), nullable=False),
sa.Column("object_id", sa.String(length=128), nullable=True),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("summary", sa.Text(), nullable=False),
sa.Column("diff", sa.Text(), nullable=False),
sa.Column("payload", json_type, nullable=False),
sa.Column("edited_payload", json_type, nullable=True),
sa.Column("confidence", sa.Float(), nullable=True),
sa.Column("risk_level", sa.String(length=16), nullable=False, server_default="medium"),
sa.Column("status", sa.String(length=32), nullable=False, server_default="pending_review"),
sa.Column("reviewed_at", sa.TIMESTAMP(), nullable=True),
)
op.create_index("ix_pmflow_change_items_tenant_id", "pmflow_change_items", ["tenant_id"], unique=False)
op.create_index("ix_pmflow_change_items_change_set_id", "pmflow_change_items", ["change_set_id"], unique=False)
op.create_index("ix_pmflow_change_items_project_id", "pmflow_change_items", ["project_id"], unique=False)
op.create_index("ix_pmflow_change_items_status", "pmflow_change_items", ["status"], unique=False)
if not _table_exists(bind, "pmflow_approvals"):
op.create_table(
"pmflow_approvals",
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("change_set_id", sa.BigInteger(), sa.ForeignKey("pmflow_change_sets.id"), nullable=False),
sa.Column("change_item_id", sa.BigInteger(), sa.ForeignKey("pmflow_change_items.id"), nullable=False),
sa.Column("actor_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("action", sa.String(length=32), nullable=False),
sa.Column("decision", sa.String(length=32), nullable=False),
sa.Column("before_status", sa.String(length=32), nullable=False),
sa.Column("after_status", sa.String(length=32), nullable=False),
sa.Column("before_payload", json_type, nullable=False),
sa.Column("after_payload", json_type, nullable=False),
sa.Column("reason", sa.Text(), nullable=True),
)
op.create_index("ix_pmflow_approvals_tenant_id", "pmflow_approvals", ["tenant_id"], unique=False)
op.create_index("ix_pmflow_approvals_project_id", "pmflow_approvals", ["project_id"], unique=False)
op.create_index("ix_pmflow_approvals_change_set_id", "pmflow_approvals", ["change_set_id"], unique=False)
op.create_index("ix_pmflow_approvals_change_item_id", "pmflow_approvals", ["change_item_id"], unique=False)
op.create_index("ix_pmflow_approvals_actor_user_id", "pmflow_approvals", ["actor_user_id"], unique=False)
if not _table_exists(bind, "pmflow_evidence_links"):
op.create_table(
"pmflow_evidence_links",
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("change_set_id", sa.BigInteger(), sa.ForeignKey("pmflow_change_sets.id"), nullable=False),
sa.Column("change_item_id", sa.BigInteger(), sa.ForeignKey("pmflow_change_items.id"), nullable=True),
sa.Column("source_type", sa.String(length=64), nullable=False),
sa.Column("source_id", sa.String(length=128), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("excerpt", sa.Text(), nullable=False),
sa.Column("display_label", sa.String(length=255), nullable=False),
sa.Column("evidence_metadata", json_type, nullable=False),
)
op.create_index("ix_pmflow_evidence_links_tenant_id", "pmflow_evidence_links", ["tenant_id"], unique=False)
op.create_index("ix_pmflow_evidence_links_project_id", "pmflow_evidence_links", ["project_id"], unique=False)
op.create_index("ix_pmflow_evidence_links_change_set_id", "pmflow_evidence_links", ["change_set_id"], unique=False)
op.create_index("ix_pmflow_evidence_links_change_item_id", "pmflow_evidence_links", ["change_item_id"], unique=False)
op.create_index("ix_pmflow_evidence_links_source_type", "pmflow_evidence_links", ["source_type"], unique=False)
op.create_index("ix_pmflow_evidence_links_source_id", "pmflow_evidence_links", ["source_id"], unique=False)
def downgrade() -> None:
bind = op.get_bind()
for index_name, table_name in (
("ix_pmflow_evidence_links_source_id", "pmflow_evidence_links"),
("ix_pmflow_evidence_links_source_type", "pmflow_evidence_links"),
("ix_pmflow_evidence_links_change_item_id", "pmflow_evidence_links"),
("ix_pmflow_evidence_links_change_set_id", "pmflow_evidence_links"),
("ix_pmflow_evidence_links_project_id", "pmflow_evidence_links"),
("ix_pmflow_evidence_links_tenant_id", "pmflow_evidence_links"),
("ix_pmflow_approvals_actor_user_id", "pmflow_approvals"),
("ix_pmflow_approvals_change_item_id", "pmflow_approvals"),
("ix_pmflow_approvals_change_set_id", "pmflow_approvals"),
("ix_pmflow_approvals_project_id", "pmflow_approvals"),
("ix_pmflow_approvals_tenant_id", "pmflow_approvals"),
("ix_pmflow_change_items_status", "pmflow_change_items"),
("ix_pmflow_change_items_project_id", "pmflow_change_items"),
("ix_pmflow_change_items_change_set_id", "pmflow_change_items"),
("ix_pmflow_change_items_tenant_id", "pmflow_change_items"),
("ix_pmflow_change_sets_created_by", "pmflow_change_sets"),
("ix_pmflow_change_sets_status", "pmflow_change_sets"),
("ix_pmflow_change_sets_source_message_id", "pmflow_change_sets"),
("ix_pmflow_change_sets_thread_id", "pmflow_change_sets"),
("ix_pmflow_change_sets_project_id", "pmflow_change_sets"),
("ix_pmflow_change_sets_tenant_id", "pmflow_change_sets"),
):
if _table_exists(bind, table_name):
try:
op.drop_index(index_name, table_name=table_name)
except Exception:
pass
for table_name in ("pmflow_evidence_links", "pmflow_approvals", "pmflow_change_items", "pmflow_change_sets"):
if _table_exists(bind, table_name):
op.drop_table(table_name)

View File

@ -0,0 +1,70 @@
"""add human capacity overrides
Revision ID: 20260427_add_human_capacity_overrides
Revises: 20260426_add_pmflow_change_review_tables
Create Date: 2026-04-27
"""
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 = "20260427_add_human_capacity_overrides"
down_revision = "20260426_add_pmflow_change_review_tables"
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()
json_type = _json_type(bind)
if not _table_exists(bind, "human_capacity_overrides"):
op.create_table(
"human_capacity_overrides",
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("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("date", sa.Date(), nullable=False),
sa.Column("override_type", sa.String(length=32), nullable=False),
sa.Column("added_minutes", sa.Integer(), nullable=False, server_default="0"),
sa.Column("overtime_type", sa.String(length=32), nullable=True),
sa.Column("max_planned_utilization", sa.Float(), nullable=True),
sa.Column("work_type_bias", json_type, nullable=False),
sa.Column("high_risk_limit", sa.Integer(), nullable=True),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column("expires_at", sa.TIMESTAMP(), nullable=False),
sa.Column("created_by", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
)
op.create_index("ix_human_capacity_overrides_tenant_id", "human_capacity_overrides", ["tenant_id"], unique=False)
op.create_index("ix_human_capacity_overrides_project_date", "human_capacity_overrides", ["project_id", "date"], unique=False)
op.create_index("ix_human_capacity_overrides_user_date", "human_capacity_overrides", ["user_id", "date"], unique=False)
op.create_index("ix_human_capacity_overrides_created_by", "human_capacity_overrides", ["created_by"], unique=False)
def downgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "human_capacity_overrides"):
op.drop_index("ix_human_capacity_overrides_created_by", table_name="human_capacity_overrides")
op.drop_index("ix_human_capacity_overrides_user_date", table_name="human_capacity_overrides")
op.drop_index("ix_human_capacity_overrides_project_date", table_name="human_capacity_overrides")
op.drop_index("ix_human_capacity_overrides_tenant_id", table_name="human_capacity_overrides")
op.drop_table("human_capacity_overrides")

View File

@ -0,0 +1,124 @@
"""add phone auth identity
Revision ID: 20260428_add_phone_auth_identity
Revises: 20260428_repair_admin_yangyu_workspace_state
Create Date: 2026-04-28
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260428_add_phone_auth_identity"
down_revision = "20260428_repair_admin_yangyu_workspace_state"
branch_labels = None
depends_on = None
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _column_exists(bind, table_name: str, column_name: str) -> bool:
if not _table_exists(bind, table_name):
return False
return any(column["name"] == column_name for column in sa.inspect(bind).get_columns(table_name))
def _index_exists(bind, table_name: str, index_name: str) -> bool:
if not _table_exists(bind, table_name):
return False
return any(index["name"] == index_name for index in sa.inspect(bind).get_indexes(table_name))
def upgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "users"):
for column in (
sa.Column("phone_country_code", sa.String(length=8), nullable=True),
sa.Column("phone_number", sa.String(length=32), nullable=True),
sa.Column("phone_e164", sa.String(length=32), nullable=True),
sa.Column("phone_verified_at", sa.TIMESTAMP(), nullable=True),
sa.Column("last_login_at", sa.TIMESTAMP(), nullable=True),
sa.Column("login_method", sa.String(length=32), nullable=True),
):
if not _column_exists(bind, "users", column.name):
op.add_column("users", column)
if not _index_exists(bind, "users", "ix_users_phone_e164"):
op.create_index("ix_users_phone_e164", "users", ["phone_e164"], unique=True)
if _table_exists(bind, "tenant_invitations"):
for column in (
sa.Column("phone_country_code", sa.String(length=8), nullable=True),
sa.Column("phone_number", sa.String(length=32), nullable=True),
sa.Column("phone_e164", sa.String(length=32), nullable=True),
):
if not _column_exists(bind, "tenant_invitations", column.name):
op.add_column("tenant_invitations", column)
if not _index_exists(bind, "tenant_invitations", "ix_tenant_invitations_phone_e164"):
op.create_index("ix_tenant_invitations_phone_e164", "tenant_invitations", ["phone_e164"], unique=False)
if bind.dialect.name == "postgresql" and not _index_exists(
bind,
"tenant_invitations",
"uq_tenant_invitations_pending_phone",
):
op.execute(
"""
CREATE UNIQUE INDEX uq_tenant_invitations_pending_phone
ON tenant_invitations (tenant_id, phone_e164)
WHERE status = 'pending' AND phone_e164 IS NOT NULL
"""
)
if not _table_exists(bind, "phone_verification_codes"):
op.create_table(
"phone_verification_codes",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("phone_e164", sa.String(length=32), nullable=False, index=True),
sa.Column("purpose", sa.String(length=32), nullable=False, index=True),
sa.Column("code_hash", sa.String(length=128), nullable=False),
sa.Column("expires_at", sa.TIMESTAMP(), nullable=False),
sa.Column("consumed_at", sa.TIMESTAMP(), nullable=True),
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("request_ip", sa.String(length=64), nullable=True),
)
op.create_index(
"ix_phone_verification_codes_phone_purpose_created",
"phone_verification_codes",
["phone_e164", "purpose", "created_at"],
unique=False,
)
def downgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "phone_verification_codes"):
if _index_exists(bind, "phone_verification_codes", "ix_phone_verification_codes_phone_purpose_created"):
op.drop_index("ix_phone_verification_codes_phone_purpose_created", table_name="phone_verification_codes")
op.drop_table("phone_verification_codes")
if _table_exists(bind, "tenant_invitations"):
if bind.dialect.name == "postgresql" and _index_exists(bind, "tenant_invitations", "uq_tenant_invitations_pending_phone"):
op.drop_index("uq_tenant_invitations_pending_phone", table_name="tenant_invitations")
if _index_exists(bind, "tenant_invitations", "ix_tenant_invitations_phone_e164"):
op.drop_index("ix_tenant_invitations_phone_e164", table_name="tenant_invitations")
for column_name in ("phone_e164", "phone_number", "phone_country_code"):
if _column_exists(bind, "tenant_invitations", column_name):
op.drop_column("tenant_invitations", column_name)
if _table_exists(bind, "users"):
if _index_exists(bind, "users", "ix_users_phone_e164"):
op.drop_index("ix_users_phone_e164", table_name="users")
for column_name in (
"login_method",
"last_login_at",
"phone_verified_at",
"phone_e164",
"phone_number",
"phone_country_code",
):
if _column_exists(bind, "users", column_name):
op.drop_column("users", column_name)

View File

@ -0,0 +1,57 @@
"""add platform memberships
Revision ID: 20260428_add_platform_memberships
Revises: 20260428_add_phone_auth_identity
Create Date: 2026-04-28
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260428_add_platform_memberships"
down_revision = "20260428_add_phone_auth_identity"
branch_labels = None
depends_on = None
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _index_exists(bind, table_name: str, index_name: str) -> bool:
if not _table_exists(bind, table_name):
return False
return any(index["name"] == index_name for index in sa.inspect(bind).get_indexes(table_name))
def upgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "platform_memberships"):
return
op.create_table(
"platform_memberships",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("role", sa.String(length=50), nullable=False),
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
sa.UniqueConstraint("user_id", "role", name="uq_platform_memberships_user_role"),
)
op.create_index("ix_platform_memberships_user_id", "platform_memberships", ["user_id"], unique=False)
op.create_index("ix_platform_memberships_status", "platform_memberships", ["status"], unique=False)
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "platform_memberships"):
return
if _index_exists(bind, "platform_memberships", "ix_platform_memberships_status"):
op.drop_index("ix_platform_memberships_status", table_name="platform_memberships")
if _index_exists(bind, "platform_memberships", "ix_platform_memberships_user_id"):
op.drop_index("ix_platform_memberships_user_id", table_name="platform_memberships")
op.drop_table("platform_memberships")

View File

@ -0,0 +1,60 @@
"""add saved idea enrichment fields
Revision ID: 20260428_add_saved_idea_enrichment
Revises: 20260428_add_saved_ideas
Create Date: 2026-04-28
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
try:
from sqlalchemy.dialects.postgresql import JSONB
except Exception: # pragma: no cover
JSONB = sa.JSON
revision = "20260428_add_saved_idea_enrichment"
down_revision = "20260428_add_saved_ideas"
branch_labels = None
depends_on = None
def _json_type(bind):
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _column_exists(bind, table_name: str, column_name: str) -> bool:
return any(column["name"] == column_name for column in sa.inspect(bind).get_columns(table_name))
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "saved_ideas"):
return
if not _column_exists(bind, "saved_ideas", "enrichment_status"):
op.add_column(
"saved_ideas",
sa.Column("enrichment_status", sa.String(length=32), nullable=False, server_default="baseline"),
)
if not _column_exists(bind, "saved_ideas", "enrichment_payload"):
op.add_column("saved_ideas", sa.Column("enrichment_payload", _json_type(bind), nullable=True))
if not _column_exists(bind, "saved_ideas", "enrichment_error"):
op.add_column("saved_ideas", sa.Column("enrichment_error", sa.Text(), nullable=True))
if not _column_exists(bind, "saved_ideas", "enriched_at"):
op.add_column("saved_ideas", sa.Column("enriched_at", sa.TIMESTAMP(), nullable=True))
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "saved_ideas"):
return
for column_name in ("enriched_at", "enrichment_error", "enrichment_payload", "enrichment_status"):
if _column_exists(bind, "saved_ideas", column_name):
op.drop_column("saved_ideas", column_name)

View File

@ -0,0 +1,87 @@
"""add saved ideas
Revision ID: 20260428_add_saved_ideas
Revises: 20260428_add_platform_memberships
Create Date: 2026-04-28
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
try:
from sqlalchemy.dialects.postgresql import JSONB
except Exception: # pragma: no cover
JSONB = sa.JSON
revision = "20260428_add_saved_ideas"
down_revision = "20260428_add_platform_memberships"
branch_labels = None
depends_on = None
def _json_type(bind):
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def upgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "saved_ideas"):
return
json_type = _json_type(bind)
op.create_table(
"saved_ideas",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("idea_id", sa.String(length=64), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("raw_text", sa.Text(), nullable=False),
sa.Column("summary", sa.Text(), nullable=False),
sa.Column("source_type", sa.String(length=32), nullable=False, server_default="project_launch"),
sa.Column("source_ref", sa.String(length=255), nullable=True),
sa.Column("input_mode", sa.String(length=32), nullable=False, server_default="idea"),
sa.Column("attachments", json_type, nullable=False),
sa.Column("status", sa.String(length=32), nullable=False, server_default="saved"),
sa.Column("ai_summary", sa.Text(), nullable=True),
sa.Column("missing_questions", json_type, nullable=False),
sa.Column("readiness_score", sa.Float(), nullable=False, server_default="0"),
sa.Column("launch_draft_id", sa.String(length=64), nullable=True),
sa.Column("promoted_project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=True),
sa.Column("promoted_at", sa.TIMESTAMP(), nullable=True),
sa.Column("archived_at", sa.TIMESTAMP(), nullable=True),
sa.UniqueConstraint("tenant_id", "idea_id", name="uq_saved_ideas_tenant_idea_id"),
)
op.create_index("ix_saved_ideas_tenant_id", "saved_ideas", ["tenant_id"], unique=False)
op.create_index("ix_saved_ideas_user_id", "saved_ideas", ["user_id"], unique=False)
op.create_index("ix_saved_ideas_idea_id", "saved_ideas", ["idea_id"], unique=False)
op.create_index("ix_saved_ideas_source_type", "saved_ideas", ["source_type"], unique=False)
op.create_index("ix_saved_ideas_status", "saved_ideas", ["status"], unique=False)
op.create_index("ix_saved_ideas_launch_draft_id", "saved_ideas", ["launch_draft_id"], unique=False)
op.create_index("ix_saved_ideas_promoted_project_id", "saved_ideas", ["promoted_project_id"], unique=False)
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "saved_ideas"):
return
for index_name in (
"ix_saved_ideas_promoted_project_id",
"ix_saved_ideas_launch_draft_id",
"ix_saved_ideas_status",
"ix_saved_ideas_source_type",
"ix_saved_ideas_idea_id",
"ix_saved_ideas_user_id",
"ix_saved_ideas_tenant_id",
):
op.drop_index(index_name, table_name="saved_ideas")
op.drop_table("saved_ideas")

View File

@ -0,0 +1,129 @@
"""add workspace memberships
Revision ID: 20260428_add_workspace_memberships
Revises: 20260427_add_human_capacity_overrides
Create Date: 2026-04-28
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
try:
from sqlalchemy.dialects.postgresql import JSONB
except Exception: # pragma: no cover
JSONB = sa.JSON
revision = "20260428_add_workspace_memberships"
down_revision = "20260427_add_human_capacity_overrides"
branch_labels = None
depends_on = None
def _json_type(bind):
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _column_exists(bind, table_name: str, column_name: str) -> bool:
if not _table_exists(bind, table_name):
return False
return any(column["name"] == column_name for column in sa.inspect(bind).get_columns(table_name))
def _index_exists(bind, table_name: str, index_name: str) -> bool:
if not _table_exists(bind, table_name):
return False
return any(index["name"] == index_name for index in sa.inspect(bind).get_indexes(table_name))
def upgrade() -> None:
bind = op.get_bind()
json_type = _json_type(bind)
if _table_exists(bind, "tenants"):
if not _column_exists(bind, "tenants", "workspace_type"):
op.add_column("tenants", sa.Column("workspace_type", sa.String(length=32), nullable=False, server_default="team"))
if not _column_exists(bind, "tenants", "slug"):
op.add_column("tenants", sa.Column("slug", sa.String(length=120), nullable=True))
if not _column_exists(bind, "tenants", "owner_user_id"):
op.add_column("tenants", sa.Column("owner_user_id", sa.BigInteger(), nullable=True))
if not _column_exists(bind, "tenants", "status"):
op.add_column("tenants", sa.Column("status", sa.String(length=32), nullable=False, server_default="active"))
if not _table_exists(bind, "tenant_memberships"):
op.create_table(
"tenant_memberships",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, index=True),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("role", sa.String(length=50), nullable=False),
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
sa.Column("is_personal_owner", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.UniqueConstraint("tenant_id", "user_id", name="uq_tenant_memberships_tenant_user"),
)
op.create_index("ix_tenant_memberships_tenant_status", "tenant_memberships", ["tenant_id", "status"], unique=False)
if not _table_exists(bind, "tenant_invitations"):
op.create_table(
"tenant_invitations",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, index=True),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("email", sa.String(length=255), nullable=False, index=True),
sa.Column("role", sa.String(length=50), nullable=False),
sa.Column("project_ids", json_type, nullable=False),
sa.Column("token_hash", sa.String(length=128), nullable=False, unique=True, index=True),
sa.Column("status", sa.String(length=32), nullable=False, server_default="pending"),
sa.Column("expires_at", sa.TIMESTAMP(), nullable=False),
sa.Column("invited_by_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("accepted_by_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True),
)
op.create_index("ix_tenant_invitations_tenant_status", "tenant_invitations", ["tenant_id", "status"], unique=False)
# Backfill current single-workspace users into membership rows.
if _table_exists(bind, "users") and _table_exists(bind, "tenant_memberships"):
if bind.dialect.name == "postgresql":
op.execute(
"""
INSERT INTO tenant_memberships (tenant_id, user_id, role, status, is_personal_owner, created_at, updated_at)
SELECT users.tenant_id, users.id, users.role, 'active', false, NOW(), NOW()
FROM users
WHERE users.tenant_id IS NOT NULL
ON CONFLICT ON CONSTRAINT uq_tenant_memberships_tenant_user DO NOTHING
"""
)
else:
op.execute(
"""
INSERT OR IGNORE INTO tenant_memberships (tenant_id, user_id, role, status, is_personal_owner, created_at, updated_at)
SELECT users.tenant_id, users.id, users.role, 'active', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM users
WHERE users.tenant_id IS NOT NULL
"""
)
def downgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "tenant_invitations"):
if _index_exists(bind, "tenant_invitations", "ix_tenant_invitations_tenant_status"):
op.drop_index("ix_tenant_invitations_tenant_status", table_name="tenant_invitations")
op.drop_table("tenant_invitations")
if _table_exists(bind, "tenant_memberships"):
if _index_exists(bind, "tenant_memberships", "ix_tenant_memberships_tenant_status"):
op.drop_index("ix_tenant_memberships_tenant_status", table_name="tenant_memberships")
op.drop_table("tenant_memberships")
if _table_exists(bind, "tenants"):
for column_name in ("status", "owner_user_id", "slug", "workspace_type"):
if _column_exists(bind, "tenants", column_name):
op.drop_column("tenants", column_name)

View File

@ -0,0 +1,118 @@
"""repair admin and yangyu workspace state
Revision ID: 20260428_repair_admin_yangyu_workspace_state
Revises: 20260428_add_workspace_memberships
Create Date: 2026-04-28
"""
from __future__ import annotations
from alembic import op
revision = "20260428_repair_admin_yangyu_workspace_state"
down_revision = "20260428_add_workspace_memberships"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
if bind.dialect.name != "postgresql":
return
op.execute(
"""
INSERT INTO tenants (id, name, domain, workspace_type, slug, owner_user_id, status, created_at, updated_at)
SELECT
1,
'PMFlow 旧团队空间',
NULL,
'team',
'legacy-team',
admin_user.id,
'active',
NOW(),
NOW()
FROM users AS admin_user
WHERE admin_user.username = 'admin'
ON CONFLICT (id) DO UPDATE
SET
workspace_type = 'team',
status = 'active',
slug = COALESCE(tenants.slug, EXCLUDED.slug),
owner_user_id = COALESCE(tenants.owner_user_id, EXCLUDED.owner_user_id),
updated_at = NOW()
"""
)
op.execute("SELECT setval('tenants_id_seq', GREATEST((SELECT COALESCE(MAX(id), 1) FROM tenants), 1), true)")
op.execute(
"""
INSERT INTO tenant_memberships (tenant_id, user_id, role, status, is_personal_owner, created_at, updated_at)
SELECT 1, users.id, 'OWNER', 'active', false, NOW(), NOW()
FROM users
WHERE users.username = 'admin'
ON CONFLICT ON CONSTRAINT uq_tenant_memberships_tenant_user DO UPDATE
SET role = 'OWNER', status = 'active', is_personal_owner = false, updated_at = NOW()
"""
)
op.execute(
"""
INSERT INTO tenant_memberships (tenant_id, user_id, role, status, is_personal_owner, created_at, updated_at)
SELECT 1, users.id, 'BUILDER', 'active', false, NOW(), NOW()
FROM users
WHERE users.username = 'yangyu'
ON CONFLICT ON CONSTRAINT uq_tenant_memberships_tenant_user DO UPDATE
SET role = 'BUILDER', status = 'active', is_personal_owner = false, updated_at = NOW()
"""
)
op.execute(
"""
WITH personal_workspace AS (
SELECT users.id AS user_id, tenant_memberships.tenant_id
FROM users
JOIN tenant_memberships ON tenant_memberships.user_id = users.id
JOIN tenants ON tenants.id = tenant_memberships.tenant_id
WHERE
users.username = 'admin'
AND tenants.workspace_type = 'personal'
AND tenant_memberships.status = 'active'
AND tenant_memberships.is_personal_owner IS TRUE
ORDER BY tenants.id ASC
LIMIT 1
)
UPDATE users
SET tenant_id = personal_workspace.tenant_id, role = 'OWNER', updated_at = NOW()
FROM personal_workspace
WHERE users.id = personal_workspace.user_id
"""
)
op.execute(
"""
WITH personal_workspace AS (
SELECT users.id AS user_id, tenant_memberships.tenant_id
FROM users
JOIN tenant_memberships ON tenant_memberships.user_id = users.id
JOIN tenants ON tenants.id = tenant_memberships.tenant_id
WHERE
users.username = 'yangyu'
AND tenants.workspace_type = 'personal'
AND tenant_memberships.status = 'active'
AND tenant_memberships.is_personal_owner IS TRUE
ORDER BY tenants.id ASC
LIMIT 1
)
UPDATE users
SET tenant_id = personal_workspace.tenant_id, role = 'OWNER', updated_at = NOW()
FROM personal_workspace
WHERE users.id = personal_workspace.user_id
"""
)
def downgrade() -> None:
# Data repair is intentionally not reverted; rolling it back would reintroduce
# the broken active workspace pointers.
return

View File

@ -0,0 +1,67 @@
"""add ai call log provider payloads
Revision ID: 20260429_add_ai_call_log_provider_payloads
Revises: 20260429_add_prompt_release_checks
Create Date: 2026-04-29
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
try:
from sqlalchemy.dialects.postgresql import JSONB
except Exception: # pragma: no cover
JSONB = sa.JSON
revision = "20260429_add_ai_call_log_provider_payloads"
down_revision = "20260429_add_prompt_release_checks"
branch_labels = None
depends_on = None
def _json_type(bind):
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _column_exists(bind, table_name: str, column_name: str) -> bool:
inspector = sa.inspect(bind)
if not inspector.has_table(table_name):
return False
return any(str(item.get("name") or "") == column_name for item in inspector.get_columns(table_name))
def upgrade() -> None:
bind = op.get_bind()
table_name = "ai_call_logs"
if not _table_exists(bind, table_name):
return
for column_name in (
"provider_request_payload",
"provider_response_payload",
"provider_attempts",
):
if not _column_exists(bind, table_name, column_name):
op.add_column(table_name, sa.Column(column_name, _json_type(bind), nullable=True))
def downgrade() -> None:
bind = op.get_bind()
table_name = "ai_call_logs"
if not _table_exists(bind, table_name):
return
for column_name in (
"provider_attempts",
"provider_response_payload",
"provider_request_payload",
):
if _column_exists(bind, table_name, column_name):
op.drop_column(table_name, column_name)

View File

@ -0,0 +1,79 @@
"""add ai call trace fields
Revision ID: 20260429_add_ai_call_trace_fields
Revises: 20260428_add_saved_idea_enrichment
Create Date: 2026-04-29
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
try:
from sqlalchemy.dialects.postgresql import JSONB
except Exception: # pragma: no cover
JSONB = sa.JSON
revision = "20260429_add_ai_call_trace_fields"
down_revision = "20260428_add_saved_idea_enrichment"
branch_labels = None
depends_on = None
def _json_type(bind):
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _column_exists(bind, table_name: str, column_name: str) -> bool:
inspector = sa.inspect(bind)
if not inspector.has_table(table_name):
return False
return any(str(item.get("name") or "") == column_name for item in inspector.get_columns(table_name))
def upgrade() -> None:
bind = op.get_bind()
table_name = "ai_call_logs"
if not _table_exists(bind, table_name):
return
columns = [
("trace_id", sa.Column("trace_id", sa.String(length=80), nullable=True)),
("provider", sa.Column("provider", sa.String(length=64), nullable=True)),
("model", sa.Column("model", sa.String(length=120), nullable=True)),
("latency_ms", sa.Column("latency_ms", sa.Integer(), nullable=True)),
("routing_meta", sa.Column("routing_meta", _json_type(bind), nullable=True)),
]
for column_name, column in columns:
if not _column_exists(bind, table_name, column_name):
op.add_column(table_name, column)
existing_indexes = {index["name"] for index in sa.inspect(bind).get_indexes(table_name)}
for index_name, column_names in (
("ix_ai_call_logs_trace_id", ["trace_id"]),
("ix_ai_call_logs_provider", ["provider"]),
("ix_ai_call_logs_model", ["model"]),
):
if index_name not in existing_indexes:
op.create_index(index_name, table_name, column_names, unique=False)
def downgrade() -> None:
bind = op.get_bind()
table_name = "ai_call_logs"
if not _table_exists(bind, table_name):
return
existing_indexes = {index["name"] for index in sa.inspect(bind).get_indexes(table_name)}
for index_name in ("ix_ai_call_logs_model", "ix_ai_call_logs_provider", "ix_ai_call_logs_trace_id"):
if index_name in existing_indexes:
op.drop_index(index_name, table_name=table_name)
for column_name in ("routing_meta", "latency_ms", "model", "provider", "trace_id"):
if _column_exists(bind, table_name, column_name):
op.drop_column(table_name, column_name)

View File

@ -0,0 +1,120 @@
"""add prompt quality linkage
Revision ID: 20260429_add_prompt_quality_linkage
Revises: 20260429_add_prompt_release_checks
Create Date: 2026-04-29
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
try:
from sqlalchemy.dialects.postgresql import JSONB
except Exception: # pragma: no cover
JSONB = sa.JSON
revision = "20260429_add_prompt_quality_linkage"
down_revision = "20260429_add_prompt_release_checks"
branch_labels = None
depends_on = None
def _json_type(bind):
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _column_exists(bind, table_name: str, column_name: str) -> bool:
inspector = sa.inspect(bind)
if not inspector.has_table(table_name):
return False
return any(str(item.get("name") or "") == column_name for item in inspector.get_columns(table_name))
def _index_exists(bind, table_name: str, index_name: str) -> bool:
if not _table_exists(bind, table_name):
return False
return index_name in {str(index.get("name") or "") for index in sa.inspect(bind).get_indexes(table_name)}
def upgrade() -> None:
bind = op.get_bind()
table_name = "ai_call_logs"
if _table_exists(bind, table_name):
for column_name, column in (
("prompt_key", sa.Column("prompt_key", sa.String(length=128), nullable=True)),
("prompt_release_id", sa.Column("prompt_release_id", sa.Integer(), nullable=True)),
("prompt_source", sa.Column("prompt_source", sa.String(length=32), nullable=True)),
):
if not _column_exists(bind, table_name, column_name):
op.add_column(table_name, column)
for index_name, column_names in (
("ix_ai_call_logs_prompt_key", ["prompt_key"]),
("ix_ai_call_logs_prompt_release_id", ["prompt_release_id"]),
("ix_ai_call_logs_prompt_source", ["prompt_source"]),
):
if not _index_exists(bind, table_name, index_name):
op.create_index(index_name, table_name, column_names, unique=False)
if not _table_exists(bind, "prompt_golden_cases"):
op.create_table(
"prompt_golden_cases",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("prompt_key", sa.String(length=128), nullable=False),
sa.Column("case_name", sa.String(length=255), nullable=False),
sa.Column("status", sa.String(length=24), nullable=False, server_default="active"),
sa.Column("input_payload", _json_type(bind), nullable=True),
sa.Column("expected_contract", _json_type(bind), nullable=True),
sa.Column("expected_contains", sa.Text(), nullable=True),
sa.Column("source", sa.String(length=48), nullable=False, server_default="manual"),
sa.Column("source_ai_call_log_id", sa.Integer(), nullable=True),
sa.Column("created_by", sa.BigInteger(), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
)
for index_name, column_names in (
("ix_prompt_golden_cases_tenant_id", ["tenant_id"]),
("ix_prompt_golden_cases_prompt_key", ["prompt_key"]),
("ix_prompt_golden_cases_status", ["status"]),
("ix_prompt_golden_cases_source", ["source"]),
("ix_prompt_golden_cases_source_ai_call_log_id", ["source_ai_call_log_id"]),
("ix_prompt_golden_cases_created_by", ["created_by"]),
):
op.create_index(index_name, "prompt_golden_cases", column_names, unique=False)
def downgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "prompt_golden_cases"):
for index_name in (
"ix_prompt_golden_cases_created_by",
"ix_prompt_golden_cases_source_ai_call_log_id",
"ix_prompt_golden_cases_source",
"ix_prompt_golden_cases_status",
"ix_prompt_golden_cases_prompt_key",
"ix_prompt_golden_cases_tenant_id",
):
if _index_exists(bind, "prompt_golden_cases", index_name):
op.drop_index(index_name, table_name="prompt_golden_cases")
op.drop_table("prompt_golden_cases")
table_name = "ai_call_logs"
if _table_exists(bind, table_name):
for index_name in (
"ix_ai_call_logs_prompt_source",
"ix_ai_call_logs_prompt_release_id",
"ix_ai_call_logs_prompt_key",
):
if _index_exists(bind, table_name, index_name):
op.drop_index(index_name, table_name=table_name)
for column_name in ("prompt_source", "prompt_release_id", "prompt_key"):
if _column_exists(bind, table_name, column_name):
op.drop_column(table_name, column_name)

View File

@ -0,0 +1,78 @@
"""add prompt release checks
Revision ID: 20260429_add_prompt_release_checks
Revises: 20260429_rename_glm_provider_to_dashscope
Create Date: 2026-04-29
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
try:
from sqlalchemy.dialects.postgresql import JSONB
except Exception: # pragma: no cover
JSONB = sa.JSON
revision = "20260429_add_prompt_release_checks"
down_revision = "20260429_rename_glm_provider_to_dashscope"
branch_labels = None
depends_on = None
def _json_type(bind):
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def upgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "prompt_release_checks"):
return
op.create_table(
"prompt_release_checks",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("prompt_key", sa.String(length=128), nullable=False),
sa.Column("release_id", sa.Integer(), nullable=False),
sa.Column("check_type", sa.String(length=48), nullable=False),
sa.Column("status", sa.String(length=24), nullable=False),
sa.Column("severity", sa.String(length=16), nullable=False, server_default="info"),
sa.Column("summary", sa.Text(), nullable=False),
sa.Column("details_json", _json_type(bind), nullable=True),
sa.Column("created_by", sa.BigInteger(), nullable=True),
sa.Column("completed_at", sa.String(length=64), nullable=True),
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["release_id"], ["prompt_releases.id"], ondelete="CASCADE"),
)
op.create_index("ix_prompt_release_checks_tenant_id", "prompt_release_checks", ["tenant_id"], unique=False)
op.create_index("ix_prompt_release_checks_prompt_key", "prompt_release_checks", ["prompt_key"], unique=False)
op.create_index("ix_prompt_release_checks_release_id", "prompt_release_checks", ["release_id"], unique=False)
op.create_index("ix_prompt_release_checks_check_type", "prompt_release_checks", ["check_type"], unique=False)
op.create_index("ix_prompt_release_checks_status", "prompt_release_checks", ["status"], unique=False)
op.create_index("ix_prompt_release_checks_severity", "prompt_release_checks", ["severity"], unique=False)
op.create_index("ix_prompt_release_checks_created_by", "prompt_release_checks", ["created_by"], unique=False)
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "prompt_release_checks"):
return
for index_name in (
"ix_prompt_release_checks_created_by",
"ix_prompt_release_checks_severity",
"ix_prompt_release_checks_status",
"ix_prompt_release_checks_check_type",
"ix_prompt_release_checks_release_id",
"ix_prompt_release_checks_prompt_key",
"ix_prompt_release_checks_tenant_id",
):
op.drop_index(index_name, table_name="prompt_release_checks")
op.drop_table("prompt_release_checks")

View File

@ -0,0 +1,103 @@
"""rename glm provider code to dashscope
Revision ID: 20260429_rename_glm_provider_to_dashscope
Revises: 20260429_add_ai_call_trace_fields
Create Date: 2026-04-29
"""
from __future__ import annotations
import json
from typing import Any
from alembic import op
import sqlalchemy as sa
try:
from sqlalchemy.dialects.postgresql import JSONB
except Exception: # pragma: no cover
JSONB = sa.JSON
revision = "20260429_rename_glm_provider_to_dashscope"
down_revision = "20260429_add_ai_call_trace_fields"
branch_labels = None
depends_on = None
def _json_type(bind):
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _column_exists(bind, table_name: str, column_name: str) -> bool:
inspector = sa.inspect(bind)
if not inspector.has_table(table_name):
return False
return any(str(item.get("name") or "") == column_name for item in inspector.get_columns(table_name))
def _load_json(value: Any) -> Any:
if isinstance(value, str):
try:
return json.loads(value)
except Exception:
return value
return value
def _replace_provider_code(value: Any) -> tuple[Any, bool]:
changed = False
if isinstance(value, dict):
result: dict[Any, Any] = {}
for raw_key, raw_value in value.items():
key = "dashscope" if raw_key == "glm" else raw_key
next_value, child_changed = _replace_provider_code(raw_value)
result[key] = next_value
changed = changed or child_changed or key != raw_key
return result, changed
if isinstance(value, list):
result = []
for item in value:
next_value, child_changed = _replace_provider_code(item)
result.append(next_value)
changed = changed or child_changed
return result, changed
if value == "glm":
return "dashscope", True
return value, False
def _migrate_json_table(bind, table_name: str, column_name: str) -> None:
if not _table_exists(bind, table_name) or not _column_exists(bind, table_name, column_name):
return
table = sa.table(
table_name,
sa.column("id", sa.Integer()),
sa.column(column_name, _json_type(bind)),
)
rows = bind.execute(sa.select(table.c.id, table.c[column_name])).all()
for row_id, raw_value in rows:
payload = _load_json(raw_value)
next_payload, changed = _replace_provider_code(payload)
if changed:
bind.execute(
table.update().where(table.c.id == row_id).values({column_name: next_payload})
)
def upgrade() -> None:
bind = op.get_bind()
_migrate_json_table(bind, "integrations", "config")
_migrate_json_table(bind, "ai_call_logs", "routing_meta")
if _table_exists(bind, "ai_call_logs") and _column_exists(bind, "ai_call_logs", "provider"):
op.execute("UPDATE ai_call_logs SET provider = 'dashscope' WHERE provider = 'glm'")
def downgrade() -> None:
# Intentionally irreversible: glm was a misleading historical provider code.
pass

View File

@ -0,0 +1,136 @@
"""add prompt replay runs
Revision ID: 20260430_add_prompt_replay_runs
Revises: 20260429_add_prompt_quality_linkage
Create Date: 2026-04-30
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
try:
from sqlalchemy.dialects.postgresql import JSONB
except Exception: # pragma: no cover
JSONB = sa.JSON
revision = "20260430_add_prompt_replay_runs"
down_revision = "20260429_add_prompt_quality_linkage"
branch_labels = None
depends_on = None
def _json_type(bind):
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _index_exists(bind, table_name: str, index_name: str) -> bool:
if not _table_exists(bind, table_name):
return False
return index_name in {str(index.get("name") or "") for index in sa.inspect(bind).get_indexes(table_name)}
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "prompt_replay_runs"):
op.create_table(
"prompt_replay_runs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("prompt_key", sa.String(length=128), nullable=False),
sa.Column("release_id", sa.Integer(), nullable=False),
sa.Column("baseline_release_id", sa.Integer(), nullable=True),
sa.Column("run_status", sa.String(length=24), nullable=False, server_default="pending"),
sa.Column("run_mode", sa.String(length=32), nullable=False, server_default="contract_smoke"),
sa.Column("case_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("passed_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("failed_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("warning_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("score_delta", sa.Float(), nullable=True),
sa.Column("summary", sa.Text(), nullable=True),
sa.Column("risk_flags_json", _json_type(bind), nullable=True),
sa.Column("created_by", sa.BigInteger(), nullable=True),
sa.Column("started_at", sa.String(length=64), nullable=True),
sa.Column("completed_at", sa.String(length=64), nullable=True),
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["release_id"], ["prompt_releases.id"], ondelete="CASCADE"),
)
for index_name, column_names in (
("ix_prompt_replay_runs_tenant_id", ["tenant_id"]),
("ix_prompt_replay_runs_prompt_key", ["prompt_key"]),
("ix_prompt_replay_runs_release_id", ["release_id"]),
("ix_prompt_replay_runs_baseline_release_id", ["baseline_release_id"]),
("ix_prompt_replay_runs_run_status", ["run_status"]),
("ix_prompt_replay_runs_run_mode", ["run_mode"]),
("ix_prompt_replay_runs_created_by", ["created_by"]),
):
op.create_index(index_name, "prompt_replay_runs", column_names, unique=False)
if not _table_exists(bind, "prompt_replay_case_results"):
op.create_table(
"prompt_replay_case_results",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("run_id", sa.Integer(), nullable=False),
sa.Column("golden_case_id", sa.Integer(), nullable=False),
sa.Column("prompt_key", sa.String(length=128), nullable=False),
sa.Column("release_id", sa.Integer(), nullable=False),
sa.Column("baseline_output", sa.Text(), nullable=True),
sa.Column("candidate_output", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=24), nullable=False),
sa.Column("contract_result_json", _json_type(bind), nullable=True),
sa.Column("diff_summary", sa.Text(), nullable=True),
sa.Column("risk_flags_json", _json_type(bind), nullable=True),
sa.Column("latency_ms", sa.Integer(), nullable=True),
sa.Column("estimated_cost", sa.Float(), nullable=True),
sa.Column("error_summary", sa.Text(), nullable=True),
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["run_id"], ["prompt_replay_runs.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["golden_case_id"], ["prompt_golden_cases.id"], ondelete="CASCADE"),
)
for index_name, column_names in (
("ix_prompt_replay_case_results_tenant_id", ["tenant_id"]),
("ix_prompt_replay_case_results_run_id", ["run_id"]),
("ix_prompt_replay_case_results_golden_case_id", ["golden_case_id"]),
("ix_prompt_replay_case_results_prompt_key", ["prompt_key"]),
("ix_prompt_replay_case_results_release_id", ["release_id"]),
("ix_prompt_replay_case_results_status", ["status"]),
):
op.create_index(index_name, "prompt_replay_case_results", column_names, unique=False)
def downgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "prompt_replay_case_results"):
for index_name in (
"ix_prompt_replay_case_results_status",
"ix_prompt_replay_case_results_release_id",
"ix_prompt_replay_case_results_prompt_key",
"ix_prompt_replay_case_results_golden_case_id",
"ix_prompt_replay_case_results_run_id",
"ix_prompt_replay_case_results_tenant_id",
):
if _index_exists(bind, "prompt_replay_case_results", index_name):
op.drop_index(index_name, table_name="prompt_replay_case_results")
op.drop_table("prompt_replay_case_results")
if _table_exists(bind, "prompt_replay_runs"):
for index_name in (
"ix_prompt_replay_runs_created_by",
"ix_prompt_replay_runs_run_mode",
"ix_prompt_replay_runs_run_status",
"ix_prompt_replay_runs_baseline_release_id",
"ix_prompt_replay_runs_release_id",
"ix_prompt_replay_runs_prompt_key",
"ix_prompt_replay_runs_tenant_id",
):
if _index_exists(bind, "prompt_replay_runs", index_name):
op.drop_index(index_name, table_name="prompt_replay_runs")
op.drop_table("prompt_replay_runs")

View File

@ -0,0 +1,104 @@
"""extend prompt eval replay
Revision ID: 20260430_extend_prompt_eval_replay
Revises: 20260430_add_prompt_replay_runs
Create Date: 2026-04-30
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
try:
from sqlalchemy.dialects.postgresql import JSONB
except Exception: # pragma: no cover
JSONB = sa.JSON
revision = "20260430_extend_prompt_eval_replay"
down_revision = "20260430_add_prompt_replay_runs"
branch_labels = None
depends_on = None
def _json_type(bind):
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _column_exists(bind, table_name: str, column_name: str) -> bool:
if not _table_exists(bind, table_name):
return False
return column_name in {str(column.get("name") or "") for column in sa.inspect(bind).get_columns(table_name)}
def _index_exists(bind, table_name: str, index_name: str) -> bool:
if not _table_exists(bind, table_name):
return False
return index_name in {str(index.get("name") or "") for index in sa.inspect(bind).get_indexes(table_name)}
def _add_column_if_missing(bind, table_name: str, column: sa.Column) -> None:
if not _column_exists(bind, table_name, str(column.name)):
op.add_column(table_name, column)
def _create_index_if_missing(bind, index_name: str, table_name: str, columns: list[str]) -> None:
if not _index_exists(bind, table_name, index_name):
op.create_index(index_name, table_name, columns, unique=False)
def upgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "prompt_golden_cases"):
_add_column_if_missing(bind, "prompt_golden_cases", sa.Column("case_type", sa.String(length=32), nullable=False, server_default="regression"))
_add_column_if_missing(bind, "prompt_golden_cases", sa.Column("priority", sa.String(length=24), nullable=False, server_default="blocking"))
_add_column_if_missing(bind, "prompt_golden_cases", sa.Column("scenario_key", sa.String(length=128), nullable=True))
_add_column_if_missing(bind, "prompt_golden_cases", sa.Column("tags", _json_type(bind), nullable=True))
_add_column_if_missing(bind, "prompt_golden_cases", sa.Column("last_run_status", sa.String(length=24), nullable=True))
_add_column_if_missing(bind, "prompt_golden_cases", sa.Column("last_failed_at", sa.String(length=64), nullable=True))
_create_index_if_missing(bind, "ix_prompt_golden_cases_case_type", "prompt_golden_cases", ["case_type"])
_create_index_if_missing(bind, "ix_prompt_golden_cases_priority", "prompt_golden_cases", ["priority"])
_create_index_if_missing(bind, "ix_prompt_golden_cases_scenario_key", "prompt_golden_cases", ["scenario_key"])
_create_index_if_missing(bind, "ix_prompt_golden_cases_last_run_status", "prompt_golden_cases", ["last_run_status"])
if _table_exists(bind, "prompt_replay_case_results"):
_add_column_if_missing(bind, "prompt_replay_case_results", sa.Column("baseline_ai_call_log_id", sa.Integer(), nullable=True))
_add_column_if_missing(bind, "prompt_replay_case_results", sa.Column("candidate_ai_call_log_id", sa.Integer(), nullable=True))
_add_column_if_missing(bind, "prompt_replay_case_results", sa.Column("judge_score", sa.Float(), nullable=True))
_add_column_if_missing(bind, "prompt_replay_case_results", sa.Column("judge_label", sa.String(length=32), nullable=True))
_add_column_if_missing(bind, "prompt_replay_case_results", sa.Column("judge_reason", sa.Text(), nullable=True))
_create_index_if_missing(bind, "ix_prompt_replay_case_results_judge_label", "prompt_replay_case_results", ["judge_label"])
_create_index_if_missing(bind, "ix_prompt_replay_case_results_baseline_ai_call_log_id", "prompt_replay_case_results", ["baseline_ai_call_log_id"])
_create_index_if_missing(bind, "ix_prompt_replay_case_results_candidate_ai_call_log_id", "prompt_replay_case_results", ["candidate_ai_call_log_id"])
def downgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "prompt_replay_case_results"):
for index_name in (
"ix_prompt_replay_case_results_candidate_ai_call_log_id",
"ix_prompt_replay_case_results_baseline_ai_call_log_id",
"ix_prompt_replay_case_results_judge_label",
):
if _index_exists(bind, "prompt_replay_case_results", index_name):
op.drop_index(index_name, table_name="prompt_replay_case_results")
for column_name in ("judge_reason", "judge_label", "judge_score", "candidate_ai_call_log_id", "baseline_ai_call_log_id"):
if _column_exists(bind, "prompt_replay_case_results", column_name):
op.drop_column("prompt_replay_case_results", column_name)
if _table_exists(bind, "prompt_golden_cases"):
for index_name in (
"ix_prompt_golden_cases_last_run_status",
"ix_prompt_golden_cases_scenario_key",
"ix_prompt_golden_cases_priority",
"ix_prompt_golden_cases_case_type",
):
if _index_exists(bind, "prompt_golden_cases", index_name):
op.drop_index(index_name, table_name="prompt_golden_cases")
for column_name in ("last_failed_at", "last_run_status", "tags", "scenario_key", "priority", "case_type"):
if _column_exists(bind, "prompt_golden_cases", column_name):
op.drop_column("prompt_golden_cases", column_name)

View File

@ -0,0 +1,95 @@
"""add pmflow launch draft jobs
Revision ID: 20260501_add_pmflow_launch_draft_jobs
Revises: 20260430_extend_prompt_eval_replay
Create Date: 2026-05-01
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
try:
from sqlalchemy.dialects.postgresql import JSONB
except Exception: # pragma: no cover
JSONB = sa.JSON
revision = "20260501_add_pmflow_launch_draft_jobs"
down_revision = "20260430_extend_prompt_eval_replay"
branch_labels = None
depends_on = None
def _json_type(bind):
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def upgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "pmflow_launch_draft_jobs"):
return
json_type = _json_type(bind)
op.create_table(
"pmflow_launch_draft_jobs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("job_id", sa.String(length=64), nullable=False),
sa.Column("revision_id", sa.String(length=64), nullable=False),
sa.Column("parent_draft_id", sa.String(length=64), nullable=True),
sa.Column("superseded_by_job_id", sa.String(length=64), nullable=True),
sa.Column("result_draft_id", sa.String(length=64), nullable=True),
sa.Column("input_text", sa.Text(), nullable=False),
sa.Column("input_type", sa.String(length=32), nullable=False, server_default="text"),
sa.Column("source", sa.String(length=32), nullable=False, server_default="web"),
sa.Column("attachments", json_type, nullable=False),
sa.Column("mode", sa.String(length=32), nullable=False, server_default="create"),
sa.Column("revision_note", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False, server_default="queued"),
sa.Column("result_payload", json_type, nullable=True),
sa.Column("changed_fields", json_type, nullable=False),
sa.Column("error_code", sa.String(length=64), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("started_at", sa.TIMESTAMP(), nullable=True),
sa.Column("completed_at", sa.TIMESTAMP(), nullable=True),
sa.Column("expires_at", sa.TIMESTAMP(), nullable=False),
sa.UniqueConstraint("tenant_id", "job_id", name="uq_pmflow_launch_draft_jobs_tenant_job_id"),
sa.UniqueConstraint("tenant_id", "revision_id", name="uq_pmflow_launch_draft_jobs_tenant_revision_id"),
)
op.create_index("ix_pmflow_launch_draft_jobs_tenant_id", "pmflow_launch_draft_jobs", ["tenant_id"], unique=False)
op.create_index("ix_pmflow_launch_draft_jobs_user_id", "pmflow_launch_draft_jobs", ["user_id"], unique=False)
op.create_index("ix_pmflow_launch_draft_jobs_job_id", "pmflow_launch_draft_jobs", ["job_id"], unique=False)
op.create_index("ix_pmflow_launch_draft_jobs_revision_id", "pmflow_launch_draft_jobs", ["revision_id"], unique=False)
op.create_index("ix_pmflow_launch_draft_jobs_parent_draft_id", "pmflow_launch_draft_jobs", ["parent_draft_id"], unique=False)
op.create_index("ix_pmflow_launch_draft_jobs_superseded_by_job_id", "pmflow_launch_draft_jobs", ["superseded_by_job_id"], unique=False)
op.create_index("ix_pmflow_launch_draft_jobs_result_draft_id", "pmflow_launch_draft_jobs", ["result_draft_id"], unique=False)
op.create_index("ix_pmflow_launch_draft_jobs_status", "pmflow_launch_draft_jobs", ["status"], unique=False)
op.create_index("ix_pmflow_launch_draft_jobs_expires_at", "pmflow_launch_draft_jobs", ["expires_at"], unique=False)
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "pmflow_launch_draft_jobs"):
return
for index_name in (
"ix_pmflow_launch_draft_jobs_expires_at",
"ix_pmflow_launch_draft_jobs_status",
"ix_pmflow_launch_draft_jobs_result_draft_id",
"ix_pmflow_launch_draft_jobs_superseded_by_job_id",
"ix_pmflow_launch_draft_jobs_parent_draft_id",
"ix_pmflow_launch_draft_jobs_revision_id",
"ix_pmflow_launch_draft_jobs_job_id",
"ix_pmflow_launch_draft_jobs_user_id",
"ix_pmflow_launch_draft_jobs_tenant_id",
):
op.drop_index(index_name, table_name="pmflow_launch_draft_jobs")
op.drop_table("pmflow_launch_draft_jobs")

View File

@ -0,0 +1,120 @@
"""add pmflow ai feedback events
Revision ID: 20260503_add_pmflow_ai_feedback_events
Revises: 20260501_add_pmflow_launch_draft_jobs
Create Date: 2026-05-03
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
try:
from sqlalchemy.dialects.postgresql import JSONB
except Exception: # pragma: no cover
JSONB = sa.JSON
revision = "20260503_add_pmflow_ai_feedback_events"
down_revision = "20260501_add_pmflow_launch_draft_jobs"
branch_labels = None
depends_on = None
def _json_type(bind):
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def upgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "pmflow_ai_feedback_events"):
return
json_type = _json_type(bind)
op.create_table(
"pmflow_ai_feedback_events",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
sa.Column("thread_id", sa.BigInteger(), sa.ForeignKey("pmflow_threads.id"), nullable=True),
sa.Column("message_id", sa.BigInteger(), sa.ForeignKey("pmflow_thread_messages.id"), nullable=True),
sa.Column("change_set_id", sa.BigInteger(), sa.ForeignKey("pmflow_change_sets.id"), nullable=True),
sa.Column("ai_call_log_id", sa.BigInteger(), nullable=True),
sa.Column("actor_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("target_type", sa.String(length=64), nullable=False),
sa.Column("target_id", sa.String(length=128), nullable=False),
sa.Column("answer_kind", sa.String(length=64), nullable=False, server_default="chat_answer"),
sa.Column("rating", sa.String(length=16), nullable=False),
sa.Column("reason_tags", json_type, nullable=False),
sa.Column("comment", sa.Text(), nullable=True),
sa.Column("scene_key", sa.String(length=128), nullable=True),
sa.Column("prompt_key", sa.String(length=128), nullable=True),
sa.Column("prompt_release_id", sa.Integer(), nullable=True),
sa.Column("runtime_profile_id", sa.String(length=128), nullable=True),
sa.Column("model_provider", sa.String(length=64), nullable=True),
sa.Column("model_name", sa.String(length=120), nullable=True),
sa.Column("feedback_metadata", json_type, nullable=False),
sa.UniqueConstraint(
"tenant_id",
"project_id",
"actor_user_id",
"target_type",
"target_id",
name="uq_pmflow_ai_feedback_target_user",
),
)
for column_name in (
"tenant_id",
"project_id",
"thread_id",
"message_id",
"change_set_id",
"ai_call_log_id",
"actor_user_id",
"target_type",
"target_id",
"answer_kind",
"rating",
"scene_key",
"prompt_key",
"prompt_release_id",
"runtime_profile_id",
):
op.create_index(
f"ix_pmflow_ai_feedback_events_{column_name}",
"pmflow_ai_feedback_events",
[column_name],
unique=False,
)
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "pmflow_ai_feedback_events"):
return
for column_name in (
"runtime_profile_id",
"prompt_release_id",
"prompt_key",
"scene_key",
"rating",
"answer_kind",
"target_id",
"target_type",
"actor_user_id",
"ai_call_log_id",
"change_set_id",
"message_id",
"thread_id",
"project_id",
"tenant_id",
):
op.drop_index(f"ix_pmflow_ai_feedback_events_{column_name}", table_name="pmflow_ai_feedback_events")
op.drop_table("pmflow_ai_feedback_events")

View File

@ -0,0 +1,45 @@
"""add pmflow changeset committer
Revision ID: 20260505_add_pmflow_changeset_committer
Revises: 20260503_add_pmflow_ai_feedback_events
Create Date: 2026-05-05
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260505_add_pmflow_changeset_committer"
down_revision = "20260503_add_pmflow_ai_feedback_events"
branch_labels = None
depends_on = None
def _column_exists(bind, table_name: str, column_name: str) -> bool:
if not sa.inspect(bind).has_table(table_name):
return False
return any(column["name"] == column_name for column in sa.inspect(bind).get_columns(table_name))
def _index_exists(bind, table_name: str, index_name: str) -> bool:
if not sa.inspect(bind).has_table(table_name):
return False
return any(index["name"] == index_name for index in sa.inspect(bind).get_indexes(table_name))
def upgrade() -> None:
bind = op.get_bind()
if not _column_exists(bind, "pmflow_change_sets", "committed_by"):
op.add_column("pmflow_change_sets", sa.Column("committed_by", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True))
if not _index_exists(bind, "pmflow_change_sets", "ix_pmflow_change_sets_committed_by"):
op.create_index("ix_pmflow_change_sets_committed_by", "pmflow_change_sets", ["committed_by"], unique=False)
def downgrade() -> None:
bind = op.get_bind()
if _index_exists(bind, "pmflow_change_sets", "ix_pmflow_change_sets_committed_by"):
op.drop_index("ix_pmflow_change_sets_committed_by", table_name="pmflow_change_sets")
if _column_exists(bind, "pmflow_change_sets", "committed_by"):
op.drop_column("pmflow_change_sets", "committed_by")

View File

@ -0,0 +1,92 @@
"""add execution runtime credentials
Revision ID: 20260506_add_execution_runtime_credentials
Revises: 20260505_add_pmflow_changeset_committer
Create Date: 2026-05-06
"""
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 = "20260506_add_execution_runtime_credentials"
down_revision = "20260505_add_pmflow_changeset_committer"
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 any(index["name"] == index_name for index in sa.inspect(bind).get_indexes(table_name))
def upgrade() -> None:
bind = op.get_bind()
table_name = "pmflow_execution_runtime_credentials"
if not _table_exists(bind, table_name):
op.create_table(
table_name,
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("key_id", sa.String(length=80), nullable=False),
sa.Column("secret_hash", sa.String(length=128), nullable=False),
sa.Column("project_id", sa.BigInteger(), nullable=False),
sa.Column("task_id", sa.BigInteger(), nullable=False),
sa.Column("prompt_package_id", sa.BigInteger(), nullable=False),
sa.Column("repo_binding_id", sa.BigInteger(), nullable=True),
sa.Column("runtime_target", sa.String(length=32), nullable=False),
sa.Column("allowed_sources", _json_type(bind), nullable=False),
sa.Column("status", sa.String(length=32), nullable=False),
sa.Column("expires_at", sa.TIMESTAMP(), nullable=False),
sa.Column("created_by", sa.BigInteger(), nullable=True),
sa.Column("last_used_at", sa.TIMESTAMP(), nullable=True),
sa.Column("use_count", sa.Integer(), 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.ForeignKeyConstraint(["created_by"], ["users.id"]),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"]),
sa.ForeignKeyConstraint(["prompt_package_id"], ["prompt_packages.id"]),
sa.ForeignKeyConstraint(["repo_binding_id"], ["repo_bindings.id"]),
sa.ForeignKeyConstraint(["task_id"], ["tasks.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "key_id", name="uq_pmflow_runtime_credentials_tenant_key"),
)
for column_name in (
"tenant_id",
"key_id",
"project_id",
"task_id",
"prompt_package_id",
"repo_binding_id",
"runtime_target",
"status",
"expires_at",
"created_by",
):
index_name = f"ix_{table_name}_{column_name}"
if not _index_exists(bind, table_name, index_name):
op.create_index(index_name, table_name, [column_name], unique=False)
def downgrade() -> None:
bind = op.get_bind()
table_name = "pmflow_execution_runtime_credentials"
if _table_exists(bind, table_name):
op.drop_table(table_name)

View File

@ -0,0 +1,59 @@
"""add qa context workspaces
Revision ID: 20260507_add_qa_context_workspaces
Revises: 20260506_add_execution_runtime_credentials
Create Date: 2026-05-07
"""
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 = "20260507_add_qa_context_workspaces"
down_revision = "20260506_add_execution_runtime_credentials"
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 upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "qa_session_states"):
return
with op.batch_alter_table("qa_session_states") as batch_op:
if not _column_exists(bind, "qa_session_states", "active_workspace"):
batch_op.add_column(sa.Column("active_workspace", _json_type(bind), nullable=True))
if not _column_exists(bind, "qa_session_states", "context_capsules"):
batch_op.add_column(sa.Column("context_capsules", _json_type(bind), nullable=True))
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "qa_session_states"):
return
with op.batch_alter_table("qa_session_states") as batch_op:
if _column_exists(bind, "qa_session_states", "context_capsules"):
batch_op.drop_column("context_capsules")
if _column_exists(bind, "qa_session_states", "active_workspace"):
batch_op.drop_column("active_workspace")

View File

@ -0,0 +1,113 @@
"""seed turn runtime prompt releases
Revision ID: 20260511_seed_turn_runtime_prompt_releases
Revises: 20260507_add_qa_context_workspaces
Create Date: 2026-05-11
"""
from __future__ import annotations
import os
from alembic import op
import sqlalchemy as sa
revision = "20260511_seed_turn_runtime_prompt_releases"
down_revision = "20260507_add_qa_context_workspaces"
branch_labels = None
depends_on = None
PROMPT_KEYS = (
"PROMPT_PMFLOW_AI_TURN_GOAL_STATE_ASSESSOR",
"PROMPT_PMFLOW_AI_TURN_STATE_ANSWER_SYNTHESIZER",
)
SEED_NOTES = "Seeded by 20260511_seed_turn_runtime_prompt_releases"
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _prompt_body(prompt_key: str) -> str:
from app.config import ai_prompts
return str(getattr(ai_prompts, prompt_key, "") or "").strip()
def _seed_active_release(bind, *, tenant_id: int, prompt_key: str, body_snapshot: str) -> None:
active = bind.execute(
sa.text(
"""
SELECT id FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
LIMIT 1
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
).first()
if active is not None:
return
max_version = bind.execute(
sa.text(
"""
SELECT MAX(version) FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
).scalar()
bind.execute(
sa.text(
"""
INSERT INTO prompt_releases
(tenant_id, prompt_key, version, body_snapshot, status, notes, created_by, activated_by, activated_at)
VALUES
(:tenant_id, :prompt_key, :version, :body_snapshot, 'active', :notes, NULL, NULL, :activated_at)
"""
),
{
"tenant_id": tenant_id,
"prompt_key": prompt_key,
"version": int(max_version or 0) + 1,
"body_snapshot": body_snapshot,
"notes": SEED_NOTES,
"activated_at": "2026-05-11T00:00:00",
},
)
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "prompt_releases"):
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
for prompt_key in PROMPT_KEYS:
body_snapshot = _prompt_body(prompt_key)
if body_snapshot:
_seed_active_release(bind, tenant_id=tenant_id, prompt_key=prompt_key, body_snapshot=body_snapshot)
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "prompt_releases"):
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
bind.execute(
sa.text(
"""
DELETE FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key IN :prompt_keys
AND notes = :notes
"""
).bindparams(sa.bindparam("prompt_keys", expanding=True)),
{"tenant_id": tenant_id, "prompt_keys": PROMPT_KEYS, "notes": SEED_NOTES},
)

View File

@ -0,0 +1,250 @@
"""add durable pmflow agent runtime records
Revision ID: 20260513_add_pmflow_agent_runtime
Revises: 20260511_seed_turn_runtime_prompt_releases
Create Date: 2026-05-13
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260513_add_pmflow_agent_runtime"
down_revision = "20260511_seed_turn_runtime_prompt_releases"
branch_labels = None
depends_on = None
def _json_type():
bind = op.get_bind()
if bind.dialect.name == "postgresql":
from sqlalchemy.dialects import postgresql
return postgresql.JSONB()
return sa.JSON()
def _table_exists(table_name: str) -> bool:
return sa.inspect(op.get_bind()).has_table(table_name)
def _index_exists(table_name: str, index_name: str) -> bool:
inspector = sa.inspect(op.get_bind())
return any(item.get("name") == index_name for item in inspector.get_indexes(table_name))
def _create_index_once(index_name: str, table_name: str, columns: list[str]) -> None:
if _table_exists(table_name) and not _index_exists(table_name, index_name):
op.create_index(index_name, table_name, columns)
def _drop_table_once(table_name: str) -> None:
if _table_exists(table_name):
op.drop_table(table_name)
def _base_columns() -> list[sa.Column]:
return [
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.text("now()")),
]
def upgrade() -> None:
json_type = _json_type()
if not _table_exists("pmflow_agent_runs"):
op.create_table(
"pmflow_agent_runs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("public_run_id", sa.String(length=80), nullable=False),
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("qa_session_id", sa.BigInteger(), sa.ForeignKey("qa_sessions.id"), nullable=True),
sa.Column("workspace_scope", sa.String(length=80), nullable=True),
sa.Column("surface", sa.String(length=80), nullable=True),
sa.Column("presentation_mode", sa.String(length=40), nullable=True),
sa.Column("source_channel", sa.String(length=40), nullable=True),
sa.Column("source_ref", sa.String(length=160), nullable=True),
sa.Column("title", sa.String(length=240), nullable=True),
sa.Column("goal", sa.Text(), nullable=False, server_default=""),
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
sa.Column("active_object_refs", json_type, nullable=True),
sa.Column("current_step_id", sa.String(length=80), nullable=True),
sa.Column("final_answer", sa.Text(), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("run_metadata", json_type, nullable=True),
sa.Column("started_at", sa.TIMESTAMP(), nullable=True),
sa.Column("completed_at", sa.TIMESTAMP(), nullable=True),
*_base_columns(),
sa.UniqueConstraint("tenant_id", "public_run_id", name="uq_pmflow_agent_runs_tenant_public_run_id"),
)
_create_index_once("ix_pmflow_agent_runs_tenant_id", "pmflow_agent_runs", ["tenant_id"])
_create_index_once("ix_pmflow_agent_runs_public_run_id", "pmflow_agent_runs", ["public_run_id"])
_create_index_once("ix_pmflow_agent_runs_user_id", "pmflow_agent_runs", ["user_id"])
_create_index_once("ix_pmflow_agent_runs_qa_session_id", "pmflow_agent_runs", ["qa_session_id"])
_create_index_once("ix_pmflow_agent_runs_status", "pmflow_agent_runs", ["status"])
_create_index_once("ix_pmflow_agent_runs_started_at", "pmflow_agent_runs", ["started_at"])
if not _table_exists("pmflow_agent_steps"):
op.create_table(
"pmflow_agent_steps",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column(
"run_id",
sa.BigInteger(),
sa.ForeignKey("pmflow_agent_runs.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("public_step_id", sa.String(length=80), nullable=False),
sa.Column("step_index", sa.Integer(), nullable=False, server_default="0"),
sa.Column("step_kind", sa.String(length=64), nullable=False, server_default="runtime_step"),
sa.Column("status", sa.String(length=32), nullable=False, server_default="completed"),
sa.Column("input_summary", sa.Text(), nullable=True),
sa.Column("output_summary", sa.Text(), nullable=True),
sa.Column("tool_name", sa.String(length=120), nullable=True),
sa.Column("duration_ms", sa.Integer(), nullable=True),
sa.Column("step_payload", json_type, nullable=True),
sa.Column("started_at", sa.TIMESTAMP(), nullable=True),
sa.Column("completed_at", sa.TIMESTAMP(), nullable=True),
*_base_columns(),
sa.UniqueConstraint("tenant_id", "run_id", "public_step_id", name="uq_pmflow_agent_steps_tenant_run_public_step"),
)
_create_index_once("ix_pmflow_agent_steps_tenant_id", "pmflow_agent_steps", ["tenant_id"])
_create_index_once("ix_pmflow_agent_steps_run_id", "pmflow_agent_steps", ["run_id"])
_create_index_once("ix_pmflow_agent_steps_public_step_id", "pmflow_agent_steps", ["public_step_id"])
_create_index_once("ix_pmflow_agent_steps_step_index", "pmflow_agent_steps", ["step_index"])
_create_index_once("ix_pmflow_agent_steps_step_kind", "pmflow_agent_steps", ["step_kind"])
_create_index_once("ix_pmflow_agent_steps_status", "pmflow_agent_steps", ["status"])
_create_index_once("ix_pmflow_agent_steps_tool_name", "pmflow_agent_steps", ["tool_name"])
if not _table_exists("pmflow_agent_observations"):
op.create_table(
"pmflow_agent_observations",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column(
"run_id",
sa.BigInteger(),
sa.ForeignKey("pmflow_agent_runs.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("step_id", sa.BigInteger(), sa.ForeignKey("pmflow_agent_steps.id", ondelete="SET NULL"), nullable=True),
sa.Column("public_observation_id", sa.String(length=80), nullable=False),
sa.Column("source_kind", sa.String(length=80), nullable=False, server_default="agent_runtime"),
sa.Column("customer_safe_summary", sa.Text(), nullable=True),
sa.Column("confidence", sa.String(length=32), nullable=True),
sa.Column("facts", json_type, nullable=True),
sa.Column("gaps", json_type, nullable=True),
sa.Column("raw_payload", json_type, nullable=True),
sa.Column("observed_at", sa.TIMESTAMP(), nullable=True),
*_base_columns(),
sa.UniqueConstraint(
"tenant_id",
"run_id",
"public_observation_id",
name="uq_pmflow_agent_observations_tenant_run_public_observation",
),
)
_create_index_once("ix_pmflow_agent_observations_tenant_id", "pmflow_agent_observations", ["tenant_id"])
_create_index_once("ix_pmflow_agent_observations_run_id", "pmflow_agent_observations", ["run_id"])
_create_index_once("ix_pmflow_agent_observations_step_id", "pmflow_agent_observations", ["step_id"])
_create_index_once("ix_pmflow_agent_observations_public_observation_id", "pmflow_agent_observations", ["public_observation_id"])
_create_index_once("ix_pmflow_agent_observations_source_kind", "pmflow_agent_observations", ["source_kind"])
_create_index_once("ix_pmflow_agent_observations_observed_at", "pmflow_agent_observations", ["observed_at"])
if not _table_exists("pmflow_agent_artifacts"):
op.create_table(
"pmflow_agent_artifacts",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column(
"run_id",
sa.BigInteger(),
sa.ForeignKey("pmflow_agent_runs.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("step_id", sa.BigInteger(), sa.ForeignKey("pmflow_agent_steps.id", ondelete="SET NULL"), nullable=True),
sa.Column("public_artifact_id", sa.String(length=80), nullable=False),
sa.Column("artifact_kind", sa.String(length=80), nullable=False),
sa.Column("title", sa.String(length=240), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False, server_default="created"),
sa.Column("payload", json_type, nullable=True),
sa.Column("customer_safe_payload", json_type, nullable=True),
sa.Column("created_by_step_id", sa.String(length=80), nullable=True),
*_base_columns(),
sa.UniqueConstraint("tenant_id", "run_id", "public_artifact_id", name="uq_pmflow_agent_artifacts_tenant_run_public_artifact"),
)
_create_index_once("ix_pmflow_agent_artifacts_tenant_id", "pmflow_agent_artifacts", ["tenant_id"])
_create_index_once("ix_pmflow_agent_artifacts_run_id", "pmflow_agent_artifacts", ["run_id"])
_create_index_once("ix_pmflow_agent_artifacts_step_id", "pmflow_agent_artifacts", ["step_id"])
_create_index_once("ix_pmflow_agent_artifacts_public_artifact_id", "pmflow_agent_artifacts", ["public_artifact_id"])
_create_index_once("ix_pmflow_agent_artifacts_artifact_kind", "pmflow_agent_artifacts", ["artifact_kind"])
_create_index_once("ix_pmflow_agent_artifacts_status", "pmflow_agent_artifacts", ["status"])
_create_index_once("ix_pmflow_agent_artifacts_created_by_step_id", "pmflow_agent_artifacts", ["created_by_step_id"])
if not _table_exists("pmflow_agent_verifications"):
op.create_table(
"pmflow_agent_verifications",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column(
"run_id",
sa.BigInteger(),
sa.ForeignKey("pmflow_agent_runs.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("step_id", sa.BigInteger(), sa.ForeignKey("pmflow_agent_steps.id", ondelete="SET NULL"), nullable=True),
sa.Column("public_verification_id", sa.String(length=80), nullable=False),
sa.Column("target_ref", sa.String(length=160), nullable=True),
sa.Column("result", sa.String(length=32), nullable=False, server_default="unknown"),
sa.Column("retry_would_change_state", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("findings", json_type, nullable=True),
sa.Column("verification_payload", json_type, nullable=True),
sa.Column("verified_at", sa.TIMESTAMP(), nullable=True),
*_base_columns(),
sa.UniqueConstraint(
"tenant_id",
"run_id",
"public_verification_id",
name="uq_pmflow_agent_verifications_tenant_run_public_verification",
),
)
_create_index_once("ix_pmflow_agent_verifications_tenant_id", "pmflow_agent_verifications", ["tenant_id"])
_create_index_once("ix_pmflow_agent_verifications_run_id", "pmflow_agent_verifications", ["run_id"])
_create_index_once("ix_pmflow_agent_verifications_step_id", "pmflow_agent_verifications", ["step_id"])
_create_index_once("ix_pmflow_agent_verifications_public_verification_id", "pmflow_agent_verifications", ["public_verification_id"])
_create_index_once("ix_pmflow_agent_verifications_target_ref", "pmflow_agent_verifications", ["target_ref"])
_create_index_once("ix_pmflow_agent_verifications_result", "pmflow_agent_verifications", ["result"])
_create_index_once("ix_pmflow_agent_verifications_verified_at", "pmflow_agent_verifications", ["verified_at"])
if not _table_exists("pmflow_agent_memories"):
op.create_table(
"pmflow_agent_memories",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column(
"run_id",
sa.BigInteger(),
sa.ForeignKey("pmflow_agent_runs.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("memory_kind", sa.String(length=80), nullable=False),
sa.Column("scope_ref", sa.String(length=160), nullable=True),
sa.Column("outcome_signal", sa.String(length=80), nullable=True),
sa.Column("content", json_type, nullable=True),
sa.Column("memory_payload", json_type, nullable=True),
*_base_columns(),
)
_create_index_once("ix_pmflow_agent_memories_tenant_id", "pmflow_agent_memories", ["tenant_id"])
_create_index_once("ix_pmflow_agent_memories_run_id", "pmflow_agent_memories", ["run_id"])
_create_index_once("ix_pmflow_agent_memories_memory_kind", "pmflow_agent_memories", ["memory_kind"])
_create_index_once("ix_pmflow_agent_memories_scope_ref", "pmflow_agent_memories", ["scope_ref"])
_create_index_once("ix_pmflow_agent_memories_outcome_signal", "pmflow_agent_memories", ["outcome_signal"])
def downgrade() -> None:
_drop_table_once("pmflow_agent_memories")
_drop_table_once("pmflow_agent_verifications")
_drop_table_once("pmflow_agent_artifacts")
_drop_table_once("pmflow_agent_observations")
_drop_table_once("pmflow_agent_steps")
_drop_table_once("pmflow_agent_runs")

View File

@ -0,0 +1,138 @@
"""add pmflow ai threads and messages
Revision ID: 20260513_add_pmflow_ai_threads
Revises: 20260513_merge_pmflow_agent_runtime_heads
Create Date: 2026-05-13
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260513_add_pmflow_ai_threads"
down_revision = "20260513_merge_pmflow_agent_runtime_heads"
branch_labels = None
depends_on = None
def _json_type():
bind = op.get_bind()
if bind.dialect.name == "postgresql":
from sqlalchemy.dialects import postgresql
return postgresql.JSONB()
return sa.JSON()
def _table_exists(table_name: str) -> bool:
return sa.inspect(op.get_bind()).has_table(table_name)
def _column_exists(table_name: str, column_name: str) -> bool:
if not _table_exists(table_name):
return False
inspector = sa.inspect(op.get_bind())
return any(item.get("name") == column_name for item in inspector.get_columns(table_name))
def _index_exists(table_name: str, index_name: str) -> bool:
if not _table_exists(table_name):
return False
inspector = sa.inspect(op.get_bind())
return any(item.get("name") == index_name for item in inspector.get_indexes(table_name))
def _create_index_once(index_name: str, table_name: str, columns: list[str]) -> None:
if _table_exists(table_name) and not _index_exists(table_name, index_name):
op.create_index(index_name, table_name, columns)
def _base_columns() -> list[sa.Column]:
return [
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.text("now()")),
]
def upgrade() -> None:
json_type = _json_type()
if not _table_exists("pmflow_ai_threads"):
op.create_table(
"pmflow_ai_threads",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("public_thread_id", sa.String(length=80), nullable=False),
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("qa_session_id", sa.BigInteger(), sa.ForeignKey("qa_sessions.id", ondelete="SET NULL"), nullable=True),
sa.Column("workspace_scope", sa.String(length=80), nullable=True),
sa.Column("surface", sa.String(length=80), nullable=True),
sa.Column("title", sa.String(length=240), nullable=False, server_default="PMFlow AI"),
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
sa.Column("current_object_refs", json_type, nullable=True),
sa.Column("last_run_public_id", sa.String(length=80), nullable=True),
sa.Column("last_message_preview", sa.Text(), nullable=True),
sa.Column("message_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("thread_metadata", json_type, nullable=True),
sa.Column("started_at", sa.TIMESTAMP(), nullable=True),
sa.Column("last_message_at", sa.TIMESTAMP(), nullable=True),
*_base_columns(),
sa.UniqueConstraint("tenant_id", "public_thread_id", name="uq_pmflow_ai_threads_tenant_public_thread_id"),
)
_create_index_once("ix_pmflow_ai_threads_tenant_id", "pmflow_ai_threads", ["tenant_id"])
_create_index_once("ix_pmflow_ai_threads_public_thread_id", "pmflow_ai_threads", ["public_thread_id"])
_create_index_once("ix_pmflow_ai_threads_user_id", "pmflow_ai_threads", ["user_id"])
_create_index_once("ix_pmflow_ai_threads_qa_session_id", "pmflow_ai_threads", ["qa_session_id"])
_create_index_once("ix_pmflow_ai_threads_workspace_scope", "pmflow_ai_threads", ["workspace_scope"])
_create_index_once("ix_pmflow_ai_threads_status", "pmflow_ai_threads", ["status"])
_create_index_once("ix_pmflow_ai_threads_last_run_public_id", "pmflow_ai_threads", ["last_run_public_id"])
_create_index_once("ix_pmflow_ai_threads_started_at", "pmflow_ai_threads", ["started_at"])
_create_index_once("ix_pmflow_ai_threads_last_message_at", "pmflow_ai_threads", ["last_message_at"])
if _table_exists("pmflow_agent_runs") and not _column_exists("pmflow_agent_runs", "thread_id"):
op.add_column("pmflow_agent_runs", sa.Column("thread_id", sa.BigInteger(), nullable=True))
op.create_foreign_key(
"fk_pmflow_agent_runs_thread_id_pmflow_ai_threads",
"pmflow_agent_runs",
"pmflow_ai_threads",
["thread_id"],
["id"],
ondelete="SET NULL",
)
_create_index_once("ix_pmflow_agent_runs_thread_id", "pmflow_agent_runs", ["thread_id"])
if not _table_exists("pmflow_ai_messages"):
op.create_table(
"pmflow_ai_messages",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("thread_id", sa.BigInteger(), sa.ForeignKey("pmflow_ai_threads.id", ondelete="CASCADE"), nullable=False),
sa.Column("public_message_id", sa.String(length=80), nullable=False),
sa.Column("role", sa.String(length=24), nullable=False),
sa.Column("message_index", sa.Integer(), nullable=False, server_default="0"),
sa.Column("run_public_id", sa.String(length=80), nullable=True),
sa.Column("qa_record_id", sa.BigInteger(), sa.ForeignKey("qa_records.id", ondelete="SET NULL"), nullable=True),
sa.Column("content", sa.Text(), nullable=False, server_default=""),
sa.Column("message_payload", json_type, nullable=True),
sa.Column("sent_at", sa.TIMESTAMP(), nullable=True),
*_base_columns(),
sa.UniqueConstraint("tenant_id", "thread_id", "public_message_id", name="uq_pmflow_ai_messages_tenant_thread_public_message"),
)
_create_index_once("ix_pmflow_ai_messages_tenant_id", "pmflow_ai_messages", ["tenant_id"])
_create_index_once("ix_pmflow_ai_messages_thread_id", "pmflow_ai_messages", ["thread_id"])
_create_index_once("ix_pmflow_ai_messages_public_message_id", "pmflow_ai_messages", ["public_message_id"])
_create_index_once("ix_pmflow_ai_messages_role", "pmflow_ai_messages", ["role"])
_create_index_once("ix_pmflow_ai_messages_message_index", "pmflow_ai_messages", ["message_index"])
_create_index_once("ix_pmflow_ai_messages_run_public_id", "pmflow_ai_messages", ["run_public_id"])
_create_index_once("ix_pmflow_ai_messages_qa_record_id", "pmflow_ai_messages", ["qa_record_id"])
_create_index_once("ix_pmflow_ai_messages_sent_at", "pmflow_ai_messages", ["sent_at"])
def downgrade() -> None:
if _table_exists("pmflow_ai_messages"):
op.drop_table("pmflow_ai_messages")
if _table_exists("pmflow_agent_runs") and _column_exists("pmflow_agent_runs", "thread_id"):
op.drop_column("pmflow_agent_runs", "thread_id")
if _table_exists("pmflow_ai_threads"):
op.drop_table("pmflow_ai_threads")

View File

@ -0,0 +1,25 @@
"""merge pmflow agent runtime migration heads
Revision ID: 20260513_merge_pmflow_agent_runtime_heads
Revises: 20260429_add_ai_call_log_provider_payloads, 20260513_add_pmflow_agent_runtime
Create Date: 2026-05-13
"""
from __future__ import annotations
revision = "20260513_merge_pmflow_agent_runtime_heads"
down_revision = (
"20260429_add_ai_call_log_provider_payloads",
"20260513_add_pmflow_agent_runtime",
)
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass

View File

@ -0,0 +1,64 @@
"""add agent run linkage to ai call logs
Revision ID: 20260514_add_agent_run_audit_linkage
Revises: 20260513_add_pmflow_ai_threads
Create Date: 2026-05-14
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260514_add_agent_run_audit_linkage"
down_revision = "20260513_add_pmflow_ai_threads"
branch_labels = None
depends_on = None
def _table_exists(table_name: str) -> bool:
return sa.inspect(op.get_bind()).has_table(table_name)
def _column_exists(table_name: str, column_name: str) -> bool:
if not _table_exists(table_name):
return False
inspector = sa.inspect(op.get_bind())
return any(item.get("name") == column_name for item in inspector.get_columns(table_name))
def _index_exists(table_name: str, index_name: str) -> bool:
if not _table_exists(table_name):
return False
inspector = sa.inspect(op.get_bind())
return any(item.get("name") == index_name for item in inspector.get_indexes(table_name))
def _create_index_once(index_name: str, table_name: str, columns: list[str]) -> None:
if _table_exists(table_name) and not _index_exists(table_name, index_name):
op.create_index(index_name, table_name, columns)
def upgrade() -> None:
if not _table_exists("ai_call_logs"):
return
if not _column_exists("ai_call_logs", "agent_run_id"):
op.add_column("ai_call_logs", sa.Column("agent_run_id", sa.String(length=80), nullable=True))
if not _column_exists("ai_call_logs", "agent_step_key"):
op.add_column("ai_call_logs", sa.Column("agent_step_key", sa.String(length=120), nullable=True))
_create_index_once("ix_ai_call_logs_agent_run_id", "ai_call_logs", ["agent_run_id"])
_create_index_once("ix_ai_call_logs_agent_step_key", "ai_call_logs", ["agent_step_key"])
def downgrade() -> None:
if not _table_exists("ai_call_logs"):
return
if _index_exists("ai_call_logs", "ix_ai_call_logs_agent_step_key"):
op.drop_index("ix_ai_call_logs_agent_step_key", table_name="ai_call_logs")
if _index_exists("ai_call_logs", "ix_ai_call_logs_agent_run_id"):
op.drop_index("ix_ai_call_logs_agent_run_id", table_name="ai_call_logs")
if _column_exists("ai_call_logs", "agent_step_key"):
op.drop_column("ai_call_logs", "agent_step_key")
if _column_exists("ai_call_logs", "agent_run_id"):
op.drop_column("ai_call_logs", "agent_run_id")

View File

@ -0,0 +1,132 @@
"""seed PMFlow AI runtime prompt releases
Revision ID: 20260515_seed_pmflow_ai_runtime_prompt_releases
Revises: 20260514_add_agent_run_audit_linkage
Create Date: 2026-05-15
"""
from __future__ import annotations
import os
from alembic import op
import sqlalchemy as sa
revision = "20260515_seed_pmflow_ai_runtime_prompt_releases"
down_revision = "20260514_add_agent_run_audit_linkage"
branch_labels = None
depends_on = None
PROMPT_KEYS = (
"PROMPT_PMFLOW_AI_OPERATING_MISSION",
"PROMPT_PMFLOW_AI_AGENT_GOAL_FRAME",
"PROMPT_PMFLOW_AI_AGENT_STEP_PLANNER",
"PROMPT_PMFLOW_AI_AGENT_EVIDENCE_NORMALIZER",
"PROMPT_PMFLOW_AI_AGENT_STATE_CRITIC",
"PROMPT_PMFLOW_AI_AGENT_ANSWER_COMPOSER",
"PROMPT_PMFLOW_AI_AGENT_ANSWER_REVISER",
)
SEED_NOTES = "Seeded by 20260515_seed_pmflow_ai_runtime_prompt_releases"
REFRESH_NOTES = "Refreshed by 20260515_seed_pmflow_ai_runtime_prompt_releases for operating-agent runtime"
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _prompt_body(prompt_key: str) -> str:
from app.config import ai_prompts
return str(getattr(ai_prompts, prompt_key, "") or "").strip()
def _seed_active_release(bind, *, tenant_id: int, prompt_key: str, body_snapshot: str) -> None:
active = bind.execute(
sa.text(
"""
SELECT id, body_snapshot FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
LIMIT 1
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
).mappings().first()
if active is not None and str(active.get("body_snapshot") or "").strip() == body_snapshot:
return
if active is not None:
bind.execute(
sa.text(
"""
UPDATE prompt_releases
SET status = 'archived'
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
)
max_version = bind.execute(
sa.text(
"""
SELECT MAX(version) FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
).scalar()
bind.execute(
sa.text(
"""
INSERT INTO prompt_releases
(tenant_id, prompt_key, version, body_snapshot, status, notes, created_by, activated_by, activated_at)
VALUES
(:tenant_id, :prompt_key, :version, :body_snapshot, 'active', :notes, NULL, NULL, :activated_at)
"""
),
{
"tenant_id": tenant_id,
"prompt_key": prompt_key,
"version": int(max_version or 0) + 1,
"body_snapshot": body_snapshot,
"notes": REFRESH_NOTES if active is not None else SEED_NOTES,
"activated_at": "2026-05-15T00:00:00",
},
)
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "prompt_releases"):
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
for prompt_key in PROMPT_KEYS:
body_snapshot = _prompt_body(prompt_key)
if body_snapshot:
_seed_active_release(bind, tenant_id=tenant_id, prompt_key=prompt_key, body_snapshot=body_snapshot)
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "prompt_releases"):
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
bind.execute(
sa.text(
"""
DELETE FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key IN :prompt_keys
AND notes IN :notes
"""
).bindparams(sa.bindparam("prompt_keys", expanding=True), sa.bindparam("notes", expanding=True)),
{"tenant_id": tenant_id, "prompt_keys": PROMPT_KEYS, "notes": (SEED_NOTES, REFRESH_NOTES)},
)

View File

@ -0,0 +1,76 @@
"""raise answer composer budget for hypothesis synthesis
Revision ID: 20260516_agent_answer_composer_budget
Revises: 20260516_agent_hypothesis_answer_policy
Create Date: 2026-05-16
"""
from __future__ import annotations
import json
import os
from typing import Any
from alembic import op
import sqlalchemy as sa
revision = "20260516_agent_answer_composer_budget"
down_revision = "20260516_agent_hypothesis_answer_policy"
branch_labels = None
depends_on = None
def _table_exists(bind: Any, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "integrations"):
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
row = bind.execute(
sa.text(
"""
SELECT id, config FROM integrations
WHERE tenant_id = :tenant_id
AND type = 'ai_function_runtime_profiles'
AND is_active = true
ORDER BY id DESC
LIMIT 1
"""
),
{"tenant_id": tenant_id},
).mappings().first()
if row is None:
return
config = row.get("config") if isinstance(row.get("config"), dict) else {}
changed = False
for stage in ("active", "draft"):
profiles = ((config.get(stage) or {}).get("profiles") or {}) if isinstance(config.get(stage), dict) else {}
profile = profiles.get("pmflow_ai_agent_answer_composer")
if not isinstance(profile, dict):
continue
updates = {
"provider_timeout_seconds": 20,
"job_timeout_seconds": 22,
"max_tokens": 3200,
"response_mode": "fast",
}
for key, value in updates.items():
if profile.get(key) != value:
profile[key] = value
changed = True
if not changed:
return
config["active_version"] = "profile-v20260516-agent-answer-composer-budget"
config["version"] = config["active_version"]
bind.execute(
sa.text("UPDATE integrations SET config = CAST(:config AS jsonb) WHERE id = :id"),
{"id": int(row["id"]), "config": json.dumps(config, ensure_ascii=False)},
)
def downgrade() -> None:
return None

View File

@ -0,0 +1,122 @@
"""refresh compound goal assumption prompt
Revision ID: 20260516_agent_compound_goal_assumption
Revises: 20260516_agent_hypothesis_competition
Create Date: 2026-05-16
"""
from __future__ import annotations
import os
from typing import Any
from alembic import op
import sqlalchemy as sa
revision = "20260516_agent_compound_goal_assumption"
down_revision = "20260516_agent_hypothesis_competition"
branch_labels = None
depends_on = None
PROMPT_KEYS = (
"PROMPT_PMFLOW_AI_AGENT_GOAL_FRAME",
"PROMPT_PMFLOW_AI_AGENT_STEP_PLANNER",
)
REFRESH_NOTES = "Refreshed by 20260516_agent_compound_goal_assumption"
def _table_exists(bind: Any, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _prompt_body(prompt_key: str) -> str:
from app.config import ai_prompts
return str(getattr(ai_prompts, prompt_key, "") or "").strip()
def _refresh_active_prompt(bind: Any, *, tenant_id: int, prompt_key: str, body_snapshot: str) -> None:
active = bind.execute(
sa.text(
"""
SELECT id, body_snapshot FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
LIMIT 1
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
).mappings().first()
if active is not None and str(active.get("body_snapshot") or "").strip() == body_snapshot:
return
bind.execute(
sa.text(
"""
UPDATE prompt_releases
SET status = 'archived'
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
)
max_version = bind.execute(
sa.text(
"""
SELECT MAX(version) FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
).scalar()
bind.execute(
sa.text(
"""
INSERT INTO prompt_releases
(tenant_id, prompt_key, version, body_snapshot, status, notes, created_by, activated_by, activated_at)
VALUES
(:tenant_id, :prompt_key, :version, :body_snapshot, 'active', :notes, NULL, NULL, :activated_at)
"""
),
{
"tenant_id": tenant_id,
"prompt_key": prompt_key,
"version": int(max_version or 0) + 1,
"body_snapshot": body_snapshot,
"notes": REFRESH_NOTES,
"activated_at": "2026-05-16T00:00:00",
},
)
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "prompt_releases"):
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
for prompt_key in PROMPT_KEYS:
body_snapshot = _prompt_body(prompt_key)
if body_snapshot:
_refresh_active_prompt(bind, tenant_id=tenant_id, prompt_key=prompt_key, body_snapshot=body_snapshot)
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "prompt_releases"):
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
bind.execute(
sa.text(
"""
DELETE FROM prompt_releases
WHERE tenant_id = :tenant_id
AND notes = :notes
"""
),
{"tenant_id": tenant_id, "notes": REFRESH_NOTES},
)

View File

@ -0,0 +1,102 @@
"""refresh controlled hypothesis answer prompt
Revision ID: 20260516_agent_controlled_hypothesis_prompt
Revises: 20260516_agent_fast_latency_budget
Create Date: 2026-05-16
"""
from __future__ import annotations
import os
from typing import Any
from alembic import op
import sqlalchemy as sa
revision = "20260516_agent_controlled_hypothesis_prompt"
down_revision = "20260516_agent_fast_latency_budget"
branch_labels = None
depends_on = None
PROMPT_KEY = "PROMPT_PMFLOW_AI_AGENT_ANSWER_COMPOSER"
REFRESH_NOTES = "Refreshed by 20260516_agent_controlled_hypothesis_prompt"
def _table_exists(bind: Any, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _prompt_body() -> str:
from app.config import ai_prompts
return str(getattr(ai_prompts, PROMPT_KEY, "") or "").strip()
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "prompt_releases"):
return
body_snapshot = _prompt_body()
if not body_snapshot:
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
active = bind.execute(
sa.text(
"""
SELECT id, body_snapshot FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
LIMIT 1
"""
),
{"tenant_id": tenant_id, "prompt_key": PROMPT_KEY},
).mappings().first()
if active is not None and str(active.get("body_snapshot") or "").strip() == body_snapshot:
return
bind.execute(
sa.text(
"""
UPDATE prompt_releases
SET status = 'archived'
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
"""
),
{"tenant_id": tenant_id, "prompt_key": PROMPT_KEY},
)
max_version = bind.execute(
sa.text(
"""
SELECT MAX(version) FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
"""
),
{"tenant_id": tenant_id, "prompt_key": PROMPT_KEY},
).scalar()
bind.execute(
sa.text(
"""
INSERT INTO prompt_releases
(tenant_id, prompt_key, version, body_snapshot, status, notes, created_by, activated_by, activated_at)
VALUES
(:tenant_id, :prompt_key, :version, :body_snapshot, 'active', :notes, NULL, NULL, :activated_at)
"""
),
{
"tenant_id": tenant_id,
"prompt_key": PROMPT_KEY,
"version": int(max_version or 0) + 1,
"body_snapshot": body_snapshot,
"notes": REFRESH_NOTES,
"activated_at": "2026-05-16T00:00:00",
},
)
def downgrade() -> None:
return None

View File

@ -0,0 +1,168 @@
"""tighten agent runtime latency budgets
Revision ID: 20260516_agent_fast_latency_budget
Revises: 20260516_agent_objective_contract_fast_path
Create Date: 2026-05-16
"""
from __future__ import annotations
import json
import os
from typing import Any
from alembic import op
import sqlalchemy as sa
revision = "20260516_agent_fast_latency_budget"
down_revision = "20260516_agent_objective_contract_fast_path"
branch_labels = None
depends_on = None
REFRESH_NOTES = "Refreshed by 20260516_agent_fast_latency_budget"
PROMPT_KEYS = (
"PROMPT_PMFLOW_AI_AGENT_GOAL_FRAME",
"PROMPT_PMFLOW_AI_AGENT_STEP_PLANNER",
"PROMPT_PMFLOW_AI_AGENT_EVIDENCE_NORMALIZER",
"PROMPT_PMFLOW_AI_AGENT_STATE_CRITIC",
"PROMPT_PMFLOW_AI_AGENT_ANSWER_COMPOSER",
"PROMPT_PMFLOW_AI_AGENT_ANSWER_REVISER",
)
def _table_exists(bind: Any, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _refresh_runtime_profiles(bind: Any, *, tenant_id: int) -> None:
if not _table_exists(bind, "integrations"):
return
row = bind.execute(
sa.text(
"""
SELECT id, config FROM integrations
WHERE tenant_id = :tenant_id
AND type = 'ai_function_runtime_profiles'
AND is_active = true
ORDER BY id DESC
LIMIT 1
"""
),
{"tenant_id": tenant_id},
).mappings().first()
if row is None:
return
config = row.get("config") if isinstance(row.get("config"), dict) else {}
changed = False
caps = {
"pmflow_ai_agent_goal_frame": (12, 14),
"pmflow_ai_agent_step_planner": (8, 10),
"pmflow_ai_agent_evidence_normalizer": (12, 14),
"pmflow_ai_agent_state_critic": (8, 10),
"pmflow_ai_agent_answer_composer": (14, 16),
"pmflow_ai_agent_answer_reviser": (10, 12),
}
for stage in ("active", "draft"):
profiles = ((config.get(stage) or {}).get("profiles") or {}) if isinstance(config.get(stage), dict) else {}
for key, (provider_timeout, job_timeout) in caps.items():
profile = profiles.get(key)
if not isinstance(profile, dict):
continue
if int(profile.get("provider_timeout_seconds") or 0) > provider_timeout:
profile["provider_timeout_seconds"] = provider_timeout
changed = True
if int(profile.get("job_timeout_seconds") or 0) > job_timeout:
profile["job_timeout_seconds"] = job_timeout
changed = True
if profile.get("response_mode") != "fast":
profile["response_mode"] = "fast"
changed = True
if not changed:
return
config["active_version"] = "profile-v20260516-agent-fast-latency-budget"
config["version"] = config["active_version"]
bind.execute(
sa.text("UPDATE integrations SET config = CAST(:config AS jsonb) WHERE id = :id"),
{"id": int(row["id"]), "config": json.dumps(config, ensure_ascii=False)},
)
def _prompt_body(prompt_key: str) -> str:
from app.config import ai_prompts
return str(getattr(ai_prompts, prompt_key, "") or "").strip()
def _refresh_active_prompt(bind: Any, *, tenant_id: int, prompt_key: str, body_snapshot: str) -> None:
if not _table_exists(bind, "prompt_releases"):
return
active = bind.execute(
sa.text(
"""
SELECT id, body_snapshot FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
LIMIT 1
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
).mappings().first()
if active is not None and str(active.get("body_snapshot") or "").strip() == body_snapshot:
return
bind.execute(
sa.text(
"""
UPDATE prompt_releases
SET status = 'archived'
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
)
max_version = bind.execute(
sa.text(
"""
SELECT MAX(version) FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
).scalar()
bind.execute(
sa.text(
"""
INSERT INTO prompt_releases
(tenant_id, prompt_key, version, body_snapshot, status, notes, created_by, activated_by, activated_at)
VALUES
(:tenant_id, :prompt_key, :version, :body_snapshot, 'active', :notes, NULL, NULL, :activated_at)
"""
),
{
"tenant_id": tenant_id,
"prompt_key": prompt_key,
"version": int(max_version or 0) + 1,
"body_snapshot": body_snapshot,
"notes": REFRESH_NOTES,
"activated_at": "2026-05-16T00:00:00",
},
)
def upgrade() -> None:
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
bind = op.get_bind()
for prompt_key in PROMPT_KEYS:
body = _prompt_body(prompt_key)
if body:
_refresh_active_prompt(bind, tenant_id=tenant_id, prompt_key=prompt_key, body_snapshot=body)
_refresh_runtime_profiles(bind, tenant_id=tenant_id)
def downgrade() -> None:
return None

View File

@ -0,0 +1,162 @@
"""refresh hypothesis answer policy and latency budgets
Revision ID: 20260516_agent_hypothesis_answer_policy
Revises: 20260516_agent_controlled_hypothesis_prompt
Create Date: 2026-05-16
"""
from __future__ import annotations
import json
import os
from typing import Any
from alembic import op
import sqlalchemy as sa
revision = "20260516_agent_hypothesis_answer_policy"
down_revision = "20260516_agent_controlled_hypothesis_prompt"
branch_labels = None
depends_on = None
REFRESH_NOTES = "Refreshed by 20260516_agent_hypothesis_answer_policy"
PROMPT_KEYS = (
"PROMPT_PMFLOW_AI_AGENT_ANSWER_COMPOSER",
)
def _table_exists(bind: Any, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _prompt_body(prompt_key: str) -> str:
from app.config import ai_prompts
return str(getattr(ai_prompts, prompt_key, "") or "").strip()
def _refresh_active_prompt(bind: Any, *, tenant_id: int, prompt_key: str, body_snapshot: str) -> None:
if not _table_exists(bind, "prompt_releases"):
return
active = bind.execute(
sa.text(
"""
SELECT id, body_snapshot FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
LIMIT 1
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
).mappings().first()
if active is not None and str(active.get("body_snapshot") or "").strip() == body_snapshot:
return
bind.execute(
sa.text(
"""
UPDATE prompt_releases
SET status = 'archived'
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
)
max_version = bind.execute(
sa.text(
"""
SELECT MAX(version) FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
).scalar()
bind.execute(
sa.text(
"""
INSERT INTO prompt_releases
(tenant_id, prompt_key, version, body_snapshot, status, notes, created_by, activated_by, activated_at)
VALUES
(:tenant_id, :prompt_key, :version, :body_snapshot, 'active', :notes, NULL, NULL, :activated_at)
"""
),
{
"tenant_id": tenant_id,
"prompt_key": prompt_key,
"version": int(max_version or 0) + 1,
"body_snapshot": body_snapshot,
"notes": REFRESH_NOTES,
"activated_at": "2026-05-16T00:00:00",
},
)
def _refresh_runtime_profiles(bind: Any, *, tenant_id: int) -> None:
if not _table_exists(bind, "integrations"):
return
row = bind.execute(
sa.text(
"""
SELECT id, config FROM integrations
WHERE tenant_id = :tenant_id
AND type = 'ai_function_runtime_profiles'
AND is_active = true
ORDER BY id DESC
LIMIT 1
"""
),
{"tenant_id": tenant_id},
).mappings().first()
if row is None:
return
config = row.get("config") if isinstance(row.get("config"), dict) else {}
changed = False
caps = {
"pmflow_ai_agent_goal_frame": (8, 10),
"pmflow_ai_agent_step_planner": (4, 6),
"pmflow_ai_agent_state_critic": (4, 6),
"pmflow_ai_agent_answer_composer": (14, 16),
"pmflow_ai_agent_answer_reviser": (10, 12),
}
for stage in ("active", "draft"):
profiles = ((config.get(stage) or {}).get("profiles") or {}) if isinstance(config.get(stage), dict) else {}
for key, (provider_timeout, job_timeout) in caps.items():
profile = profiles.get(key)
if not isinstance(profile, dict):
continue
if int(profile.get("provider_timeout_seconds") or 0) != provider_timeout:
profile["provider_timeout_seconds"] = provider_timeout
changed = True
if int(profile.get("job_timeout_seconds") or 0) != job_timeout:
profile["job_timeout_seconds"] = job_timeout
changed = True
if profile.get("response_mode") != "fast":
profile["response_mode"] = "fast"
changed = True
if not changed:
return
config["active_version"] = "profile-v20260516-agent-hypothesis-answer-policy"
config["version"] = config["active_version"]
bind.execute(
sa.text("UPDATE integrations SET config = CAST(:config AS jsonb) WHERE id = :id"),
{"id": int(row["id"]), "config": json.dumps(config, ensure_ascii=False)},
)
def upgrade() -> None:
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
bind = op.get_bind()
for prompt_key in PROMPT_KEYS:
body = _prompt_body(prompt_key)
if body:
_refresh_active_prompt(bind, tenant_id=tenant_id, prompt_key=prompt_key, body_snapshot=body)
_refresh_runtime_profiles(bind, tenant_id=tenant_id)
def downgrade() -> None:
return None

View File

@ -0,0 +1,126 @@
"""refresh agent runtime hypothesis competition prompts
Revision ID: 20260516_agent_hypothesis_competition
Revises: 20260516_agent_runtime_semantic_claims
Create Date: 2026-05-16
"""
from __future__ import annotations
import os
from typing import Any
from alembic import op
import sqlalchemy as sa
revision = "20260516_agent_hypothesis_competition"
down_revision = "20260516_agent_runtime_semantic_claims"
branch_labels = None
depends_on = None
PROMPT_KEYS = (
"PROMPT_PMFLOW_AI_AGENT_GOAL_FRAME",
"PROMPT_PMFLOW_AI_AGENT_STEP_PLANNER",
"PROMPT_PMFLOW_AI_AGENT_EVIDENCE_NORMALIZER",
"PROMPT_PMFLOW_AI_AGENT_STATE_CRITIC",
"PROMPT_PMFLOW_AI_AGENT_ANSWER_COMPOSER",
"PROMPT_PMFLOW_AI_AGENT_ANSWER_REVISER",
)
REFRESH_NOTES = "Refreshed by 20260516_agent_hypothesis_competition"
def _table_exists(bind: Any, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _prompt_body(prompt_key: str) -> str:
from app.config import ai_prompts
return str(getattr(ai_prompts, prompt_key, "") or "").strip()
def _refresh_active_prompt(bind: Any, *, tenant_id: int, prompt_key: str, body_snapshot: str) -> None:
active = bind.execute(
sa.text(
"""
SELECT id, body_snapshot FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
LIMIT 1
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
).mappings().first()
if active is not None and str(active.get("body_snapshot") or "").strip() == body_snapshot:
return
bind.execute(
sa.text(
"""
UPDATE prompt_releases
SET status = 'archived'
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
)
max_version = bind.execute(
sa.text(
"""
SELECT MAX(version) FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
).scalar()
bind.execute(
sa.text(
"""
INSERT INTO prompt_releases
(tenant_id, prompt_key, version, body_snapshot, status, notes, created_by, activated_by, activated_at)
VALUES
(:tenant_id, :prompt_key, :version, :body_snapshot, 'active', :notes, NULL, NULL, :activated_at)
"""
),
{
"tenant_id": tenant_id,
"prompt_key": prompt_key,
"version": int(max_version or 0) + 1,
"body_snapshot": body_snapshot,
"notes": REFRESH_NOTES,
"activated_at": "2026-05-16T00:00:00",
},
)
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "prompt_releases"):
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
for prompt_key in PROMPT_KEYS:
body_snapshot = _prompt_body(prompt_key)
if body_snapshot:
_refresh_active_prompt(bind, tenant_id=tenant_id, prompt_key=prompt_key, body_snapshot=body_snapshot)
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "prompt_releases"):
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
bind.execute(
sa.text(
"""
DELETE FROM prompt_releases
WHERE tenant_id = :tenant_id
AND notes = :notes
"""
),
{"tenant_id": tenant_id, "notes": REFRESH_NOTES},
)

View File

@ -0,0 +1,189 @@
"""refresh agent objective contract and fast path prompts
Revision ID: 20260516_agent_objective_contract_fast_path
Revises: 20260516_agent_compound_goal_assumption
Create Date: 2026-05-16
"""
from __future__ import annotations
import json
import os
from typing import Any
from alembic import op
import sqlalchemy as sa
revision = "20260516_agent_objective_contract_fast_path"
down_revision = "20260516_agent_compound_goal_assumption"
branch_labels = None
depends_on = None
PROMPT_KEYS = (
"PROMPT_PMFLOW_AI_AGENT_GOAL_FRAME",
"PROMPT_PMFLOW_AI_AGENT_STEP_PLANNER",
"PROMPT_PMFLOW_AI_AGENT_EVIDENCE_NORMALIZER",
"PROMPT_PMFLOW_AI_AGENT_STATE_CRITIC",
"PROMPT_PMFLOW_AI_AGENT_ANSWER_COMPOSER",
"PROMPT_PMFLOW_AI_AGENT_ANSWER_REVISER",
)
REFRESH_NOTES = "Refreshed by 20260516_agent_objective_contract_fast_path"
def _table_exists(bind: Any, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _prompt_body(prompt_key: str) -> str:
from app.config import ai_prompts
return str(getattr(ai_prompts, prompt_key, "") or "").strip()
def _refresh_active_prompt(bind: Any, *, tenant_id: int, prompt_key: str, body_snapshot: str) -> None:
active = bind.execute(
sa.text(
"""
SELECT id, body_snapshot FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
LIMIT 1
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
).mappings().first()
if active is not None and str(active.get("body_snapshot") or "").strip() == body_snapshot:
return
bind.execute(
sa.text(
"""
UPDATE prompt_releases
SET status = 'archived'
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
)
max_version = bind.execute(
sa.text(
"""
SELECT MAX(version) FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
).scalar()
bind.execute(
sa.text(
"""
INSERT INTO prompt_releases
(tenant_id, prompt_key, version, body_snapshot, status, notes, created_by, activated_by, activated_at)
VALUES
(:tenant_id, :prompt_key, :version, :body_snapshot, 'active', :notes, NULL, NULL, :activated_at)
"""
),
{
"tenant_id": tenant_id,
"prompt_key": prompt_key,
"version": int(max_version or 0) + 1,
"body_snapshot": body_snapshot,
"notes": REFRESH_NOTES,
"activated_at": "2026-05-16T00:00:00",
},
)
def _refresh_runtime_profiles(bind: Any, *, tenant_id: int) -> None:
if not _table_exists(bind, "integrations"):
return
row = bind.execute(
sa.text(
"""
SELECT id, config FROM integrations
WHERE tenant_id = :tenant_id
AND type = 'ai_function_runtime_profiles'
AND is_active = true
ORDER BY id DESC
LIMIT 1
"""
),
{"tenant_id": tenant_id},
).mappings().first()
if row is None:
return
config = row.get("config") if isinstance(row.get("config"), dict) else {}
changed = False
for stage in ("active", "draft"):
profiles = ((config.get(stage) or {}).get("profiles") or {}) if isinstance(config.get(stage), dict) else {}
for key in (
"pmflow_ai_agent_goal_frame",
"pmflow_ai_agent_step_planner",
"pmflow_ai_agent_state_critic",
"pmflow_ai_agent_answer_composer",
):
profile = profiles.get(key)
if not isinstance(profile, dict):
continue
if profile.get("response_mode") != "fast":
profile["response_mode"] = "fast"
changed = True
if key in {"pmflow_ai_agent_goal_frame", "pmflow_ai_agent_step_planner", "pmflow_ai_agent_state_critic"}:
if int(profile.get("provider_timeout_seconds") or 0) > 25:
profile["provider_timeout_seconds"] = 25
changed = True
if int(profile.get("job_timeout_seconds") or 0) > 30:
profile["job_timeout_seconds"] = 30
changed = True
normalizer = profiles.get("pmflow_ai_agent_evidence_normalizer")
if isinstance(normalizer, dict):
if normalizer.get("response_mode") != "fast":
normalizer["response_mode"] = "fast"
changed = True
if int(normalizer.get("provider_timeout_seconds") or 0) > 22:
normalizer["provider_timeout_seconds"] = 22
changed = True
if int(normalizer.get("job_timeout_seconds") or 0) > 25:
normalizer["job_timeout_seconds"] = 25
changed = True
if not changed:
return
config["active_version"] = "profile-v20260516-objective-fast-path"
config["version"] = config["active_version"]
bind.execute(
sa.text("UPDATE integrations SET config = CAST(:config AS jsonb) WHERE id = :id"),
{"id": int(row["id"]), "config": json.dumps(config, ensure_ascii=False)},
)
def upgrade() -> None:
bind = op.get_bind()
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
if _table_exists(bind, "prompt_releases"):
for prompt_key in PROMPT_KEYS:
body_snapshot = _prompt_body(prompt_key)
if body_snapshot:
_refresh_active_prompt(bind, tenant_id=tenant_id, prompt_key=prompt_key, body_snapshot=body_snapshot)
_refresh_runtime_profiles(bind, tenant_id=tenant_id)
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "prompt_releases"):
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
bind.execute(
sa.text(
"""
DELETE FROM prompt_releases
WHERE tenant_id = :tenant_id
AND notes = :notes
"""
),
{"tenant_id": tenant_id, "notes": REFRESH_NOTES},
)

View File

@ -0,0 +1,175 @@
"""refresh agent runtime semantic claim contracts
Revision ID: 20260516_agent_runtime_semantic_claims
Revises: 20260516_refresh_agent_goal_frame_prompt
Create Date: 2026-05-16
"""
from __future__ import annotations
import json
import os
from typing import Any
from alembic import op
import sqlalchemy as sa
revision = "20260516_agent_runtime_semantic_claims"
down_revision = "20260516_refresh_agent_goal_frame_prompt"
branch_labels = None
depends_on = None
PROMPT_KEYS = (
"PROMPT_PMFLOW_AI_AGENT_GOAL_FRAME",
"PROMPT_PMFLOW_AI_AGENT_EVIDENCE_NORMALIZER",
"PROMPT_PMFLOW_AI_AGENT_ANSWER_COMPOSER",
"PROMPT_PMFLOW_AI_AGENT_ANSWER_REVISER",
)
REFRESH_NOTES = "Refreshed by 20260516_agent_runtime_semantic_claims"
def _table_exists(bind: Any, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _prompt_body(prompt_key: str) -> str:
from app.config import ai_prompts
return str(getattr(ai_prompts, prompt_key, "") or "").strip()
def _refresh_active_prompt(bind: Any, *, tenant_id: int, prompt_key: str, body_snapshot: str) -> None:
active = bind.execute(
sa.text(
"""
SELECT id, body_snapshot FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
LIMIT 1
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
).mappings().first()
if active is not None and str(active.get("body_snapshot") or "").strip() == body_snapshot:
return
bind.execute(
sa.text(
"""
UPDATE prompt_releases
SET status = 'archived'
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
)
max_version = bind.execute(
sa.text(
"""
SELECT MAX(version) FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
).scalar()
bind.execute(
sa.text(
"""
INSERT INTO prompt_releases
(tenant_id, prompt_key, version, body_snapshot, status, notes, created_by, activated_by, activated_at)
VALUES
(:tenant_id, :prompt_key, :version, :body_snapshot, 'active', :notes, NULL, NULL, :activated_at)
"""
),
{
"tenant_id": tenant_id,
"prompt_key": prompt_key,
"version": int(max_version or 0) + 1,
"body_snapshot": body_snapshot,
"notes": REFRESH_NOTES,
"activated_at": "2026-05-16T00:00:00",
},
)
def _refresh_runtime_profiles(bind: Any, *, tenant_id: int) -> None:
if not _table_exists(bind, "integrations"):
return
row = bind.execute(
sa.text(
"""
SELECT id, config FROM integrations
WHERE tenant_id = :tenant_id
AND type = 'ai_function_runtime_profiles'
AND is_active = true
ORDER BY id DESC
LIMIT 1
"""
),
{"tenant_id": tenant_id},
).mappings().first()
if row is None:
return
config = row.get("config") if isinstance(row.get("config"), dict) else {}
changed = False
for stage in ("active", "draft"):
profiles = ((config.get(stage) or {}).get("profiles") or {}) if isinstance(config.get(stage), dict) else {}
normalizer = profiles.get("pmflow_ai_agent_evidence_normalizer")
if isinstance(normalizer, dict):
if normalizer.get("runtime_mode") != "active":
normalizer["runtime_mode"] = "active"
changed = True
if normalizer.get("quality_gate_mode") != "strict":
normalizer["quality_gate_mode"] = "strict"
changed = True
for key in ("pmflow_ai_agent_answer_composer", "pmflow_ai_agent_answer_reviser"):
profile = profiles.get(key)
if not isinstance(profile, dict):
continue
if profile.get("runtime_mode") != "active":
profile["runtime_mode"] = "active"
changed = True
if profile.get("quality_gate_mode") != "off":
profile["quality_gate_mode"] = "off"
changed = True
if not changed:
return
config["active_version"] = "profile-v20260516-semantic-claims"
config["version"] = config["active_version"]
bind.execute(
sa.text("UPDATE integrations SET config = CAST(:config AS jsonb) WHERE id = :id"),
{"id": int(row["id"]), "config": json.dumps(config, ensure_ascii=False)},
)
def upgrade() -> None:
bind = op.get_bind()
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
if _table_exists(bind, "prompt_releases"):
for prompt_key in PROMPT_KEYS:
body_snapshot = _prompt_body(prompt_key)
if body_snapshot:
_refresh_active_prompt(bind, tenant_id=tenant_id, prompt_key=prompt_key, body_snapshot=body_snapshot)
_refresh_runtime_profiles(bind, tenant_id=tenant_id)
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "prompt_releases"):
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
bind.execute(
sa.text(
"""
DELETE FROM prompt_releases
WHERE tenant_id = :tenant_id
AND notes = :notes
"""
),
{"tenant_id": tenant_id, "notes": REFRESH_NOTES},
)

View File

@ -0,0 +1,189 @@
"""refresh PMFlow AI agent GoalFrame prompt release
Revision ID: 20260516_refresh_agent_goal_frame_prompt
Revises: 20260515_seed_pmflow_ai_runtime_prompt_releases
Create Date: 2026-05-16
"""
from __future__ import annotations
import os
from alembic import op
import sqlalchemy as sa
revision = "20260516_refresh_agent_goal_frame_prompt"
down_revision = "20260515_seed_pmflow_ai_runtime_prompt_releases"
branch_labels = None
depends_on = None
PROMPT_KEY = "PROMPT_PMFLOW_AI_AGENT_GOAL_FRAME"
REFRESH_NOTES = "Refreshed by 20260516_refresh_agent_goal_frame_prompt for broader product-market GoalFrame semantics"
BODY_SNAPSHOT = """
你是 PMFlow Space AI 的 GoalFrame Runtime。你的产物不是最终答案,也不是路由分类;它是后续只读证据搜索、证据归因和答案验收使用的“用户目标合同”。
核心任务:
- 把用户这句话真正想推进的问题写清楚,不要只复述成“获取整体态势/方向性理解”。
- 先拆清用户要的结果、短语里的对象、可能的产品化/内容化/服务化/组织化/流程化解释分支,再写 user_goal。
- 同时拆出可能的语义对象、用户期望的输出形态、需要补的证据、答对标准和客户安全边界。
- 对短句、复合词和新兴市场问题,宁可保留 2-3 个可竞争解释分支,也不要过早压成一个泛泛解释。
判断原则:
- 先区分“用户要的结果”和“用户话里的对象”。不要把“怎么样/怎么看/市场如何”这类输出诉求压成空泛理解,也不要把复合对象按词面翻译成唯一主题。
- 对存在复合修饰关系的短语,默认拆出 2-3 个可竞争解释分支,例如某能力进入某对象后形成的产品/服务/工具生态、围绕该主题形成的内容/活动/组织生态、以及流程/工作流改造生态。具体分支必须由本轮语言和上下文生成,不得来自预置行业词表。
- selected_interpretation 必须由用户目标、上下文锚点和证据需求共同支撑;没有明显锚点时保留多个候选,让 Planner 通过搜索证据竞争确认,不得靠固定词表把某个分支强行置顶。
- selected_interpretation 必须写完整自然语言解释,不要只写 branch_id。branch_id 只用于证据归因。
- 不要把“最近”自动变成严格时间窗;默认 freshness_policy=prefer_recent_not_strict。只有用户给出明确日期范围、问精确当前事实,或涉及高风险领域,才使用 strict_time_window。
- 开放市场、产品、趋势、机会问题默认 answer_policy=directional_answer、source_policy=credible_public_sources、missing_evidence_policy=answer_with_boundaries;不要自行升级成 official_only、fail_closed 或审计报告。
- objects_to_bind 只能来自显式对象、当前页面强焦点、reply/card/work_item 等结构化锚点;不能因为 visible list 里只有一个项目就强绑定。
- evidence_requirements 必须写成可驱动搜索/读取的自然语义,不要写内部表名、函数名、prompt key、trace、模型供应商或源码实现。
- success_criteria 要能约束后续答案:答什么、按什么维度判断、哪些证据不足必须说明。
- boundaries 写客户安全边界和不可执行边界,不写内部运行时术语。
- 只输出 JSON,不输出解释、Markdown 或多余文本。
分支写法要求:
- interpretation_candidates 给简短候选解释。
- interpretation_branches 是后续搜索和证据归因合同,不是业务枚举。
- 每个 branch 都必须包含 branch_id、meaning、semantic_decomposition、user_language_prior、confidence、anchor_terms、missing_anchors。
- user_language_prior 表示仅从用户原话看该解释有多自然;confidence 表示结合上下文后可作为工作假设的把握。
- 不要因为你选中了某个分支,就把另一个合理分支删掉;只要它可能改变搜索查询或答案边界,就保留。
反例:
- 不要把复合短句只改写成“了解某主题整体态势”。
- 不要因为短语里出现某个对象词,就默认它只表示内容/活动/组织,而忽略该对象作为产品场景、服务场景或工作流场景的可能性。
- 不要把缺少某个预置行业词当成压低分支优先级的理由;锚点只能来自用户当前文本、结构化上下文或后续证据竞争结果。
输出 JSON:
{{
"user_goal": "用户真实目标",
"desired_output": "用户想拿到什么样的结果",
"interpretation_candidates": [
{{"meaning": "一种可能解释", "confidence": "high | medium | low | unknown"}}
],
"interpretation_branches": [
{{
"branch_id": "b1",
"meaning": "一种可能解释",
"semantic_decomposition": "这个解释如何拆解用户短语",
"user_language_prior": "high | medium | low | unknown",
"confidence": "high | medium | low | unknown",
"anchor_terms": ["用户原话或上下文里支持该解释的锚点"],
"missing_anchors": ["还缺什么锚点才能把该解释当成确定解释"]
}}
],
"selected_interpretation": "本轮优先采用的解释;无歧义时可为空",
"answer_policy": "directional_answer | partial_answer | strict_verified | official_only | clarify_first",
"source_policy": "credible_public_sources | official_preferred | official_only | internal_customer_safe",
"freshness_policy": "prefer_recent_not_strict | strict_time_window | not_required",
"missing_evidence_policy": "answer_with_boundaries | fail_closed | ask_user",
"objects_to_bind": ["current_project | external_product:xxx | saved_idea:xxx | space_context 等自然对象"],
"evidence_requirements": ["需要读取或确认的材料"],
"success_criteria": ["怎样才算答对"],
"boundaries": ["不能越过的客户安全边界"],
"confidence": "high | medium | low | unknown"
}}
用户问题:
{question}
conversation_context:
{conversation_context_json}
tenant_project_context:
{tenant_project_context_json}
可用工具合同:
{tool_contracts_json}
""".strip()
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "prompt_releases"):
return
body_snapshot = BODY_SNAPSHOT
if not body_snapshot:
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
active = bind.execute(
sa.text(
"""
SELECT id, body_snapshot FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
LIMIT 1
"""
),
{"tenant_id": tenant_id, "prompt_key": PROMPT_KEY},
).mappings().first()
if active is not None and str(active.get("body_snapshot") or "").strip() == body_snapshot:
return
bind.execute(
sa.text(
"""
UPDATE prompt_releases
SET status = 'archived'
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
"""
),
{"tenant_id": tenant_id, "prompt_key": PROMPT_KEY},
)
max_version = bind.execute(
sa.text(
"""
SELECT MAX(version) FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
"""
),
{"tenant_id": tenant_id, "prompt_key": PROMPT_KEY},
).scalar()
bind.execute(
sa.text(
"""
INSERT INTO prompt_releases
(tenant_id, prompt_key, version, body_snapshot, status, notes, created_by, activated_by, activated_at)
VALUES
(:tenant_id, :prompt_key, :version, :body_snapshot, 'active', :notes, NULL, NULL, :activated_at)
"""
),
{
"tenant_id": tenant_id,
"prompt_key": PROMPT_KEY,
"version": int(max_version or 0) + 1,
"body_snapshot": body_snapshot,
"notes": REFRESH_NOTES,
"activated_at": "2026-05-16T00:00:00",
},
)
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "prompt_releases"):
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
bind.execute(
sa.text(
"""
DELETE FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND notes = :notes
"""
),
{"tenant_id": tenant_id, "prompt_key": PROMPT_KEY, "notes": REFRESH_NOTES},
)

View File

@ -0,0 +1,128 @@
"""refresh agent action affordance prompt releases
Revision ID: 20260517_agent_action_affordance_prompt_contract
Revises: 20260517_agent_disclosure_goal_contract
Create Date: 2026-05-17
"""
from __future__ import annotations
import os
from typing import Any
from alembic import op
import sqlalchemy as sa
revision = "20260517_agent_action_affordance_prompt_contract"
down_revision = "20260517_agent_disclosure_goal_contract"
branch_labels = None
depends_on = None
PROMPT_KEYS = (
"PROMPT_PMFLOW_AI_AGENT_GOAL_FRAME",
"PROMPT_PMFLOW_AI_AGENT_ANSWER_COMPOSER",
)
REFRESH_NOTES = "Refreshed by 20260517_agent_action_affordance_prompt_contract for deliverable slots and PMFlow actions"
def _table_exists(bind: Any, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _prompt_body(prompt_key: str) -> str:
from app.config import ai_prompts
return str(getattr(ai_prompts, prompt_key, "") or "").strip()
def _refresh_active_release(bind: Any, *, tenant_id: int, prompt_key: str, body_snapshot: str) -> None:
active = bind.execute(
sa.text(
"""
SELECT id, body_snapshot FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
LIMIT 1
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
).mappings().first()
if active is not None and str(active.get("body_snapshot") or "").strip() == body_snapshot:
return
if active is not None:
bind.execute(
sa.text(
"""
UPDATE prompt_releases
SET status = 'archived'
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
)
max_version = bind.execute(
sa.text(
"""
SELECT MAX(version) FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
).scalar()
bind.execute(
sa.text(
"""
INSERT INTO prompt_releases
(tenant_id, prompt_key, version, body_snapshot, status, notes, created_by, activated_by, activated_at)
VALUES
(:tenant_id, :prompt_key, :version, :body_snapshot, 'active', :notes, NULL, NULL, :activated_at)
"""
),
{
"tenant_id": tenant_id,
"prompt_key": prompt_key,
"version": int(max_version or 0) + 1,
"body_snapshot": body_snapshot,
"notes": REFRESH_NOTES,
"activated_at": "2026-05-17T00:00:00",
},
)
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "prompt_releases"):
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
for prompt_key in PROMPT_KEYS:
body_snapshot = _prompt_body(prompt_key)
if body_snapshot:
_refresh_active_release(bind, tenant_id=tenant_id, prompt_key=prompt_key, body_snapshot=body_snapshot)
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "prompt_releases"):
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
bind.execute(
sa.text(
"""
DELETE FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key IN :prompt_keys
AND notes = :notes
"""
).bindparams(sa.bindparam("prompt_keys", expanding=True)),
{"tenant_id": tenant_id, "prompt_keys": PROMPT_KEYS, "notes": REFRESH_NOTES},
)

View File

@ -0,0 +1,118 @@
"""refresh agent goal frame disclosure contract
Revision ID: 20260517_agent_disclosure_goal_contract
Revises: 20260517_agent_runtime_llm_wait_alignment
Create Date: 2026-05-17
"""
from __future__ import annotations
import os
from typing import Any
from alembic import op
import sqlalchemy as sa
revision = "20260517_agent_disclosure_goal_contract"
down_revision = "20260517_agent_runtime_llm_wait_alignment"
branch_labels = None
depends_on = None
PROMPT_KEY = "PROMPT_PMFLOW_AI_AGENT_GOAL_FRAME"
REFRESH_NOTES = "Refreshed by 20260517_agent_disclosure_goal_contract for disclosure policy contract"
def _table_exists(bind: Any, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _prompt_body() -> str:
from app.config import ai_prompts
return str(getattr(ai_prompts, PROMPT_KEY, "") or "").strip()
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "prompt_releases"):
return
body_snapshot = _prompt_body()
if not body_snapshot:
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
active = bind.execute(
sa.text(
"""
SELECT id, body_snapshot FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
LIMIT 1
"""
),
{"tenant_id": tenant_id, "prompt_key": PROMPT_KEY},
).mappings().first()
if active is not None and str(active.get("body_snapshot") or "").strip() == body_snapshot:
return
bind.execute(
sa.text(
"""
UPDATE prompt_releases
SET status = 'archived'
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
"""
),
{"tenant_id": tenant_id, "prompt_key": PROMPT_KEY},
)
max_version = bind.execute(
sa.text(
"""
SELECT MAX(version) FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
"""
),
{"tenant_id": tenant_id, "prompt_key": PROMPT_KEY},
).scalar()
bind.execute(
sa.text(
"""
INSERT INTO prompt_releases
(tenant_id, prompt_key, version, body_snapshot, status, notes, created_by, activated_by, activated_at)
VALUES
(:tenant_id, :prompt_key, :version, :body_snapshot, 'active', :notes, NULL, NULL, :activated_at)
"""
),
{
"tenant_id": tenant_id,
"prompt_key": PROMPT_KEY,
"version": int(max_version or 0) + 1,
"body_snapshot": body_snapshot,
"notes": REFRESH_NOTES,
"activated_at": "2026-05-17T00:00:00",
},
)
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "prompt_releases"):
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
bind.execute(
sa.text(
"""
DELETE FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND notes = :notes
"""
),
{"tenant_id": tenant_id, "prompt_key": PROMPT_KEY, "notes": REFRESH_NOTES},
)

View File

@ -0,0 +1,132 @@
"""refresh agent dynamic research prompt releases
Revision ID: 20260517_agent_dynamic_research_prompt_contract
Revises: 20260517_agent_evidence_normalizer_profile_contract
Create Date: 2026-05-17
"""
from __future__ import annotations
import os
from typing import Any
from alembic import op
import sqlalchemy as sa
revision = "20260517_agent_dynamic_research_prompt_contract"
down_revision = "20260517_agent_evidence_normalizer_profile_contract"
branch_labels = None
depends_on = None
PROMPT_KEYS = (
"PROMPT_PMFLOW_AI_AGENT_GOAL_FRAME",
"PROMPT_PMFLOW_AI_AGENT_STEP_PLANNER",
"PROMPT_PMFLOW_AI_AGENT_EVIDENCE_NORMALIZER",
"PROMPT_PMFLOW_AI_AGENT_STATE_CRITIC",
"PROMPT_PMFLOW_AI_AGENT_ANSWER_COMPOSER",
"PROMPT_PMFLOW_AI_AGENT_ANSWER_REVISER",
)
REFRESH_NOTES = "Refreshed by 20260517_agent_dynamic_research_prompt_contract for dynamic research lanes"
def _table_exists(bind: Any, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _prompt_body(prompt_key: str) -> str:
from app.config import ai_prompts
return str(getattr(ai_prompts, prompt_key, "") or "").strip()
def _refresh_active_release(bind: Any, *, tenant_id: int, prompt_key: str, body_snapshot: str) -> None:
active = bind.execute(
sa.text(
"""
SELECT id, body_snapshot FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
LIMIT 1
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
).mappings().first()
if active is not None and str(active.get("body_snapshot") or "").strip() == body_snapshot:
return
if active is not None:
bind.execute(
sa.text(
"""
UPDATE prompt_releases
SET status = 'archived'
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
)
max_version = bind.execute(
sa.text(
"""
SELECT MAX(version) FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
).scalar()
bind.execute(
sa.text(
"""
INSERT INTO prompt_releases
(tenant_id, prompt_key, version, body_snapshot, status, notes, created_by, activated_by, activated_at)
VALUES
(:tenant_id, :prompt_key, :version, :body_snapshot, 'active', :notes, NULL, NULL, :activated_at)
"""
),
{
"tenant_id": tenant_id,
"prompt_key": prompt_key,
"version": int(max_version or 0) + 1,
"body_snapshot": body_snapshot,
"notes": REFRESH_NOTES,
"activated_at": "2026-05-17T00:00:00",
},
)
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "prompt_releases"):
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
for prompt_key in PROMPT_KEYS:
body_snapshot = _prompt_body(prompt_key)
if body_snapshot:
_refresh_active_release(bind, tenant_id=tenant_id, prompt_key=prompt_key, body_snapshot=body_snapshot)
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "prompt_releases"):
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
bind.execute(
sa.text(
"""
DELETE FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key IN :prompt_keys
AND notes = :notes
"""
).bindparams(sa.bindparam("prompt_keys", expanding=True)),
{"tenant_id": tenant_id, "prompt_keys": PROMPT_KEYS, "notes": REFRESH_NOTES},
)

View File

@ -0,0 +1,82 @@
"""extend agent evidence normalizer budget for source synthesis
Revision ID: 20260517_agent_evidence_normalizer_deep_budget
Revises: 20260517_agent_evidence_normalizer_wait_budget
Create Date: 2026-05-17
"""
from __future__ import annotations
import json
import os
from typing import Any
from alembic import op
import sqlalchemy as sa
revision = "20260517_agent_evidence_normalizer_deep_budget"
down_revision = "20260517_agent_evidence_normalizer_wait_budget"
branch_labels = None
depends_on = None
PROFILE_UPDATES: dict[str, Any] = {
"provider_timeout_seconds": 60,
"job_timeout_seconds": 72,
"foreground_wait_seconds": 30,
"max_tokens": 2600,
"response_mode": "fast",
"runtime_mode": "active",
"quality_gate_mode": "off",
}
def _table_exists(bind: Any, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "integrations"):
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
row = bind.execute(
sa.text(
"""
SELECT id, config FROM integrations
WHERE tenant_id = :tenant_id
AND type = 'ai_function_runtime_profiles'
AND is_active = true
ORDER BY id DESC
LIMIT 1
"""
),
{"tenant_id": tenant_id},
).mappings().first()
if row is None:
return
config = row.get("config") if isinstance(row.get("config"), dict) else {}
changed = False
for stage in ("active", "draft"):
stage_packet = config.get(stage) if isinstance(config.get(stage), dict) else {}
profiles = stage_packet.get("profiles") if isinstance(stage_packet.get("profiles"), dict) else {}
profile = profiles.get("pmflow_ai_agent_evidence_normalizer")
if not isinstance(profile, dict):
continue
for key, value in PROFILE_UPDATES.items():
if profile.get(key) != value:
profile[key] = value
changed = True
if not changed:
return
config["active_version"] = "profile-v20260517-agent-evidence-normalizer-deep-budget"
config["version"] = config["active_version"]
bind.execute(
sa.text("UPDATE integrations SET config = CAST(:config AS jsonb) WHERE id = :id"),
{"id": int(row["id"]), "config": json.dumps(config, ensure_ascii=False)},
)
def downgrade() -> None:
return None

View File

@ -0,0 +1,77 @@
"""keep evidence normalizer configured foreground within profile contract
Revision ID: 20260517_agent_evidence_normalizer_profile_contract
Revises: 20260517_agent_evidence_normalizer_deep_budget
Create Date: 2026-05-17
"""
from __future__ import annotations
import json
import os
from typing import Any
from alembic import op
import sqlalchemy as sa
revision = "20260517_agent_evidence_normalizer_profile_contract"
down_revision = "20260517_agent_evidence_normalizer_deep_budget"
branch_labels = None
depends_on = None
def _table_exists(bind: Any, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "integrations"):
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
row = bind.execute(
sa.text(
"""
SELECT id, config FROM integrations
WHERE tenant_id = :tenant_id
AND type = 'ai_function_runtime_profiles'
AND is_active = true
ORDER BY id DESC
LIMIT 1
"""
),
{"tenant_id": tenant_id},
).mappings().first()
if row is None:
return
config = row.get("config") if isinstance(row.get("config"), dict) else {}
changed = False
for stage in ("active", "draft"):
stage_packet = config.get(stage) if isinstance(config.get(stage), dict) else {}
profiles = stage_packet.get("profiles") if isinstance(stage_packet.get("profiles"), dict) else {}
profile = profiles.get("pmflow_ai_agent_evidence_normalizer")
if not isinstance(profile, dict):
continue
updates = {
"provider_timeout_seconds": 60,
"job_timeout_seconds": 72,
"foreground_wait_seconds": 30,
"max_tokens": 2600,
}
for key, value in updates.items():
if profile.get(key) != value:
profile[key] = value
changed = True
if not changed:
return
config["active_version"] = "profile-v20260517-agent-evidence-normalizer-profile-contract"
config["version"] = config["active_version"]
bind.execute(
sa.text("UPDATE integrations SET config = CAST(:config AS jsonb) WHERE id = :id"),
{"id": int(row["id"]), "config": json.dumps(config, ensure_ascii=False)},
)
def downgrade() -> None:
return None

View File

@ -0,0 +1,82 @@
"""align agent evidence normalizer wait with semantic provider budget
Revision ID: 20260517_agent_evidence_normalizer_wait_budget
Revises: 20260517_agent_runtime_semantic_wait_budget
Create Date: 2026-05-17
"""
from __future__ import annotations
import json
import os
from typing import Any
from alembic import op
import sqlalchemy as sa
revision = "20260517_agent_evidence_normalizer_wait_budget"
down_revision = "20260517_agent_runtime_semantic_wait_budget"
branch_labels = None
depends_on = None
PROFILE_UPDATES: dict[str, Any] = {
"provider_timeout_seconds": 30,
"job_timeout_seconds": 36,
"foreground_wait_seconds": 30,
"max_tokens": 2200,
"response_mode": "fast",
"runtime_mode": "active",
"quality_gate_mode": "off",
}
def _table_exists(bind: Any, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "integrations"):
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
row = bind.execute(
sa.text(
"""
SELECT id, config FROM integrations
WHERE tenant_id = :tenant_id
AND type = 'ai_function_runtime_profiles'
AND is_active = true
ORDER BY id DESC
LIMIT 1
"""
),
{"tenant_id": tenant_id},
).mappings().first()
if row is None:
return
config = row.get("config") if isinstance(row.get("config"), dict) else {}
changed = False
for stage in ("active", "draft"):
stage_packet = config.get(stage) if isinstance(config.get(stage), dict) else {}
profiles = stage_packet.get("profiles") if isinstance(stage_packet.get("profiles"), dict) else {}
profile = profiles.get("pmflow_ai_agent_evidence_normalizer")
if not isinstance(profile, dict):
continue
for key, value in PROFILE_UPDATES.items():
if profile.get(key) != value:
profile[key] = value
changed = True
if not changed:
return
config["active_version"] = "profile-v20260517-agent-evidence-normalizer-wait-budget"
config["version"] = config["active_version"]
bind.execute(
sa.text("UPDATE integrations SET config = CAST(:config AS jsonb) WHERE id = :id"),
{"id": int(row["id"]), "config": json.dumps(config, ensure_ascii=False)},
)
def downgrade() -> None:
return None

View File

@ -0,0 +1,112 @@
"""align agent LLM foreground waits with provider budgets
Revision ID: 20260517_agent_runtime_llm_wait_alignment
Revises: 20260517_agent_dynamic_research_prompt_contract
Create Date: 2026-05-17
"""
from __future__ import annotations
import json
import os
from typing import Any
from alembic import op
import sqlalchemy as sa
revision = "20260517_agent_runtime_llm_wait_alignment"
down_revision = "20260517_agent_dynamic_research_prompt_contract"
branch_labels = None
depends_on = None
PROFILE_UPDATES: dict[str, dict[str, Any]] = {
"pmflow_ai_agent_goal_frame": {
"provider_timeout_seconds": 45,
"foreground_wait_seconds": 45,
"job_timeout_seconds": 55,
"max_tokens": 1800,
},
"pmflow_ai_agent_step_planner": {
"provider_timeout_seconds": 45,
"foreground_wait_seconds": 45,
"job_timeout_seconds": 55,
"max_tokens": 1800,
},
"pmflow_ai_agent_evidence_normalizer": {
"provider_timeout_seconds": 75,
"foreground_wait_seconds": 75,
"job_timeout_seconds": 90,
"max_tokens": 3200,
},
"pmflow_ai_agent_state_critic": {
"provider_timeout_seconds": 30,
"foreground_wait_seconds": 30,
"job_timeout_seconds": 40,
"max_tokens": 1800,
},
"pmflow_ai_agent_answer_composer": {
"provider_timeout_seconds": 60,
"foreground_wait_seconds": 60,
"job_timeout_seconds": 75,
"max_tokens": 3600,
},
"pmflow_ai_agent_answer_reviser": {
"provider_timeout_seconds": 45,
"foreground_wait_seconds": 45,
"job_timeout_seconds": 55,
"max_tokens": 3000,
},
}
def _table_exists(bind: Any, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "integrations"):
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
row = bind.execute(
sa.text(
"""
SELECT id, config FROM integrations
WHERE tenant_id = :tenant_id
AND type = 'ai_function_runtime_profiles'
AND is_active = true
ORDER BY id DESC
LIMIT 1
"""
),
{"tenant_id": tenant_id},
).mappings().first()
if row is None:
return
config = row.get("config") if isinstance(row.get("config"), dict) else {}
changed = False
for stage in ("active", "draft"):
stage_packet = config.get(stage) if isinstance(config.get(stage), dict) else {}
profiles = stage_packet.get("profiles") if isinstance(stage_packet.get("profiles"), dict) else {}
for profile_name, updates in PROFILE_UPDATES.items():
profile = profiles.get(profile_name)
if not isinstance(profile, dict):
continue
for key, value in updates.items():
if profile.get(key) != value:
profile[key] = value
changed = True
if not changed:
return
config["active_version"] = "profile-v20260517-agent-runtime-llm-wait-alignment"
config["version"] = config["active_version"]
bind.execute(
sa.text("UPDATE integrations SET config = CAST(:config AS jsonb) WHERE id = :id"),
{"id": int(row["id"]), "config": json.dumps(config, ensure_ascii=False)},
)
def downgrade() -> None:
return None

View File

@ -0,0 +1,94 @@
"""align critical agent semantic waits with provider budget
Revision ID: 20260517_agent_runtime_semantic_wait_budget
Revises: 20260517_agent_runtime_valid_profile_timeouts
Create Date: 2026-05-17
"""
from __future__ import annotations
import json
import os
from typing import Any
from alembic import op
import sqlalchemy as sa
revision = "20260517_agent_runtime_semantic_wait_budget"
down_revision = "20260517_agent_runtime_valid_profile_timeouts"
branch_labels = None
depends_on = None
PROFILE_UPDATES: dict[str, dict[str, Any]] = {
"pmflow_ai_agent_goal_frame": {
"provider_timeout_seconds": 30,
"job_timeout_seconds": 36,
"foreground_wait_seconds": 30,
"max_tokens": 1800,
"response_mode": "fast",
"runtime_mode": "active",
"quality_gate_mode": "off",
},
"pmflow_ai_agent_step_planner": {
"provider_timeout_seconds": 30,
"job_timeout_seconds": 36,
"foreground_wait_seconds": 30,
"max_tokens": 1800,
"response_mode": "fast",
"runtime_mode": "active",
"quality_gate_mode": "off",
},
}
def _table_exists(bind: Any, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "integrations"):
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
row = bind.execute(
sa.text(
"""
SELECT id, config FROM integrations
WHERE tenant_id = :tenant_id
AND type = 'ai_function_runtime_profiles'
AND is_active = true
ORDER BY id DESC
LIMIT 1
"""
),
{"tenant_id": tenant_id},
).mappings().first()
if row is None:
return
config = row.get("config") if isinstance(row.get("config"), dict) else {}
changed = False
for stage in ("active", "draft"):
stage_packet = config.get(stage) if isinstance(config.get(stage), dict) else {}
profiles = stage_packet.get("profiles") if isinstance(stage_packet.get("profiles"), dict) else {}
for function_name, updates in PROFILE_UPDATES.items():
profile = profiles.get(function_name)
if not isinstance(profile, dict):
continue
for key, value in updates.items():
if profile.get(key) != value:
profile[key] = value
changed = True
if not changed:
return
config["active_version"] = "profile-v20260517-agent-runtime-semantic-wait-budget"
config["version"] = config["active_version"]
bind.execute(
sa.text("UPDATE integrations SET config = CAST(:config AS jsonb) WHERE id = :id"),
{"id": int(row["id"]), "config": json.dumps(config, ensure_ascii=False)},
)
def downgrade() -> None:
return None

View File

@ -0,0 +1,126 @@
"""raise agent runtime profile timeouts above validation floor
Revision ID: 20260517_agent_runtime_valid_profile_timeouts
Revises: 20260516_agent_answer_composer_budget
Create Date: 2026-05-17
"""
from __future__ import annotations
import json
import os
from typing import Any
from alembic import op
import sqlalchemy as sa
revision = "20260517_agent_runtime_valid_profile_timeouts"
down_revision = "20260516_agent_answer_composer_budget"
branch_labels = None
depends_on = None
PROFILE_UPDATES: dict[str, dict[str, Any]] = {
"pmflow_ai_agent_goal_frame": {
"provider_timeout_seconds": 20,
"job_timeout_seconds": 24,
"foreground_wait_seconds": 8,
"max_tokens": 1600,
"repair_max_tokens": 600,
"response_mode": "fast",
"runtime_mode": "active",
"quality_gate_mode": "off",
},
"pmflow_ai_agent_step_planner": {
"provider_timeout_seconds": 20,
"job_timeout_seconds": 24,
"foreground_wait_seconds": 8,
"max_tokens": 1600,
"repair_max_tokens": 600,
"response_mode": "fast",
"runtime_mode": "active",
"quality_gate_mode": "off",
},
"pmflow_ai_agent_state_critic": {
"provider_timeout_seconds": 16,
"job_timeout_seconds": 20,
"foreground_wait_seconds": 6,
"max_tokens": 1600,
"repair_max_tokens": 600,
"response_mode": "fast",
"runtime_mode": "active",
"quality_gate_mode": "off",
},
"pmflow_ai_agent_answer_composer": {
"provider_timeout_seconds": 35,
"job_timeout_seconds": 40,
"foreground_wait_seconds": 10,
"max_tokens": 3600,
"repair_max_tokens": 900,
"response_mode": "fast",
"runtime_mode": "active",
"quality_gate_mode": "off",
},
"pmflow_ai_agent_answer_reviser": {
"provider_timeout_seconds": 20,
"job_timeout_seconds": 24,
"foreground_wait_seconds": 8,
"max_tokens": 2600,
"repair_max_tokens": 900,
"response_mode": "fast",
"runtime_mode": "active",
"quality_gate_mode": "off",
},
}
def _table_exists(bind: Any, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "integrations"):
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
row = bind.execute(
sa.text(
"""
SELECT id, config FROM integrations
WHERE tenant_id = :tenant_id
AND type = 'ai_function_runtime_profiles'
AND is_active = true
ORDER BY id DESC
LIMIT 1
"""
),
{"tenant_id": tenant_id},
).mappings().first()
if row is None:
return
config = row.get("config") if isinstance(row.get("config"), dict) else {}
changed = False
for stage in ("active", "draft"):
stage_packet = config.get(stage) if isinstance(config.get(stage), dict) else {}
profiles = stage_packet.get("profiles") if isinstance(stage_packet.get("profiles"), dict) else {}
for function_name, updates in PROFILE_UPDATES.items():
profile = profiles.get(function_name)
if not isinstance(profile, dict):
continue
for key, value in updates.items():
if profile.get(key) != value:
profile[key] = value
changed = True
if not changed:
return
config["active_version"] = "profile-v20260517-agent-runtime-valid-profile-timeouts"
config["version"] = config["active_version"]
bind.execute(
sa.text("UPDATE integrations SET config = CAST(:config AS jsonb) WHERE id = :id"),
{"id": int(row["id"]), "config": json.dumps(config, ensure_ascii=False)},
)
def downgrade() -> None:
return None

View File

@ -0,0 +1,130 @@
"""refresh operating mission and specialist prompt releases
Revision ID: 20260517_operating_mission_specialist_prompt_contract
Revises: 20260517_agent_action_affordance_prompt_contract
Create Date: 2026-05-17
"""
from __future__ import annotations
import os
from typing import Any
from alembic import op
import sqlalchemy as sa
revision = "20260517_operating_mission_specialist_prompt_contract"
down_revision = "20260517_agent_action_affordance_prompt_contract"
branch_labels = None
depends_on = None
PROMPT_KEYS = (
"PROMPT_PMFLOW_AI_OPERATING_MISSION",
"PROMPT_PMFLOW_AI_OPERATING_MISSION_CRITIC",
"PROMPT_PMFLOW_AI_SPECIALIST_WORK_PRODUCT",
"PROMPT_PMFLOW_AI_OPERATING_COUNCIL",
)
REFRESH_NOTES = "Refreshed by 20260517_operating_mission_specialist_prompt_contract for mission-first specialist registry"
def _table_exists(bind: Any, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _prompt_body(prompt_key: str) -> str:
from app.config import ai_prompts
return str(getattr(ai_prompts, prompt_key, "") or "").strip()
def _refresh_active_release(bind: Any, *, tenant_id: int, prompt_key: str, body_snapshot: str) -> None:
active = bind.execute(
sa.text(
"""
SELECT id, body_snapshot FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
LIMIT 1
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
).mappings().first()
if active is not None and str(active.get("body_snapshot") or "").strip() == body_snapshot:
return
if active is not None:
bind.execute(
sa.text(
"""
UPDATE prompt_releases
SET status = 'archived'
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
)
max_version = bind.execute(
sa.text(
"""
SELECT MAX(version) FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
).scalar()
bind.execute(
sa.text(
"""
INSERT INTO prompt_releases
(tenant_id, prompt_key, version, body_snapshot, status, notes, created_by, activated_by, activated_at)
VALUES
(:tenant_id, :prompt_key, :version, :body_snapshot, 'active', :notes, NULL, NULL, :activated_at)
"""
),
{
"tenant_id": tenant_id,
"prompt_key": prompt_key,
"version": int(max_version or 0) + 1,
"body_snapshot": body_snapshot,
"notes": REFRESH_NOTES,
"activated_at": "2026-05-17T00:00:00",
},
)
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "prompt_releases"):
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
for prompt_key in PROMPT_KEYS:
body_snapshot = _prompt_body(prompt_key)
if body_snapshot:
_refresh_active_release(bind, tenant_id=tenant_id, prompt_key=prompt_key, body_snapshot=body_snapshot)
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "prompt_releases"):
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
bind.execute(
sa.text(
"""
DELETE FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key IN :prompt_keys
AND notes = :notes
"""
).bindparams(sa.bindparam("prompt_keys", expanding=True)),
{"tenant_id": tenant_id, "prompt_keys": PROMPT_KEYS, "notes": REFRESH_NOTES},
)

View File

@ -0,0 +1,132 @@
"""publish operating answer prompt contract
Revision ID: 20260518_operating_answer_prompt_contract
Revises: 20260518_operating_mission_critic_prompt_contract
Create Date: 2026-05-18
"""
from __future__ import annotations
import os
from typing import Any
from alembic import op
import sqlalchemy as sa
revision = "20260518_operating_answer_prompt_contract"
down_revision = "20260518_operating_mission_critic_prompt_contract"
branch_labels = None
depends_on = None
PROMPT_KEYS = (
"PROMPT_PMFLOW_AI_OPERATING_MISSION",
"PROMPT_PMFLOW_AI_OPERATING_MISSION_CRITIC",
"PROMPT_PMFLOW_AI_SPECIALIST_WORK_PRODUCT",
"PROMPT_PMFLOW_AI_OPERATING_ANSWER_COMPOSER",
"PROMPT_PMFLOW_AI_AGENT_ANSWER_COMPOSER",
"PROMPT_PMFLOW_LAUNCH_DRAFT",
)
REFRESH_NOTES = "Published by 20260518_operating_answer_prompt_contract for idea-to-monetization operating answer"
def _table_exists(bind: Any, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _prompt_body(prompt_key: str) -> str:
from app.config import ai_prompts
return str(getattr(ai_prompts, prompt_key, "") or "").strip()
def _refresh_active_release(bind: Any, *, tenant_id: int, prompt_key: str, body_snapshot: str) -> None:
active = bind.execute(
sa.text(
"""
SELECT id, body_snapshot FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
LIMIT 1
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
).mappings().first()
if active is not None and str(active.get("body_snapshot") or "").strip() == body_snapshot:
return
if active is not None:
bind.execute(
sa.text(
"""
UPDATE prompt_releases
SET status = 'archived'
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
)
max_version = bind.execute(
sa.text(
"""
SELECT MAX(version) FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
).scalar()
bind.execute(
sa.text(
"""
INSERT INTO prompt_releases
(tenant_id, prompt_key, version, body_snapshot, status, notes, created_by, activated_by, activated_at)
VALUES
(:tenant_id, :prompt_key, :version, :body_snapshot, 'active', :notes, NULL, NULL, :activated_at)
"""
),
{
"tenant_id": tenant_id,
"prompt_key": prompt_key,
"version": int(max_version or 0) + 1,
"body_snapshot": body_snapshot,
"notes": REFRESH_NOTES,
"activated_at": "2026-05-18T00:00:00",
},
)
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "prompt_releases"):
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
for prompt_key in PROMPT_KEYS:
body_snapshot = _prompt_body(prompt_key)
if body_snapshot:
_refresh_active_release(bind, tenant_id=tenant_id, prompt_key=prompt_key, body_snapshot=body_snapshot)
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "prompt_releases"):
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
bind.execute(
sa.text(
"""
DELETE FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key IN :prompt_keys
AND notes = :notes
"""
).bindparams(sa.bindparam("prompt_keys", expanding=True)),
{"tenant_id": tenant_id, "prompt_keys": PROMPT_KEYS, "notes": REFRESH_NOTES},
)

View File

@ -0,0 +1,129 @@
"""publish operating mission critic prompt contract
Revision ID: 20260518_operating_mission_critic_prompt_contract
Revises: 20260517_operating_mission_specialist_prompt_contract
Create Date: 2026-05-18
"""
from __future__ import annotations
import os
from typing import Any
from alembic import op
import sqlalchemy as sa
revision = "20260518_operating_mission_critic_prompt_contract"
down_revision = "20260517_operating_mission_specialist_prompt_contract"
branch_labels = None
depends_on = None
PROMPT_KEYS = (
"PROMPT_PMFLOW_AI_OPERATING_MISSION_CRITIC",
)
REFRESH_NOTES = "Published by 20260518_operating_mission_critic_prompt_contract for mission critic and post-evidence replan"
def _table_exists(bind: Any, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _prompt_body(prompt_key: str) -> str:
from app.config import ai_prompts
return str(getattr(ai_prompts, prompt_key, "") or "").strip()
def _refresh_active_release(bind: Any, *, tenant_id: int, prompt_key: str, body_snapshot: str) -> None:
active = bind.execute(
sa.text(
"""
SELECT id, body_snapshot FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
LIMIT 1
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
).mappings().first()
if active is not None and str(active.get("body_snapshot") or "").strip() == body_snapshot:
return
if active is not None:
bind.execute(
sa.text(
"""
UPDATE prompt_releases
SET status = 'archived'
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND status = 'active'
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
)
max_version = bind.execute(
sa.text(
"""
SELECT MAX(version) FROM prompt_releases
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key},
).scalar()
bind.execute(
sa.text(
"""
INSERT INTO prompt_releases
(tenant_id, prompt_key, version, body_snapshot, status, notes, created_by, activated_by, activated_at)
VALUES
(:tenant_id, :prompt_key, :version, :body_snapshot, 'active', :notes, NULL, NULL, :activated_at)
"""
),
{
"tenant_id": tenant_id,
"prompt_key": prompt_key,
"version": int(max_version or 0) + 1,
"body_snapshot": body_snapshot,
"notes": REFRESH_NOTES,
"activated_at": "2026-05-18T00:00:00",
},
)
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "prompt_releases"):
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
for prompt_key in PROMPT_KEYS:
body_snapshot = _prompt_body(prompt_key)
if body_snapshot:
_refresh_active_release(bind, tenant_id=tenant_id, prompt_key=prompt_key, body_snapshot=body_snapshot)
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "prompt_releases"):
return
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
for prompt_key in PROMPT_KEYS:
bind.execute(
sa.text(
"""
UPDATE prompt_releases
SET status = 'archived'
WHERE tenant_id = :tenant_id
AND prompt_key = :prompt_key
AND notes = :notes
AND status = 'active'
"""
),
{"tenant_id": tenant_id, "prompt_key": prompt_key, "notes": REFRESH_NOTES},
)

View File

@ -0,0 +1,99 @@
"""add pmflow ai layered context memories
Revision ID: 20260519_add_pmflow_ai_context_memories
Revises: 20260518_operating_answer_prompt_contract
Create Date: 2026-05-19
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260519_add_pmflow_ai_context_memories"
down_revision = "20260518_operating_answer_prompt_contract"
branch_labels = None
depends_on = None
def _json_type():
bind = op.get_bind()
if bind.dialect.name == "postgresql":
from sqlalchemy.dialects import postgresql
return postgresql.JSONB()
return sa.JSON()
def _table_exists(table_name: str) -> bool:
return sa.inspect(op.get_bind()).has_table(table_name)
def _index_exists(table_name: str, index_name: str) -> bool:
if not _table_exists(table_name):
return False
inspector = sa.inspect(op.get_bind())
return any(item.get("name") == index_name for item in inspector.get_indexes(table_name))
def _create_index_once(index_name: str, table_name: str, columns: list[str]) -> None:
if _table_exists(table_name) and not _index_exists(table_name, index_name):
op.create_index(index_name, table_name, columns)
def _base_columns() -> list[sa.Column]:
return [
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.text("now()")),
]
def upgrade() -> None:
json_type = _json_type()
if not _table_exists("pmflow_ai_context_memories"):
op.create_table(
"pmflow_ai_context_memories",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("public_memory_id", sa.String(length=80), nullable=False),
sa.Column("memory_scope", sa.String(length=40), nullable=False),
sa.Column("scope_ref", sa.String(length=160), nullable=False),
sa.Column("memory_key", sa.String(length=120), nullable=False),
sa.Column("source_thread_id", sa.BigInteger(), sa.ForeignKey("pmflow_ai_threads.id", ondelete="SET NULL"), nullable=True),
sa.Column("source_run_public_id", sa.String(length=80), nullable=True),
sa.Column("summary", sa.Text(), nullable=False, server_default=""),
sa.Column("durable_facts", json_type, nullable=True),
sa.Column("open_loops", json_type, nullable=True),
sa.Column("decisions", json_type, nullable=True),
sa.Column("preferences", json_type, nullable=True),
sa.Column("object_refs", json_type, nullable=True),
sa.Column("source_message_range", json_type, nullable=True),
sa.Column("confidence", sa.String(length=32), nullable=True),
sa.Column("token_estimate", sa.Integer(), nullable=False, server_default="0"),
sa.Column("compaction_payload", json_type, nullable=True),
sa.Column("last_compacted_at", sa.TIMESTAMP(), nullable=True),
sa.Column("last_accessed_at", sa.TIMESTAMP(), nullable=True),
*_base_columns(),
sa.UniqueConstraint(
"tenant_id",
"memory_scope",
"scope_ref",
"memory_key",
name="uq_pmflow_ai_context_memories_scope_key",
),
)
_create_index_once("ix_pmflow_ai_context_memories_tenant_id", "pmflow_ai_context_memories", ["tenant_id"])
_create_index_once("ix_pmflow_ai_context_memories_public_memory_id", "pmflow_ai_context_memories", ["public_memory_id"])
_create_index_once("ix_pmflow_ai_context_memories_memory_scope", "pmflow_ai_context_memories", ["memory_scope"])
_create_index_once("ix_pmflow_ai_context_memories_scope_ref", "pmflow_ai_context_memories", ["scope_ref"])
_create_index_once("ix_pmflow_ai_context_memories_memory_key", "pmflow_ai_context_memories", ["memory_key"])
_create_index_once("ix_pmflow_ai_context_memories_source_thread_id", "pmflow_ai_context_memories", ["source_thread_id"])
_create_index_once("ix_pmflow_ai_context_memories_source_run_public_id", "pmflow_ai_context_memories", ["source_run_public_id"])
_create_index_once("ix_pmflow_ai_context_memories_last_compacted_at", "pmflow_ai_context_memories", ["last_compacted_at"])
_create_index_once("ix_pmflow_ai_context_memories_last_accessed_at", "pmflow_ai_context_memories", ["last_accessed_at"])
def downgrade() -> None:
if _table_exists("pmflow_ai_context_memories"):
op.drop_table("pmflow_ai_context_memories")

View File

@ -0,0 +1,84 @@
"""add context memory lifecycle status fields
Revision ID: 20260520_context_memory_status_gate
Revises: 20260519_add_pmflow_ai_context_memories
Create Date: 2026-05-20
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260520_context_memory_status_gate"
down_revision = "20260519_add_pmflow_ai_context_memories"
branch_labels = None
depends_on = None
def _table_exists(table_name: str) -> bool:
return sa.inspect(op.get_bind()).has_table(table_name)
def _column_exists(table_name: str, column_name: str) -> bool:
if not _table_exists(table_name):
return False
inspector = sa.inspect(op.get_bind())
return any(item.get("name") == column_name for item in inspector.get_columns(table_name))
def _index_exists(table_name: str, index_name: str) -> bool:
if not _table_exists(table_name):
return False
inspector = sa.inspect(op.get_bind())
return any(item.get("name") == index_name for item in inspector.get_indexes(table_name))
def _create_index_once(index_name: str, table_name: str, columns: list[str]) -> None:
if _table_exists(table_name) and not _index_exists(table_name, index_name):
op.create_index(index_name, table_name, columns)
def upgrade() -> None:
if not _table_exists("pmflow_ai_context_memories"):
return
if not _column_exists("pmflow_ai_context_memories", "status"):
op.add_column("pmflow_ai_context_memories", sa.Column("status", sa.String(length=32), nullable=False, server_default="active"))
if not _column_exists("pmflow_ai_context_memories", "confidence_score"):
op.add_column("pmflow_ai_context_memories", sa.Column("confidence_score", sa.Float(), nullable=False, server_default="0.6"))
if not _column_exists("pmflow_ai_context_memories", "supersedes_memory_id"):
op.add_column("pmflow_ai_context_memories", sa.Column("supersedes_memory_id", sa.BigInteger(), nullable=True))
op.create_foreign_key(
"fk_pmflow_ai_context_memories_supersedes",
"pmflow_ai_context_memories",
"pmflow_ai_context_memories",
["supersedes_memory_id"],
["id"],
ondelete="SET NULL",
)
if not _column_exists("pmflow_ai_context_memories", "invalidation_reason"):
op.add_column("pmflow_ai_context_memories", sa.Column("invalidation_reason", sa.Text(), nullable=True))
if not _column_exists("pmflow_ai_context_memories", "last_validated_at"):
op.add_column("pmflow_ai_context_memories", sa.Column("last_validated_at", sa.TIMESTAMP(), nullable=True))
_create_index_once("ix_pmflow_ai_context_memories_status", "pmflow_ai_context_memories", ["status"])
_create_index_once("ix_pmflow_ai_context_memories_confidence_score", "pmflow_ai_context_memories", ["confidence_score"])
_create_index_once("ix_pmflow_ai_context_memories_supersedes_memory_id", "pmflow_ai_context_memories", ["supersedes_memory_id"])
_create_index_once("ix_pmflow_ai_context_memories_last_validated_at", "pmflow_ai_context_memories", ["last_validated_at"])
def downgrade() -> None:
if not _table_exists("pmflow_ai_context_memories"):
return
for index_name in (
"ix_pmflow_ai_context_memories_last_validated_at",
"ix_pmflow_ai_context_memories_supersedes_memory_id",
"ix_pmflow_ai_context_memories_confidence_score",
"ix_pmflow_ai_context_memories_status",
):
if _index_exists("pmflow_ai_context_memories", index_name):
op.drop_index(index_name, table_name="pmflow_ai_context_memories")
for column_name in ("last_validated_at", "invalidation_reason", "supersedes_memory_id", "confidence_score", "status"):
if _column_exists("pmflow_ai_context_memories", column_name):
op.drop_column("pmflow_ai_context_memories", column_name)

View File

@ -0,0 +1,89 @@
"""add context memory compaction jobs
Revision ID: 20260521_context_memory_compaction_jobs
Revises: 20260520_context_memory_status_gate
Create Date: 2026-05-21
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260521_context_memory_compaction_jobs"
down_revision = "20260520_context_memory_status_gate"
branch_labels = None
depends_on = None
TABLE_NAME = "pmflow_ai_context_memory_compaction_jobs"
def _table_exists(table_name: str) -> bool:
return sa.inspect(op.get_bind()).has_table(table_name)
def _index_exists(table_name: str, index_name: str) -> bool:
if not _table_exists(table_name):
return False
inspector = sa.inspect(op.get_bind())
return any(item.get("name") == index_name for item in inspector.get_indexes(table_name))
def _create_index_once(index_name: str, table_name: str, columns: list[str]) -> None:
if _table_exists(table_name) and not _index_exists(table_name, index_name):
op.create_index(index_name, table_name, columns)
def upgrade() -> None:
if not _table_exists(TABLE_NAME):
op.create_table(
TABLE_NAME,
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("public_job_id", sa.String(length=80), nullable=False),
sa.Column("thread_id", sa.BigInteger(), nullable=True),
sa.Column("user_id", sa.BigInteger(), nullable=True),
sa.Column("qa_session_id", sa.BigInteger(), nullable=True),
sa.Column("run_public_id", sa.String(length=80), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False, server_default="queued"),
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("max_attempts", sa.Integer(), nullable=False, server_default="2"),
sa.Column("priority", sa.Integer(), nullable=False, server_default="0"),
sa.Column("job_payload", sa.JSON(), nullable=True),
sa.Column("result_payload", sa.JSON(), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("superseded_by_job_id", sa.BigInteger(), nullable=True),
sa.Column("queued_at", sa.TIMESTAMP(), nullable=True),
sa.Column("started_at", sa.TIMESTAMP(), nullable=True),
sa.Column("completed_at", sa.TIMESTAMP(), nullable=True),
sa.Column("next_retry_at", sa.TIMESTAMP(), nullable=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["qa_session_id"], ["qa_sessions.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["superseded_by_job_id"], [f"{TABLE_NAME}.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["thread_id"], ["pmflow_ai_threads.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "public_job_id", name="uq_pmflow_ai_context_memory_compaction_jobs_public_job"),
)
_create_index_once("ix_ctx_mem_jobs_tenant_id", TABLE_NAME, ["tenant_id"])
_create_index_once("ix_ctx_mem_jobs_public_job_id", TABLE_NAME, ["public_job_id"])
_create_index_once("ix_ctx_mem_jobs_thread_id", TABLE_NAME, ["thread_id"])
_create_index_once("ix_ctx_mem_jobs_user_id", TABLE_NAME, ["user_id"])
_create_index_once("ix_ctx_mem_jobs_qa_session_id", TABLE_NAME, ["qa_session_id"])
_create_index_once("ix_ctx_mem_jobs_run_public_id", TABLE_NAME, ["run_public_id"])
_create_index_once("ix_ctx_mem_jobs_status", TABLE_NAME, ["status"])
_create_index_once("ix_ctx_mem_jobs_priority", TABLE_NAME, ["priority"])
_create_index_once("ix_ctx_mem_jobs_superseded_by_job_id", TABLE_NAME, ["superseded_by_job_id"])
_create_index_once("ix_ctx_mem_jobs_queued_at", TABLE_NAME, ["queued_at"])
_create_index_once("ix_ctx_mem_jobs_started_at", TABLE_NAME, ["started_at"])
_create_index_once("ix_ctx_mem_jobs_completed_at", TABLE_NAME, ["completed_at"])
_create_index_once("ix_ctx_mem_jobs_next_retry_at", TABLE_NAME, ["next_retry_at"])
def downgrade() -> None:
if _table_exists(TABLE_NAME):
op.drop_table(TABLE_NAME)

View File

@ -0,0 +1,79 @@
"""add context assemblies
Revision ID: 20260522_context_assemblies
Revises: 20260521_context_memory_compaction_jobs
Create Date: 2026-05-22
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260522_context_assemblies"
down_revision = "20260521_context_memory_compaction_jobs"
branch_labels = None
depends_on = None
TABLE_NAME = "pmflow_ai_context_assemblies"
def _table_exists(table_name: str) -> bool:
return sa.inspect(op.get_bind()).has_table(table_name)
def _index_exists(table_name: str, index_name: str) -> bool:
if not _table_exists(table_name):
return False
inspector = sa.inspect(op.get_bind())
return any(item.get("name") == index_name for item in inspector.get_indexes(table_name))
def _create_index_once(index_name: str, table_name: str, columns: list[str]) -> None:
if _table_exists(table_name) and not _index_exists(table_name, index_name):
op.create_index(index_name, table_name, columns)
def upgrade() -> None:
if not _table_exists(TABLE_NAME):
op.create_table(
TABLE_NAME,
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("public_assembly_id", sa.String(length=80), nullable=False),
sa.Column("thread_id", sa.BigInteger(), nullable=True),
sa.Column("user_id", sa.BigInteger(), nullable=True),
sa.Column("qa_session_id", sa.BigInteger(), nullable=True),
sa.Column("run_public_id", sa.String(length=80), nullable=True),
sa.Column("context_packet_version", sa.String(length=80), nullable=True),
sa.Column("user_question_preview", sa.Text(), nullable=True),
sa.Column("assembly_payload", sa.JSON(), nullable=True),
sa.Column("observability_payload", sa.JSON(), nullable=True),
sa.Column("used_context_payload", sa.JSON(), nullable=True),
sa.Column("retrieval_critic_payload", sa.JSON(), nullable=True),
sa.Column("grounding_payload", sa.JSON(), nullable=True),
sa.Column("replay_payload", sa.JSON(), nullable=True),
sa.Column("assembled_at", sa.TIMESTAMP(), nullable=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["qa_session_id"], ["qa_sessions.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["thread_id"], ["pmflow_ai_threads.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "public_assembly_id", name="uq_pmflow_ai_context_assemblies_public_id"),
)
_create_index_once("ix_ctx_assemblies_tenant_id", TABLE_NAME, ["tenant_id"])
_create_index_once("ix_ctx_assemblies_public_id", TABLE_NAME, ["public_assembly_id"])
_create_index_once("ix_ctx_assemblies_thread_id", TABLE_NAME, ["thread_id"])
_create_index_once("ix_ctx_assemblies_user_id", TABLE_NAME, ["user_id"])
_create_index_once("ix_ctx_assemblies_qa_session_id", TABLE_NAME, ["qa_session_id"])
_create_index_once("ix_ctx_assemblies_run_public_id", TABLE_NAME, ["run_public_id"])
_create_index_once("ix_ctx_assemblies_assembled_at", TABLE_NAME, ["assembled_at"])
def downgrade() -> None:
if _table_exists(TABLE_NAME):
op.drop_table(TABLE_NAME)

View File

@ -0,0 +1,81 @@
"""add context topic workspaces
Revision ID: 20260523_context_topic_workspaces
Revises: 20260522_context_assemblies
Create Date: 2026-05-23
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260523_context_topic_workspaces"
down_revision = "20260522_context_assemblies"
branch_labels = None
depends_on = None
TABLE_NAME = "pmflow_ai_topic_workspaces"
def _table_exists(table_name: str) -> bool:
return sa.inspect(op.get_bind()).has_table(table_name)
def _index_exists(table_name: str, index_name: str) -> bool:
if not _table_exists(table_name):
return False
return any(item.get("name") == index_name for item in sa.inspect(op.get_bind()).get_indexes(table_name))
def _create_index_once(index_name: str, table_name: str, columns: list[str]) -> None:
if _table_exists(table_name) and not _index_exists(table_name, index_name):
op.create_index(index_name, table_name, columns)
def upgrade() -> None:
if not _table_exists(TABLE_NAME):
op.create_table(
TABLE_NAME,
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("public_topic_id", sa.String(length=80), nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=True),
sa.Column("workspace_scope", sa.String(length=80), nullable=False, server_default="space"),
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
sa.Column("topic_signature", sa.String(length=80), nullable=False),
sa.Column("topic_summary", sa.Text(), nullable=False, server_default=""),
sa.Column("current_objective", sa.Text(), nullable=False, server_default=""),
sa.Column("known_assumptions", sa.JSON(), nullable=True),
sa.Column("prior_proposals", sa.JSON(), nullable=True),
sa.Column("open_questions", sa.JSON(), nullable=True),
sa.Column("monetization_hypothesis", sa.Text(), nullable=True),
sa.Column("next_actionable_package", sa.Text(), nullable=True),
sa.Column("linked_thread_ids", sa.JSON(), nullable=True),
sa.Column("linked_run_ids", sa.JSON(), nullable=True),
sa.Column("linked_memory_ids", sa.JSON(), nullable=True),
sa.Column("confidence_score", sa.Float(), nullable=False, server_default="0.6"),
sa.Column("topic_payload", sa.JSON(), nullable=True),
sa.Column("last_activated_at", sa.TIMESTAMP(), nullable=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "public_topic_id", name="uq_pmflow_ai_topic_workspaces_public_id"),
)
_create_index_once("ix_pmflow_ai_topic_workspaces_tenant_id", TABLE_NAME, ["tenant_id"])
_create_index_once("ix_pmflow_ai_topic_workspaces_public_topic_id", TABLE_NAME, ["public_topic_id"])
_create_index_once("ix_pmflow_ai_topic_workspaces_user_id", TABLE_NAME, ["user_id"])
_create_index_once("ix_pmflow_ai_topic_workspaces_workspace_scope", TABLE_NAME, ["workspace_scope"])
_create_index_once("ix_pmflow_ai_topic_workspaces_status", TABLE_NAME, ["status"])
_create_index_once("ix_pmflow_ai_topic_workspaces_topic_signature", TABLE_NAME, ["topic_signature"])
_create_index_once("ix_pmflow_ai_topic_workspaces_confidence_score", TABLE_NAME, ["confidence_score"])
_create_index_once("ix_pmflow_ai_topic_workspaces_last_activated_at", TABLE_NAME, ["last_activated_at"])
def downgrade() -> None:
if _table_exists(TABLE_NAME):
op.drop_table(TABLE_NAME)

View File

@ -1,55 +1,11 @@
"""
API 路由模块
"""API package.
按业务域拆分路由,便于维护与扩展。
Route registration lives in ``app.api.router``. Keep this package entrypoint
lightweight so focused tests can import a single API module without importing
optional integrations such as Feishu or MCP.
"""
from .signals_api import router as signals_router
from .feedbacks import router as feedbacks_router
from .experiments import router as experiments_router
from .context import router as context_router
from .tasks import router as tasks_router
from .webhooks import router as webhooks_router
from .auth import router as auth_router
from .users_api import router as users_router
from .endpoints.skill import router as skill_router
from .sstc_api import router as sstc_router
from .ai_steward_api import router as ai_steward_router
from .feishu_api import router as feishu_router
from .wecom_api import router as wecom_router
from .model_capability_api import router as model_capability_router
from .budget import router as budget_router
from .scheduler import router as scheduler_router
from .notifications import router as notifications_router
from .llm_engines import router as llm_engines_router
from .llm_providers import router as llm_providers_router
from .prompt_profiles import router as prompt_profiles_router
from .knowledge_graph import router as knowledge_graph_router
from .operation_catalog import router as operation_catalog_router
from .ai_pmo_api import router as ai_pmo_router
__all__ = [
"signals_router",
"feedbacks_router",
"experiments_router",
"context_router",
"tasks_router",
"webhooks_router",
"auth_router",
"users_router",
"skill_router",
"sstc_router",
"ai_steward_router",
"feishu_router",
"wecom_router",
"model_capability_router",
"budget_router",
"scheduler_router",
"notifications_router",
"llm_engines_router",
"llm_providers_router",
"prompt_profiles_router",
"knowledge_graph_router",
"operation_catalog_router",
"ai_pmo_router",
"pmflow_launch_api",
"pmflow_os_api",
]

View File

@ -0,0 +1,46 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
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.account_settings import AccountSettingsUpdateRequest
from ...services.account_settings_service import AccountSettingsService
router = APIRouter(prefix="/account-settings", tags=["account-settings"])
def _service(db: AsyncSession, current_user: User) -> AccountSettingsService:
return AccountSettingsService(db, tenant_id=int(current_user.tenant_id))
@router.get("/me")
async def get_my_account_settings(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
settings = await _service(db, current_user).get_settings(user=current_user)
return success_response(settings.model_dump(mode="json"))
@router.put("/me")
async def update_my_account_settings(
body: AccountSettingsUpdateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
service = _service(db, current_user)
try:
settings = await service.update_settings(user=current_user, body=body)
except ValueError as exc:
await db.rollback()
return JSONResponse(status_code=400, content=error_response(400, str(exc)))
await db.commit()
return success_response(settings.model_dump(mode="json"))
__all__ = ["router"]

View File

@ -0,0 +1,61 @@
"""Platform full-chain Agent OS 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_capability
from ..models import User
from ..services.agent_run_audit_service import AgentRunAuditService
router = APIRouter(prefix="/agent-run-audits", tags=["agent_run_audits"])
require_platform_audit_view = require_platform_capability("platform.audit.view")
@router.get("")
async def list_agent_run_audits(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=30, ge=1, le=100),
tenant_id: Optional[int] = Query(default=None),
user_id: Optional[int] = Query(default=None),
status: Optional[str] = Query(default=None, max_length=80),
source_channel: Optional[str] = Query(default=None, max_length=80),
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_audit_view),
db: AsyncSession = Depends(get_db),
):
_ = current_user
data = await AgentRunAuditService(db).list_runs(
page=page,
page_size=page_size,
tenant_id=tenant_id,
user_id=user_id,
status=status,
source_channel=source_channel,
keyword=keyword,
since_hours=since_hours,
)
return success_response(data)
@router.get("/{public_run_id}")
async def get_agent_run_audit(
public_run_id: str,
current_user: User = Depends(require_platform_audit_view),
db: AsyncSession = Depends(get_db),
):
_ = current_user
try:
data = await AgentRunAuditService(db).get_run(public_run_id)
except ValueError as exc:
return JSONResponse(status_code=404, content=error_response(404, str(exc)))
return success_response(data)

View File

@ -0,0 +1,72 @@
"""AI call trace audit API."""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, Query
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from ..core.database import get_db
from ..core.response import error_response, success_response
from ..dependencies.permissions import require_platform_ai_ops
from ..models import User
from ..services.ai_call_trace_service import AICallTraceService
router = APIRouter(prefix="/ai-call-traces", tags=["ai_call_traces"])
@router.get("")
async def list_ai_call_traces(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=30, ge=1, le=100),
status: Optional[str] = Query(default=None, max_length=80),
provider: Optional[str] = Query(default=None, max_length=80),
model: Optional[str] = Query(default=None, max_length=160),
function_name: Optional[str] = Query(default=None, max_length=255),
scene_key: Optional[str] = Query(default=None, max_length=120),
slot_key: Optional[str] = Query(default=None, max_length=80),
prompt_key: Optional[str] = Query(default=None, max_length=128),
prompt_source: Optional[str] = Query(default=None, max_length=32),
prompt_release_id: Optional[int] = Query(default=None),
trace_id: Optional[str] = Query(default=None, max_length=120),
tenant_id: Optional[int] = Query(default=None),
keyword: Optional[str] = Query(default=None, max_length=300),
since_hours: Optional[int] = Query(default=168, ge=1, le=8760),
current_user: User = Depends(require_platform_ai_ops),
db: AsyncSession = Depends(get_db),
):
_ = current_user
data = await AICallTraceService(db).list_traces(
page=page,
page_size=page_size,
status=status,
provider=provider,
model=model,
function_name=function_name,
scene_key=scene_key,
slot_key=slot_key,
prompt_key=prompt_key,
prompt_source=prompt_source,
prompt_release_id=prompt_release_id,
trace_id=trace_id,
tenant_id=tenant_id,
keyword=keyword,
since_hours=since_hours,
)
return success_response(data)
@router.get("/{trace_id_or_log_id}")
async def get_ai_call_trace(
trace_id_or_log_id: str,
current_user: User = Depends(require_platform_ai_ops),
db: AsyncSession = Depends(get_db),
):
_ = current_user
try:
data = await AICallTraceService(db).get_trace(trace_id_or_log_id)
except ValueError as exc:
return JSONResponse(status_code=404, content=error_response(404, str(exc)))
return success_response(data)

341
backend/app/api/ai_logs.py Normal file
View File

@ -0,0 +1,341 @@
"""AI 处理可视化日志 API。"""
from __future__ import annotations
import json
from datetime import date, datetime
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import and_, case, func, or_, select, union_all
from sqlalchemy.ext.asyncio import AsyncSession
from ..core.response import success_response
from ..database import get_db
from ..dependencies.auth import get_current_user
from ..models import (
AIProcessingLog,
FeishuItem,
ProjectSignalLink,
ProjectSuggestion,
Signal,
User,
WorkItem,
)
router = APIRouter(prefix="/ai-logs", tags=["AI Logs"])
def _normalize_bullets(value: Any) -> list[str]:
if isinstance(value, list):
return [str(x).strip() for x in value if str(x).strip()][:3]
if isinstance(value, str):
raw = value.strip()
if not raw:
return []
try:
parsed = json.loads(raw)
if isinstance(parsed, list):
return [str(x).strip() for x in parsed if str(x).strip()][:3]
except Exception:
pass
return [line.strip(" -•\t") for line in raw.splitlines() if line.strip()][:3]
return []
def _safe_json(value: Optional[str]) -> Any:
text = (value or "").strip()
if not text:
return None
try:
return json.loads(text)
except Exception:
return None
def _serialize_log_row(row: AIProcessingLog) -> dict[str, Any]:
return {
"id": str(row.id),
"feishu_message_id": row.feishu_message_id,
"feishu_item_id": str(row.feishu_item_id) if row.feishu_item_id is not None else None,
"raw_message": row.raw_message,
"project_score": row.project_score,
"signal_confidence": row.signal_confidence,
"level": row.level,
"routing_decision": row.routing_decision,
"routing_reason": row.routing_reason,
"reasoning_bullets": _normalize_bullets(row.reasoning_bullets),
"processing_status": row.processing_status,
"error_message": row.error_message,
"duration_ms": row.duration_ms,
"created_at": row.created_at.isoformat() if row.created_at else None,
}
def _serialize_log_detail(row: AIProcessingLog) -> dict[str, Any]:
payload = _serialize_log_row(row)
payload["ai_raw_output"] = row.ai_raw_output
payload["ai_raw_output_json"] = _safe_json(row.ai_raw_output)
return payload
def _apply_project_filter(stmt, *, tenant_id: int, project_id: Optional[int]):
if project_id is None:
return stmt
work_item_message_ids = select(func.distinct(WorkItem.source_message_id)).where(
and_(
WorkItem.tenant_id == tenant_id,
WorkItem.project_id == int(project_id),
WorkItem.source_system == "feishu",
WorkItem.source_message_id.is_not(None),
)
)
direct_items = select(FeishuItem.id.label("item_id")).where(
and_(
FeishuItem.tenant_id == tenant_id,
FeishuItem.matched_project_id == int(project_id),
)
)
signal_matched_items = select(Signal.feishu_item_id.label("item_id")).where(
and_(
Signal.tenant_id == tenant_id,
Signal.matched_project_id == int(project_id),
Signal.feishu_item_id.is_not(None),
)
)
linked_items = (
select(Signal.feishu_item_id.label("item_id"))
.select_from(Signal)
.join(
ProjectSignalLink,
and_(
ProjectSignalLink.tenant_id == tenant_id,
ProjectSignalLink.project_id == int(project_id),
ProjectSignalLink.signal_id == Signal.id,
),
)
.where(
and_(
Signal.tenant_id == tenant_id,
Signal.feishu_item_id.is_not(None),
)
)
)
auto_created_items = select(ProjectSuggestion.feishu_item_id.label("item_id")).where(
and_(
ProjectSuggestion.tenant_id == tenant_id,
ProjectSuggestion.created_project_id == int(project_id),
ProjectSuggestion.feishu_item_id.is_not(None),
)
)
all_item_ids = union_all(
direct_items,
signal_matched_items,
linked_items,
auto_created_items,
).subquery("project_item_ids")
item_ids_stmt = select(func.distinct(all_item_ids.c.item_id)).where(all_item_ids.c.item_id.is_not(None))
message_ids_stmt = select(func.distinct(FeishuItem.feishu_message_id)).where(
and_(
FeishuItem.tenant_id == tenant_id,
FeishuItem.id.in_(item_ids_stmt),
FeishuItem.feishu_message_id.is_not(None),
)
)
return stmt.where(
or_(
AIProcessingLog.feishu_item_id.in_(item_ids_stmt),
AIProcessingLog.feishu_message_id.in_(message_ids_stmt),
AIProcessingLog.feishu_message_id.in_(work_item_message_ids),
)
)
@router.get("")
async def list_ai_logs(
project_id: Optional[int] = Query(default=None, ge=1),
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
tenant_id = int(current_user.tenant_id)
total_stmt = select(func.count(AIProcessingLog.id)).where(AIProcessingLog.tenant_id == tenant_id)
data_stmt = (
select(AIProcessingLog)
.where(AIProcessingLog.tenant_id == tenant_id)
.order_by(AIProcessingLog.created_at.desc(), AIProcessingLog.id.desc())
.offset(offset)
.limit(limit)
)
total_stmt = _apply_project_filter(total_stmt, tenant_id=tenant_id, project_id=project_id)
data_stmt = _apply_project_filter(data_stmt, tenant_id=tenant_id, project_id=project_id)
total = int((await db.execute(total_stmt)).scalar() or 0)
rows = (await db.execute(data_stmt)).scalars().all()
items = [_serialize_log_row(row) for row in rows]
return success_response(
{
"items": items,
"total": total,
"limit": limit,
"offset": offset,
"has_more": (offset + len(items)) < total,
}
)
@router.get("/stats")
async def get_ai_log_stats(
project_id: Optional[int] = Query(default=None, ge=1),
date_value: str = Query(default="today", alias="date"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
tenant_id = int(current_user.tenant_id)
if date_value == "today":
target_date = datetime.now().date()
else:
try:
target_date = date.fromisoformat(date_value)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"invalid_date:{date_value}") from exc
stats_stmt = (
select(
func.count(AIProcessingLog.id).label("received_count"),
func.sum(case((AIProcessingLog.processing_status == "success", 1), else_=0)).label("success_count"),
func.sum(case((AIProcessingLog.processing_status == "failed", 1), else_=0)).label("failed_count"),
func.sum(
case((AIProcessingLog.routing_decision == "duplicate_filtered", 1), else_=0)
).label("duplicate_filtered_count"),
func.sum(
case((AIProcessingLog.routing_decision == "execute_reply_guard_triggered", 1), else_=0)
).label("execute_reply_guard_count"),
func.sum(
case(
(
AIProcessingLog.routing_decision.in_(
["auto_create_project", "auto_enter_signal"]
),
1,
),
else_=0,
)
).label("auto_entered_count"),
func.sum(
case(
(
AIProcessingLog.routing_decision.in_(
[
"pending_low_confidence",
"pending_cold_start",
"pending_uncertain_project",
]
),
1,
),
else_=0,
)
).label("pending_count"),
func.sum(case((AIProcessingLog.processing_status == "processing", 1), else_=0)).label(
"processing_count"
),
func.avg(AIProcessingLog.signal_confidence).label("avg_signal_confidence"),
func.avg(AIProcessingLog.project_score).label("avg_project_score"),
)
.where(
and_(
AIProcessingLog.tenant_id == tenant_id,
func.date(AIProcessingLog.created_at) == target_date.isoformat(),
)
)
)
stats_stmt = _apply_project_filter(stats_stmt, tenant_id=tenant_id, project_id=project_id)
row = (await db.execute(stats_stmt)).one()
return success_response(
{
"date": target_date.isoformat(),
"received_count": int(row.received_count or 0),
"success_count": int(row.success_count or 0),
"failed_count": int(row.failed_count or 0),
"duplicate_filtered_count": int(row.duplicate_filtered_count or 0),
"execute_reply_guard_count": int(row.execute_reply_guard_count or 0),
"auto_entered_count": int(row.auto_entered_count or 0),
"pending_count": int(row.pending_count or 0),
"processing_count": int(row.processing_count or 0),
"avg_signal_confidence": round(float(row.avg_signal_confidence or 0.0), 4),
"avg_project_score": round(float(row.avg_project_score or 0.0), 4),
}
)
@router.get("/alerts/execute-reply-guard")
async def list_execute_reply_guard_alerts(
limit: int = Query(default=20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
tenant_id = int(current_user.tenant_id)
rows = (
await db.execute(
select(AIProcessingLog)
.where(
and_(
AIProcessingLog.tenant_id == tenant_id,
AIProcessingLog.routing_decision == "execute_reply_guard_triggered",
)
)
.order_by(AIProcessingLog.created_at.desc(), AIProcessingLog.id.desc())
.limit(limit)
)
).scalars().all()
return success_response(
{
"count": len(rows),
"items": [_serialize_log_row(row) for row in rows],
}
)
@router.get("/latest")
async def get_latest_ai_log(
project_id: Optional[int] = Query(default=None, ge=1),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
tenant_id = int(current_user.tenant_id)
stmt = (
select(AIProcessingLog)
.where(AIProcessingLog.tenant_id == tenant_id)
.order_by(AIProcessingLog.created_at.desc(), AIProcessingLog.id.desc())
.limit(1)
)
stmt = _apply_project_filter(stmt, tenant_id=tenant_id, project_id=project_id)
row = (await db.execute(stmt)).scalar_one_or_none()
return success_response(_serialize_log_detail(row) if row else None)
@router.get("/{log_id}")
async def get_ai_log_detail(
log_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
tenant_id = int(current_user.tenant_id)
row = (
await db.execute(
select(AIProcessingLog).where(
and_(
AIProcessingLog.tenant_id == tenant_id,
AIProcessingLog.id == int(log_id),
)
)
)
).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="ai_log_not_found")
return success_response(_serialize_log_detail(row))

417
backend/app/api/ai_pmo.py Normal file
View File

@ -0,0 +1,417 @@
"""AI PMO 决策中枢 API。"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..core.response import success_response
from ..database import get_db
from ..dependencies.auth import get_current_user
from ..dependencies.tenant import get_tenant_id
from ..models import DecisionRecord, IngestEvent, PlanBundle, PortfolioSnapshot, Project, ReplyReceipt, User, WorkItem
from ..services.ai_pmo_cadence_service import AIPMOCadenceService
from ..services.ai_pmo_kernel_service import AIPMOKernelService
router = APIRouter(prefix="/ai-pmo", tags=["AI PMO"])
class PMOProcessTextRequest(BaseModel):
message_text: str = Field(..., min_length=1, max_length=4000)
sender: Optional[str] = Field(default=None, max_length=120)
source_message_id: Optional[str] = Field(default=None, max_length=128)
project_id: Optional[int] = Field(default=None)
class PMOCadenceRunRequest(BaseModel):
cadence_type: str = Field(..., min_length=1, max_length=32)
project_id: Optional[int] = Field(default=None)
class PMOResumeWorkItemRequest(BaseModel):
project_id: Optional[int] = Field(default=None)
source_message_id: Optional[str] = Field(default=None, max_length=128)
@router.post("/process-text")
async def process_text(
request: PMOProcessTextRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
service = AIPMOKernelService(db, tenant_id=tenant_id)
result = await service.process_ingress(
source_system="pmflow_manual",
source_message_id=request.source_message_id,
message_text=request.message_text,
sender=str(request.sender or current_user.name or current_user.username or "PMFlow用户"),
channel_ref="pmflow_manual",
source_sender_open_id=None,
captured_at=None,
chat_type="manual",
project_hint_id=request.project_id,
)
if not result.get("handled"):
raise HTTPException(status_code=400, detail=str(result.get("reason") or "AI PMO kernel 未处理该输入"))
return success_response(result)
@router.get("/work-items")
async def list_work_items(
limit: int = 20,
state: Optional[str] = None,
project_id: Optional[int] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_ = current_user
stmt = (
select(WorkItem, IngestEvent, Project)
.join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id)
.outerjoin(Project, and_(Project.tenant_id == tenant_id, Project.id == WorkItem.project_id))
.where(WorkItem.tenant_id == tenant_id)
.order_by(WorkItem.updated_at.desc(), WorkItem.id.desc())
)
if state:
stmt = stmt.where(WorkItem.state == state)
if project_id is not None:
stmt = stmt.where(WorkItem.project_id == int(project_id))
rows = (await db.execute(stmt.limit(max(1, min(limit, 100))))).all()
items = [
_serialize_work_item_summary(row=row, ingest=ingest, project=project)
for row, ingest, project in rows
]
return success_response({"items": items})
@router.get("/work-items/{work_item_id}")
async def get_work_item(
work_item_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_ = current_user
row = (
await db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == tenant_id,
WorkItem.id == int(work_item_id),
)
)
)
).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="work_item_not_found")
decision_rows = (
await db.execute(
select(DecisionRecord).where(
and_(
DecisionRecord.tenant_id == tenant_id,
DecisionRecord.work_item_id == int(work_item_id),
)
).order_by(DecisionRecord.id.asc())
)
).scalars().all()
plan_rows = (
await db.execute(
select(PlanBundle).where(
and_(
PlanBundle.tenant_id == tenant_id,
PlanBundle.work_item_id == int(work_item_id),
)
).order_by(PlanBundle.id.asc())
)
).scalars().all()
reply_rows = (
await db.execute(
select(ReplyReceipt).where(
and_(
ReplyReceipt.tenant_id == tenant_id,
ReplyReceipt.work_item_id == int(work_item_id),
)
).order_by(ReplyReceipt.id.asc())
)
).scalars().all()
payload: dict[str, Any] = {
"id": int(row.id),
"source_system": row.source_system,
"source_message_id": row.source_message_id,
"state": row.state,
"intent": row.intent,
"decision_level": row.decision_level,
"project_id": _safe_int(row.project_id),
"signal_id": _safe_int(row.signal_id),
"context_snapshot": row.context_snapshot,
"action_bundle": row.action_bundle,
"execution_result": row.execution_result,
"review_summary": row.review_summary,
"reply_template_key": row.reply_template_key,
"decision_records": [
{
"id": int(item.id),
"decision_level": item.decision_level,
"decision_object_type": item.decision_object_type,
"decision_object_id": _safe_int(item.decision_object_id),
"rationale": item.rationale,
"risk_score": item.risk_score,
"budget_impact": item.budget_impact,
"resource_impact": item.resource_impact,
"simulation_result": item.simulation_result,
"policy_gate_hit": bool(item.policy_gate_hit),
"policy_gate_code": item.policy_gate_code,
"approval_requirement": item.approval_requirement,
"execution_result": item.execution_result,
"final_outcome": item.final_outcome,
}
for item in decision_rows
],
"plan_bundles": [
{
"id": int(item.id),
"plan_type": item.plan_type,
"summary": item.summary,
"payload": item.payload,
}
for item in plan_rows
],
"reply_receipts": [
{
"id": int(item.id),
"receipt_phase": item.receipt_phase,
"template_key": item.template_key,
"payload": item.payload,
"status": item.status,
}
for item in reply_rows
],
}
return success_response(payload)
@router.post("/work-items/{work_item_id}/resume")
async def resume_work_item(
work_item_id: int,
request: PMOResumeWorkItemRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
target = (
await db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == tenant_id,
WorkItem.id == int(work_item_id),
)
).limit(1)
)
).scalar_one_or_none()
if target is None:
raise HTTPException(status_code=404, detail="work_item_not_found")
service = AIPMOKernelService(db, tenant_id=tenant_id)
result = await service.process_ingress(
source_system="ai_pmo_ui",
source_message_id=request.source_message_id or f"resume-{int(work_item_id)}-{int(datetime.now().timestamp() * 1000)}",
message_text=f"继续处理 work_item #{int(work_item_id)}",
sender=str(current_user.name or current_user.username or "PMFlow用户"),
channel_ref="ai_pmo_ui",
source_sender_open_id=None,
captured_at=None,
chat_type="manual",
project_hint_id=request.project_id or _safe_int(target.project_id),
)
if not result.get("handled"):
raise HTTPException(status_code=400, detail=str(result.get("reason") or "resume_failed"))
return success_response(result)
@router.post("/cadence/run")
async def run_cadence(
request: PMOCadenceRunRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_ = current_user
service = AIPMOCadenceService(db, tenant_id=tenant_id)
try:
snapshot = await service.generate_snapshot(
cadence_type=request.cadence_type,
project_id=request.project_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
await db.commit()
return success_response(snapshot)
@router.get("/cadence/latest")
async def get_latest_cadence_snapshot(
cadence_type: str,
project_id: Optional[int] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_ = current_user
service = AIPMOCadenceService(db, tenant_id=tenant_id)
snapshot = await service.get_latest_snapshot(
cadence_type=cadence_type,
project_id=project_id,
)
return success_response(snapshot)
@router.get("/portfolio/program-view")
async def get_program_view(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_ = current_user
service = AIPMOCadenceService(db, tenant_id=tenant_id)
return success_response(await service.build_program_view())
@router.get("/portfolio/latest")
async def get_latest_portfolio_snapshot(
scope_type: str = "tenant",
project_id: Optional[int] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_ = current_user
scope_ref = f"project:{int(project_id)}" if project_id is not None else f"tenant:{tenant_id}"
row = (
await db.execute(
select(PortfolioSnapshot).where(
and_(
PortfolioSnapshot.tenant_id == tenant_id,
PortfolioSnapshot.scope_type == scope_type,
PortfolioSnapshot.scope_ref == scope_ref,
)
).order_by(PortfolioSnapshot.id.desc()).limit(1)
)
).scalar_one_or_none()
if row is None:
return success_response(None)
return success_response(
{
"id": int(row.id),
"scope_type": row.scope_type,
"scope_ref": row.scope_ref,
"summary": row.summary,
"payload": row.payload,
"created_at": row.created_at.isoformat() if row.created_at else None,
}
)
def _safe_int(value: Any) -> Optional[int]:
try:
if value is None or value == "":
return None
return int(value)
except (TypeError, ValueError):
return None
def _compact_text(value: Any, limit: int) -> str:
text = " ".join(str(value or "").split()).strip()
if len(text) <= limit:
return text
return f"{text[: max(limit - 1, 1)].rstrip()}…"
def _priority_from_review(review_summary: Any) -> tuple[str, int]:
payload = review_summary if isinstance(review_summary, dict) else {}
risk_score = float(payload.get("risk_score_after") or 0.0)
if risk_score >= 0.75:
return "high", 3
if risk_score >= 0.45:
return "medium", 2
return "low", 1
def _blocking_reason(row: WorkItem) -> Optional[str]:
execution_result = row.execution_result if isinstance(row.execution_result, dict) else {}
review_summary = row.review_summary if isinstance(row.review_summary, dict) else {}
reason = str(execution_result.get("reason") or execution_result.get("error_message") or "").strip()
if reason:
return reason
divergences = review_summary.get("divergences") if isinstance(review_summary.get("divergences"), list) else []
if divergences and isinstance(divergences[0], dict):
return str(divergences[0].get("detail") or "").strip() or None
return None
def _next_step_text(row: WorkItem) -> Optional[str]:
review_summary = row.review_summary if isinstance(row.review_summary, dict) else {}
next_actions = review_summary.get("next_actions") if isinstance(review_summary.get("next_actions"), list) else []
if next_actions:
return str(next_actions[0]).strip()
action_bundle = row.action_bundle if isinstance(row.action_bundle, dict) else {}
summary = str(action_bundle.get("summary") or "").strip()
return summary or None
def _stage_text(state: str) -> str:
mapping = {
"received": "已接收",
"modeled": "已建模",
"decided": "已决策",
"planned": "已规划",
"executing": "执行中",
"waiting_manual": "待人工确认",
"succeeded": "执行完成",
"failed": "执行失败",
"ignored": "已忽略",
}
return mapping.get(str(state or "").strip(), str(state or "").strip() or "-")
def _serialize_work_item_summary(*, row: WorkItem, ingest: IngestEvent, project: Optional[Project]) -> dict[str, Any]:
payload = ingest.payload if isinstance(ingest.payload, dict) else {}
message_text = str(payload.get("message_text") or "").strip()
summary = _compact_text(message_text or _next_step_text(row) or row.intent or "AI PMO 工作项", 180)
title = _compact_text(message_text or f"AI PMO · {row.intent or 'work_item'}", 64)
priority, priority_rank = _priority_from_review(row.review_summary)
project_name = None
if project is not None:
project_name = str(project.title or project.name or "").strip() or None
project_id = _safe_int(row.project_id)
return {
"id": int(row.id),
"source_system": row.source_system,
"source_message_id": row.source_message_id,
"state": row.state,
"intent": row.intent,
"decision_level": row.decision_level,
"project_id": project_id,
"project_name": project_name,
"signal_id": _safe_int(row.signal_id),
"reply_template_key": row.reply_template_key,
"title": title or f"工作项#{int(row.id)}",
"summary": summary,
"ai_plan": _compact_text(((row.action_bundle or {}) if isinstance(row.action_bundle, dict) else {}).get("summary") or "", 180) or None,
"blocking_reason": _blocking_reason(row),
"next_step_text": _next_step_text(row),
"stage_text": _stage_text(str(row.state or "")),
"priority": priority,
"priority_rank": priority_rank,
"target_url": f"/projects/{project_id}/board" if project_id is not None else None,
"review_summary": row.review_summary,
"execution_result": row.execution_result,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}

View File

@ -1,6 +1,7 @@
from __future__ import annotations
from .shared import *
from .shared import _visible_work_item_filter
from .helpers_inbox_filters import _build_latest_decision_projection, _should_hide_work_item_from_inbox, _safe_int
from .helpers_inbox_payload import (
_build_closure_payload_overrides,
@ -11,6 +12,7 @@ from .helpers_inbox_payload import (
from .schemas import PMOProcessTextRequest
from ...services.ai_pmo.project_operating_context_builder import ProjectOperatingContextBuilder
@router.post("/process-text")
async def process_text(
request: PMOProcessTextRequest,
@ -52,12 +54,12 @@ async def list_work_items(
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_ = current_user
stmt = (
select(WorkItem, IngestEvent, Project)
.join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id)
.outerjoin(Project, and_(Project.tenant_id == tenant_id, Project.id == WorkItem.project_id))
.where(WorkItem.tenant_id == tenant_id)
.where(_visible_work_item_filter(current_user=current_user, tenant_id=tenant_id))
.order_by(WorkItem.updated_at.desc(), WorkItem.id.desc())
)
if state:
@ -103,6 +105,7 @@ async def get_work_item(
and_(
WorkItem.tenant_id == tenant_id,
WorkItem.id == int(work_item_id),
_visible_work_item_filter(current_user=current_user, tenant_id=tenant_id),
)
)
)

View File

@ -1,6 +1,7 @@
from __future__ import annotations
from .shared import *
from .shared import _visible_work_item_filter
from .helpers_resume import _queue_resume_work_item, _reject_work_item
from .schemas import PMODraftDecisionRequest, PMOResumeWorkItemRequest, PMOWorkItemDecisionRequest
from ...services.ai_pmo.draft_confirm_handler import DraftConfirmHandler
@ -19,6 +20,7 @@ async def resume_work_item(
and_(
WorkItem.tenant_id == tenant_id,
WorkItem.id == int(work_item_id),
_visible_work_item_filter(current_user=current_user, tenant_id=tenant_id),
)
).limit(1)
)
@ -61,6 +63,7 @@ async def decide_work_item(
and_(
WorkItem.tenant_id == tenant_id,
WorkItem.id == int(work_item_id),
_visible_work_item_filter(current_user=current_user, tenant_id=tenant_id),
)
).limit(1)
)
@ -165,6 +168,7 @@ async def decide_draft_work_item(
and_(
WorkItem.tenant_id == tenant_id,
WorkItem.id == int(work_item_id),
_visible_work_item_filter(current_user=current_user, tenant_id=tenant_id),
)
).limit(1)
)

View File

@ -6,7 +6,7 @@ from typing import Any, Literal, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import and_, select
from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ...core.logging import get_logger
@ -14,10 +14,40 @@ from ...core.response import success_response
from ...core.database import AsyncSessionLocal, get_db
from ...dependencies.auth import get_current_user
from ...dependencies.tenant import get_tenant_id
from ...models import DecisionRecord, IngestEvent, PlanBundle, PortfolioSnapshot, Project, ReplyReceipt, User, WorkItem
from ...models import DecisionRecord, IngestEvent, PlanBundle, PortfolioSnapshot, Project, ReplyReceipt, User, UserProject, WorkItem
from ...services.auth import has_any_role
from ...services.ai_pmo.ai_pmo_cadence import AIPMOCadenceService
from ...services.ai_pmo.ai_pmo_kernel import AIPMOKernelService
router = APIRouter(prefix="/ai-pmo", tags=["AI PMO"])
logger = get_logger(__name__)
FRONT_NOISE_INTENTS = {"query.read_only", "query.answer"}
def _can_view_all_work_items(user: User) -> bool:
return has_any_role(str(getattr(user, "role", "") or ""), ["OWNER", "REVIEWER"])
def _visible_work_item_filter(*, current_user: User, tenant_id: int):
if _can_view_all_work_items(current_user):
return True
return or_(
WorkItem.owner_user_id == int(current_user.id),
WorkItem.project_id.in_(
select(Project.id)
.outerjoin(
UserProject,
and_(
UserProject.tenant_id == Project.tenant_id,
UserProject.project_id == Project.id,
UserProject.user_id == int(current_user.id),
),
)
.where(
and_(
Project.tenant_id == int(tenant_id),
or_(Project.owner_id == int(current_user.id), UserProject.user_id.is_not(None)),
)
)
),
)

View File

@ -0,0 +1,32 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from ...core.database import get_db
from ...core.response import success_response
from ...dependencies import get_current_user, require_platform_ai_ops
from ...models import User
from ...services.ai_readiness_service import AIReadinessService
router = APIRouter(prefix="/ai-readiness", tags=["ai-readiness"])
@router.get("/project-launch")
async def get_project_launch_ai_readiness(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
payload = await AIReadinessService(db, tenant_id=int(current_user.tenant_id)).get_project_launch_readiness()
return success_response(payload)
@router.post("/project-launch/check")
async def run_project_launch_ai_readiness_check(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_platform_ai_ops),
):
payload = await AIReadinessService(db, tenant_id=int(current_user.tenant_id)).run_project_launch_check(
user_id=int(current_user.id),
)
return success_response(payload)

View File

@ -0,0 +1,457 @@
"""AI routing and provider governance API."""
from __future__ import annotations
from typing import Any, Literal, Optional
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from ..core.database import get_db
from ..core.response import error_response, success_response
from ..dependencies.auth import get_current_user
from ..dependencies.permissions import require_platform_ai_ops
from ..dependencies.tenant import get_tenant_id
from ..models import User
from ..services.ai_function_runtime_profile_service import AIFunctionRuntimeProfileService
from ..services.ai_routing_governance_service import AIRoutingGovernanceService
from ..services.auth import can_approve_ai_routing_change, can_view_ai_policy
from ..services.operation_log_service import log_operation
from ..services.pmflow_change_review_service import PMFlowChangeReviewError, PMFlowChangeReviewService
from ..services.pmflow_launch_service import PMFlowLaunchError, PMFlowLaunchService
router = APIRouter(prefix="/ai-routing", tags=["ai_routing"])
class TeamPolicySceneUpdate(BaseModel):
key: str = Field(min_length=1, max_length=80)
policy: str = Field(min_length=1, max_length=300)
mode: Optional[str] = Field(default=None, max_length=80)
human_confirmation: bool = True
protected: bool = True
class TeamPolicyUpdateRequest(BaseModel):
strategy: Literal["quality_first", "balanced", "speed_first"] = "balanced"
scenes: list[TeamPolicySceneUpdate] = Field(default_factory=list)
automation_boundary: Optional[str] = Field(default=None, max_length=300)
budget_guard: dict[str, Any] = Field(default_factory=dict)
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):
required_slot: Optional[str] = Field(default=None, min_length=1, max_length=40)
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)
runtime_mode: Optional[Literal["active", "shadow", "bypass", "disabled"]] = None
quality_gate_mode: Optional[Literal["strict", "relaxed", "shadow", "off"]] = None
class FunctionRuntimeProfilePublishRequest(BaseModel):
summary: Optional[str] = Field(default=None, max_length=200)
def _forbidden(message: str) -> JSONResponse:
return JSONResponse(status_code=403, content=error_response(403, message))
def _service(db: AsyncSession, tenant_id: int) -> AIRoutingGovernanceService:
return AIRoutingGovernanceService(db, tenant_id=int(tenant_id))
def _profile_service(db: AsyncSession) -> AIFunctionRuntimeProfileService:
return AIFunctionRuntimeProfileService(db)
@router.get("/overview")
async def get_ai_routing_overview(
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_platform_ai_ops),
):
data = await _service(db, tenant_id).get_overview()
return success_response(data)
@router.get("/team-policy")
async def get_ai_team_policy(
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
):
if not can_view_ai_policy(str(current_user.role or "")):
return _forbidden("仅团队负责人可查看团队 AI 策略")
data = await _service(db, tenant_id).get_team_policy()
return success_response(data)
@router.put("/team-policy")
async def update_ai_team_policy(
body: TeamPolicyUpdateRequest,
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
):
if not can_approve_ai_routing_change(str(current_user.role or "")):
return _forbidden("仅 OWNER 可保存团队 AI 策略")
service = _service(db, tenant_id)
before = await service.get_team_policy()
after = await service.update_team_policy(
payload=body.model_dump(mode="json"),
updated_by=int(current_user.id),
)
await log_operation(
db,
tenant_id=int(tenant_id),
user_id=int(current_user.id),
project_id=None,
entity_type="ai_routing_policy",
entity_id="team-policy",
action="update",
before_data=before,
after_data=after,
remark="更新团队 AI 策略",
)
await db.commit()
return success_response(after)
@router.get("/admin-console")
async def get_ai_routing_admin_console(
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_admin_console()
return success_response(data)
@router.get("/capability-slots")
async def get_ai_capability_slots(
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_platform_ai_ops),
):
data = await _service(db, tenant_id).get_capability_slots()
return success_response(data)
@router.put("/capability-slots/{slot_key}")
async def update_ai_capability_slot(
slot_key: str,
body: CapabilitySlotUpdateRequest,
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_platform_ai_ops),
):
service = _service(db, tenant_id)
before = await service.get_capability_slots()
try:
after = await service.update_capability_slot(
slot_key=slot_key,
payload=body.model_dump(mode="json"),
updated_by=int(current_user.id),
)
except ValueError as exc:
await db.rollback()
return JSONResponse(status_code=400, content=error_response(400, str(exc)))
await log_operation(
db,
tenant_id=int(tenant_id),
user_id=int(current_user.id),
project_id=None,
entity_type="ai_capability_slot",
entity_id=str(slot_key),
action="draft_update",
before_data=before,
after_data=after,
remark="保存 AI 能力槽位草稿",
)
await db.commit()
return success_response(after)
@router.put("/capability-slots/{slot_key}/draft")
async def update_ai_capability_slot_draft(
slot_key: str,
body: CapabilitySlotUpdateRequest,
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_platform_ai_ops),
):
return await update_ai_capability_slot(
slot_key=slot_key,
body=body,
db=db,
tenant_id=tenant_id,
current_user=current_user,
)
@router.post("/capability-slots/validate")
async def validate_ai_capability_slots(
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_platform_ai_ops),
):
data = await _service(db, tenant_id).validate_capability_slots()
return success_response(data)
@router.post("/capability-slots/publish")
async def publish_ai_capability_slots(
body: CapabilitySlotPublishRequest,
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_platform_ai_ops),
):
service = _service(db, tenant_id)
before = await service.get_capability_slots()
try:
after = await service.publish_capability_slots(
published_by=int(current_user.id),
summary=body.summary,
)
except ValueError as exc:
await db.rollback()
return JSONResponse(status_code=400, content=error_response(400, str(exc)))
await log_operation(
db,
tenant_id=int(tenant_id),
user_id=int(current_user.id),
project_id=None,
entity_type="ai_capability_slot_release",
entity_id=str(after.get("active_version") or ""),
action="publish",
before_data=before,
after_data=after,
remark="发布 AI 能力槽位版本",
)
await db.commit()
return success_response(after)
@router.post("/capability-slots/rollback")
async def rollback_ai_capability_slots(
body: CapabilitySlotRollbackRequest,
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_platform_ai_ops),
):
service = _service(db, tenant_id)
before = await service.get_capability_slots()
try:
after = await service.rollback_capability_slots(
target_version=body.target_version,
rolled_back_by=int(current_user.id),
)
except ValueError as exc:
await db.rollback()
return JSONResponse(status_code=400, content=error_response(400, str(exc)))
await log_operation(
db,
tenant_id=int(tenant_id),
user_id=int(current_user.id),
project_id=None,
entity_type="ai_capability_slot_release",
entity_id=str(after.get("active_version") or ""),
action="rollback",
before_data=before,
after_data=after,
remark="回滚 AI 能力槽位版本",
)
await db.commit()
return success_response(after)
@router.get("/function-runtime-profiles")
async def get_ai_function_runtime_profiles(
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_platform_ai_ops),
):
_ = tenant_id
data = await _profile_service(db).get_profiles()
return success_response(data)
@router.put("/function-runtime-profiles/{function_name}/draft")
async def update_ai_function_runtime_profile_draft(
function_name: str,
body: FunctionRuntimeProfileUpdateRequest,
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_platform_ai_ops),
):
_ = tenant_id
service = _profile_service(db)
before = await service.get_profiles()
try:
after = await service.update_profile(
function_name=function_name,
payload=body.model_dump(exclude_none=True, mode="json"),
updated_by=int(current_user.id),
)
except ValueError as exc:
await db.rollback()
return JSONResponse(status_code=400, content=error_response(400, str(exc)))
await log_operation(
db,
tenant_id=int(tenant_id),
user_id=int(current_user.id),
project_id=None,
entity_type="ai_function_runtime_profile",
entity_id=str(function_name),
action="draft_update",
before_data=before,
after_data=after,
remark="保存函数运行合同草稿",
)
await db.commit()
return success_response(after)
@router.post("/function-runtime-profiles/validate")
async def validate_ai_function_runtime_profiles(
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_platform_ai_ops),
):
_ = tenant_id
data = await _profile_service(db).get_profiles()
return success_response(data.get("validation") or {})
@router.post("/function-runtime-profiles/{function_name}/smoke")
async def smoke_ai_function_runtime_profile(
function_name: str,
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_platform_ai_ops),
):
profile_service = _profile_service(db)
if not AIFunctionRuntimeProfileService.smoke_supported(function_name):
return JSONResponse(
status_code=400,
content=error_response(
400,
"当前函数未接入受控 smoke,也不是发布必需 smoke;请处理发布门禁里标出的 smoke_required 函数。",
),
)
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()
payload = error_response(400, str(exc))
payload["data"] = after
return JSONResponse(status_code=400, content=payload)
await log_operation(
db,
tenant_id=int(tenant_id),
user_id=int(current_user.id),
project_id=None,
entity_type="ai_function_runtime_profile",
entity_id=str(function_name),
action="smoke",
before_data=None,
after_data=after,
remark="运行函数运行合同 smoke",
)
await db.commit()
return success_response(after)
@router.post("/function-runtime-profiles/publish")
async def publish_ai_function_runtime_profiles(
body: FunctionRuntimeProfilePublishRequest,
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_platform_ai_ops),
):
_ = tenant_id
service = _profile_service(db)
before = await service.get_profiles()
try:
after = await service.publish_profiles(
published_by=int(current_user.id),
summary=body.summary,
)
except ValueError as exc:
await db.rollback()
return JSONResponse(status_code=400, content=error_response(400, str(exc)))
await log_operation(
db,
tenant_id=int(tenant_id),
user_id=int(current_user.id),
project_id=None,
entity_type="ai_function_runtime_profile_release",
entity_id=str(after.get("active_version") or ""),
action="publish",
before_data=before,
after_data=after,
remark="发布函数运行合同版本",
)
await db.commit()
return success_response(after)
@router.post("/diagnostics/run")
async def run_ai_routing_diagnostics(
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_platform_ai_ops),
):
data = await _service(db, tenant_id).run_diagnostics()
return success_response(data)

File diff suppressed because it is too large Load Diff

View File

@ -1,45 +1,13 @@
from __future__ import annotations
from .shared import *
from .schemas import BrowserCaptureRequest, FeishuCaptureRequest, GitHubCaptureRequest, ProjectFromTextRequest, ProjectFromTextResponse
from .schemas import BrowserCaptureRequest, FeishuCaptureRequest, GitHubCaptureRequest
@router.post("/project-from-text", response_model=ApiResponse[ProjectFromTextResponse])
async def create_project_from_text(
request: ProjectFromTextRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
零配置项目创建 - 从一句话生成完整项目
示例输入:
```
我们要做一个 AI 客服工具,基于 GPT-4o mini,对标 XX 竞品,3 周上线
```
AI 将自动生成:
- 完整的项目配置
- 竞品分析信号
- 项目宪章
- 任务链(含依赖关系)
- 风险预警规则
"""
try:
service = get_ai_steward_service(db)
result = await service.create_project_from_natural_language(
input_text=request.input_text,
creator_id=int(current_user.id),
tenant_id=tenant_id
)
return success_response(ProjectFromTextResponse(**result).model_dump())
except Exception as e:
@router.post("/project-from-text")
async def create_project_from_text():
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"项目创建失败: {str(e)}"),
status_code=410,
content=error_response(410, "旧项目创建入口已下线,请使用新项目启动台生成草案并确认创建。"),
)
@router.post("/signal/capture-feishu")

View File

@ -24,7 +24,7 @@ async def get_executive_briefing(
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
if not _manager_roles(current_user).intersection({"BOSS", "ADMIN"}):
if not _manager_roles(current_user).intersection({"OWNER", "REVIEWER"}):
return _forbidden_response()
try:
@ -40,7 +40,7 @@ async def get_executive_briefing_config(
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
if not _manager_roles(current_user).intersection({"BOSS", "ADMIN"}):
if not _manager_roles(current_user).intersection({"OWNER", "REVIEWER"}):
return _forbidden_response()
payload = await ExecutiveBriefingService(db, tenant_id=tenant_id).get_config()
return success_response(payload)
@ -53,7 +53,7 @@ async def update_executive_briefing_config(
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
if not _manager_roles(current_user).intersection({"BOSS", "ADMIN"}):
if not _manager_roles(current_user).intersection({"OWNER", "REVIEWER"}):
return _forbidden_response()
service = ExecutiveBriefingService(db, tenant_id=tenant_id)

View File

@ -15,7 +15,7 @@ from ...services.auth import parse_role_values
from ...services.feedback_center import FeedbackCenterService, list_reason_catalog
from .shared import *
MANAGER_ROLES = {"ADMIN", "BOSS"}
MANAGER_ROLES = {"REVIEWER", "OWNER"}
def _is_manager_user(current_user: User) -> bool:

View File

@ -2,46 +2,6 @@ from __future__ import annotations
from .shared import *
class ProjectFromTextRequest(BaseModel):
"""零配置项目创建请求"""
input_text: str = Field(
...,
description="自然语言项目描述,例如:我们要做一个 AI 客服工具,基于 GPT-4o mini,对标 XX 竞品,3 周上线",
min_length=10,
max_length=500
)
class ProjectFromTextSignalSource(BaseModel):
registry_id: int | str | None = None
id: int | str | None = None
project_id: int | str | None = None
signal_id: int | str | None = None
source_kind: str
source_name: str
source_identifier: str
display_source_kind: Optional[str] = None
technical_identifier: Optional[str] = None
description: Optional[str] = None
requested_via: Optional[str] = None
status: Optional[str] = None
onboarding_status: Optional[str] = None
section: Optional[str] = None
title: Optional[str] = None
priority: Optional[str] = None
origin: Optional[str] = None
raw_context: Optional[Dict[str, Any]] = None
config: Optional[Dict[str, Any]] = None
class ProjectFromTextResponse(BaseModel):
"""零配置项目创建响应"""
project: Dict[str, Any]
signal: Dict[str, Any]
charter: Dict[str, Any]
tasks: List[Dict[str, Any]]
risks: List[Dict[str, Any]]
signal_sources: List[ProjectFromTextSignalSource] = Field(default_factory=list)
analysis: Dict[str, Any]
class CognitiveConflictResponse(BaseModel):
"""认知冲突响应"""
id: str

View File

@ -42,8 +42,8 @@ async def list_audit_operations(
db: AsyncSession = Depends(get_db),
header_tenant_id: int = Depends(get_tenant_id),
):
if current_user is not None and not has_any_role(_user_role(current_user), ["BOSS", "ADMIN"]):
raise HTTPException(status_code=403, detail="Access denied. Required roles: BOSS, ADMIN")
if current_user is not None and not has_any_role(_user_role(current_user), ["OWNER", "REVIEWER"]):
raise HTTPException(status_code=403, detail="Access denied. Required roles: OWNER, REVIEWER")
if current_user is None:
effective_tenant_id = int(header_tenant_id or 1)
else:

View File

@ -1,14 +1,23 @@
"""认证 API"""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import or_
from sqlalchemy.future import select
from ..core.database import get_db
from ..models import User, UserStatus
from ..schemas import UserLoginResponse
from ..schemas import PhoneCodeSendRequest, PhoneCodeVerifyRequest, UserLoginResponse, UserRegisterRequest
from ..services.auth import AuthService
from ..services.phone_auth_service import send_phone_verification_code, verify_phone_and_login
from ..services.platform_access_service import get_platform_context
from ..services.workspace_service import (
ensure_active_workspace_membership,
ensure_personal_workspace,
list_user_workspaces,
register_user_with_personal_workspace,
)
router = APIRouter(prefix="/auth", tags=["auth"])
@ -20,7 +29,7 @@ async def login(
):
"""用户登录,返回 JWT"""
result = await db.execute(
select(User).where(User.username == credentials.username)
select(User).where(or_(User.username == credentials.username, User.phone_e164 == credentials.username))
)
user = result.scalar_one_or_none()
@ -39,10 +48,76 @@ async def login(
detail="User is disabled",
)
await ensure_personal_workspace(db, user=user, commit=False)
await ensure_active_workspace_membership(db, user=user, commit=False)
await db.commit()
access_token = AuthService.create_access_token(data={"sub": user.username})
workspaces = await list_user_workspaces(db, user=user)
platform_context = await get_platform_context(db, user=user)
return UserLoginResponse(
access_token=access_token,
token_type="bearer",
user=user.to_dict(),
user={**user.to_dict(), "workspace_context": workspaces, "platform_context": platform_context},
)
@router.post("/phone/send-code")
async def send_phone_code(
body: PhoneCodeSendRequest,
request: Request,
db: AsyncSession = Depends(get_db),
):
"""发送手机号验证码。开发环境会返回 dev_code,生产环境接短信服务商后不返回验证码。"""
return await send_phone_verification_code(
db,
country_code=body.phone_country_code,
phone_number=body.phone_number,
purpose=body.purpose,
request_ip=request.client.host if request.client else None,
)
@router.post("/phone/verify", response_model=UserLoginResponse)
async def verify_phone_code_login(
body: PhoneCodeVerifyRequest,
db: AsyncSession = Depends(get_db),
):
"""手机号验证码登录/自注册,并可绑定邀请。"""
result = await verify_phone_and_login(
db,
country_code=body.phone_country_code,
phone_number=body.phone_number,
code=body.code,
purpose=body.purpose,
invite_token=body.invite_token,
name=body.name,
)
return UserLoginResponse(
access_token=result["access_token"],
token_type=result["token_type"],
user=result["user"],
)
@router.post("/register", response_model=UserLoginResponse)
async def register(
body: UserRegisterRequest,
db: AsyncSession = Depends(get_db),
):
"""注册账号并自动创建个人空间。"""
user = await register_user_with_personal_workspace(
db,
username=body.username,
password=body.password,
name=body.name,
email=body.email or f"{body.username}@placeholder.local",
)
access_token = AuthService.create_access_token(data={"sub": user.username})
workspaces = await list_user_workspaces(db, user=user)
platform_context = await get_platform_context(db, user=user)
return UserLoginResponse(
access_token=access_token,
token_type="bearer",
user={**user.to_dict(), "workspace_context": workspaces, "platform_context": platform_context},
)

View File

@ -80,7 +80,7 @@ async def list_budgets(
@router.post("/")
async def create_budget(
request: BudgetCreateRequest,
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
@ -122,7 +122,7 @@ async def create_budget(
async def update_budget(
budget_id: int,
request: BudgetUpdateRequest,
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
@ -163,7 +163,7 @@ async def update_budget(
async def override_budget(
budget_id: int,
request: BudgetOverrideRequest,
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):

302
backend/app/api/dev_sync.py Normal file
View File

@ -0,0 +1,302 @@
from __future__ import annotations
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Request
from pydantic import BaseModel
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..core.database import get_db
from ..core.response import success_response
from ..dependencies import get_current_user
from ..models import Project, User, UserProject
from ..services.ai_project_control_service import AIProjectControlService
from ..services.auth import has_any_role
router = APIRouter(tags=["dev-sync"])
class RepoBindingCreateRequest(BaseModel):
project_id: int
provider: str = "github"
owner: str
repo_name: str
default_branch: str = "main"
external_repo_id: str | None = None
installation_id: str | None = None
installation_account_login: str | None = None
webhook_secret_hint: str | None = None
class RepoBindingSelectRequest(BaseModel):
project_id: int
github_repository_id: int
class ProgressEventCreateRequest(BaseModel):
project_id: int
event_type: str
source: str = "manual"
summary: str | None = None
repo_binding_id: int | None = None
workspace_binding_id: int | None = None
payload: dict[str, object] | None = None
occurred_at: str | None = None
@router.get("/integrations/github/app/install-url")
async def get_github_app_install_url(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
payload = await control.get_github_app_install_center()
return success_response(
{
"install_url": payload.get("install_url"),
"configured": bool(payload.get("configured")),
"callback_url": payload.get("callback_url"),
"webhook_signature_configured": bool(payload.get("webhook_signature_configured")),
"app_slug": payload.get("app_slug"),
}
)
@router.get("/integrations/github/app/installations")
async def list_github_app_installations(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
payload = await control.get_github_app_install_center()
return success_response(payload)
@router.post("/integrations/github/app/installations/sync")
async def sync_github_app_installations(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
try:
payload = await control.sync_github_installations()
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return success_response(payload)
@router.get("/integrations/github/repos/available")
async def list_available_github_repos(
project_id: int | None = Query(default=None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if project_id is not None:
await _ensure_project_member_or_manager(
db,
tenant_id=int(current_user.tenant_id),
project_id=int(project_id),
current_user=current_user,
)
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
return success_response(await control.list_available_github_repositories())
async def _ensure_project_member_or_manager(
db: AsyncSession,
*,
tenant_id: int,
project_id: int,
current_user: User,
) -> None:
project_result = await db.execute(
select(Project).where(and_(Project.id == int(project_id), Project.tenant_id == int(tenant_id)))
)
project = project_result.scalar_one_or_none()
if project is None:
raise HTTPException(status_code=404, detail="项目不存在")
if has_any_role(str(getattr(current_user, "role", "") or ""), ["OWNER", "REVIEWER"]):
return
member_result = await db.execute(
select(UserProject).where(
and_(
UserProject.tenant_id == int(tenant_id),
UserProject.project_id == int(project_id),
UserProject.user_id == int(current_user.id),
)
)
)
if member_result.scalar_one_or_none() is None:
raise HTTPException(status_code=403, detail="当前用户无权操作该项目")
@router.get("/me/dev-tasks")
async def list_my_dev_tasks(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
return success_response(await control.get_dev_tasks_for_user(user_id=int(current_user.id)))
@router.get("/projects/{project_id}/dev-sync/summary")
async def get_project_dev_sync_summary(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _ensure_project_member_or_manager(
db,
tenant_id=int(current_user.tenant_id),
project_id=int(project_id),
current_user=current_user,
)
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
return success_response(
await control.get_project_dev_sync_summary(
project_id=int(project_id),
current_user_id=int(current_user.id),
)
)
@router.post("/tasks/{task_id}/progress-events")
async def create_progress_event(
task_id: int,
body: ProgressEventCreateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _ensure_project_member_or_manager(
db,
tenant_id=int(current_user.tenant_id),
project_id=int(body.project_id),
current_user=current_user,
)
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
payload = await control.create_or_apply_progress_event(
task_id=int(task_id),
project_id=int(body.project_id),
event_type=body.event_type,
source=body.source,
payload=dict(body.payload or {}),
summary=body.summary,
user_id=int(current_user.id),
repo_binding_id=int(body.repo_binding_id) if body.repo_binding_id is not None else None,
workspace_binding_id=int(body.workspace_binding_id) if body.workspace_binding_id is not None else None,
occurred_at=body.occurred_at,
)
return success_response(payload)
@router.post("/internal/debug/integrations/github/repos")
async def register_github_repo(
body: RepoBindingCreateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _ensure_project_member_or_manager(
db,
tenant_id=int(current_user.tenant_id),
project_id=int(body.project_id),
current_user=current_user,
)
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
payload = await control.register_repo_binding(
project_id=int(body.project_id),
provider=body.provider,
owner=body.owner,
repo_name=body.repo_name,
default_branch=body.default_branch,
external_repo_id=body.external_repo_id,
installation_id=body.installation_id,
installation_account_login=body.installation_account_login,
webhook_secret_hint=body.webhook_secret_hint,
)
return success_response(payload)
@router.post("/integrations/github/repos/bind")
async def bind_github_repo(
body: RepoBindingSelectRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _ensure_project_member_or_manager(
db,
tenant_id=int(current_user.tenant_id),
project_id=int(body.project_id),
current_user=current_user,
)
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
try:
payload = await control.bind_github_repository(
project_id=int(body.project_id),
github_repository_id=int(body.github_repository_id),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return success_response(payload)
@router.get("/integrations/github/deliveries")
async def list_github_deliveries(
project_id: int | None = Query(default=None),
limit: int = Query(default=20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if project_id is not None:
await _ensure_project_member_or_manager(
db,
tenant_id=int(current_user.tenant_id),
project_id=int(project_id),
current_user=current_user,
)
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
return success_response(await control.list_github_deliveries(project_id=project_id, limit=limit))
@router.post("/integrations/github/webhook")
async def ingest_github_webhook(
request: Request,
db: AsyncSession = Depends(get_db),
event_type: str | None = Header(default=None, alias="X-GitHub-Event"),
signature_256: str | None = Header(default=None, alias="X-Hub-Signature-256"),
delivery_id: str | None = Header(default=None, alias="X-GitHub-Delivery"),
):
raw_body = await request.body()
try:
payload = await request.json()
except Exception as exc:
raise HTTPException(status_code=400, detail="GitHub webhook body 不是合法 JSON") from exc
tenant_id = int((payload.get("tenant_id") or 1))
control = AIProjectControlService(db, tenant_id=tenant_id)
try:
result = await control.ingest_github_webhook(
event_type=str(event_type or payload.get("event_type") or "").strip(),
delivery_id=str(delivery_id or payload.get("delivery_id") or "").strip(),
signature_256=signature_256,
payload=payload,
raw_body=raw_body,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return success_response(result)
@router.post("/internal/debug/integrations/github/events")
async def ingest_github_event_debug(
payload: dict = Body(...),
db: AsyncSession = Depends(get_db),
event_type: str | None = Header(default=None, alias="X-GitHub-Event"),
):
tenant_id = int((payload.get("tenant_id") or 1))
control = AIProjectControlService(db, tenant_id=tenant_id)
try:
result = await control.ingest_github_event(
event_type=str(event_type or payload.get("event_type") or "").strip(),
payload=payload,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return success_response({"debug": True, **result})

View File

@ -27,5 +27,5 @@ def skill_tag_to_response(tag) -> SkillTagResponse:
def ensure_self_or_admin(*, current_user, target_user_id: int, detail: str) -> None:
current_role = current_user.role.value if isinstance(current_user.role, Enum) else current_user.role
if current_user.id != target_user_id and not has_any_role(str(current_role), ["BOSS", "ADMIN"]):
if current_user.id != target_user_id and not has_any_role(str(current_role), ["OWNER", "REVIEWER"]):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail)

View File

@ -40,7 +40,7 @@ async def list_skill_tags(
async def create_skill_tag(
body: SkillTagCreate,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
db: AsyncSession = Depends(get_db),
):
_ = current_user
@ -78,7 +78,7 @@ async def update_skill_tag(
tag_id: UUID,
body: SkillTagUpdate,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
db: AsyncSession = Depends(get_db),
):
_ = current_user
@ -103,7 +103,7 @@ async def update_skill_tag(
async def delete_skill_tag(
tag_id: UUID,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
db: AsyncSession = Depends(get_db),
):
_ = current_user

376
backend/app/api/feishu.py Normal file
View File

@ -0,0 +1,376 @@
"""Feishu Integration API - 飞书集成接口。"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any
from ..database import get_db
from ..dependencies.auth import get_current_user
from ..dependencies.tenant import get_tenant_id
from ..models import User
from ..services.auth import has_any_role
from ..services.feishu_integration import get_feishu_service
from ..services.feishu_long_connection import get_feishu_long_connection_manager
router = APIRouter(prefix="/feishu", tags=["Feishu Integration"])
def _build_long_connection_failure(long_status: Dict[str, Any]) -> tuple[str, Optional[str]]:
code = str(long_status.get("last_error_code") or "").strip().lower()
last_error = str(long_status.get("last_error") or "").strip()
retryable = bool(long_status.get("last_error_retryable"))
if code in {"network_error", "thread_exited"} and not retryable:
return (
"长连接自动重连次数已耗尽,请检查网络/凭证后手动重启飞书长连接",
last_error or None,
)
if code == "auth_failed":
return (
"长连接鉴权失败,请检查 App ID / App Secret、应用凭证是否已生效,以及飞书应用权限是否完整",
last_error or None,
)
if code == "network_error":
return (
"长连接预检失败,后端到飞书开放平台的网络或 TLS 证书链异常",
last_error or None,
)
if code == "sdk_missing":
return ("长连接 SDK 未安装(缺少 lark-oapi)", None)
if code == "thread_exited":
return ("长连接已退出,系统正在等待下一次自动重连窗口", last_error or None)
if last_error:
return ("长连接未建立,请检查配置与后端日志", last_error)
return ("长连接未建立,请检查配置后重试", None)
# ==================== Request/Response Models ====================
class FeishuConfigRequest(BaseModel):
"""飞书配置请求"""
app_id: str = Field(..., description="飞书 App ID")
app_secret: str = Field(..., description="飞书 App Secret")
encrypt_key: Optional[str] = Field(None, description="已废弃字段,保留仅为兼容旧客户端")
verification_token: Optional[str] = Field(None, description="已废弃字段,保留仅为兼容旧客户端")
event_mode: str = Field("long_connection", description="事件接收模式,现仅支持 long_connection")
class FeishuSendMessageRequest(BaseModel):
"""发送消息请求"""
chat_id: str = Field(..., description="群聊 ID")
message: str = Field(..., description="消息内容")
class FeishuSendCardRequest(BaseModel):
"""发送卡片请求"""
chat_id: str = Field(..., description="群聊 ID")
title: str = Field(..., description="卡片标题")
content: List[Dict[str, Any]] = Field(..., description="卡片内容")
# ==================== Configuration Endpoints ====================
@router.post("/config")
async def save_feishu_config(
request: FeishuConfigRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
保存飞书配置
配置信息说明:
- app_id: 飞书应用的 App ID,从飞书开放平台获取
- app_secret: 飞书应用的 App Secret
获取步骤:
1. 访问 https://open.feishu.cn/
2. 创建企业自建应用
3. 在「凭证与基础信息」页面获取 App ID 和 App Secret
4. 添加权限:im:message, im:message:group_at_msg, im:chat
5. 订阅事件:接收消息 v1 (message.receive_v1)
"""
# 检查用户权限
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
raise HTTPException(status_code=403, detail="只有管理员可以配置飞书集成")
try:
event_mode = "long_connection"
service = get_feishu_service(db)
await service.save_config(
app_id=request.app_id,
app_secret=request.app_secret,
encrypt_key="",
verification_token="",
event_mode=event_mode,
tenant_id=tenant_id
)
manager = get_feishu_long_connection_manager()
if manager:
await manager.restart()
next_steps = [
"1. 飞书应用事件订阅至少包含:接收消息 v1 (message.receive_v1)",
"2. 当前模式:long_connection",
"3. 后端已自动重启飞书长连接",
]
return {
'status': 'success',
'message': '飞书配置已保存,并已切换到长连接模式',
'next_steps': next_steps
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"保存配置失败: {str(e)}"
)
@router.get("/config")
async def get_feishu_config(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""获取飞书配置状态。管理员可获取完整配置用于回填修改表单。"""
try:
service = get_feishu_service(db)
await service.load_config(tenant_id)
manager = get_feishu_long_connection_manager()
can_view_secrets = has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"])
return {
'is_configured': service.is_configured(),
'app_id': service.app_id,
'app_secret': service.app_secret if can_view_secrets else None,
'event_mode': "long_connection",
'long_connection': manager.status() if manager else None,
}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"获取配置失败: {str(e)}"
)
@router.delete("/config")
async def delete_feishu_config(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""删除飞书配置"""
from ..models import Integration
from sqlalchemy.future import select
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
raise HTTPException(status_code=403, detail="只有管理员可以删除配置")
try:
result = await db.execute(
select(Integration).where(
Integration.tenant_id == tenant_id,
Integration.type == 'feishu'
)
)
integration = result.scalar_one_or_none()
if integration:
integration.is_active = False
await db.commit()
manager = get_feishu_long_connection_manager()
if manager:
await manager.restart()
return {'status': 'success', 'message': '飞书配置已删除'}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"删除配置失败: {str(e)}"
)
@router.get("/long-connection/status")
async def feishu_long_connection_status(
current_user: User = Depends(get_current_user),
):
"""查询飞书长连接状态。"""
manager = get_feishu_long_connection_manager()
if not manager:
return {
"status": "error",
"message": "Feishu 长连接管理器未初始化",
"mode": "long_connection",
"data": None,
}
return {
"status": "success",
"mode": "long_connection",
"data": manager.status(),
}
@router.post("/long-connection/restart")
async def restart_feishu_long_connection(
current_user: User = Depends(get_current_user),
):
"""手动重启飞书长连接。"""
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
raise HTTPException(status_code=403, detail="只有管理员可以重启飞书长连接")
manager = get_feishu_long_connection_manager()
if not manager:
raise HTTPException(status_code=500, detail="Feishu 长连接管理器未初始化")
await manager.restart()
return {
"status": "success",
"message": "Feishu 长连接已重启",
"mode": "long_connection",
"data": manager.status(),
}
# ==================== Message Endpoints ====================
@router.post("/send-message")
async def send_feishu_message(
request: FeishuSendMessageRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
发送文本消息到飞书群
用于发送简报、通知等
"""
try:
service = get_feishu_service(db)
await service.load_config(tenant_id)
result = await service.send_message_to_group(
chat_id=request.chat_id,
message=request.message
)
return {
'status': 'success',
'result': result
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"发送消息失败: {str(e)}"
)
@router.post("/send-card")
async def send_feishu_card(
request: FeishuSendCardRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
发送富文本卡片到飞书群
用于发送格式化的每日简报、风险预警等
"""
try:
service = get_feishu_service(db)
await service.load_config(tenant_id)
result = await service.send_rich_card(
chat_id=request.chat_id,
title=request.title,
content=request.content
)
return {
'status': 'success',
'result': result
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"发送卡片失败: {str(e)}"
)
# ==================== Test Endpoints ====================
@router.post("/test-connection")
async def test_feishu_connection(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""测试飞书连接是否正常"""
try:
service = get_feishu_service(db)
await service.load_config(tenant_id)
manager = get_feishu_long_connection_manager()
if not service.is_configured():
return {
'status': 'error',
'message': '飞书未配置'
}
# 尝试获取 tenant_access_token 测试连接
try:
access_token = await service._get_tenant_access_token()
event_mode = "long_connection"
long_status = manager.status() if manager else None
if not long_status:
return {
'status': 'error',
'message': '长连接管理器未初始化',
'event_mode': event_mode,
}
if not long_status.get("sdk_available"):
return {
'status': 'error',
'message': '长连接 SDK 未安装(缺少 lark-oapi)',
'event_mode': event_mode,
'long_connection': long_status,
}
if not long_status.get("thread_alive"):
message, detail = _build_long_connection_failure(long_status)
return {
'status': 'error',
'message': message,
'event_mode': event_mode,
'long_connection': long_status,
'detail': detail,
}
return {
'status': 'success',
'message': '飞书连接正常',
'has_token': bool(access_token),
'mode': 'long_connection',
'event_mode': event_mode,
'long_connection': long_status,
}
except Exception as token_error:
return {
'status': 'error',
'message': f'获取 Token 失败: {str(token_error)}',
'hint': '请检查 App ID 和 App Secret 是否正确'
}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"测试连接失败: {str(e)}"
)

View File

@ -39,7 +39,7 @@ async def save_feishu_config(
4. 添加权限:im:message, im:message:group_at_msg, im:chat
5. 订阅事件:接收消息 v1 (message.receive_v1)
"""
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
if not has_any_role(str(current_user.role or ""), ["REVIEWER", "OWNER"]):
raise HTTPException(status_code=403, detail="只有管理员可以配置飞书集成")
try:
@ -85,7 +85,7 @@ async def get_feishu_config(
service = get_feishu_service(db)
await service.load_config(tenant_id)
manager = get_feishu_long_connection_manager()
can_view_secrets = has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"])
can_view_secrets = has_any_role(str(current_user.role or ""), ["REVIEWER", "OWNER"])
return {
"is_configured": service.is_configured(),
@ -105,7 +105,7 @@ async def delete_feishu_config(
tenant_id: int = Depends(get_tenant_id),
):
"""删除飞书配置。"""
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
if not has_any_role(str(current_user.role or ""), ["REVIEWER", "OWNER"]):
raise HTTPException(status_code=403, detail="只有管理员可以删除配置")
try:

View File

@ -38,7 +38,7 @@ async def restart_feishu_long_connection(
current_user: User = Depends(get_current_user),
):
"""手动重启飞书长连接。"""
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
if not has_any_role(str(current_user.role or ""), ["REVIEWER", "OWNER"]):
raise HTTPException(status_code=403, detail="只有管理员可以重启飞书长连接")
manager = get_feishu_long_connection_manager()

View File

@ -50,7 +50,7 @@ class VerifyContextConsumptionRequest(BaseModel):
def _require_manager(user: User) -> None:
if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]):
if not has_any_role(str(user.role or ""), ["OWNER", "REVIEWER"]):
raise HTTPException(status_code=403, detail="仅管理员可操作知识图谱同步")

View File

@ -0,0 +1,16 @@
from __future__ import annotations
from fastapi import APIRouter
from starlette.responses import JSONResponse
from ..core.response import error_response
router = APIRouter(prefix="/ai-steward", tags=["AI Steward Legacy"])
@router.post("/project-from-text")
async def create_project_from_text_legacy_gone():
return JSONResponse(
status_code=410,
content=error_response(410, "旧项目创建入口已下线,请使用新项目启动台生成草案并确认创建。"),
)

View File

@ -10,10 +10,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from ..core.response import success_response
from ..core.database import get_db
from ..dependencies.auth import get_current_user
from ..dependencies.permissions import require_platform_ai_ops
from ..dependencies.tenant import get_tenant_id
from ..models import User
from ..services.auth import has_any_role
from ..services.llm_service import llm_service
router = APIRouter(prefix="/ai-steward/llm-engines", tags=["AI Steward"])
@ -21,37 +20,78 @@ router = APIRouter(prefix="/ai-steward/llm-engines", tags=["AI Steward"])
class LLMEngineCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=80)
provider: Literal["deepseek", "glm", "openai", "ollama"]
provider: Literal["deepseek", "dashscope", "openai", "zhipu", "ollama"]
api_key: Optional[str] = Field(default="", max_length=500)
model: Optional[str] = Field(default=None, max_length=120)
base_url: Optional[str] = Field(default=None, max_length=255)
set_active: bool = True
capabilities: dict[str, object] = Field(default_factory=dict)
class LLMEngineUpdateRequest(BaseModel):
name: Optional[str] = Field(default=None, min_length=1, max_length=80)
provider: Optional[Literal["deepseek", "glm", "openai", "ollama"]] = None
provider: Optional[Literal["deepseek", "dashscope", "openai", "zhipu", "ollama"]] = None
api_key: Optional[str] = Field(default=None, max_length=500)
model: Optional[str] = Field(default=None, max_length=120)
base_url: Optional[str] = Field(default=None, max_length=255)
capabilities: Optional[dict[str, object]] = None
class LLMEngineTestRequest(BaseModel):
prompt: Optional[str] = Field(default=None, max_length=1000)
class LLMEngineCapabilityProbeRequest(BaseModel):
mode: Literal["quick", "full", "advanced"] = "full"
targets: list[
Literal[
"basic",
"chat",
"basic_chat",
"model_self_report",
"self_report",
"model_report",
"structured",
"structured_output",
"json",
"json_schema",
"tool",
"tool_calling",
"function_calling",
"stream",
"streaming",
"long_context",
"long_context_32k",
"long_context_128k",
"vision",
"vision_input",
"audio",
"audio_input",
]
] = Field(default_factory=list, max_length=12)
allow_high_cost: bool = False
class LLMEngineCapabilityAttestationRequest(BaseModel):
capability_key: Literal["long_context"] = "long_context"
tested_tokens: int = Field(..., gt=0, le=10_000_000)
source_type: Literal["official_doc", "provider_console", "contract", "manual_verification"] = "manual_verification"
source_url: Optional[str] = Field(default=None, max_length=500)
source_note: Optional[str] = Field(default=None, max_length=500)
expires_at: Optional[str] = Field(default=None, max_length=80)
class LLMEngineSettingsUpdateRequest(BaseModel):
enable_local_kb_qa: bool = False
def _require_manager(user: User) -> None:
if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]):
raise HTTPException(status_code=403, detail="仅管理员可操作 LLM 引擎配置")
class LLMEngineEnabledUpdateRequest(BaseModel):
enabled: bool = True
@router.get("")
async def list_llm_engines(
current_user: User = Depends(get_current_user),
current_user: User = Depends(require_platform_ai_ops),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
@ -63,11 +103,10 @@ async def list_llm_engines(
@router.post("")
async def create_llm_engine(
body: LLMEngineCreateRequest,
current_user: User = Depends(get_current_user),
current_user: User = Depends(require_platform_ai_ops),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_require_manager(current_user)
try:
engine = await llm_service.create_engine(
db,
@ -78,6 +117,7 @@ async def create_llm_engine(
model=body.model,
base_url=body.base_url,
set_active=body.set_active,
capabilities=body.capabilities,
)
all_items = await llm_service.list_engines(db, tenant_id=tenant_id)
return success_response({"engine": engine, **all_items})
@ -88,11 +128,10 @@ async def create_llm_engine(
@router.put("/settings")
async def update_llm_engine_settings(
body: LLMEngineSettingsUpdateRequest,
current_user: User = Depends(get_current_user),
current_user: User = Depends(require_platform_ai_ops),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_require_manager(current_user)
try:
settings = await llm_service.update_engine_settings(
db,
@ -109,11 +148,10 @@ async def update_llm_engine_settings(
async def update_llm_engine(
engine_id: str,
body: LLMEngineUpdateRequest,
current_user: User = Depends(get_current_user),
current_user: User = Depends(require_platform_ai_ops),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_require_manager(current_user)
try:
engine = await llm_service.update_engine(
db,
@ -124,6 +162,7 @@ async def update_llm_engine(
api_key=body.api_key,
model=body.model,
base_url=body.base_url,
capabilities=body.capabilities,
)
all_items = await llm_service.list_engines(db, tenant_id=tenant_id)
return success_response({"engine": engine, **all_items})
@ -134,13 +173,12 @@ async def update_llm_engine(
@router.post("/{engine_id}/activate")
async def activate_llm_engine(
engine_id: str,
current_user: User = Depends(get_current_user),
current_user: User = Depends(require_platform_ai_ops),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_require_manager(current_user)
try:
result = await llm_service.activate_engine(
result = await llm_service.set_default_fallback_engine(
db,
tenant_id=tenant_id,
engine_id=engine_id,
@ -151,20 +189,60 @@ async def activate_llm_engine(
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{engine_id}/default-fallback")
async def set_llm_engine_default_fallback(
engine_id: str,
current_user: User = Depends(require_platform_ai_ops),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
try:
result = await llm_service.set_default_fallback_engine(
db,
tenant_id=tenant_id,
engine_id=engine_id,
)
all_items = await llm_service.list_engines(db, tenant_id=tenant_id)
return success_response({**result, **all_items})
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.put("/{engine_id}/enabled")
async def update_llm_engine_enabled(
engine_id: str,
body: LLMEngineEnabledUpdateRequest,
current_user: User = Depends(require_platform_ai_ops),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
try:
result = await llm_service.set_engine_enabled(
db,
tenant_id=tenant_id,
engine_id=engine_id,
enabled=body.enabled,
)
all_items = await llm_service.list_engines(db, tenant_id=tenant_id)
return success_response({**result, **all_items})
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/test-active")
async def test_active_llm_engine(
body: LLMEngineTestRequest,
current_user: User = Depends(get_current_user),
current_user: User = Depends(require_platform_ai_ops),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_require_manager(current_user)
try:
data = await llm_service.test_engine_connection(
db,
tenant_id=tenant_id,
engine_id=None,
prompt=body.prompt,
user_id=int(current_user.id),
)
return success_response(data)
except ValueError as exc:
@ -175,31 +253,81 @@ async def test_active_llm_engine(
async def test_llm_engine(
engine_id: str,
body: LLMEngineTestRequest,
current_user: User = Depends(get_current_user),
current_user: User = Depends(require_platform_ai_ops),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_require_manager(current_user)
try:
data = await llm_service.test_engine_connection(
db,
tenant_id=tenant_id,
engine_id=engine_id,
prompt=body.prompt,
user_id=int(current_user.id),
)
return success_response(data)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{engine_id}/capability-probe")
async def probe_llm_engine_capabilities(
engine_id: str,
body: LLMEngineCapabilityProbeRequest,
current_user: User = Depends(require_platform_ai_ops),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
try:
data = await llm_service.probe_engine_capabilities(
db,
tenant_id=tenant_id,
engine_id=engine_id,
targets=body.targets,
mode=body.mode,
allow_high_cost=body.allow_high_cost,
user_id=int(current_user.id),
)
all_items = await llm_service.list_engines(db, tenant_id=tenant_id)
return success_response({"probe": data, **all_items})
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{engine_id}/capability-attest")
async def attest_llm_engine_capability(
engine_id: str,
body: LLMEngineCapabilityAttestationRequest,
current_user: User = Depends(require_platform_ai_ops),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
try:
engine = await llm_service.attest_engine_capability(
db,
tenant_id=tenant_id,
engine_id=engine_id,
capability_key=body.capability_key,
tested_tokens=body.tested_tokens,
source_type=body.source_type,
source_url=body.source_url,
source_note=body.source_note,
expires_at=body.expires_at,
user_id=int(current_user.id),
)
all_items = await llm_service.list_engines(db, tenant_id=tenant_id)
return success_response({"engine": engine, **all_items})
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.delete("/{engine_id}")
async def delete_llm_engine(
engine_id: str,
current_user: User = Depends(get_current_user),
current_user: User = Depends(require_platform_ai_ops),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_require_manager(current_user)
try:
result = await llm_service.delete_engine(
db,

View File

@ -20,25 +20,31 @@ def _build_provider_payload() -> dict[str, Any]:
low_tier = dict(tier_defaults.get("low") or {})
high_tier = dict(tier_defaults.get("high") or {})
openai_defaults = dict(provider_defaults.get("openai") or {})
glm_defaults = dict(provider_defaults.get("glm") or {})
dashscope_defaults = dict(provider_defaults.get("dashscope") or {})
zhipu_defaults = dict(provider_defaults.get("zhipu") or {})
deepseek_defaults = dict(provider_defaults.get("deepseek") or {})
ollama_defaults = dict(provider_defaults.get("ollama") or {})
high_model = str(high_tier.get("model") or openai_defaults.get("model") or "GLM-5").strip()
return {
"low": str(low_tier.get("model") or glm_defaults.get("model") or "qwen-plus").strip(),
"low": str(low_tier.get("model") or dashscope_defaults.get("model") or "qwen-plus").strip(),
"high": high_model,
"base_url": str(high_tier.get("base_url") or openai_defaults.get("base_url") or "https://api.edgefn.net/v1").strip(),
"providers": {
"glm": {
"model": str(glm_defaults.get("model") or "qwen-plus").strip(),
"base_url": str(glm_defaults.get("base_url") or "https://dashscope.aliyuncs.com/compatible-mode/v1").strip(),
"api_env": str(glm_defaults.get("api_env") or "DASHSCOPE_API_KEY").strip(),
"dashscope": {
"model": str(dashscope_defaults.get("model") or "qwen-plus").strip(),
"base_url": str(dashscope_defaults.get("base_url") or "https://dashscope.aliyuncs.com/compatible-mode/v1").strip(),
"api_env": str(dashscope_defaults.get("api_env") or "DASHSCOPE_API_KEY").strip(),
},
"openai": {
"model": high_model,
"base_url": str(openai_defaults.get("base_url") or "https://api.edgefn.net/v1").strip(),
"api_env": str(openai_defaults.get("api_env") or "OPENAI_API_KEY").strip(),
},
"zhipu": {
"model": str(zhipu_defaults.get("model") or "glm-5.1").strip(),
"base_url": str(zhipu_defaults.get("base_url") or "https://open.bigmodel.cn/api/paas/v4").strip(),
"api_env": str(zhipu_defaults.get("api_env") or "ZHIPU_API_KEY").strip(),
},
"deepseek": {
"model": str(deepseek_defaults.get("model") or "deepseek-chat").strip(),
"base_url": str(deepseek_defaults.get("base_url") or "https://api.deepseek.com/v1").strip(),

View File

@ -0,0 +1,491 @@
"""模型能力矩阵 API"""
from typing import Optional
from fastapi import APIRouter, Depends, Query
from fastapi.responses import JSONResponse
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..core.response import ErrorCode, error_response, success_response
from ..database import get_db
from ..dependencies import get_current_user, get_tenant_id
from ..models import Experiment, TruthNode, User
from ..schemas.model_capability import (
CostOptimizationAnalyzeRequest,
ExperimentSyncRequest,
ExperimentSyncReviewRequest,
ExperimentSyncRollbackRequest,
GenerateMigrationTasksRequest,
LifecycleImpactRequest,
ValidateMatchRequest,
)
from ..services.ai_service import get_ai_service
from ..services.cost_optimization_service import CostOptimizationService
from ..services.experiment_sync_service import ExperimentSyncService
from ..services.model_capability_service import ModelCapabilityService
from ..services.model_lifecycle_service import ModelLifecycleService
router = APIRouter(prefix="/model-capability", tags=["model-capability"])
def _build_service(db: AsyncSession) -> ModelCapabilityService:
return ModelCapabilityService(db=db, ai_service=get_ai_service())
@router.post("/validate-match")
async def validate_model_requirement_match(
request: ValidateMatchRequest,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""需求-模型匹配校验"""
_ = current_user
service = _build_service(db)
try:
data = await service.validate_model_requirement_match(
truth_node_id=request.truth_node_id,
model_version_id=request.model_version_id,
tenant_id=tenant_id,
project_id=request.project_id,
)
return success_response(data=data)
except ValueError as exc:
return JSONResponse(
status_code=404,
content=error_response(ErrorCode.NOT_FOUND, str(exc)),
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"匹配校验失败: {exc}"),
)
@router.get("/capability-gaps")
async def list_capability_gaps(
status: Optional[str] = Query(default=None),
severity: Optional[str] = Query(default=None),
limit: int = Query(default=100, ge=1, le=500),
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""列出能力差距预警"""
_ = current_user
service = _build_service(db)
try:
data = await service.list_capability_gaps(
tenant_id=tenant_id,
status=status,
severity=severity,
limit=limit,
)
return success_response(data=data)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"查询能力差距失败: {exc}"),
)
@router.get("/cost-tracking/{truth_node_id}")
async def get_cost_tracking(
truth_node_id: int,
period: str = Query(default="weekly", pattern="^(daily|weekly|monthly|project)$"),
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""查询成本跟踪与 ROI"""
_ = current_user
service = _build_service(db)
try:
data = await service.get_cost_tracking(
truth_node_id=truth_node_id,
period=period,
tenant_id=tenant_id,
)
return success_response(data=data)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"查询成本跟踪失败: {exc}"),
)
@router.get("/cost-optimization/{truth_node_id}")
async def get_cost_optimization(
truth_node_id: int,
period: str = Query(default="weekly", pattern="^(daily|weekly|monthly|project)$"),
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""查询成本优化建议(必要时自动触发一次计算)"""
_ = current_user
service = _build_service(db)
try:
tracking = await service.get_cost_tracking(
truth_node_id=truth_node_id,
period=period,
tenant_id=tenant_id,
)
optimization = tracking.get("optimization_suggestions") or {}
if not optimization:
computed = await service.track_cost_and_roi(
truth_node_id=truth_node_id,
period=period,
tenant_id=tenant_id,
)
optimization = computed.get("optimization_suggestions", {})
tracking = await service.get_cost_tracking(
truth_node_id=truth_node_id,
period=period,
tenant_id=tenant_id,
)
return success_response(
data={
"truth_node_id": truth_node_id,
"period": period,
"optimization_suggestions": optimization,
"roi_score": tracking.get("roi_score"),
"actual_cost_usd": tracking.get("actual_cost_usd", 0.0),
}
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"查询成本优化失败: {exc}"),
)
@router.post("/lifecycle/assess-impact")
async def assess_model_update_impact(
request: LifecycleImpactRequest,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""评估模型升级对现有需求集合的影响。"""
_ = current_user
service = ModelLifecycleService(db)
try:
data = await service.assess_model_update_impact(
old_model_version_id=request.old_model_version_id,
new_model_version_id=request.new_model_version_id,
tenant_id=tenant_id,
)
return success_response(data=data)
except ValueError as exc:
return JSONResponse(
status_code=404,
content=error_response(ErrorCode.NOT_FOUND, str(exc)),
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"影响评估失败: {exc}"),
)
@router.get("/lifecycle/impacts")
async def list_lifecycle_impacts(
limit: int = Query(default=50, ge=1, le=200),
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""列出模型生命周期影响评估记录。"""
_ = current_user
service = ModelLifecycleService(db)
try:
data = await service.list_impacts(tenant_id=tenant_id, limit=limit)
return success_response(data=data)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"查询影响评估失败: {exc}"),
)
@router.post("/lifecycle/{impact_id}/migration-tasks")
async def create_migration_tasks(
impact_id: int,
request: GenerateMigrationTasksRequest,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""基于影响评估自动生成迁移任务建议。"""
_ = current_user
service = ModelLifecycleService(db)
try:
data = await service.generate_migration_tasks(
impact_id=impact_id,
tenant_id=tenant_id,
max_tasks=request.max_tasks,
)
return success_response(data=data)
except ValueError as exc:
return JSONResponse(
status_code=404,
content=error_response(ErrorCode.NOT_FOUND, str(exc)),
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"生成迁移任务失败: {exc}"),
)
@router.post("/experiment-sync")
async def create_experiment_sync(
request: ExperimentSyncRequest,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""创建实验结果同步记录,满足阈值时自动同步。"""
_ = current_user
service = ExperimentSyncService(db)
try:
data = await service.auto_sync_experiment_result(
experiment_id=request.experiment_id,
truth_node_id=request.truth_node_id,
tenant_id=tenant_id,
project_id=request.project_id,
sync_type=request.sync_type,
auto_apply_threshold=request.auto_apply_threshold,
)
return success_response(data=data)
except ValueError as exc:
return JSONResponse(
status_code=404,
content=error_response(ErrorCode.NOT_FOUND, str(exc)),
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"创建同步失败: {exc}"),
)
@router.get("/experiment-sync")
async def list_experiment_sync_records(
truth_node_id: Optional[int] = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""查询实验同步记录。"""
_ = current_user
service = ExperimentSyncService(db)
try:
data = await service.list_sync_records(
tenant_id=tenant_id,
truth_node_id=truth_node_id,
limit=limit,
)
return success_response(data=data)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"查询同步记录失败: {exc}"),
)
@router.post("/experiment-sync/{sync_id}/review")
async def review_experiment_sync(
sync_id: int,
request: ExperimentSyncReviewRequest,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""人工审核实验同步记录。"""
service = ExperimentSyncService(db)
try:
data = await service.review_sync(
sync_id=sync_id,
tenant_id=tenant_id,
reviewer_id=current_user.id,
action=request.action,
review_notes=request.review_notes,
)
return success_response(data=data)
except ValueError as exc:
return JSONResponse(
status_code=400,
content=error_response(ErrorCode.BAD_REQUEST, str(exc)),
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"审核同步失败: {exc}"),
)
@router.post("/experiment-sync/{sync_id}/rollback")
async def rollback_experiment_sync(
sync_id: int,
request: ExperimentSyncRollbackRequest,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""回滚已应用的实验同步。"""
service = ExperimentSyncService(db)
try:
data = await service.rollback_sync(
sync_id=sync_id,
tenant_id=tenant_id,
reviewer_id=current_user.id,
rollback_notes=request.rollback_notes,
)
return success_response(data=data)
except ValueError as exc:
return JSONResponse(
status_code=400,
content=error_response(ErrorCode.BAD_REQUEST, str(exc)),
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"回滚同步失败: {exc}"),
)
@router.post("/cost-optimization/analyze")
async def analyze_cost_optimization(
request: CostOptimizationAnalyzeRequest,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""生成并持久化成本优化建议。"""
_ = current_user
service = CostOptimizationService(db)
try:
data = await service.analyze_cost_optimization(
truth_node_id=request.truth_node_id,
tenant_id=tenant_id,
period=request.period,
project_id=request.project_id,
)
return success_response(data=data)
except ValueError as exc:
return JSONResponse(
status_code=400,
content=error_response(ErrorCode.BAD_REQUEST, str(exc)),
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"成本优化分析失败: {exc}"),
)
@router.get("/context/projects/{project_id}/truth-nodes")
async def list_project_truth_nodes(
project_id: int,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""获取项目上下文中的宪章列表,用于前端选择器。"""
_ = current_user
result = await db.execute(
select(TruthNode).where(
and_(
TruthNode.tenant_id == tenant_id,
TruthNode.project_id == project_id,
)
).order_by(TruthNode.created_at.desc())
)
nodes = result.scalars().all()
return success_response(
data={
"items": [
{
"id": n.id,
"title": n.title,
"status": n.status,
"node_type": n.node_type,
}
for n in nodes
]
}
)
@router.get("/context/projects/{project_id}/experiments")
async def list_project_experiments(
project_id: int,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""获取项目上下文中的实验列表(基于 linked_context_nodes 关联)。"""
_ = current_user
truth_ids_result = await db.execute(
select(TruthNode.id).where(
and_(
TruthNode.tenant_id == tenant_id,
TruthNode.project_id == project_id,
)
)
)
truth_ids = {int(row[0]) for row in truth_ids_result.all()}
if not truth_ids:
return success_response(data={"items": []})
exp_result = await db.execute(
select(Experiment)
.where(Experiment.tenant_id == tenant_id)
.order_by(Experiment.created_at.desc())
)
experiments = exp_result.scalars().all()
def _linked_ids(raw) -> set[int]:
ids = set()
if isinstance(raw, list):
for x in raw:
try:
ids.add(int(x))
except (TypeError, ValueError):
continue
return ids
items = []
for e in experiments:
if e.project_id is not None:
if int(e.project_id) != int(project_id):
continue
items.append(
{
"id": e.id,
"title": e.title or f"Experiment {e.id}",
"status": e.status,
"target_metric": e.target_metric,
}
)
continue
# 迁移兼容:project_id 为空时,回退到 linked_context_nodes 交叉判断。
if _linked_ids(e.linked_context_nodes).isdisjoint(truth_ids):
continue
items.append(
{
"id": e.id,
"title": e.title or f"Experiment {e.id}",
"status": e.status,
"target_metric": e.target_metric,
}
)
return success_response(data={"items": items})

View File

@ -34,12 +34,13 @@ async def analyze_cost_optimization(
@router.get("/context/projects/{project_id}/truth-nodes")
async def list_project_truth_nodes(
project_id: int,
permission_user: User = Depends(require_project_member_or_manager),
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""获取项目上下文中的宪章列表,用于前端选择器。"""
_ = current_user
_ = (current_user, permission_user)
result = await db.execute(
select(TruthNode).where(
and_(
@ -66,12 +67,13 @@ async def list_project_truth_nodes(
@router.get("/context/projects/{project_id}/experiments")
async def list_project_experiments(
project_id: int,
permission_user: User = Depends(require_project_member_or_manager),
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""获取项目上下文中的实验列表(基于 linked_context_nodes 关联)。"""
_ = current_user
_ = (current_user, permission_user)
truth_ids_result = await db.execute(
select(TruthNode.id).where(
and_(
@ -128,4 +130,3 @@ async def list_project_experiments(
}
)
return success_response(data={"items": items})

View File

@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from ...core.response import ErrorCode, error_response, success_response
from ...core.database import get_db
from ...dependencies import get_current_user, get_tenant_id
from ...dependencies import get_current_user, get_tenant_id, require_project_member_or_manager
from ...models import Experiment, TruthNode, User
from ...schemas.model_capability import (
CostOptimizationAnalyzeRequest,

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