Compare commits

..

77 Commits
main ... master

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 2eaf8e9deb Unify structured runtime semantics and internal domain clarify 2026-04-20 20:03:10 +08:00
Yuuuu afa51fc45a feat: unify project operating context and polish Feishu QA handoff 2026-04-18 14:13:34 +08:00
Yuuuu 3f94cf702c Add explicit QA anchors and route governance pages 2026-04-17 14:01:53 +08:00
Yuuuu 4720c8117d feat: add external query and semantic facts runtime 2026-04-16 11:17:52 +08:00
Yuuuu e42652b619 chore: reset repository history 2026-04-14 10:54:06 +08:00
1655 changed files with 438659 additions and 15810 deletions

View File

@ -1,3 +1,6 @@
# 唯一配置文件,backend/.env 已移除
# 所有环境变量在此统一管理
# Docker Compose Runtime Secrets / Config # Docker Compose Runtime Secrets / Config
POSTGRES_DB=pmflow POSTGRES_DB=pmflow
POSTGRES_USER=pmflow POSTGRES_USER=pmflow
@ -8,35 +11,48 @@ DB_PORT=5435
REDIS_PORT=6379 REDIS_PORT=6379
BACKEND_PORT=8000 BACKEND_PORT=8000
FRONTEND_PORT=5174 FRONTEND_PORT=5174
FRONTEND_PROD_PORT=5175
PMFLOW_EXPECTED_DB_DIALECT=postgresql
# Backend Runtime # Backend Runtime
CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174 CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174
LLM_PROVIDER=openai LLM_PROVIDER=dashscope
LLM_FALLBACK_PROVIDER=ollama LLM_FALLBACK_PROVIDER=dashscope
OPENAI_BASE_URL=http://openclaw:18789/v1 CONTROL_PLANE_ROUTING_ENABLED=true
OPENAI_API_KEY= CONTROL_PLANE_ROUTING_RULES_PATH=backend/.control_plane/pmflow-routing-rules.json
OPENCLAW_ROUTING_ENABLED=true CONTROL_PLANE_AGENTS_DEFAULT_TIMEOUT_SECONDS=120
OPENCLAW_ROUTING_RULES_PATH=/app/backend/.openclaw/pmflow-routing-rules.json LLM_DIRECT_EMERGENCY_ENABLED=true
OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS=120
LLM_MIXED_ROUTING_ENABLED=false LLM_MIXED_ROUTING_ENABLED=false
LLM_ROUTING_DEFAULT_TIER=low
LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true
LLM_ROUTING_LOW_PROVIDER=openai LLM_REQUEST_TIMEOUT=90
LLM_ROUTING_LOW_MODEL=zai/glm-4-flash
LLM_ROUTING_HIGH_PROVIDER=openai # 模型默认值统一由 backend/.control_plane/pmflow-routing-rules.json 管理,不再依赖 LLM_ROUTING_* / *_MODEL 环境变量
LLM_ROUTING_HIGH_MODEL=minimax/MiniMax-M2.5 DASHSCOPE_API_KEY=
LLM_ROUTING_SAFETY_PROVIDER=openai OPENAI_API_KEY=
LLM_ROUTING_SAFETY_MODEL=deepseek/deepseek-chat ZHIPU_API_KEY=
LLM_DIRECT_EMERGENCY_ENABLED=false
RAG_DEEP_RETRIEVAL_STEPS=5
RAG_DEEP_VECTOR_TOP_K=12
RAG_DEEP_CONTEXT_BLOCKS=16
RAG_DEEP_ANSWER_MAX_TOKENS=1400
GLM_API_KEY=
DEEPSEEK_API_KEY= DEEPSEEK_API_KEY=
OLLAMA_HOST=http://host.docker.internal:11434 OLLAMA_HOST=http://host.docker.internal:11434
OLLAMA_BASE_URL=http://host.docker.internal:11434 OLLAMA_BASE_URL=http://host.docker.internal:11434
OLLAMA_MODEL=llama3:latest OLLAMA_MODEL=llama3:latest
# Adaptive RAG 深入思考模式
RAG_DEEP_RETRIEVAL_STEPS=5
RAG_DEEP_VECTOR_TOP_K=12
RAG_DEEP_CONTEXT_BLOCKS=16
RAG_DEEP_ANSWER_MAX_TOKENS=1400
# Feishu long connection retry policy (1s -> 2s -> 4s, max 3 attempts) # Feishu long connection retry policy (1s -> 2s -> 4s, max 3 attempts)
FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS=1 FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS=1
FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS=3 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=

40
.github/workflows/contract-check.yml vendored Normal file
View File

@ -0,0 +1,40 @@
name: contract-check
on:
pull_request:
paths:
- "backend/app/api/**"
- "backend/app/schemas/**"
- "backend/app/services/**"
- "frontend/src/api/**"
- "frontend/src/types/**"
- "docs/architecture/**"
- "scripts/check_contract_sync.sh"
- "scripts/semantic_lint.py"
- "scripts/semantic_lint_baseline.txt"
- ".github/workflows/contract-check.yml"
push:
branches: [ main, master ]
paths:
- "backend/app/api/**"
- "backend/app/schemas/**"
- "backend/app/services/**"
- "frontend/src/api/**"
- "frontend/src/types/**"
- "docs/architecture/**"
- "scripts/check_contract_sync.sh"
- "scripts/semantic_lint.py"
- "scripts/semantic_lint_baseline.txt"
- ".github/workflows/contract-check.yml"
jobs:
contract-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run contract sync check
run: |
bash scripts/check_contract_sync.sh
- name: Run semantic lint
run: |
python3 scripts/semantic_lint.py

View File

@ -12,7 +12,6 @@ on:
- "backend/app/main.py" - "backend/app/main.py"
- "backend/evals/feishu_replay_eval.py" - "backend/evals/feishu_replay_eval.py"
- "backend/evals/golden_feishu_replay_v1.jsonl" - "backend/evals/golden_feishu_replay_v1.jsonl"
- ".openclaw/pmflow-routing-rules.json"
- "backend/.openclaw/pmflow-routing-rules.json" - "backend/.openclaw/pmflow-routing-rules.json"
- "scripts/openclaw_routing_lint.py" - "scripts/openclaw_routing_lint.py"
- ".github/workflows/feishu-replay-gate.yml" - ".github/workflows/feishu-replay-gate.yml"
@ -21,7 +20,6 @@ on:
paths: paths:
- "backend/evals/feishu_replay_eval.py" - "backend/evals/feishu_replay_eval.py"
- "backend/evals/golden_feishu_replay_v1.jsonl" - "backend/evals/golden_feishu_replay_v1.jsonl"
- ".openclaw/pmflow-routing-rules.json"
- "backend/.openclaw/pmflow-routing-rules.json" - "backend/.openclaw/pmflow-routing-rules.json"
- "scripts/openclaw_routing_lint.py" - "scripts/openclaw_routing_lint.py"
- ".github/workflows/feishu-replay-gate.yml" - ".github/workflows/feishu-replay-gate.yml"
@ -66,7 +64,6 @@ jobs:
- name: Lint OpenClaw routing rules - name: Lint OpenClaw routing rules
run: | run: |
python scripts/openclaw_routing_lint.py --input .openclaw/pmflow-routing-rules.json
python scripts/openclaw_routing_lint.py --input backend/.openclaw/pmflow-routing-rules.json python scripts/openclaw_routing_lint.py --input backend/.openclaw/pmflow-routing-rules.json
- name: Run replay eval - name: Run replay eval

17
.gitignore vendored
View File

@ -45,6 +45,7 @@ lerna-debug.log*
*.swp *.swp
*.swo *.swo
.DS_Store .DS_Store
.worktrees/
# Logs # Logs
logs/ logs/
@ -67,3 +68,19 @@ Pixelle-Video/
.env .env
.env.* .env.*
!.env.example !.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

@ -1,4 +1,4 @@
{ {
"version": 1, "version": 1,
"onboardingCompletedAt": "2026-03-04T07:50:38.457Z" "setupCompletedAt": "2026-03-26T16:00:06.817Z"
} }

View File

@ -0,0 +1,2 @@
{"type":"click","text":"C. Tab 分对象类型\n \n 概览 | 项目 | 任务 | 信号 | 文档 | 人员 | 宪章 | 发布 | 复盘\n \n 当前 tab facts 数\n 失败数\n 最后抽取\n \n 当前对象类型的列表和详情\n \n \n \n \n 方案 C\n 对象边界最清楚,但你要横向看整体积压和失败会来回切 tab,运营效率最低。","choice":"c","id":null,"timestamp":1776240598958}
{"type":"click","text":"C. Tab 分对象类型\n \n 概览 | 项目 | 任务 | 信号 | 文档 | 人员 | 宪章 | 发布 | 复盘\n \n 当前 tab facts 数\n 失败数\n 最后抽取\n \n 当前对象类型的列表和详情\n \n \n \n \n 方案 C\n 对象边界最清楚,但你要横向看整体积压和失败会来回切 tab,运营效率最低。","choice":"c","id":null,"timestamp":1776241280184}

View File

@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1776243098908}

View File

@ -0,0 +1 @@
95325

View File

@ -0,0 +1,96 @@
<h2>Semantic Facts 状态页设计稿</h2>
<p class="subtitle">先确认页面信息架构和操作区布局。后端统一按“提交后自动入队 + 失活旧 facts + 手动重抽入口”设计。</p>
<div class="cards">
<div class="card" data-choice="a" onclick="toggleSelect(this)">
<div class="card-image">
<div class="mockup">
<div class="mockup-header">A. 总览台 + 队列栏 + 对象表 + 详情抽屉</div>
<div class="mockup-body">
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:10px;">
<div class="placeholder" style="height:68px;">对象总数</div>
<div class="placeholder" style="height:68px;">active facts</div>
<div class="placeholder" style="height:68px;">失败队列</div>
<div class="placeholder" style="height:68px;">积压时长</div>
</div>
<div style="display:grid;grid-template-columns:1.2fr 0.8fr;gap:10px;margin-bottom:10px;">
<div class="placeholder" style="height:92px;">按对象类型统计:项目 / 任务 / 信号 / 文档 / 人员 / 宪章 / 发布 / 复盘</div>
<div class="placeholder" style="height:92px;">抽取队列状态:pending / running / retry / dead_letter</div>
</div>
<div style="display:grid;grid-template-columns:1fr 0.55fr;gap:10px;">
<div class="placeholder" style="height:170px;">可搜索对象表:对象类型 / 标题 / facts 条数 / 最后抽取 / 队列状态 / 操作</div>
<div class="placeholder" style="height:170px;">右侧详情抽屉:facts 列表 / 证据 / 最近 run / 重抽按钮</div>
</div>
</div>
</div>
</div>
<div class="card-body">
<h3>方案 A</h3>
<p>一页看全:先看风险和积压,再搜对象,再在右侧展开 facts 详情。我推荐这个,最适合排查“哪个对象没抽上、为什么失败、要不要重跑”。</p>
</div>
</div>
<div class="card" data-choice="b" onclick="toggleSelect(this)">
<div class="card-image">
<div class="mockup">
<div class="mockup-header">B. 双栏工作台</div>
<div class="mockup-body">
<div style="display:grid;grid-template-columns:0.9fr 1.1fr;gap:10px;">
<div>
<div class="placeholder" style="height:78px;margin-bottom:10px;">队列状态总览</div>
<div class="placeholder" style="height:194px;">对象列表 + 类型筛选 + 搜索</div>
</div>
<div>
<div class="placeholder" style="height:88px;margin-bottom:10px;">当前对象摘要:facts 数 / 最后抽取 / 最近失败</div>
<div class="placeholder" style="height:184px;">facts 明细、run 时间线、手动重抽</div>
</div>
</div>
</div>
</div>
</div>
<div class="card-body">
<h3>方案 B</h3>
<p>偏“工作台”操作感,适合长时间盯一个对象排障;但全局统计和横向比较会弱一点。</p>
</div>
</div>
<div class="card" data-choice="c" onclick="toggleSelect(this)">
<div class="card-image">
<div class="mockup">
<div class="mockup-header">C. Tab 分对象类型</div>
<div class="mockup-body">
<div class="mock-nav" style="margin-bottom:10px;">概览 | 项目 | 任务 | 信号 | 文档 | 人员 | 宪章 | 发布 | 复盘</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:10px;">
<div class="placeholder" style="height:76px;">当前 tab facts 数</div>
<div class="placeholder" style="height:76px;">失败数</div>
<div class="placeholder" style="height:76px;">最后抽取</div>
</div>
<div class="placeholder" style="height:190px;">当前对象类型的列表和详情</div>
</div>
</div>
</div>
<div class="card-body">
<h3>方案 C</h3>
<p>对象边界最清楚,但你要横向看整体积压和失败会来回切 tab,运营效率最低。</p>
</div>
</div>
</div>
<div class="pros-cons" style="margin-top:18px;">
<div class="pros">
<h4>我推荐 A 的原因</h4>
<ul>
<li>既能看全局积压,也能快速钻到单对象 facts。</li>
<li>和现有治理子页风格更一致,适合放到智能配置中心。</li>
<li>后面要加 bulk backfill、失败重放、对象覆盖率都比较自然。</li>
</ul>
</div>
<div class="cons">
<h4>设计约束</h4>
<ul>
<li>这轮“需求”按宪章(charter)展示,不含运行中 draft。</li>
<li>队列状态会直接暴露 pending / retry / dead_letter,不做粉饰。</li>
<li>对象详情默认展示 active facts,历史失活 facts 放二级查看。</li>
</ul>
</div>
</div>

31
.tmp/case2_reply_242.html Normal file
View File

@ -0,0 +1,31 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Case2 Feishu Reply</title>
<style>
body { font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif; background:#f5f7fb; margin:0; padding:24px; }
.card { max-width: 980px; margin: 0 auto; background: #fff; border: 1px solid #e5eaf3; border-radius: 12px; padding: 20px; box-shadow: 0 4px 18px rgba(19,33,68,.06); }
h1 { margin: 0 0 10px; font-size: 20px; }
.meta { color:#4b5b7c; font-size:14px; margin-bottom: 12px; }
pre { white-space: pre-wrap; word-break: break-word; line-height: 1.6; background:#f8faff; border:1px solid #e7edf8; border-radius:8px; padding:14px; font-size:14px; }
.flag { margin-top: 10px; font-size: 14px; }
.bad { color:#b42318; font-weight:600; }
</style>
</head>
<body>
<div class="card">
<h1>Case2 真实飞书回复</h1>
<div class="meta">source_message_id: real-scene-case2-1773913396 · outbox_id: 242 · run_id: 91</div>
<pre>当前未接入实时数据,以下基于训练数据(截止2024年)。
[行业动态]
近期AI行业呈现以下核心趋势:
1. **大模型竞争白热化**:OpenAI、Anthropic、Google等厂商持续迭代模型(如GPT-4o、Claude 3.5),上下文窗口与推理能力显著提升,价格战促使API成本下降。
2. **Agent(智能体)爆发**:行业重心从单一对话转向具备自主规划、工具调用能力的Agent,自动化解决复杂工作流成为热点。
3. **垂直领域落地加速**:医疗、法律、HR等专业场景的AI应用从“尝鲜”转向“实效”,</pre>
<div class="flag">[建议] 段检查:<span class="bad">未出现</span></div>
</div>
</body>
</html>

BIN
.tmp/case2_reply_242.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

7
.tmp/case2_reply_242.txt Normal file
View File

@ -0,0 +1,7 @@
当前未接入实时数据,以下基于训练数据(截止2024年)。
[行业动态]
近期AI行业呈现以下核心趋势:
1. **大模型竞争白热化**:OpenAI、Anthropic、Google等厂商持续迭代模型(如GPT-4o、Claude 3.5),上下文窗口与推理能力显著提升,价格战促使API成本下降。
2. **Agent(智能体)爆发**:行业重心从单一对话转向具备自主规划、工具调用能力的Agent,自动化解决复杂工作流成为热点。
3. **垂直领域落地加速**:医疗、法律、HR等专业场景的AI应用从“尝鲜”转向“实效”,

View File

@ -0,0 +1 @@
data:text/html;charset=utf-8,%3C%21doctype%20html%3E%3Cmeta%20charset%3D%22utf-8%22%3E%3Cstyle%3Ebody%7Bfont-family%3AArial%2Csans-serif%3Bpadding%3A20px%7Dpre%7Bwhite-space%3Apre-wrap%3Bbackground%3A%23f5f5f5%3Bpadding%3A12px%3Bborder-radius%3A8px%7D.bad%7Bcolor%3A%23b42318%3Bfont-weight%3A700%7D%3C/style%3E%3Ch2%3ECase2%20%E7%9C%9F%E5%AE%9E%E9%A3%9E%E4%B9%A6%E5%9B%9E%E5%A4%8D%3C/h2%3E%3Cdiv%3Esource_message_id%3A%20real-scene-case2-1773913396%3C/div%3E%3Cpre%3E%E5%BD%93%E5%89%8D%E6%9C%AA%E6%8E%A5%E5%85%A5%E5%AE%9E%E6%97%B6%E6%95%B0%E6%8D%AE%EF%BC%8C%E4%BB%A5%E4%B8%8B%E5%9F%BA%E4%BA%8E%E8%AE%AD%E7%BB%83%E6%95%B0%E6%8D%AE%EF%BC%88%E6%88%AA%E6%AD%A22024%E5%B9%B4%EF%BC%89%E3%80%82%0A%0A%5B%E8%A1%8C%E4%B8%9A%E5%8A%A8%E6%80%81%5D%0A%E8%BF%91%E6%9C%9FAI%E8%A1%8C%E4%B8%9A%E5%91%88%E7%8E%B0%E4%BB%A5%E4%B8%8B%E6%A0%B8%E5%BF%83%E8%B6%8B%E5%8A%BF%EF%BC%9A%0A1.%20%2A%2A%E5%A4%A7%E6%A8%A1%E5%9E%8B%E7%AB%9E%E4%BA%89%E7%99%BD%E7%83%AD%E5%8C%96%2A%2A%EF%BC%9AOpenAI%E3%80%81Anthropic%E3%80%81Google%E7%AD%89%E5%8E%82%E5%95%86%E6%8C%81%E7%BB%AD%E8%BF%AD%E4%BB%A3%E6%A8%A1%E5%9E%8B%EF%BC%88%E5%A6%82GPT-4o%E3%80%81Claude%203.5%EF%BC%89%EF%BC%8C%E4%B8%8A%E4%B8%8B%E6%96%87%E7%AA%97%E5%8F%A3%E4%B8%8E%E6%8E%A8%E7%90%86%E8%83%BD%E5%8A%9B%E6%98%BE%E8%91%97%E6%8F%90%E5%8D%87%EF%BC%8C%E4%BB%B7%E6%A0%BC%E6%88%98%E4%BF%83%E4%BD%BFAPI%E6%88%90%E6%9C%AC%E4%B8%8B%E9%99%8D%E3%80%82%0A2.%20%2A%2AAgent%EF%BC%88%E6%99%BA%E8%83%BD%E4%BD%93%EF%BC%89%E7%88%86%E5%8F%91%2A%2A%EF%BC%9A%E8%A1%8C%E4%B8%9A%E9%87%8D%E5%BF%83%E4%BB%8E%E5%8D%95%E4%B8%80%E5%AF%B9%E8%AF%9D%E8%BD%AC%E5%90%91%E5%85%B7%E5%A4%87%E8%87%AA%E4%B8%BB%E8%A7%84%E5%88%92%E3%80%81%E5%B7%A5%E5%85%B7%E8%B0%83%E7%94%A8%E8%83%BD%E5%8A%9B%E7%9A%84Agent%EF%BC%8C%E8%87%AA%E5%8A%A8%E5%8C%96%E8%A7%A3%E5%86%B3%E5%A4%8D%E6%9D%82%E5%B7%A5%E4%BD%9C%E6%B5%81%E6%88%90%E4%B8%BA%E7%83%AD%E7%82%B9%E3%80%82%0A3.%20%2A%2A%E5%9E%82%E7%9B%B4%E9%A2%86%E5%9F%9F%E8%90%BD%E5%9C%B0%E5%8A%A0%E9%80%9F%2A%2A%EF%BC%9A%E5%8C%BB%E7%96%97%E3%80%81%E6%B3%95%E5%BE%8B%E3%80%81HR%E7%AD%89%E4%B8%93%E4%B8%9A%E5%9C%BA%E6%99%AF%E7%9A%84AI%E5%BA%94%E7%94%A8%E4%BB%8E%E2%80%9C%E5%B0%9D%E9%B2%9C%E2%80%9D%E8%BD%AC%E5%90%91%E2%80%9C%E5%AE%9E%E6%95%88%E2%80%9D%EF%BC%8C%0A%3C/pre%3E%3Cdiv%3E%5B%E5%BB%BA%E8%AE%AE%5D%20%E6%AE%B5%E6%A3%80%E6%9F%A5%EF%BC%9A%3Cspan%20class%3D%22bad%22%3E%E6%9C%AA%E5%87%BA%E7%8E%B0%3C/span%3E%3C/div%3E

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

28
.tmp/case3_v9_result.html Normal file
View File

@ -0,0 +1,28 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>Case3 验收结果</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; margin: 24px; background: #f5f7fb; }
.card { background: #fff; border: 1px solid #d9e1ef; border-radius: 12px; padding: 20px; max-width: 980px; }
h1 { margin: 0 0 12px; font-size: 22px; }
.meta { color: #344054; margin: 6px 0; }
.fail { color: #b42318; font-weight: 600; }
.reply { margin-top: 12px; padding: 14px; border: 1px dashed #98a2b3; border-radius: 8px; background: #f8fafc; white-space: pre-wrap; }
code { background: #eef2ff; padding: 2px 6px; border-radius: 6px; }
</style>
</head>
<body>
<div class="card">
<h1>Case3 验收截图(v9)</h1>
<div class="meta">source_message_id: <code>real-scene-p1-v9-3-1773884746</code></div>
<div class="meta">run_id: <code>89</code></div>
<div class="meta">当前 run 状态: <code>failed</code>(retry_count=3/max_retries=3)</div>
<div class="meta fail">验收结果: 不通过(未返回业务答案)</div>
<div class="meta">飞书实际回复(outbox):</div>
<div class="reply">1) 已收到,正在处理。
2) 已自动重试3次仍失败,请重新发送或换个说法。</div>
</div>
</body>
</html>

BIN
.tmp/case3_v9_result.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
.tmp/dark_model_routing.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

@ -0,0 +1 @@
<!doctype html><html><head><meta charset="utf-8"><style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;padding:24px;background:#f5f7fb;color:#222}.card{background:#fff;border:1px solid #dbe2ea;border-radius:12px;padding:20px;max-width:900px}pre{white-space:pre-wrap;word-break:break-word;font-size:15px;line-height:1.5}.meta{color:#667085;font-size:13px;margin-bottom:10px}</style></head><body><div class="card"><h2>Case 1 回复</h2><div class="meta">source_message_id: real-scene-p1-v4-1-1773760579</div><pre>杨宇当前负载为 199.17%。根据系统快照,他身兼多职(FE, BOSS, AI, BE, ADMIN),负责 4 个项目,负载已严重超载,建议尽快进行任务调配。</pre></div></body></html>

BIN
.tmp/feishu_case1_v4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
.tmp/feishu_case2_fail.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

25
.tmp/feishu_case2_v4.html Normal file
View File

@ -0,0 +1,25 @@
<!doctype html><html><head><meta charset="utf-8"><style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;padding:24px;background:#f5f7fb;color:#222}.card{background:#fff;border:1px solid #dbe2ea;border-radius:12px;padding:20px;max-width:900px}pre{white-space:pre-wrap;word-break:break-word;font-size:15px;line-height:1.5}.meta{color:#667085;font-size:13px;margin-bottom:10px}</style></head><body><div class="card"><h2>Case 2 回复</h2><div class="meta">source_message_id: real-scene-p1-v4-2-1773760726</div><pre>[行业动态]
当前未接入实时数据,以下基于训练数据(截止2024年4月):
**大模型领域:**
- GPT-4 Turbo、Claude 3、Gemini 1.5 Pro 等多模态大模型持续迭代,上下文窗口突破百万级
- 开源模型快速追赶,Llama 3、Mistral、Qwen 等性能逼近闭源模型
- 模型成本大幅下降,API 价格战激烈,Token 成本降低约 10 倍
**应用落地趋势:**
- AI Agent(智能体)成为主流方向,从单轮对话转向多步骤任务执行
- RAG(检索增强生成)技术成熟,企业知识库问答成为标配场景
- 垂直领域 AI 应用爆发:客服、法律、医疗、教育等场景深度渗透
**行业监管:**
- 欧盟 AI 法案正式通过,全球 AI 治理框架逐步成型
- 中国发布生成式 AI 管理办法,合规要求趋严
---
[对项目的影响]
对你们 **AI劝退助手** 项目的影响:
1. **技术红利**:大模型成本下降、推理能力提升,可降低项目运营成本,支持更复杂的对话策略(如多轮情绪分析、个性化劝退话术生成)
2. **竞争压力**:AI 应用门槛降低</pre></div></body></html>

BIN
.tmp/feishu_case2_v4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

313
.tmp/p1_real_validate.py Normal file
View File

@ -0,0 +1,313 @@
import asyncio
import json
import time
import urllib.request
import urllib.parse
from datetime import datetime
from typing import Any, Optional
from sqlalchemy import select, func
from app.core.database import AsyncSessionLocal
from app.models import AIOrchestrationRun, FeishuReplyOutbox, AIProcessingLog, AICallLog
BASE = "http://127.0.0.1:8000"
CHAT_ID = "oc_c487d87563e48c2f9566ccd5a758212c"
SENDER = "P1验收机器人"
CASES = [
{"idx": 1, "message": "杨宇现在负载多少", "expected_scene": "member_workload", "keys": ["杨宇", "负载"]},
{"idx": 2, "message": "最近AI行业有什么动态", "expected_scene": "external_trend", "keys": ["AI行业", "行业", "动态", "趋势"]},
{"idx": 3, "message": "帮我创建个项目叫路由验收测试,顺便问下现在有几个进行中的项目", "expected_scene": "mixed", "keys": ["顺便", "问题部分", "需求部分", "进行中的项目"]},
{"idx": 4, "message": "AI劝退助手项目进展怎么样", "expected_scene": "default", "keys": ["AI劝退助手", "进展怎么样", "项目进展"]},
{"idx": 5, "message": "帮我看看宠物行业最新动态", "expected_scene": "external_trend", "keys": ["宠物行业", "最新动态", "行业", "动态"]},
]
BANNED_PREFIXES = ["让我先", "让我尝试", "我来看看", "我先查", "让我看看", "我需要先"]
def login_token() -> str:
data = urllib.parse.urlencode({"username": "admin", "password": "admin123"}).encode()
req = urllib.request.Request(
f"{BASE}/auth/login",
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
with urllib.request.urlopen(req, timeout=30) as resp:
payload = json.loads(resp.read().decode("utf-8"))
return str(payload.get("access_token") or "")
def post_capture(token: str, *, message: str, message_id: str) -> dict[str, Any]:
payload = {
"group_chat_id": CHAT_ID,
"message_content": message,
"sender": SENDER,
"feishu_message_id": message_id,
}
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
f"{BASE}/ai-steward/ai-steward/signal/capture-feishu",
data=body,
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=240) as resp:
text = resp.read().decode("utf-8")
try:
return json.loads(text)
except Exception:
return {"raw": text}
def detect_prompt_scene(prompt: str) -> Optional[str]:
p = str(prompt or "")
if "你是 PMFlow 的团队协作助手。" in p:
return "member_workload"
if "你是 PMFlow 的市场情报助手。" in p:
return "external_trend"
if "用户消息同时包含问题和需求。" in p:
return "mixed"
if "你是 PMFlow 的 AI 项目管理官。" in p:
return "ai_pmo"
if "用户通过飞书直接提问。" in p:
return "feishu_direct"
if "你是 PMFlow 的 RAG 问答助手" in p:
return "default"
return None
def infer_scene_from_reply(reply: str) -> str:
t = str(reply or "")
if "[回答]" in t and "[已收到需求]" in t:
return "mixed"
if "[行业动态]" in t and "[建议]" in t:
return "external_trend"
if ("负载" in t or "工作量" in t) and "%" in t and "项目列表" not in t:
return "member_workload"
if "完成率" in t:
return "default"
return "unknown"
def has_thinking_leak(reply: str) -> bool:
for line in str(reply or "").splitlines():
s = line.strip()
if not s:
continue
if any(s.startswith(prefix) for prefix in BANNED_PREFIXES):
return True
return False
async def fetch_max_ai_call_id(db) -> int:
m = (await db.execute(select(func.max(AICallLog.id)))).scalar()
return int(m or 0)
async def wait_run(db, source_message_id: str, timeout: int = 540) -> Optional[AIOrchestrationRun]:
deadline = time.time() + timeout
last = None
while time.time() < deadline:
row = (
await db.execute(
select(AIOrchestrationRun)
.where(AIOrchestrationRun.source_message_id == source_message_id)
.order_by(AIOrchestrationRun.id.desc())
.limit(1)
)
).scalar_one_or_none()
if row is not None:
last = row
s = str(row.status or "").lower()
if s in {"succeeded", "failed", "cancelled"}:
return row
await asyncio.sleep(2)
return last
async def fetch_outbox(db, source_message_id: str):
return (
await db.execute(
select(FeishuReplyOutbox)
.where(FeishuReplyOutbox.source_message_id == source_message_id)
.order_by(FeishuReplyOutbox.id.asc())
)
).scalars().all()
async def wait_outbox(db, source_message_id: str, run_status: str, timeout: int = 240):
deadline = time.time() + timeout
expected = 2 if run_status == "succeeded" else 1
latest = []
while time.time() < deadline:
latest = await fetch_outbox(db, source_message_id)
sent = [r for r in latest if str(r.status or "") == "sent"]
if len(sent) >= expected:
return latest
await asyncio.sleep(2)
return latest
async def fetch_processing(db, source_message_id: str):
return (
await db.execute(
select(AIProcessingLog)
.where(AIProcessingLog.feishu_message_id == source_message_id)
.order_by(AIProcessingLog.id.desc())
.limit(1)
)
).scalar_one_or_none()
async def fetch_scene_from_call_logs(db, min_id: int, keys: list[str]):
rows = (
await db.execute(
select(AICallLog)
.where(
AICallLog.id > min_id,
AICallLog.function_name.in_(["adaptive_rag_answer_normal", "adaptive_rag_answer_deep"]),
)
.order_by(AICallLog.id.asc())
.limit(160)
)
).scalars().all()
picked = "unknown"
inspected = []
lk = [k.lower() for k in keys if k]
for r in rows:
prompt = str(r.prompt or "")
pl = prompt.lower()
if lk and not any(k in pl for k in lk):
continue
sc = detect_prompt_scene(prompt)
inspected.append(
{
"id": int(r.id),
"scene": sc or "unknown",
"function_name": str(r.function_name or ""),
"created_at": str(r.created_at or ""),
}
)
if sc:
picked = sc
if picked == "unknown" and inspected:
picked = str(inspected[-1].get("scene") or "unknown")
return picked, inspected
def evaluate(case_idx: int, scene: str, reply: str, run_status: str):
leak = has_thinking_leak(reply)
if case_idx == 1:
ok = scene == "member_workload" and "%" in reply and "项目列表" not in reply and not leak
return ok, "member_workload+百分比+无项目列表"
if case_idx == 2:
sec = "[行业动态]" in reply and ("[对项目影响]" in reply or "[对项目的影响]" in reply) and "[建议]" in reply
src = "未接入实时数据" in reply or "训练数据" in reply or "数据来源" in reply
ok = scene == "external_trend" and sec and src and not leak
return ok, "external_trend+三段+来源限制"
if case_idx == 3:
mixed = "[回答]" in reply and "[已收到需求]" in reply
prog = "进行中" in reply
ok = scene == "mixed" and run_status == "succeeded" and mixed and prog and not leak
return ok, "mixed+创建+进行中数量"
if case_idx == 4:
ok = scene == "default" and "完成率" in reply and "%" in reply and not leak
return ok, "default+完成率"
if case_idx == 5:
ok = scene == "external_trend" and not leak
return ok, "external_trend+无思考外漏"
return False, "unknown"
async def run_case(token: str, case: dict[str, Any]):
idx = int(case["idx"])
message = str(case["message"])
source_message_id = f"real-scene-p1-v2-{idx}-{int(time.time())}"
async with AsyncSessionLocal() as db:
before_call_id = await fetch_max_ai_call_id(db)
capture = post_capture(token, message=message, message_id=source_message_id)
async with AsyncSessionLocal() as db:
run_row = await wait_run(db, source_message_id)
run_status = str(getattr(run_row, "status", "missing") or "missing") if run_row is not None else "missing"
outbox_rows = await wait_outbox(db, source_message_id, run_status)
processing = await fetch_processing(db, source_message_id)
prompt_scene, candidates = await fetch_scene_from_call_logs(db, before_call_id, list(case.get("keys") or []))
sent = [r for r in outbox_rows if str(r.status or "") == "sent"]
ack = str(sent[0].message_text or "").strip() if sent else ""
final_reply = str(sent[-1].message_text or "").strip() if sent else ""
if not final_reply:
final_reply = str(capture.get("direct_reply_text") or "").strip()
inferred_scene = infer_scene_from_reply(final_reply)
actual_scene = prompt_scene if prompt_scene != "unknown" else inferred_scene
passed, rule = evaluate(idx, actual_scene, final_reply, run_status)
return {
"idx": idx,
"source_message_id": source_message_id,
"message": message,
"expected_scene": str(case["expected_scene"]),
"prompt_scene": prompt_scene,
"inferred_scene": inferred_scene,
"actual_scene": actual_scene,
"capture_status": str(capture.get("status") or ""),
"run_status": run_status,
"ack": ack,
"final_reply": final_reply,
"has_capacity_warning": "⚠ 产能提示" in final_reply,
"thinking_leak": has_thinking_leak(final_reply),
"processing": {
"route": str(getattr(processing, "route", "") or ""),
"intent": str(getattr(processing, "intent", "") or ""),
"status": str(getattr(processing, "processing_status", "") or ""),
"duration_ms": int(getattr(processing, "duration_ms", 0) or 0),
"error_message": str(getattr(processing, "error_message", "") or ""),
"reply_source": str(getattr(processing, "reply_source", "") or ""),
},
"outbox": [
{
"id": int(r.id),
"status": str(r.status or ""),
"attempts": int(r.attempts or 0),
"created_at": str(r.created_at or ""),
"text": str(r.message_text or ""),
}
for r in outbox_rows
],
"call_log_candidates": candidates,
"pass": passed,
"pass_rule": rule,
}
async def main():
token = login_token()
if not token:
raise RuntimeError("token empty")
results = []
for case in CASES:
print(f"[RUN] case {case['idx']} -> {case['message']}", flush=True)
one = await run_case(token, case)
print(f"[DONE] case {one['idx']} scene={one['actual_scene']} pass={one['pass']} run={one['run_status']}", flush=True)
results.append(one)
await asyncio.sleep(2)
payload = {
"generated_at": datetime.now().isoformat(),
"chat_id": CHAT_ID,
"results": results,
}
print("===RESULT_JSON_START===")
print(json.dumps(payload, ensure_ascii=False))
print("===RESULT_JSON_END===")
asyncio.run(main())

330
.tmp/p1_real_validate_v4.py Normal file
View File

@ -0,0 +1,330 @@
import asyncio
import json
import time
import urllib.parse
import urllib.request
from datetime import datetime
from typing import Any
from sqlalchemy import text
from app.core.database import AsyncSessionLocal
BASE = "http://127.0.0.1:8000"
CHAT_ID = "oc_c487d87563e48c2f9566ccd5a758212c"
SENDER = "P1验收机器人"
CASES = [
{"idx": 1, "message": "杨宇现在负载多少", "expected_scene": "member_workload", "keys": ["杨宇", "负载"]},
{"idx": 2, "message": "最近AI行业有什么动态", "expected_scene": "external_trend", "keys": ["AI行业", "行业", "动态", "趋势"]},
{"idx": 3, "message": "帮我创建个项目叫路由验收测试,顺便问下现在有几个进行中的项目", "expected_scene": "mixed", "keys": ["顺便", "问题部分", "需求部分", "进行中的项目"]},
{"idx": 4, "message": "AI劝退助手项目进展怎么样", "expected_scene": "default", "keys": ["AI劝退助手", "进展怎么样", "项目进展"]},
{"idx": 5, "message": "帮我看看宠物行业最新动态", "expected_scene": "external_trend", "keys": ["宠物行业", "最新动态", "行业", "动态"]},
]
BANNED_PREFIXES = ["让我先", "让我尝试", "我来看看", "我先查", "让我看看", "我需要先"]
def login_token() -> str:
data = urllib.parse.urlencode({"username": "admin", "password": "admin123"}).encode()
req = urllib.request.Request(
f"{BASE}/auth/login",
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
with urllib.request.urlopen(req, timeout=30) as resp:
payload = json.loads(resp.read().decode("utf-8"))
return str(payload.get("access_token") or "")
def post_capture(token: str, message: str, source_message_id: str) -> dict[str, Any]:
payload = {
"group_chat_id": CHAT_ID,
"message_content": message,
"sender": SENDER,
"feishu_message_id": source_message_id,
}
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
f"{BASE}/ai-steward/ai-steward/signal/capture-feishu",
data=body,
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=240) as resp:
text_body = resp.read().decode("utf-8")
try:
return json.loads(text_body)
except Exception:
return {"raw": text_body}
def detect_prompt_scene(prompt: str) -> str:
p = str(prompt or "")
if "你是 PMFlow 的团队协作助手。" in p:
return "member_workload"
if "你是 PMFlow 的市场情报助手。" in p:
return "external_trend"
if "用户消息同时包含问题和需求。" in p:
return "mixed"
if "你是 PMFlow 的 AI 项目管理官。" in p:
return "ai_pmo"
if "用户通过飞书直接提问。" in p:
return "feishu_direct"
if "你是 PMFlow 的 RAG 问答助手" in p:
return "default"
return "unknown"
def infer_scene_from_reply(reply: str) -> str:
t = str(reply or "")
if "[回答]" in t and "[已收到需求]" in t:
return "mixed"
if "[行业动态]" in t and "[建议]" in t:
return "external_trend"
if ("负载" in t or "工作量" in t) and "%" in t and "项目列表" not in t:
return "member_workload"
if "完成率" in t:
return "default"
return "unknown"
def has_thinking_leak(reply: str) -> bool:
for line in str(reply or "").splitlines():
s = line.strip()
if not s:
continue
if any(s.startswith(prefix) for prefix in BANNED_PREFIXES):
return True
return False
async def get_max_ai_call_id() -> int:
async with AsyncSessionLocal() as db:
row = (
await db.execute(text("SELECT COALESCE(MAX(id), 0) AS v FROM ai_call_logs"))
).mappings().first()
return int((row or {}).get("v") or 0)
async def wait_run(source_message_id: str, timeout_sec: int = 600) -> dict[str, Any] | None:
deadline = time.time() + timeout_sec
last = None
while time.time() < deadline:
async with AsyncSessionLocal() as db:
row = (
await db.execute(
text(
"""
SELECT id, status, trace_id, source_message_id, created_at, updated_at
FROM ai_orchestration_runs
WHERE source_message_id = :sid
ORDER BY id DESC
LIMIT 1
"""
),
{"sid": source_message_id},
)
).mappings().first()
if row:
last = dict(row)
status = str(last.get("status") or "").lower()
if status in {"succeeded", "failed", "cancelled", "waiting_manual"}:
return last
await asyncio.sleep(2)
return last
async def wait_outbox(source_message_id: str, expected_sent: int, timeout_sec: int = 300) -> list[dict[str, Any]]:
deadline = time.time() + timeout_sec
latest: list[dict[str, Any]] = []
while time.time() < deadline:
async with AsyncSessionLocal() as db:
rows = (
await db.execute(
text(
"""
SELECT id, status, attempts, message_text, created_at
FROM feishu_reply_outbox
WHERE source_message_id = :sid
ORDER BY id ASC
"""
),
{"sid": source_message_id},
)
).mappings().all()
latest = [dict(r) for r in rows]
sent_count = sum(1 for r in latest if str(r.get("status") or "") == "sent")
if sent_count >= expected_sent:
return latest
await asyncio.sleep(2)
return latest
async def fetch_processing(source_message_id: str) -> dict[str, Any]:
async with AsyncSessionLocal() as db:
row = (
await db.execute(
text(
"""
SELECT id, route, intent, processing_status, duration_ms, error_message, reply_source, created_at
FROM ai_processing_logs
WHERE feishu_message_id = :sid
ORDER BY id DESC
LIMIT 1
"""
),
{"sid": source_message_id},
)
).mappings().first()
return dict(row or {})
async def fetch_prompt_scene(min_id: int, keys: list[str]) -> tuple[str, list[dict[str, Any]]]:
async with AsyncSessionLocal() as db:
rows = (
await db.execute(
text(
"""
SELECT id, function_name, prompt, created_at
FROM ai_call_logs
WHERE id > :min_id
AND function_name IN ('adaptive_rag_answer_normal', 'adaptive_rag_answer_deep')
ORDER BY id ASC
LIMIT 400
"""
),
{"min_id": min_id},
)
).mappings().all()
lowered = [str(k).lower() for k in (keys or []) if str(k).strip()]
picked = "unknown"
candidates: list[dict[str, Any]] = []
for r in rows:
prompt = str(r.get("prompt") or "")
pl = prompt.lower()
if lowered and not any(k in pl for k in lowered):
continue
scene = detect_prompt_scene(prompt)
candidates.append(
{
"id": int(r.get("id") or 0),
"scene": scene,
"function_name": str(r.get("function_name") or ""),
"created_at": str(r.get("created_at") or ""),
}
)
if scene != "unknown":
picked = scene
if picked == "unknown" and candidates:
picked = str(candidates[-1].get("scene") or "unknown")
return picked, candidates
def evaluate(case_idx: int, scene: str, reply: str, run_status: str) -> tuple[bool, str]:
leak = has_thinking_leak(reply)
if case_idx == 1:
ok = scene == "member_workload" and "%" in reply and "项目列表" not in reply and not leak
return ok, "member_workload+百分比+无项目列表"
if case_idx == 2:
sec = "[行业动态]" in reply and ("[对项目影响]" in reply or "[对项目的影响]" in reply) and "[建议]" in reply
src = "未接入实时数据" in reply or "训练数据" in reply or "数据来源" in reply
ok = scene == "external_trend" and sec and src and not leak
return ok, "external_trend+三段+来源限制"
if case_idx == 3:
mixed = "[回答]" in reply and "[已收到需求]" in reply
prog = "进行中" in reply
ok = scene == "mixed" and run_status == "succeeded" and mixed and prog and not leak
return ok, "mixed+创建+进行中数量"
if case_idx == 4:
ok = scene == "default" and "完成率" in reply and "%" in reply and not leak
return ok, "default+完成率"
if case_idx == 5:
ok = scene == "external_trend" and not leak
return ok, "external_trend+无思考外漏"
return False, "unknown"
async def run_case(token: str, case: dict[str, Any]) -> dict[str, Any]:
idx = int(case["idx"])
source_message_id = f"real-scene-p1-v4-{idx}-{int(time.time())}"
message = str(case["message"])
before_call_id = await get_max_ai_call_id()
capture = post_capture(token, message, source_message_id)
run_row = await wait_run(source_message_id)
run_status = str((run_row or {}).get("status") or "missing")
expected_sent = 2 if run_status == "succeeded" else 1
outbox_rows = await wait_outbox(source_message_id, expected_sent)
sent = [r for r in outbox_rows if str(r.get("status") or "") == "sent"]
ack = str(sent[0].get("message_text") or "").strip() if sent else ""
final_reply = str(sent[-1].get("message_text") or "").strip() if sent else ""
if not final_reply:
final_reply = str(capture.get("direct_reply_text") or "").strip()
processing = await fetch_processing(source_message_id)
prompt_scene, candidates = await fetch_prompt_scene(before_call_id, list(case.get("keys") or []))
inferred_scene = infer_scene_from_reply(final_reply)
actual_scene = prompt_scene if prompt_scene != "unknown" else inferred_scene
passed, pass_rule = evaluate(idx, actual_scene, final_reply, run_status)
return {
"idx": idx,
"source_message_id": source_message_id,
"message": message,
"expected_scene": str(case["expected_scene"]),
"capture_status": str(capture.get("status") or ""),
"run_status": run_status,
"prompt_scene": prompt_scene,
"inferred_scene": inferred_scene,
"actual_scene": actual_scene,
"ack": ack,
"final_reply": final_reply,
"thinking_leak": has_thinking_leak(final_reply),
"has_capacity_warning": "⚠ 产能提示" in final_reply,
"processing": {
"route": str(processing.get("route") or ""),
"intent": str(processing.get("intent") or ""),
"status": str(processing.get("processing_status") or ""),
"duration_ms": int(processing.get("duration_ms") or 0),
"error_message": str(processing.get("error_message") or ""),
"reply_source": str(processing.get("reply_source") or ""),
},
"outbox": outbox_rows,
"call_log_candidates": candidates,
"pass": passed,
"pass_rule": pass_rule,
}
async def main() -> None:
token = login_token()
if not token:
raise RuntimeError("token empty")
results = []
for case in CASES:
print(f"[RUN] case {case['idx']} -> {case['message']}", flush=True)
one = await run_case(token, case)
print(
f"[DONE] case {one['idx']} scene={one['actual_scene']} pass={one['pass']} run={one['run_status']} leak={one['thinking_leak']}",
flush=True,
)
results.append(one)
await asyncio.sleep(2)
payload = {
"generated_at": datetime.now().isoformat(),
"chat_id": CHAT_ID,
"results": results,
}
print("===RESULT_JSON_START===")
print(json.dumps(payload, ensure_ascii=False))
print("===RESULT_JSON_END===")
if __name__ == "__main__":
asyncio.run(main())

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

BIN
.tmp/real_feishu_case_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

BIN
.tmp/real_feishu_case_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

BIN
.tmp/real_feishu_case_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
.tmp/real_feishu_case_4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
.tmp/real_feishu_case_5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

291
.tmp/run_feishu_case.py Normal file
View File

@ -0,0 +1,291 @@
import argparse
import asyncio
import json
import time
import urllib.parse
import urllib.request
from datetime import datetime
from typing import Any, Optional
from sqlalchemy import func, select
from app.core.database import AsyncSessionLocal
from app.models import AICallLog, AIOrchestrationRun, AIProcessingLog, FeishuReplyOutbox
BASE = "http://127.0.0.1:8000"
CHAT_ID = "oc_c487d87563e48c2f9566ccd5a758212c"
SENDER = "P1验收机器人"
BANNED_PREFIXES = ["让我先", "让我尝试", "我来看看", "我先查", "让我看看", "我需要先"]
def login_token() -> str:
data = urllib.parse.urlencode({"username": "admin", "password": "admin123"}).encode()
req = urllib.request.Request(
f"{BASE}/auth/login",
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
with urllib.request.urlopen(req, timeout=30) as resp:
payload = json.loads(resp.read().decode("utf-8"))
return str(payload.get("access_token") or "")
def post_capture(token: str, message: str, source_message_id: str) -> dict[str, Any]:
payload = {
"group_chat_id": CHAT_ID,
"message_content": message,
"sender": SENDER,
"feishu_message_id": source_message_id,
}
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
f"{BASE}/ai-steward/ai-steward/signal/capture-feishu",
data=body,
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=120) as resp:
text = resp.read().decode("utf-8")
try:
return json.loads(text)
except Exception:
return {"raw": text}
def detect_prompt_scene(prompt: str) -> Optional[str]:
p = str(prompt or "")
if "你是 PMFlow 的团队协作助手。" in p:
return "member_workload"
if "你是 PMFlow 的市场情报助手。" in p:
return "external_trend"
if "用户消息同时包含问题和需求。" in p:
return "mixed"
if "你是 PMFlow 的 AI 项目管理官。" in p:
return "ai_pmo"
if "用户通过飞书直接提问。" in p:
return "feishu_direct"
if "你是 PMFlow 的 RAG 问答助手" in p:
return "default"
return None
def infer_scene_from_reply(reply: str) -> str:
t = str(reply or "")
if "[回答]" in t and "[已收到需求]" in t:
return "mixed"
if "[行业动态]" in t and "[建议]" in t:
return "external_trend"
if ("负载" in t or "工作量" in t) and "%" in t and "项目列表" not in t:
return "member_workload"
if "完成率" in t:
return "default"
return "unknown"
def has_thinking_leak(text: str) -> bool:
for line in str(text or "").splitlines():
s = line.strip()
if not s:
continue
if any(s.startswith(prefix) for prefix in BANNED_PREFIXES):
return True
return False
async def fetch_max_ai_call_id(db) -> int:
row = (await db.execute(select(func.max(AICallLog.id)))).scalar()
return int(row or 0)
async def wait_run(db, source_message_id: str, timeout_sec: int = 720) -> Optional[AIOrchestrationRun]:
deadline = time.time() + timeout_sec
last = None
while time.time() < deadline:
row = (
await db.execute(
select(AIOrchestrationRun)
.where(AIOrchestrationRun.source_message_id == source_message_id)
.order_by(AIOrchestrationRun.id.desc())
.limit(1)
)
).scalar_one_or_none()
if row is not None:
last = row
st = str(row.status or "").lower()
if st in {"succeeded", "failed", "cancelled"}:
return row
await asyncio.sleep(2)
return last
async def wait_outbox(db, source_message_id: str, run_status: str, timeout_sec: int = 300) -> list[FeishuReplyOutbox]:
deadline = time.time() + timeout_sec
expected = 2 if run_status == "succeeded" else 1
latest: list[FeishuReplyOutbox] = []
while time.time() < deadline:
latest = (
await db.execute(
select(FeishuReplyOutbox)
.where(FeishuReplyOutbox.source_message_id == source_message_id)
.order_by(FeishuReplyOutbox.id.asc())
)
).scalars().all()
sent = [r for r in latest if str(r.status or "") == "sent"]
if len(sent) >= expected:
return latest
await asyncio.sleep(2)
return latest
async def fetch_processing(db, source_message_id: str) -> Optional[AIProcessingLog]:
return (
await db.execute(
select(AIProcessingLog)
.where(AIProcessingLog.feishu_message_id == source_message_id)
.order_by(AIProcessingLog.id.desc())
.limit(1)
)
).scalar_one_or_none()
async def fetch_prompt_scene(db, min_call_id: int, keys: list[str]) -> tuple[str, list[dict[str, Any]]]:
rows = (
await db.execute(
select(AICallLog)
.where(
AICallLog.id > min_call_id,
AICallLog.function_name.in_(["adaptive_rag_answer_normal", "adaptive_rag_answer_deep"]),
)
.order_by(AICallLog.id.asc())
.limit(200)
)
).scalars().all()
lowered = [k.lower() for k in keys if k]
picked = "unknown"
candidates: list[dict[str, Any]] = []
for row in rows:
prompt = str(row.prompt or "")
pl = prompt.lower()
if lowered and not any(k in pl for k in lowered):
continue
scene = detect_prompt_scene(prompt)
candidates.append(
{
"id": int(row.id),
"scene": scene or "unknown",
"function": str(row.function_name or ""),
"created_at": str(row.created_at or ""),
}
)
if scene:
picked = scene
if picked == "unknown" and candidates:
picked = str(candidates[-1].get("scene") or "unknown")
return picked, candidates
def evaluate(idx: int, actual_scene: str, final_reply: str, run_status: str) -> tuple[bool, str]:
leak = has_thinking_leak(final_reply)
if idx == 1:
ok = actual_scene == "member_workload" and "%" in final_reply and "项目列表" not in final_reply and not leak
return ok, "member_workload+百分比+无项目列表"
if idx == 2:
sec = "[行业动态]" in final_reply and ("[对项目影响]" in final_reply or "[对项目的影响]" in final_reply) and "[建议]" in final_reply
src = "未接入实时数据" in final_reply or "训练数据" in final_reply or "数据来源" in final_reply
ok = actual_scene == "external_trend" and sec and src and not leak
return ok, "external_trend+三段输出+来源限制"
if idx == 3:
mixed = "[回答]" in final_reply and "[已收到需求]" in final_reply
prog = "进行中" in final_reply
ok = actual_scene == "mixed" and run_status == "succeeded" and mixed and prog and not leak
return ok, "mixed+创建成功+进行中数量"
if idx == 4:
ok = actual_scene == "default" and "完成率" in final_reply and "%" in final_reply and not leak
return ok, "default+项目状态完成率"
if idx == 5:
ok = actual_scene == "external_trend" and not leak
return ok, "external_trend+无思考外漏"
return False, "unknown"
async def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--idx", type=int, required=True)
parser.add_argument("--message", type=str, required=True)
parser.add_argument("--keys", type=str, default="")
parser.add_argument("--prefix", type=str, default="real-scene-p1-manual")
args = parser.parse_args()
keys = [k.strip() for k in str(args.keys).split("|") if k.strip()]
source_message_id = f"{args.prefix}-{args.idx}-{int(time.time())}"
token = login_token()
if not token:
raise RuntimeError("token empty")
async with AsyncSessionLocal() as db:
before_call_id = await fetch_max_ai_call_id(db)
capture = post_capture(token, args.message, source_message_id)
async with AsyncSessionLocal() as db:
run_row = await wait_run(db, source_message_id)
run_status = str(getattr(run_row, "status", "missing") or "missing") if run_row is not None else "missing"
outbox_rows = await wait_outbox(db, source_message_id, run_status)
processing = await fetch_processing(db, source_message_id)
prompt_scene, candidates = await fetch_prompt_scene(db, before_call_id, keys)
sent_rows = [r for r in outbox_rows if str(r.status or "") == "sent"]
ack = str(sent_rows[0].message_text or "").strip() if sent_rows else ""
final_reply = str(sent_rows[-1].message_text or "").strip() if sent_rows else ""
if not final_reply:
final_reply = str(capture.get("direct_reply_text") or "").strip()
inferred_scene = infer_scene_from_reply(final_reply)
actual_scene = prompt_scene if prompt_scene != "unknown" else inferred_scene
passed, pass_rule = evaluate(args.idx, actual_scene, final_reply, run_status)
payload = {
"generated_at": datetime.now().isoformat(),
"idx": args.idx,
"source_message_id": source_message_id,
"message": args.message,
"capture_status": str(capture.get("status") or ""),
"run_status": run_status,
"ack": ack,
"final_reply": final_reply,
"prompt_scene": prompt_scene,
"inferred_scene": inferred_scene,
"actual_scene": actual_scene,
"thinking_leak": has_thinking_leak(final_reply),
"has_capacity_warning": "⚠ 产能提示" in final_reply,
"processing": {
"route": str(getattr(processing, "route", "") or ""),
"intent": str(getattr(processing, "intent", "") or ""),
"status": str(getattr(processing, "processing_status", "") or ""),
"duration_ms": int(getattr(processing, "duration_ms", 0) or 0),
"error_message": str(getattr(processing, "error_message", "") or ""),
"reply_source": str(getattr(processing, "reply_source", "") or ""),
},
"outbox": [
{
"id": int(r.id),
"status": str(r.status or ""),
"attempts": int(r.attempts or 0),
"created_at": str(r.created_at or ""),
"text": str(r.message_text or ""),
}
for r in outbox_rows
],
"call_log_candidates": candidates,
"pass": passed,
"pass_rule": pass_rule,
}
print(json.dumps(payload, ensure_ascii=False))
if __name__ == "__main__":
asyncio.run(main())

218
AGENTS.md
View File

@ -2,7 +2,16 @@
你是一位资深的全栈工程师,精通 Python 3.11+、FastAPI、SQLAlchemy 2.0、LangChain、React 18、TypeScript、shadcn/ui、Tailwind CSS。 你是一位资深的全栈工程师,精通 Python 3.11+、FastAPI、SQLAlchemy 2.0、LangChain、React 18、TypeScript、shadcn/ui、Tailwind CSS。
你正在为 **PMFlow** 开发代码:这是一款 AI 原生、人员能力驱动的项目管理操作系统,专为 10-50 人 AI 开发团队打造。 你正在为 **PMFlow** 开发代码:这是一款 AI 原生、人员能力驱动的项目执行操作系统,首版面向个人开发者、一人公司、半技术产品创作者与 1-10 人 AI 产品小团队,采用 `solo-first, team-ready` 方向。
---
## USER.md 边界
- 本项目内用户真源以 `USER.md` 为准;若 `~/.codex/AGENTS.md` 与本项目 `USER.md` 冲突,以 `USER.md` 为准。
- `USER.md` 承载本项目内的用户协作偏好、判断优先级、已拍板决策、反复踩坑与暂不处理问题。
- 工程硬约束、系统边界、安全规则与验证要求仍以本文件和更外层提示为准。
- 执行时先满足硬约束,再在不违背硬约束的前提下尽量贴合 `USER.md`。
--- ---
@ -16,7 +25,12 @@
3. **数据库规范**:使用 SQLAlchemy 2.0,所有模型继承 Base 基类,必须包含 `tenant_id` 字段,所有查询必须自动过滤 `tenant_id`(使用依赖注入实现)。 3. **数据库规范**:使用 SQLAlchemy 2.0,所有模型继承 Base 基类,必须包含 `tenant_id` 字段,所有查询必须自动过滤 `tenant_id`(使用依赖注入实现)。
4. **AI 能力规范**:所有 AI 能力必须封装在独立的 service 层,使用 LangChain 实现,Prompt 放在 `./backend/app/config/ai_prompts.py` 独立管理,禁止硬编码。 4. **AI 能力规范**:
- 所有 AI 能力必须封装在独立的 service 层
- AI 能力通过 PMFlow 本地路由层调用
- 路由规则参考 `backend/.control_plane/pmflow-routing-rules.json`
- Prompt 统一由 `./backend/app/config/ai_prompts.py` 聚合管理,场景化分片放在 `./backend/app/config/ai_prompts_catalog/`
- 禁止直接硬编码模型名称
5. **AI 调用规范**:所有 AI 调用必须: 5. **AI 调用规范**:所有 AI 调用必须:
- 记录日志到 `ai_call_logs` 表 - 记录日志到 `ai_call_logs` 表
@ -70,3 +84,203 @@ frontend/ # React 前端
## 执行要求 ## 执行要求
根据用户的具体需求,生成高质量、可直接运行的代码。 根据用户的具体需求,生成高质量、可直接运行的代码。
- 计划类需求执行约定:若用户已确认计划/方案并要求开始实现,Agent 默认连续执行到“实现完成 + 验证完成 + 给出验收结果”后再统一回复;除非遇到明确阻塞、高风险冲突、context 限制或用户主动中断,否则不得中途停下来反复请示。
---
## 8. 服务边界约束与 AI 系统宪章(禁止越界)
- 数据库写操作必须通过 Alembic migration,禁止直接 `ALTER TABLE`
- 新增接口必须遵循响应包络:`{code, message, data}`,不得裸返回
- 禁止在 API 层写业务逻辑,业务逻辑归属 `services/`
- 禁止在前端组件内直接调用 `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 策略。
- 显式 `reply/thread`、卡片动作和 `work_item_id` 属于更强锚点,必须优先于旧 session 和记忆续写。
- 进入 `requirement_plan` / draft confirm 的线程必须留在对应主链,禁止掉回 `query.answer`;其中 draft confirm 只允许显式 `reply/thread`、卡片按钮或 `work_item_id` 进入,禁止无锚点按“同群/同人”回捞旧 pending draft。若同 scope 旧 plan/draft 已被判定污染,运行时必须先终止该 scope 内的 active topic/work item,再回退到 `query.answer`。选项协议回复必须复用单点结构化解析器,不得散落关键词/正则。
- 已绑定 active requirement context 的线程里,显式 `退出需求模式` 必须先挂起当前 collecting/ready draft 对应的 requirement topic,再允许回到普通 `query.answer`;同一 `conversation_scope` 下的 topic 选择必须优先命中仍带 active draft 的 live requirement workspace,禁止被后续 idle query topic 抢走。
- draft confirm 若命中显式 `reply/thread` 锚点,候选 mounted draft / pending draft 必须先通过该锚点精确命中;同群、同 scope、同人但锚点不匹配的历史草稿不得参与打分或回捞,避免 requirement-plan A/B/C 线程被旧 draft 劫持。
- Feishu `requirement_plan` 首轮/追问回复若命中 `reply_template_key=requirement_plan_followup`,必须保留模板键并优先渲染交互卡片;卡片按钮回流必须复用 canonical Feishu `reply/thread` 上下文,禁止因 query renderer 吞模板键或按钮回流丢锚点而退回纯文本旧模板。
- 冷启动 `WorkflowSemanticPlanner` / `pmflow_semantic_context_decision` 失败时,运行时只能降级到带 `semantic_planner_degraded=true` 审计标记的中性 `query.answer` 场景;后续优先重试 semantic backfill,显式 active requirement context / reply anchor 仍留在 requirement-plan 主链,无锚点事实问句禁止硬降级成 requirement consultation。
- Feishu 显式 `reply/thread` 到机器人消息时,必须先通过 `feishu_reply_outbox.sent_message_id -> source_message_id` 归一化 canonical thread context,再进入后续主链。
- 外部 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`,禁止冒充成功。
- 运行时长清单、Feishu / requirement-plan / AdaptiveRAG 细则、已知边界与假设,统一以 `docs/architecture/RUNTIME_GUARDRAILS.md` 为准。
## 9. 新增代码的参考模式
- 新增后端接口:参考 `backend/app/api/sstc_api/` 的结构
- 新增前端 API 调用:参考 `frontend/src/api/sstc.ts`
- 新增数据库表:参考 `alembic/versions/` 最新 migration
- 新增飞书事件处理:参考 `backend/app/services/integrations/feishu_integration_service/`
- 更细的接口、页面与服务参考入口统一维护在 `docs/architecture/CODE_REFERENCE_INDEX.md`
## 10. 幂等与事务约束
- 所有入站事件必须经过 `ingest_events` 幂等去重
- 飞书回复必须经过 `feishu_reply_outbox`,禁止同步直接发送
- 跨表写操作必须在同一个数据库事务内完成
## 11. 已知 BOUNDARY(不要幻构行为)
- 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用
- `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,动态层不缓存
## 12. 前端组件约束
- `ui/` 目录下组件禁止修改,仅使用
- `frontend/src/components/sstc/` 目录已模块化拆分,新增文件与职责如下:
- `SSTCCharterStagePanel.tsx`:宪章阶段主面板(内容编辑、版本/状态展示)
- `SSTCExecutionBoardPanel.tsx`:执行看板阶段主面板(执行视图聚合)
- `SSTCReleaseCreateDialog.tsx`:发布创建弹窗
- `SSTCReleaseStagePanel.tsx`:发布阶段主面板(发布列表与校验项)
- `SSTCReviewCreateDialog.tsx`:复盘创建弹窗
- `SSTCReviewStagePanel.tsx`:复盘阶段主面板(复盘内容与改进项)
- `SSTCSignalStagePanel.tsx`:信号阶段主面板(信号录入/状态流转)
- `SSTCTaskCreateDialog.tsx`:任务创建弹窗
- `SSTCTaskStagePanel.tsx`:任务阶段主面板(任务列表、筛选、状态流转)
- `sstcUtils.ts`:SSTC 跨阶段工具函数与转换逻辑
- `useSSTCWorkspaceOrchestration.ts`:SSTC 工作区编排 Hook(状态聚合、事件编排、数据装配)
- 修改前必须执行 `FIND_USAGE` 的组件/模块清单(强制):
- `SSTCWorkspace.tsx`
- `useSSTCWorkspaceOrchestration.ts`
- `sstcUtils.ts`
- `SSTCCharterStagePanel.tsx`
- `SSTCSignalStagePanel.tsx`
- `SSTCTaskStagePanel.tsx`
- `SSTCExecutionBoardPanel.tsx`
- `SSTCReleaseStagePanel.tsx`
- `SSTCReviewStagePanel.tsx`
- `SSTCReleaseCreateDialog.tsx`
- `SSTCReviewCreateDialog.tsx`
- `SSTCTaskCreateDialog.tsx`
- 新增业务组件放入对应功能目录,禁止在 `pages/` 内写内联组件
- 样式只用 Tailwind utility class,禁止内联 `style`
## 13. 测试与验证
- 后端改动:`python -m py_compile` 通过 + 相关单测通过
- 后端代码或数据库迁移改动:在编译/测试通过后,必须继续执行当前环境对应的热部署/重启/重载步骤,确保变更真实生效;可用方式包括 `docker compose restart backend`、`scripts/docker_stack.sh restart|dev-restart`、`systemctl restart neural-sync`、`nginx -s reload` 等。若当前环境不是部署机、权限不足或不具备热部署条件,必须明确说明未执行原因与阻塞点,禁止默认跳过
- 前端改动:`npm run build` 通过 + `tsc --noEmit` 零错误
- 新增接口:必须在 `backend/tests/` 补充对应单测
## 14. 文档同步规则(强制)
每次任务完成后,若以下内容发生变化,必须同步更新对应文档(`CLAUDE.md`、`docs/architecture/ARCHITECTURE_INVENTORY.md`、`docs/architecture/RUNTIME_GUARDRAILS.md`、`docs/architecture/CODE_REFERENCE_INDEX.md`):
- 新增/删除/重命名文件或目录
→ 更新 `CLAUDE.md` 第3-5节目录结构,并同步更新 `docs/architecture/ARCHITECTURE_INVENTORY.md`
- 新增/修改服务边界或禁止操作
→ 更新 `AGENTS.md` 第8节摘要规则,并同步更新 `docs/architecture/RUNTIME_GUARDRAILS.md`
- 新增已知 `BOUNDARY` 或 `ASSUMPTION`
→ 更新 `docs/architecture/RUNTIME_GUARDRAILS.md`
- 新增/修改 API 接口
→ 更新 `docs/architecture/CODE_REFERENCE_INDEX.md`,必要时同步 `CLAUDE.md` 摘要索引
文档更新必须在 `[Task:Done]` 之前完成。
未更新文档的任务视为未完成。
## 15. 反迎合约束(强制)
- 若用户方案存在明显根本风险,必须先直接指出风险,再继续执行。
- 若存在更优方案且后果并不显而易见,必须简短说明更优路径与取舍;用户确认坚持后可继续执行原方案。
## 16. Context 管理约定
- 大功能开发或结构性重构,单任务涉及文件超过5个时优先拆分为独立子任务;缺陷修复/回归修复允许围绕同一根因跨相关文件端到端完成
- 单任务预估改动超过200行 → 拆分执行
- 发生 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 统一合并共享文件。
## 安全约束(必须严格遵守)
### 🚫 绝对禁止的操作:
- 禁止未确认的破坏性删除;只有在用户明确要求删除死代码/死文件、已先列出目标路径并说明后果、且用户再次确认后,才允许执行文件删除
- 禁止执行带 -rf、--force、-y 参数的命令
- 禁止 DROP TABLE、TRUNCATE 等数据库破坏性操作
- 禁止修改 /etc、/sys、/boot 等系统目录
### ✅ 文件操作前必须:
1. 仅在破坏性删除、不可逆改名或其它高风险文件操作前,先列出将要操作的文件路径
2. 明确告知用户操作后果
3. 等待用户输入"确认"后才能执行

506
CLAUDE.md

File diff suppressed because one or more lines are too long

View File

@ -120,11 +120,11 @@ source venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
# 配置环境变量 # 配置环境变量
cp .env.example .env cp /opt/neural-sync/.env.example /opt/neural-sync/.env
nano .env nano /opt/neural-sync/.env
``` ```
编辑 `.env` 文件: 编辑项目根目录 `.env` 文件:
```env ```env
# 数据库 - PostgreSQL # 数据库 - PostgreSQL
@ -227,7 +227,7 @@ Type=simple
User=root User=root
WorkingDirectory=/opt/neural-sync/backend WorkingDirectory=/opt/neural-sync/backend
Environment="PATH=/opt/neural-sync/backend/venv/bin" Environment="PATH=/opt/neural-sync/backend/venv/bin"
EnvironmentFile=/opt/neural-sync/backend/.env EnvironmentFile=/opt/neural-sync/.env
ExecStart=/opt/neural-sync/backend/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 ExecStart=/opt/neural-sync/backend/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000
Restart=always Restart=always
RestartSec=10 RestartSec=10

View File

@ -3,3 +3,13 @@
# Keep this file empty (or with only comments) to skip heartbeat API calls. # Keep this file empty (or with only comments) to skip heartbeat API calls.
# Add tasks below when you want the agent to check something periodically. # Add tasks below when you want the agent to check something periodically.
## 每日工单推送
- 触发时间:每天 08:30
- 任务:查询当天工单并推送到飞书
- 执行命令:
```bash
docker exec pmflow-db psql "postgresql://readonly:readonly%40%232025@173.1.1.32:5432/middle_platform" -c "SELECT task_code, task_name, excutor_name, status, start_time FROM plat_task_center.task_instance WHERE start_time >= CURRENT_DATE AND start_time < CURRENT_DATE + INTERVAL '1 day' AND ext3 IN ('90101', '901') ORDER BY start_time DESC LIMIT 50;" -x
```
- 目标用户:ou_9e1fefbcf4cc68a84f4a4ff7d1749048

View File

@ -20,4 +20,4 @@ This isn't just metadata. It's the start of figuring out who you are.
Notes: Notes:
- Save this file at the workspace root as `IDENTITY.md`. - Save this file at the workspace root as `IDENTITY.md`.
- For avatars, use a workspace-relative path like `avatars/openclaw.png`. - For avatars, use a workspace-relative path like `avatars/control_plane.png`.

20
MEMORY.md Normal file
View File

@ -0,0 +1,20 @@
# Memory
## 飞书工单查询配置
当用户问"查询工单"、"导出工单"、"今天的工单"时,**立即执行以下命令查询外部数据库**:
### 执行命令(直接复制执行)
```bash
docker exec pmflow-db psql "postgresql://readonly:readonly%40%232025@173.1.1.32:5432/middle_platform" -c "SELECT task_code, task_name, excutor_name, status, start_time FROM plat_task_center.task_instance WHERE start_time >= CURRENT_DATE AND start_time < CURRENT_DATE + INTERVAL '1 day' AND ext3 IN ('90101', '901') ORDER BY start_time DESC LIMIT 50;" -x
```
### 导出 Excel 命令
```bash
docker exec pmflow-db psql "postgresql://readonly:readonly%40%232025@173.1.1.32:5432/middle_platform" -c "COPY (SELECT * FROM plat_task_center.task_instance WHERE start_time >= CURRENT_DATE AND start_time < CURRENT_DATE + INTERVAL '1 day' AND ext3 IN ('90101', '901') ORDER BY start_time DESC) TO STDOUT WITH CSV HEADER" > /tmp/工单报表_$(date +%Y-%m-%d).csv && python3 -c "import pandas as pd; pd.read_csv('/tmp/工单报表_$(date +%Y-%m-%d).csv').to_excel('/tmp/工单报表_$(date +%Y-%m-%d).xlsx', index=False)"
```
### ⚠️ 关键提醒
- 这是 **外部数据库 middle_platform**,不是 PMFlow 数据库
- 收到工单查询请求后 **立即执行命令**,不要只回复文字
- 导出后告诉用户文件路径:`/tmp/工单报表_YYYY-MM-DD.xlsx`

139
USER.md
View File

@ -1,17 +1,134 @@
# USER.md - About Your Human # USER.md
_Learn about the person you're helping. Update this as you go._ 本文件是这个用户在本项目内的主真源。
目标不是堆规则,而是让我在这个项目里越来越懂这个用户,稳定贴合其协作方式、判断方式、已拍板决策与反复踩过的坑。
工程、系统、安全与验证硬约束仍以 `AGENTS.md` 和更外层提示为准;若 `~/.codex/AGENTS.md` 与本文件冲突,以本文件为准。
未写进这里的内容,不默认视为本项目内已拍板决策或稳定偏好。
- **Name:** ## 协作偏好
- **What to call them:**
- **Pronouns:** _(optional)_
- **Timezone:**
- **Notes:**
## Context ### 长期稳定偏好
_(What do they care about? What projects are they working on? What annoys them? What makes them laugh? Build this over time.)_ - 默认先自己读项目、历史、日志、配置和运行痕迹;不要把本该我自己挖出来的东西再反问给用户。
- 默认直接推进;只在高风险分岔、破坏性操作或根因不明时停下来确认。
- 回答要直接、具体、贴当前对象,优先给证据链、调用链和真实落点;不要写成模板化汇报。
- 不确定就明确说不确定,不要假装完整。
- 已拍板方案不要反复推荐替代品;配置和偏好尽量收敛,别分散。
- 前端默认视觉标准:品牌主色固定为 `#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 或泛后台风格。
--- ### 学习与更新方式
The more you know, the better you can help. But remember — you're learning about a person, not building a dossier. Respect the difference. - 会话里要持续吸收用户纠正,不要让用户反复教同一件事。
- 用户纠正某次排障或分析方式时,要吸收背后的通用思路,迁移到后续问题中;不要只把反馈当成这一次案例的补丁。
- 学到的是方法论,不是死模板:后续先找最小对照/反证,再分层定位根因并验证;但要按具体问题调整,不要机械套步骤。
- 反复出现、会长期影响判断的偏好,要主动沉淀到本文件。
- 一次性情绪、临时波动和不影响后续决策的内容,不要频繁落盘。
- 非必要不要更新真源文件;只有新信息足够稳定、能减少误判或能减少用户重复纠正时,才值得更新。
## 判断优先级
### 什么时候才准问用户
- 先自己查。能从代码、配置、历史、日志里读出来的,别问用户。
- 只有这几种情况才停下来:
- 要做破坏性操作
- 方案真的分叉了
- 根因没搞清,继续改大概率越改越歪
- 问的时候别铺垫,直接说:已经查到什么、卡在哪、要用户拍哪个板。
### 改错后的默认动作
- 错了就指出具体错点,别演。
- 能直接补就直接补,补完再说原因、影响和验证。
- 有扩散风险先止血,再继续修。
- 别把一次纠错搞成新的流程负担。
### 冲突时怎么取舍
- 先查主链、上下文、路由和证据,别上来就改 prompt、补词表、补正则。
- 显式 `reply/thread`、卡片动作和当前 requirement context 优先于旧 session 和记忆续写。
- 已经进入 `requirement_plan` / draft confirm 的线程,后续必须留在对应主链,不要掉回普通 QA。
- AI 架构类回答优先讲当前调用链、入口、路由和模型网关,不要退回通用技术栈摘要。
- 证据不足时直接说不确定,不要用模板化确定性答案补满。
- 已经定过的方案别来回摇。
## 已拍板技术决策
- 后端:FastAPI + Python 3.11+ + SQLAlchemy 2.x + Alembic
- 前端:React 18 + TypeScript + Vite
- UI / 样式:shadcn/ui 风格组件层 + Radix UI + Tailwind CSS
- 数据与缓存:PostgreSQL + pgvector + Redis
- 鉴权:OAuth2PasswordBearer + JWT
- 部署:Docker Compose
- 验证基线:后端 `python -m py_compile` + 相关单测;前端 `npm run build` + `tsc --noEmit`
- AI 路由对外名称:PMFlow 本地路由层
- 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` 存量只能作为待迁移清单,不得作为新增写法。
- 历史路由命名已废弃,不再把旧名字当现行架构,也不再建议回退
- 以上栈默认不再主动建议替代方案,除非用户明确要求比较或迁移
## 反复踩过的坑 / 不要再做
- 把“AI 架构”答成通用技术栈总览,而不是当前调用链、入口、路由和模型网关。
- 证据不足时还给模板化的确定性答案。
- 短协议回复掉回通用 QA,不按当前 `thread` 和上一轮选项解释。
- 飞书连续追问时继续沿着旧上下文跑,不先尊重显式 `reply/thread`。
- 用户纠错后不先纠正当前主题,还带着旧主题继续扩写。
- 遇到链路问题先补 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 回合承载。
## 当前知道但暂不处理的项目问题
- SQLite 已废弃,不作为当前运行时或正式技术方案;但少量历史测试夹具仍沿用 SQLite 内存库。
- `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

@ -0,0 +1,177 @@
{
"version": "v3",
"updated_at": "2026-03-26T21:30:00+08:00",
"source_of_truth": "control_plane",
"description": "PMFlow 双层统一路由:调用层仅暴露 low/high,真实模型名只保留在配置真源。",
"provider_defaults": {
"deepseek": {
"name": "DeepSeek",
"base_url": "https://api.deepseek.com/v1",
"model": "deepseek-chat",
"api_env": "DEEPSEEK_API_KEY"
},
"dashscope": {
"name": "Qwen (DashScope)",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"model": "qwen-plus",
"api_env": "DASHSCOPE_API_KEY"
},
"openai": {
"name": "OpenAI",
"base_url": "https://api.edgefn.net/v1",
"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",
"model": "llama3:latest",
"api_env": ""
}
},
"tier_defaults": {
"low": {
"provider": "dashscope",
"model": "qwen-plus",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"api_env": "DASHSCOPE_API_KEY"
},
"high": {
"provider": "openai",
"model": "GLM-5",
"base_url": "https://api.edgefn.net/v1",
"api_env": "OPENAI_API_KEY"
}
},
"rules": [
{
"name": "engine_high_intent_complex_exact",
"enabled": true,
"priority": 320,
"tier": "high",
"provider": "openai",
"model": "GLM-5",
"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": "dashscope",
"model": "qwen-plus",
"api_env": "DASHSCOPE_API_KEY",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"function_exact": "pmflow_semantic_context_decision"
},
{
"name": "engine_high_semantic_context_exact",
"enabled": true,
"priority": 305,
"tier": "high",
"provider": "openai",
"model": "GLM-5",
"api_env": "OPENAI_API_KEY",
"function_exact": "pmflow_semantic_context_decision"
},
{
"name": "engine_low_execute_glm_flash",
"enabled": true,
"priority": 300,
"tier": "low",
"provider": "dashscope",
"model": "qwen-plus",
"api_env": "DASHSCOPE_API_KEY",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"match_any": [
"feishu_level_classification",
"feishu_signal_extraction",
"rag_intent_classification",
"classify_intent",
"ai_pmo_signal_title_refine",
"feishu_humanized_reply",
"extract_context_insights"
]
},
{
"name": "engine_high_reasoning_glm5",
"enabled": true,
"priority": 220,
"tier": "high",
"provider": "openai",
"model": "GLM-5",
"api_env": "OPENAI_API_KEY",
"match_any": [
"adaptive_rag_answer",
"classify_intent_complex",
"project_from_text_analysis",
"project_from_text_signal",
"project_from_text_charter",
"project_from_text_task_chain",
"project_requirement_extraction",
"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",
"ai_pmo_signal_batch_analysis",
"rag_db_project_semantic_retrieval",
"workload_optimize_prompt",
"analyze_cost_roi",
"signal_planner_analyze"
]
},
{
"name": "engine_high_extended_reasoning",
"enabled": true,
"priority": 200,
"tier": "high",
"provider": "openai",
"model": "GLM-5",
"api_env": "OPENAI_API_KEY",
"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_low_fallback",
"enabled": true,
"priority": 50,
"tier": "low",
"provider": "dashscope",
"model": "qwen-plus",
"api_env": "DASHSCOPE_API_KEY",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"match_all": true
}
]
}

View File

@ -13,6 +13,7 @@ COPY backend/requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt RUN pip install --no-cache-dir -r /tmp/requirements.txt
COPY backend /app/backend COPY backend /app/backend
COPY docs /app/docs
EXPOSE 8000 EXPOSE 8000

View File

@ -24,8 +24,7 @@ if config.config_file_name is not None:
# Set the database URL from settings # Set the database URL from settings
# Replace async driver with sync driver for migrations # Replace async driver with sync driver for migrations
db_url = settings.database_url.replace("sqlite+aiosqlite", "sqlite") db_url = settings.database_url.replace("postgresql+asyncpg", "postgresql+psycopg")
db_url = db_url.replace("postgresql+asyncpg", "postgresql+psycopg")
config.set_main_option("sqlalchemy.url", db_url) config.set_main_option("sqlalchemy.url", db_url)
# add your model's MetaData object here # add your model's MetaData object here

View File

@ -63,7 +63,7 @@ def upgrade() -> None:
sa.Column("response_mode", sa.String(length=16), nullable=False, server_default="fast"), sa.Column("response_mode", sa.String(length=16), nullable=False, server_default="fast"),
sa.Column("status", sa.String(length=20), nullable=False, server_default="queued"), sa.Column("status", sa.String(length=20), nullable=False, server_default="queued"),
sa.Column("planner_status", sa.String(length=20), nullable=True), sa.Column("planner_status", sa.String(length=20), nullable=True),
sa.Column("openclaw_status", sa.String(length=20), nullable=True), sa.Column("control_plane_status", sa.String(length=20), nullable=True),
sa.Column("worker_total", sa.Integer(), nullable=False, server_default="0"), sa.Column("worker_total", sa.Integer(), nullable=False, server_default="0"),
sa.Column("worker_completed", sa.Integer(), nullable=False, server_default="0"), sa.Column("worker_completed", sa.Integer(), nullable=False, server_default="0"),
sa.Column("worker_failed", sa.Integer(), nullable=False, server_default="0"), sa.Column("worker_failed", sa.Integer(), nullable=False, server_default="0"),
@ -89,7 +89,7 @@ def upgrade() -> None:
("ix_ai_orchestration_runs_response_mode", ["response_mode"]), ("ix_ai_orchestration_runs_response_mode", ["response_mode"]),
("ix_ai_orchestration_runs_status", ["status"]), ("ix_ai_orchestration_runs_status", ["status"]),
("ix_ai_orchestration_runs_planner_status", ["planner_status"]), ("ix_ai_orchestration_runs_planner_status", ["planner_status"]),
("ix_ai_orchestration_runs_openclaw_status", ["openclaw_status"]), ("ix_ai_orchestration_runs_control_plane_status", ["control_plane_status"]),
("ix_ai_orchestration_runs_created_by_user_id", ["created_by_user_id"]), ("ix_ai_orchestration_runs_created_by_user_id", ["created_by_user_id"]),
): ):
_create_index_if_not_exists(bind, index_name=index_name, table_name="ai_orchestration_runs", columns=columns) _create_index_if_not_exists(bind, index_name=index_name, table_name="ai_orchestration_runs", columns=columns)
@ -100,7 +100,7 @@ def downgrade() -> None:
if _table_exists(bind, "ai_orchestration_runs"): if _table_exists(bind, "ai_orchestration_runs"):
for index_name in ( for index_name in (
"ix_ai_orchestration_runs_created_by_user_id", "ix_ai_orchestration_runs_created_by_user_id",
"ix_ai_orchestration_runs_openclaw_status", "ix_ai_orchestration_runs_control_plane_status",
"ix_ai_orchestration_runs_planner_status", "ix_ai_orchestration_runs_planner_status",
"ix_ai_orchestration_runs_status", "ix_ai_orchestration_runs_status",
"ix_ai_orchestration_runs_response_mode", "ix_ai_orchestration_runs_response_mode",

View File

@ -0,0 +1,111 @@
"""add feishu outbox reply hash
Revision ID: add_feishu_outbox_reply_hash
Revises: add_ai_pmo_simulation_field
Create Date: 2026-03-10
"""
from __future__ import annotations
from typing import Any
from alembic import op
import sqlalchemy as sa
revision = "add_feishu_outbox_reply_hash"
down_revision = "add_ai_pmo_simulation_field"
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:
inspector = sa.inspect(bind)
if not inspector.has_table(table_name):
return False
return any(col.get("name") == column_name for col in inspector.get_columns(table_name))
def _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
if not inspector.has_table(table_name):
return False
return any(idx.get("name") == index_name for idx in inspector.get_indexes(table_name))
def _create_index_if_not_exists(
bind,
*,
index_name: str,
table_name: str,
columns: list[str],
unique: bool = False,
postgresql_where: Any = None,
sqlite_where: Any = None,
) -> None:
if _index_exists(bind, table_name, index_name):
return
kwargs: dict[str, Any] = {}
if postgresql_where is not None:
kwargs["postgresql_where"] = postgresql_where
if sqlite_where is not None:
kwargs["sqlite_where"] = sqlite_where
op.create_index(index_name, table_name, columns, unique=unique, **kwargs)
def _drop_index_if_exists(bind, table_name: str, index_name: str) -> None:
if _index_exists(bind, table_name, index_name):
op.drop_index(index_name, table_name=table_name)
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "feishu_reply_outbox"):
return
if not _column_exists(bind, "feishu_reply_outbox", "source_message_id"):
op.add_column("feishu_reply_outbox", sa.Column("source_message_id", sa.String(length=128), nullable=True))
if not _column_exists(bind, "feishu_reply_outbox", "reply_hash"):
op.add_column("feishu_reply_outbox", sa.Column("reply_hash", sa.String(length=32), nullable=True))
_create_index_if_not_exists(
bind,
index_name="ix_feishu_reply_outbox_source_message_id",
table_name="feishu_reply_outbox",
columns=["source_message_id"],
)
_create_index_if_not_exists(
bind,
index_name="ix_feishu_reply_outbox_reply_hash",
table_name="feishu_reply_outbox",
columns=["reply_hash"],
)
sent_where = sa.text("status = 'sent' AND reply_hash IS NOT NULL")
_create_index_if_not_exists(
bind,
index_name="uq_feishu_reply_outbox_sent_reply_hash",
table_name="feishu_reply_outbox",
columns=["tenant_id", "reply_hash"],
unique=True,
postgresql_where=sent_where,
sqlite_where=sent_where,
)
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "feishu_reply_outbox"):
return
_drop_index_if_exists(bind, "feishu_reply_outbox", "uq_feishu_reply_outbox_sent_reply_hash")
_drop_index_if_exists(bind, "feishu_reply_outbox", "ix_feishu_reply_outbox_reply_hash")
_drop_index_if_exists(bind, "feishu_reply_outbox", "ix_feishu_reply_outbox_source_message_id")
if _column_exists(bind, "feishu_reply_outbox", "reply_hash"):
op.drop_column("feishu_reply_outbox", "reply_hash")
if _column_exists(bind, "feishu_reply_outbox", "source_message_id"):
op.drop_column("feishu_reply_outbox", "source_message_id")

View File

@ -0,0 +1,59 @@
"""add ai_pmo_work_items compatibility view
Revision ID: add_ai_pmo_work_items_view
Revises: add_operation_log_audit_fields
Create Date: 2026-03-11
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "add_ai_pmo_work_items_view"
down_revision = "add_operation_log_audit_fields"
branch_labels = None
depends_on = None
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, "work_items"):
return
op.execute("DROP VIEW IF EXISTS ai_pmo_work_items")
op.execute(
"""
CREATE VIEW ai_pmo_work_items AS
SELECT
id,
ingest_event_id,
source_system,
source_message_id,
state,
intent,
decision_level,
project_id,
signal_id,
owner_user_id,
dedupe_key,
context_snapshot,
action_bundle,
execution_result,
review_summary,
reply_template_key,
tenant_id,
created_at,
updated_at
FROM work_items
"""
)
def downgrade() -> None:
op.execute("DROP VIEW IF EXISTS ai_pmo_work_items")

View File

@ -0,0 +1,82 @@
"""add conversation sessions table
Revision ID: add_conversation_sessions_table
Revises: add_kb_chunk_ivfflat_index
Create Date: 2026-03-11
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "add_conversation_sessions_table"
down_revision = "add_kb_chunk_ivfflat_index"
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:
inspector = sa.inspect(bind)
if not inspector.has_table(table_name):
return False
return any(idx.get("name") == index_name for idx in inspector.get_indexes(table_name))
def _create_index_if_missing(bind, table_name: str, index_name: str, columns: list[str], *, unique: bool = False) -> None:
if not _index_exists(bind, table_name, index_name):
op.create_index(index_name, table_name, columns, unique=unique)
def _drop_index_if_exists(bind, table_name: str, index_name: str) -> None:
if _index_exists(bind, table_name, index_name):
op.drop_index(index_name, table_name=table_name)
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "conversation_sessions"):
op.create_table(
"conversation_sessions",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
sa.Column("sender", sa.String(length=128), nullable=False),
sa.Column("current_project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=True),
sa.Column("last_intent", sa.String(length=64), nullable=True),
sa.Column("last_message_text", sa.String(length=200), nullable=True),
sa.Column("last_active_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
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(), onupdate=sa.func.now()),
sa.UniqueConstraint("tenant_id", "sender", name="uq_conversation_sessions_tenant_sender"),
)
_create_index_if_missing(bind, "conversation_sessions", "ix_conversation_sessions_tenant_id", ["tenant_id"])
_create_index_if_missing(bind, "conversation_sessions", "ix_conversation_sessions_sender", ["sender"])
_create_index_if_missing(bind, "conversation_sessions", "ix_conversation_sessions_current_project_id", ["current_project_id"])
_create_index_if_missing(bind, "conversation_sessions", "ix_conversation_sessions_last_intent", ["last_intent"])
_create_index_if_missing(bind, "conversation_sessions", "ix_conversation_sessions_last_active_at", ["last_active_at"])
_create_index_if_missing(
bind,
"conversation_sessions",
"ix_conversation_sessions_tenant_sender_last_active",
["tenant_id", "sender", "last_active_at"],
)
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "conversation_sessions"):
return
_drop_index_if_exists(bind, "conversation_sessions", "ix_conversation_sessions_tenant_sender_last_active")
_drop_index_if_exists(bind, "conversation_sessions", "ix_conversation_sessions_last_active_at")
_drop_index_if_exists(bind, "conversation_sessions", "ix_conversation_sessions_last_intent")
_drop_index_if_exists(bind, "conversation_sessions", "ix_conversation_sessions_current_project_id")
_drop_index_if_exists(bind, "conversation_sessions", "ix_conversation_sessions_sender")
_drop_index_if_exists(bind, "conversation_sessions", "ix_conversation_sessions_tenant_id")
op.drop_table("conversation_sessions")

View File

@ -0,0 +1,42 @@
"""add ivfflat index for knowledge chunk embedding
Revision ID: add_kb_chunk_ivfflat_index
Revises: add_pg_trgm_text_indexes
Create Date: 2026-03-11
"""
from __future__ import annotations
from alembic import op
revision = "add_kb_chunk_ivfflat_index"
down_revision = "add_pg_trgm_text_indexes"
branch_labels = None
depends_on = None
def _is_postgresql() -> bool:
bind = op.get_bind()
return str(getattr(getattr(bind, "dialect", None), "name", "")).lower().startswith("postgres")
def upgrade() -> None:
if not _is_postgresql():
return
op.execute(
"""
CREATE INDEX IF NOT EXISTS ix_knowledge_chunks_embedding_ivfflat
ON knowledge_base_chunks
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100)
"""
)
def downgrade() -> None:
if not _is_postgresql():
return
op.execute("DROP INDEX IF EXISTS ix_knowledge_chunks_embedding_ivfflat")

View File

@ -0,0 +1,79 @@
"""add operation log audit fields
Revision ID: add_operation_log_audit_fields
Revises: shift_all_timestamps_to_beijing
Create Date: 2026-03-11
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "add_operation_log_audit_fields"
down_revision = "shift_all_timestamps_to_beijing"
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:
inspector = sa.inspect(bind)
if not inspector.has_table(table_name):
return False
return any(col.get("name") == column_name for col in inspector.get_columns(table_name))
def _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
if not inspector.has_table(table_name):
return False
return any(idx.get("name") == index_name for idx in inspector.get_indexes(table_name))
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "operation_logs"):
return
with op.batch_alter_table("operation_logs") as batch_op:
if not _column_exists(bind, "operation_logs", "trace_id"):
batch_op.add_column(sa.Column("trace_id", sa.String(length=64), nullable=True))
if not _column_exists(bind, "operation_logs", "interface_path"):
batch_op.add_column(sa.Column("interface_path", sa.String(length=255), nullable=True))
if not _column_exists(bind, "operation_logs", "caller"):
batch_op.add_column(
sa.Column("caller", sa.String(length=16), nullable=False, server_default=sa.text("'system'"))
)
if not _index_exists(bind, "operation_logs", "ix_operation_logs_trace_id"):
op.create_index("ix_operation_logs_trace_id", "operation_logs", ["trace_id"], unique=False)
if not _index_exists(bind, "operation_logs", "ix_operation_logs_interface_path"):
op.create_index("ix_operation_logs_interface_path", "operation_logs", ["interface_path"], unique=False)
if not _index_exists(bind, "operation_logs", "ix_operation_logs_caller"):
op.create_index("ix_operation_logs_caller", "operation_logs", ["caller"], unique=False)
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "operation_logs"):
return
if _index_exists(bind, "operation_logs", "ix_operation_logs_caller"):
op.drop_index("ix_operation_logs_caller", table_name="operation_logs")
if _index_exists(bind, "operation_logs", "ix_operation_logs_interface_path"):
op.drop_index("ix_operation_logs_interface_path", table_name="operation_logs")
if _index_exists(bind, "operation_logs", "ix_operation_logs_trace_id"):
op.drop_index("ix_operation_logs_trace_id", table_name="operation_logs")
with op.batch_alter_table("operation_logs") as batch_op:
if _column_exists(bind, "operation_logs", "caller"):
batch_op.drop_column("caller")
if _column_exists(bind, "operation_logs", "interface_path"):
batch_op.drop_column("interface_path")
if _column_exists(bind, "operation_logs", "trace_id"):
batch_op.drop_column("trace_id")

View File

@ -0,0 +1,76 @@
"""add pg_trgm text indexes for project search
Revision ID: add_pg_trgm_text_indexes
Revises: add_feishu_outbox_reply_hash
Create Date: 2026-03-11
"""
from __future__ import annotations
from alembic import op
revision = "add_pg_trgm_text_indexes"
down_revision = "add_feishu_outbox_reply_hash"
branch_labels = None
depends_on = None
def _is_postgresql() -> bool:
bind = op.get_bind()
return str(getattr(getattr(bind, "dialect", None), "name", "")).lower().startswith("postgres")
def upgrade() -> None:
if not _is_postgresql():
return
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
op.execute(
"""
CREATE INDEX IF NOT EXISTS ix_projects_name_trgm
ON projects USING GIN (name gin_trgm_ops)
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS ix_projects_title_trgm
ON projects USING GIN (title gin_trgm_ops)
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS ix_projects_description_trgm
ON projects USING GIN (description gin_trgm_ops)
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS ix_signals_title_trgm
ON signals USING GIN (title gin_trgm_ops)
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS ix_signals_content_trgm
ON signals USING GIN (content gin_trgm_ops)
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS ix_tasks_title_trgm
ON tasks USING GIN (title gin_trgm_ops)
"""
)
def downgrade() -> None:
if not _is_postgresql():
return
op.execute("DROP INDEX IF EXISTS ix_tasks_title_trgm")
op.execute("DROP INDEX IF EXISTS ix_signals_content_trgm")
op.execute("DROP INDEX IF EXISTS ix_signals_title_trgm")
op.execute("DROP INDEX IF EXISTS ix_projects_description_trgm")
op.execute("DROP INDEX IF EXISTS ix_projects_title_trgm")
op.execute("DROP INDEX IF EXISTS ix_projects_name_trgm")

View File

@ -0,0 +1,74 @@
"""expand user_workloads workload_ratio precision
Revision ID: expand_user_workload_ratio_precision
Revises: add_ai_pmo_work_items_view
Create Date: 2026-03-11
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "expand_user_workload_ratio_precision"
down_revision = "add_ai_pmo_work_items_view"
branch_labels = None
depends_on = None
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _column_numeric_meta(bind, table_name: str, column_name: str) -> tuple[int | None, int | None] | None:
inspector = sa.inspect(bind)
if not inspector.has_table(table_name):
return None
for column in inspector.get_columns(table_name):
if str(column.get("name")) == column_name:
col_type = column.get("type")
return (
int(getattr(col_type, "precision", 0) or 0) or None,
int(getattr(col_type, "scale", 0) or 0) or None,
)
return None
def _alter_workload_ratio(bind, *, precision: int, scale: int) -> None:
current = _column_numeric_meta(bind, "user_workloads", "workload_ratio")
if current == (precision, scale):
return
current_precision = int((current or (4, 2))[0] or 4)
current_scale = int((current or (4, 2))[1] or 2)
with op.batch_alter_table("user_workloads") as batch_op:
batch_op.alter_column(
"workload_ratio",
existing_type=sa.Numeric(current_precision, current_scale),
type_=sa.Numeric(precision, scale),
existing_nullable=False,
)
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "user_workloads"):
return
_alter_workload_ratio(bind, precision=10, scale=2)
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "user_workloads"):
return
op.execute(
"""
UPDATE user_workloads
SET workload_ratio = CASE
WHEN workload_ratio > 99.99 THEN 99.99
WHEN workload_ratio < -99.99 THEN -99.99
ELSE workload_ratio
END
"""
)
_alter_workload_ratio(bind, precision=4, scale=2)

View File

@ -0,0 +1,71 @@
"""shift all naive timestamps to beijing and set db timezone
Revision ID: shift_all_timestamps_to_beijing
Revises: add_conversation_sessions_table
Create Date: 2026-03-11
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "shift_all_timestamps_to_beijing"
down_revision = "add_conversation_sessions_table"
branch_labels = None
depends_on = None
def _is_postgresql() -> bool:
bind = op.get_bind()
return str(getattr(getattr(bind, "dialect", None), "name", "")).lower().startswith("postgres")
def _list_timestamp_columns() -> list[tuple[str, str]]:
bind = op.get_bind()
rows = bind.execute(
sa.text(
"""
SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND data_type = 'timestamp without time zone'
ORDER BY table_name, column_name
"""
)
).fetchall()
return [(str(row[0]), str(row[1])) for row in rows]
def _shift_all_timestamp_columns(*, hours: int) -> None:
delta_expr = f"INTERVAL '{int(hours)} hours'" if int(hours) >= 0 else f"- INTERVAL '{abs(int(hours))} hours'"
for table_name, column_name in _list_timestamp_columns():
if int(hours) >= 0:
sql = f'UPDATE "{table_name}" SET "{column_name}" = "{column_name}" + {delta_expr} WHERE "{column_name}" IS NOT NULL'
else:
sql = f'UPDATE "{table_name}" SET "{column_name}" = "{column_name}" {delta_expr} WHERE "{column_name}" IS NOT NULL'
op.execute(sa.text(sql))
def _set_database_timezone(timezone_name: str) -> None:
bind = op.get_bind()
database_name = str(bind.execute(sa.text("SELECT current_database()")).scalar_one())
role_name = str(bind.execute(sa.text("SELECT current_user")).scalar_one())
op.execute(sa.text(f'ALTER DATABASE "{database_name}" SET timezone TO \'{timezone_name}\''))
op.execute(sa.text(f'ALTER ROLE "{role_name}" SET timezone TO \'{timezone_name}\''))
def upgrade() -> None:
if not _is_postgresql():
return
_set_database_timezone("Asia/Shanghai")
_shift_all_timestamp_columns(hours=8)
def downgrade() -> None:
if not _is_postgresql():
return
_shift_all_timestamp_columns(hours=-8)
# 统一策略:即使降级历史数据,也保持数据库会话时区为北京时间。
_set_database_timezone("Asia/Shanghai")

View File

@ -0,0 +1,80 @@
"""add signal planner fields
Revision ID: add_signal_planner_fields
Revises: expand_user_workload_ratio_precision
Create Date: 2026-03-12
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "add_signal_planner_fields"
down_revision = "expand_user_workload_ratio_precision"
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:
inspector = sa.inspect(bind)
if not inspector.has_table(table_name):
return False
for column in inspector.get_columns(table_name):
if str(column.get("name") or "") == column_name:
return True
return False
def _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
if not inspector.has_table(table_name):
return False
return any(str(item.get("name") or "") == index_name for item in inspector.get_indexes(table_name))
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "signals"):
return
with op.batch_alter_table("signals") as batch_op:
if not _column_exists(bind, "signals", "planner_status"):
batch_op.add_column(
sa.Column(
"planner_status",
sa.String(length=20),
nullable=False,
server_default="pending",
)
)
if not _column_exists(bind, "signals", "planner_analyzed_at"):
batch_op.add_column(sa.Column("planner_analyzed_at", sa.TIMESTAMP(), nullable=True))
if not _column_exists(bind, "signals", "planner_assignee_user_id"):
batch_op.add_column(sa.Column("planner_assignee_user_id", sa.BigInteger(), nullable=True))
if not _index_exists(bind, "signals", "ix_signals_planner_status"):
op.create_index("ix_signals_planner_status", "signals", ["planner_status"])
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "signals"):
return
if _index_exists(bind, "signals", "ix_signals_planner_status"):
op.drop_index("ix_signals_planner_status", table_name="signals")
with op.batch_alter_table("signals") as batch_op:
if _column_exists(bind, "signals", "planner_assignee_user_id"):
batch_op.drop_column("planner_assignee_user_id")
if _column_exists(bind, "signals", "planner_analyzed_at"):
batch_op.drop_column("planner_analyzed_at")
if _column_exists(bind, "signals", "planner_status"):
batch_op.drop_column("planner_status")

View File

@ -0,0 +1,165 @@
"""add ai orchestration ingress async fields
Revision ID: add_ai_orchestration_ingress_async_fields
Revises: add_signal_planner_fields
Create Date: 2026-03-16
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "add_ai_orchestration_ingress_async_fields"
down_revision = "add_signal_planner_fields"
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:
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:
inspector = sa.inspect(bind)
if not inspector.has_table(table_name):
return False
return any(str(item.get("name") or "") == index_name for item in inspector.get_indexes(table_name))
def _create_index_if_missing(bind, table_name: str, index_name: str, columns: list[str]) -> None:
if not _index_exists(bind, table_name, index_name):
op.create_index(index_name, table_name, columns)
def _drop_index_if_exists(bind, table_name: str, index_name: str) -> None:
if _index_exists(bind, table_name, index_name):
op.drop_index(index_name, table_name=table_name)
def upgrade() -> None:
bind = op.get_bind()
table_name = "ai_orchestration_runs"
if not _table_exists(bind, table_name):
return
with op.batch_alter_table(table_name) as batch_op:
if _column_exists(bind, table_name, "project_id"):
batch_op.alter_column("project_id", existing_type=sa.BigInteger(), nullable=True)
if not _column_exists(bind, table_name, "source_system"):
batch_op.add_column(sa.Column("source_system", sa.String(length=32), nullable=True))
if not _column_exists(bind, table_name, "source_message_id"):
batch_op.add_column(sa.Column("source_message_id", sa.String(length=128), nullable=True))
if not _column_exists(bind, table_name, "sender"):
batch_op.add_column(sa.Column("sender", sa.String(length=128), nullable=True))
if not _column_exists(bind, table_name, "source_sender_open_id"):
batch_op.add_column(sa.Column("source_sender_open_id", sa.String(length=128), nullable=True))
if not _column_exists(bind, table_name, "channel_ref"):
batch_op.add_column(sa.Column("channel_ref", sa.String(length=128), nullable=True))
if not _column_exists(bind, table_name, "chat_type"):
batch_op.add_column(sa.Column("chat_type", sa.String(length=32), nullable=True))
if not _column_exists(bind, table_name, "captured_at"):
batch_op.add_column(sa.Column("captured_at", sa.TIMESTAMP(), nullable=True))
if not _column_exists(bind, table_name, "message_text"):
batch_op.add_column(sa.Column("message_text", sa.Text(), nullable=True))
if not _column_exists(bind, table_name, "project_hint_id"):
batch_op.add_column(sa.Column("project_hint_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=True))
if not _column_exists(bind, table_name, "retry_count"):
batch_op.add_column(sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"))
if not _column_exists(bind, table_name, "max_retries"):
batch_op.add_column(sa.Column("max_retries", sa.Integer(), nullable=False, server_default="3"))
if not _column_exists(bind, table_name, "next_retry_at"):
batch_op.add_column(sa.Column("next_retry_at", sa.TIMESTAMP(), nullable=True))
if not _column_exists(bind, table_name, "last_error"):
batch_op.add_column(sa.Column("last_error", sa.Text(), nullable=True))
if not _column_exists(bind, table_name, "last_error_at"):
batch_op.add_column(sa.Column("last_error_at", sa.TIMESTAMP(), nullable=True))
if not _column_exists(bind, table_name, "ack_sent_at"):
batch_op.add_column(sa.Column("ack_sent_at", sa.TIMESTAMP(), nullable=True))
if not _column_exists(bind, table_name, "result_notified_at"):
batch_op.add_column(sa.Column("result_notified_at", sa.TIMESTAMP(), nullable=True))
if not _column_exists(bind, table_name, "work_item_id"):
batch_op.add_column(
sa.Column(
"work_item_id",
sa.Integer(),
sa.ForeignKey("work_items.id", ondelete="SET NULL"),
nullable=True,
)
)
_create_index_if_missing(bind, table_name, "ix_ai_orchestration_runs_source_system", ["source_system"])
_create_index_if_missing(bind, table_name, "ix_ai_orchestration_runs_source_message_id", ["source_message_id"])
_create_index_if_missing(bind, table_name, "ix_ai_orchestration_runs_source_sender_open_id", ["source_sender_open_id"])
_create_index_if_missing(bind, table_name, "ix_ai_orchestration_runs_channel_ref", ["channel_ref"])
_create_index_if_missing(bind, table_name, "ix_ai_orchestration_runs_captured_at", ["captured_at"])
_create_index_if_missing(bind, table_name, "ix_ai_orchestration_runs_project_hint_id", ["project_hint_id"])
_create_index_if_missing(bind, table_name, "ix_ai_orchestration_runs_next_retry_at", ["next_retry_at"])
_create_index_if_missing(bind, table_name, "ix_ai_orchestration_runs_work_item_id", ["work_item_id"])
_create_index_if_missing(
bind,
table_name,
"ix_ai_orchestration_runs_run_type_status_next_retry",
["run_type", "status", "next_retry_at"],
)
def downgrade() -> None:
bind = op.get_bind()
table_name = "ai_orchestration_runs"
if not _table_exists(bind, table_name):
return
_drop_index_if_exists(bind, table_name, "ix_ai_orchestration_runs_run_type_status_next_retry")
_drop_index_if_exists(bind, table_name, "ix_ai_orchestration_runs_work_item_id")
_drop_index_if_exists(bind, table_name, "ix_ai_orchestration_runs_next_retry_at")
_drop_index_if_exists(bind, table_name, "ix_ai_orchestration_runs_project_hint_id")
_drop_index_if_exists(bind, table_name, "ix_ai_orchestration_runs_captured_at")
_drop_index_if_exists(bind, table_name, "ix_ai_orchestration_runs_channel_ref")
_drop_index_if_exists(bind, table_name, "ix_ai_orchestration_runs_source_sender_open_id")
_drop_index_if_exists(bind, table_name, "ix_ai_orchestration_runs_source_message_id")
_drop_index_if_exists(bind, table_name, "ix_ai_orchestration_runs_source_system")
with op.batch_alter_table(table_name) as batch_op:
if _column_exists(bind, table_name, "work_item_id"):
batch_op.drop_column("work_item_id")
if _column_exists(bind, table_name, "result_notified_at"):
batch_op.drop_column("result_notified_at")
if _column_exists(bind, table_name, "ack_sent_at"):
batch_op.drop_column("ack_sent_at")
if _column_exists(bind, table_name, "last_error_at"):
batch_op.drop_column("last_error_at")
if _column_exists(bind, table_name, "last_error"):
batch_op.drop_column("last_error")
if _column_exists(bind, table_name, "next_retry_at"):
batch_op.drop_column("next_retry_at")
if _column_exists(bind, table_name, "max_retries"):
batch_op.drop_column("max_retries")
if _column_exists(bind, table_name, "retry_count"):
batch_op.drop_column("retry_count")
if _column_exists(bind, table_name, "project_hint_id"):
batch_op.drop_column("project_hint_id")
if _column_exists(bind, table_name, "message_text"):
batch_op.drop_column("message_text")
if _column_exists(bind, table_name, "captured_at"):
batch_op.drop_column("captured_at")
if _column_exists(bind, table_name, "chat_type"):
batch_op.drop_column("chat_type")
if _column_exists(bind, table_name, "channel_ref"):
batch_op.drop_column("channel_ref")
if _column_exists(bind, table_name, "source_sender_open_id"):
batch_op.drop_column("source_sender_open_id")
if _column_exists(bind, table_name, "sender"):
batch_op.drop_column("sender")
if _column_exists(bind, table_name, "source_message_id"):
batch_op.drop_column("source_message_id")
if _column_exists(bind, table_name, "source_system"):
batch_op.drop_column("source_system")

View File

@ -0,0 +1,92 @@
"""add work item draft confirmation fields
Revision ID: add_work_item_draft_confirmation_fields
Revises: add_ai_orchestration_ingress_async_fields
Create Date: 2026-03-23
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "add_work_item_draft_confirmation_fields"
down_revision = "add_ai_orchestration_ingress_async_fields"
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:
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:
inspector = sa.inspect(bind)
if not inspector.has_table(table_name):
return False
return any(str(item.get("name") or "") == index_name for item in inspector.get_indexes(table_name))
def upgrade() -> None:
bind = op.get_bind()
table_name = "work_items"
if not _table_exists(bind, table_name):
return
with op.batch_alter_table(table_name) as batch_op:
if not _column_exists(bind, table_name, "draft_content"):
batch_op.add_column(sa.Column("draft_content", sa.JSON(), nullable=True))
if not _column_exists(bind, table_name, "draft_expires_at"):
batch_op.add_column(sa.Column("draft_expires_at", sa.TIMESTAMP(), nullable=True))
if not _column_exists(bind, table_name, "draft_version"):
batch_op.add_column(sa.Column("draft_version", sa.Integer(), nullable=False, server_default="0"))
if not _column_exists(bind, table_name, "confirm_message_id"):
batch_op.add_column(sa.Column("confirm_message_id", sa.String(length=128), nullable=True))
if not _index_exists(bind, table_name, "ix_work_items_draft_expires"):
op.create_index(
"ix_work_items_draft_expires",
table_name,
["draft_expires_at"],
postgresql_where=sa.text("state = 'draft_pending_confirm'"),
)
if not _index_exists(bind, table_name, "uq_work_items_tenant_confirm_message"):
op.create_index(
"uq_work_items_tenant_confirm_message",
table_name,
["tenant_id", "confirm_message_id"],
unique=True,
postgresql_where=sa.text("confirm_message_id IS NOT NULL"),
)
def downgrade() -> None:
bind = op.get_bind()
table_name = "work_items"
if not _table_exists(bind, table_name):
return
if _index_exists(bind, table_name, "uq_work_items_tenant_confirm_message"):
op.drop_index("uq_work_items_tenant_confirm_message", table_name=table_name)
if _index_exists(bind, table_name, "ix_work_items_draft_expires"):
op.drop_index("ix_work_items_draft_expires", table_name=table_name)
with op.batch_alter_table(table_name) as batch_op:
if _column_exists(bind, table_name, "confirm_message_id"):
batch_op.drop_column("confirm_message_id")
if _column_exists(bind, table_name, "draft_version"):
batch_op.drop_column("draft_version")
if _column_exists(bind, table_name, "draft_expires_at"):
batch_op.drop_column("draft_expires_at")
if _column_exists(bind, table_name, "draft_content"):
batch_op.drop_column("draft_content")

View File

@ -0,0 +1,83 @@
"""add project create idempotency indexes
Revision ID: add_project_create_idempotency_indexes
Revises: add_work_item_draft_confirmation_fields
Create Date: 2026-03-24
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "add_project_create_idempotency_indexes"
down_revision = "add_work_item_draft_confirmation_fields"
branch_labels = None
depends_on = None
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _duplicate_active_titles(bind) -> list[tuple[int, str, int]]:
rows = bind.execute(
sa.text(
"""
SELECT
tenant_id,
lower(coalesce(nullif(title, ''), nullif(name, ''))) AS normalized_title,
count(*) AS duplicate_count
FROM projects
WHERE status <> 'inactive'
AND coalesce(nullif(title, ''), nullif(name, '')) IS NOT NULL
GROUP BY tenant_id, lower(coalesce(nullif(title, ''), nullif(name, '')))
HAVING count(*) > 1
ORDER BY duplicate_count DESC, tenant_id ASC
LIMIT 10
"""
)
).fetchall()
return [
(int(row[0]), str(row[1] or ""), int(row[2]))
for row in rows
]
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "projects"):
return
duplicates = _duplicate_active_titles(bind)
if duplicates:
sample = ", ".join(
f"tenant={tenant_id}, title={title}, count={count}"
for tenant_id, title, count in duplicates[:3]
)
raise RuntimeError(
"cannot create uq_projects_tenant_active_title_ci because duplicate active project titles exist: "
+ sample
)
op.execute(
sa.text(
"""
CREATE UNIQUE INDEX IF NOT EXISTS uq_projects_tenant_active_title_ci
ON projects (
tenant_id,
lower(coalesce(nullif(title, ''), nullif(name, '')))
)
WHERE status <> 'inactive'
AND coalesce(nullif(title, ''), nullif(name, '')) IS NOT NULL
"""
)
)
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "projects"):
return
op.execute(sa.text("DROP INDEX IF EXISTS uq_projects_tenant_active_title_ci"))

View File

@ -0,0 +1,40 @@
"""add feishu reply outbox sent_message_id
Revision ID: 20260325_add_feishu_reply_outbox_sent_message_id
Revises: f38d00f6fee4
Create Date: 2026-03-25 10:00:00.000000
"""
from __future__ import annotations
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "20260325_add_feishu_reply_outbox_sent_message_id"
down_revision: Union[str, Sequence[str], None] = "f38d00f6fee4"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.add_column(
"feishu_reply_outbox",
sa.Column("sent_message_id", sa.VARCHAR(length=128), nullable=True),
)
op.create_index(
"ix_feishu_reply_outbox_sent_message_id",
"feishu_reply_outbox",
["sent_message_id"],
unique=False,
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_index("ix_feishu_reply_outbox_sent_message_id", table_name="feishu_reply_outbox")
op.drop_column("feishu_reply_outbox", "sent_message_id")

View File

@ -0,0 +1,68 @@
"""add ai call log routing fields
Revision ID: 20260326_add_ai_call_log_routing_fields
Revises: 20260326_create_feedback_center_tables
Create Date: 2026-03-26
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260326_add_ai_call_log_routing_fields"
down_revision = "20260326_create_feedback_center_tables"
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:
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 = [
("planned_tier", sa.Column("planned_tier", sa.String(length=16), nullable=True)),
("executed_tier", sa.Column("executed_tier", sa.String(length=16), nullable=True)),
("routing_decision", sa.Column("routing_decision", sa.String(length=64), nullable=True)),
("route_source", sa.Column("route_source", sa.String(length=32), nullable=True)),
("route_rule_name", sa.Column("route_rule_name", sa.String(length=255), nullable=True)),
("routing_config_version", sa.Column("routing_config_version", sa.String(length=64), nullable=True)),
("routing_config_updated_at", sa.Column("routing_config_updated_at", sa.String(length=64), nullable=True)),
]
for column_name, column in columns:
if not _column_exists(bind, table_name, column_name):
op.add_column(table_name, column)
def downgrade() -> None:
bind = op.get_bind()
table_name = "ai_call_logs"
if not _table_exists(bind, table_name):
return
for column_name in (
"routing_config_updated_at",
"routing_config_version",
"route_rule_name",
"route_source",
"routing_decision",
"executed_tier",
"planned_tier",
):
if _column_exists(bind, table_name, column_name):
op.drop_column(table_name, column_name)

View File

@ -0,0 +1,61 @@
"""add task breakdown payload fields
Revision ID: 20260326_add_task_breakdown_payload_fields
Revises: add_project_create_idempotency_indexes
Create Date: 2026-03-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 = "20260326_add_task_breakdown_payload_fields"
down_revision = "add_project_create_idempotency_indexes"
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 = "tasks"
if not _table_exists(bind, table_name):
return
if not _column_exists(bind, table_name, "ai_breakdown_payload"):
op.add_column(table_name, sa.Column("ai_breakdown_payload", _json_type(bind), nullable=True))
if not _column_exists(bind, table_name, "manual_overrides"):
op.add_column(table_name, sa.Column("manual_overrides", _json_type(bind), nullable=True))
def downgrade() -> None:
bind = op.get_bind()
table_name = "tasks"
if not _table_exists(bind, table_name):
return
if _column_exists(bind, table_name, "manual_overrides"):
op.drop_column(table_name, "manual_overrides")
if _column_exists(bind, table_name, "ai_breakdown_payload"):
op.drop_column(table_name, "ai_breakdown_payload")

View File

@ -0,0 +1,520 @@
"""create feedback center tables
Revision ID: 20260326_create_feedback_center_tables
Revises: 20260326_add_task_breakdown_payload_fields
Create Date: 2026-03-26
"""
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 = "20260326_create_feedback_center_tables"
down_revision = "20260326_add_task_breakdown_payload_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 _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
if not inspector.has_table(table_name):
return False
return any(str(item.get("name") or "") == index_name for item in inspector.get_indexes(table_name))
def _safe_int(value: Any) -> int | None:
try:
if value is None:
return None
return int(value)
except (TypeError, ValueError):
return None
def _parse_json_if_needed(value: Any) -> Any:
if value is None:
return None
if isinstance(value, (dict, list)):
return value
if isinstance(value, str):
try:
return json.loads(value)
except (TypeError, ValueError):
return value
return value
def _build_reason_candidates(*, source_type: str, field_name: str, before_value: Any, after_value: Any) -> list[dict[str, str]]:
candidates: list[dict[str, str]] = []
field = str(field_name or "").strip().lower()
source = str(source_type or "").strip().lower()
def _add(code: str, label: str, description: str) -> None:
if any(str(item.get("code") or "") == code for item in candidates):
return
candidates.append({"code": code, "label": label, "description": description})
if before_value not in (None, "", [], {}) and after_value in (None, "", [], {}):
_add("incomplete_output", "输出信息不完整", "模型输出缺失关键信息字段")
if "estimated" in field or "hours" in field:
_add("effort_estimation_bias", "工时估算偏差", "任务工时估算与实际认知偏差较大")
if "priority" in field:
_add("priority_misalignment", "优先级错配", "优先级建议与业务紧急度不匹配")
if "assignee" in field or "skill" in field:
_add("capability_mismatch", "能力匹配偏差", "人员能力与任务推荐存在偏差")
if "strategic" in field or source == "signal_feedback":
_add("strategic_judgement_mismatch", "战略判断偏差", "战略属性判定与人工判断不一致")
if source == "qa_feedback":
_add("answer_incorrect", "答案不准确", "AI 输出与业务事实不一致")
if not candidates:
_add("manual_override", "人工覆盖", "由人工介入修改后形成的反馈记录")
return candidates
def _record_exists(
bind,
*,
tenant_id: int,
source_type: str,
source_id: int,
object_type: str,
object_id: int,
field_name: str,
) -> bool:
row = bind.execute(
sa.text(
"""
SELECT id
FROM feedback_center_records
WHERE tenant_id = :tenant_id
AND source_type = :source_type
AND source_id = :source_id
AND object_type = :object_type
AND object_id = :object_id
AND field_name = :field_name
LIMIT 1
"""
),
{
"tenant_id": int(tenant_id),
"source_type": str(source_type),
"source_id": int(source_id),
"object_type": str(object_type),
"object_id": int(object_id),
"field_name": str(field_name),
},
).first()
return row is not None
def _feedback_center_records_table(bind):
json_type = _json_type(bind)
return sa.table(
"feedback_center_records",
sa.column("tenant_id", sa.BigInteger()),
sa.column("project_id", sa.BigInteger()),
sa.column("source_type", sa.VARCHAR(length=32)),
sa.column("source_id", sa.BigInteger()),
sa.column("object_type", sa.VARCHAR(length=32)),
sa.column("object_id", sa.BigInteger()),
sa.column("field_name", sa.VARCHAR(length=128)),
sa.column("before_value", json_type),
sa.column("after_value", json_type),
sa.column("ai_snapshot", json_type),
sa.column("ai_confidence", sa.Float()),
sa.column("reason_candidates", json_type),
sa.column("reason_code", sa.VARCHAR(length=64)),
sa.column("reason_text", sa.Text()),
sa.column("review_status", sa.VARCHAR(length=16)),
sa.column("reviewed_by", sa.BigInteger()),
sa.column("reviewed_at", sa.TIMESTAMP()),
sa.column("source_title", sa.VARCHAR(length=500)),
sa.column("object_title", sa.VARCHAR(length=500)),
sa.column("auto_assigned", sa.Boolean()),
)
def _insert_record(bind, payload: dict[str, Any]) -> None:
bind.execute(sa.insert(_feedback_center_records_table(bind)).values(**payload))
def _backfill_from_qa_feedback(bind) -> None:
if not _table_exists(bind, "qa_feedback") or not _table_exists(bind, "qa_records"):
return
rows = bind.execute(
sa.text(
"""
SELECT
f.id AS feedback_id,
f.tenant_id AS tenant_id,
f.qa_id AS qa_id,
f.rating AS rating,
f.helpfulness AS helpfulness,
f.error_types AS error_types,
f.correct_answer AS correct_answer,
f.submitted_by AS submitted_by,
f.created_at AS created_at,
r.project_id AS project_id,
r.question AS question,
r.answer AS answer
FROM qa_feedback f
JOIN qa_records r ON r.id = f.qa_id
"""
)
).mappings().all()
for row in rows:
tenant_id = _safe_int(row.get("tenant_id"))
source_id = _safe_int(row.get("feedback_id"))
object_id = _safe_int(row.get("qa_id"))
if tenant_id is None or source_id is None or object_id is None:
continue
if _record_exists(
bind,
tenant_id=tenant_id,
source_type="qa_feedback",
source_id=source_id,
object_type="qa_record",
object_id=object_id,
field_name="qa_feedback",
):
continue
before_value = {"answer": row.get("answer")}
after_value = {
"rating": row.get("rating"),
"helpfulness": row.get("helpfulness"),
"error_types": _parse_json_if_needed(row.get("error_types")),
"correct_answer": row.get("correct_answer"),
}
candidates = _build_reason_candidates(
source_type="qa_feedback",
field_name="qa_feedback",
before_value=before_value,
after_value=after_value,
)
_insert_record(
bind,
{
"tenant_id": tenant_id,
"project_id": _safe_int(row.get("project_id")),
"source_type": "qa_feedback",
"source_id": source_id,
"object_type": "qa_record",
"object_id": object_id,
"field_name": "qa_feedback",
"before_value": before_value,
"after_value": after_value,
"ai_snapshot": {
"question": row.get("question"),
"answer": row.get("answer"),
},
"ai_confidence": None,
"reason_candidates": candidates,
"reason_code": candidates[0]["code"] if candidates else None,
"reason_text": row.get("correct_answer"),
"review_status": "reviewed",
"reviewed_by": _safe_int(row.get("submitted_by")),
"reviewed_at": row.get("created_at"),
"source_title": str(row.get("question") or "")[:500] or None,
"object_title": str(row.get("question") or "")[:500] or None,
"auto_assigned": False,
},
)
def _backfill_from_signal_feedback(bind) -> None:
if not _table_exists(bind, "signal_feedbacks") or not _table_exists(bind, "signals"):
return
rows = bind.execute(
sa.text(
"""
SELECT
f.id AS feedback_id,
f.tenant_id AS tenant_id,
f.signal_id AS signal_id,
f.was_strategic AS was_strategic,
f.reason AS reason,
f.ai_predicted_strategic AS ai_predicted_strategic,
f.ai_confidence AS ai_confidence,
f.user_id AS user_id,
f.created_at AS created_at,
s.matched_project_id AS project_id,
s.title AS signal_title
FROM signal_feedbacks f
JOIN signals s ON s.id = f.signal_id
"""
)
).mappings().all()
for row in rows:
tenant_id = _safe_int(row.get("tenant_id"))
source_id = _safe_int(row.get("feedback_id"))
object_id = _safe_int(row.get("signal_id"))
if tenant_id is None or source_id is None or object_id is None:
continue
if _record_exists(
bind,
tenant_id=tenant_id,
source_type="signal_feedback",
source_id=source_id,
object_type="signal",
object_id=object_id,
field_name="strategic_classification",
):
continue
before_value = {
"ai_predicted_strategic": row.get("ai_predicted_strategic"),
}
after_value = {
"was_strategic": row.get("was_strategic"),
"reason": row.get("reason"),
}
candidates = _build_reason_candidates(
source_type="signal_feedback",
field_name="strategic_classification",
before_value=before_value,
after_value=after_value,
)
_insert_record(
bind,
{
"tenant_id": tenant_id,
"project_id": _safe_int(row.get("project_id")),
"source_type": "signal_feedback",
"source_id": source_id,
"object_type": "signal",
"object_id": object_id,
"field_name": "strategic_classification",
"before_value": before_value,
"after_value": after_value,
"ai_snapshot": {
"ai_predicted_strategic": row.get("ai_predicted_strategic"),
"ai_confidence": row.get("ai_confidence"),
},
"ai_confidence": row.get("ai_confidence"),
"reason_candidates": candidates,
"reason_code": candidates[0]["code"] if candidates else None,
"reason_text": row.get("reason"),
"review_status": "reviewed",
"reviewed_by": _safe_int(row.get("user_id")),
"reviewed_at": row.get("created_at"),
"source_title": str(row.get("signal_title") or "")[:500] or None,
"object_title": str(row.get("signal_title") or "")[:500] or None,
"auto_assigned": False,
},
)
def _backfill_from_task_overrides(bind) -> None:
if not _table_exists(bind, "tasks"):
return
rows = bind.execute(
sa.text(
"""
SELECT
t.id AS task_id,
t.tenant_id AS tenant_id,
t.project_id AS project_id,
t.title AS task_title,
t.ai_breakdown_payload AS ai_breakdown_payload,
t.manual_overrides AS manual_overrides,
t.updated_at AS updated_at
FROM tasks t
WHERE t.manual_overrides IS NOT NULL
"""
)
).mappings().all()
for row in rows:
tenant_id = _safe_int(row.get("tenant_id"))
task_id = _safe_int(row.get("task_id"))
if tenant_id is None or task_id is None:
continue
overrides = _parse_json_if_needed(row.get("manual_overrides"))
ai_payload = _parse_json_if_needed(row.get("ai_breakdown_payload"))
if not isinstance(overrides, dict) or not overrides:
continue
if not isinstance(ai_payload, dict):
ai_payload = {}
for field_name in sorted(overrides.keys()):
if _record_exists(
bind,
tenant_id=tenant_id,
source_type="task_override",
source_id=task_id,
object_type="task",
object_id=task_id,
field_name=str(field_name),
):
continue
before_value = ai_payload.get(field_name)
after_value = overrides.get(field_name)
candidates = _build_reason_candidates(
source_type="task_override",
field_name=str(field_name),
before_value=before_value,
after_value=after_value,
)
_insert_record(
bind,
{
"tenant_id": tenant_id,
"project_id": _safe_int(row.get("project_id")),
"source_type": "task_override",
"source_id": task_id,
"object_type": "task",
"object_id": task_id,
"field_name": str(field_name)[:128],
"before_value": before_value,
"after_value": after_value,
"ai_snapshot": ai_payload,
"ai_confidence": None,
"reason_candidates": candidates,
"reason_code": candidates[0]["code"] if candidates else None,
"reason_text": None,
"review_status": "reviewed",
"reviewed_by": None,
"reviewed_at": row.get("updated_at"),
"source_title": str(row.get("task_title") or "")[:500] or None,
"object_title": str(row.get("task_title") or "")[:500] or None,
"auto_assigned": False,
},
)
def upgrade() -> None:
bind = op.get_bind()
json_type = _json_type(bind)
if not _table_exists(bind, "feedback_center_records"):
op.create_table(
"feedback_center_records",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("project_id", sa.BigInteger(), nullable=True),
sa.Column("source_type", sa.VARCHAR(length=32), nullable=False),
sa.Column("source_id", sa.BigInteger(), nullable=False),
sa.Column("object_type", sa.VARCHAR(length=32), nullable=False),
sa.Column("object_id", sa.BigInteger(), nullable=False),
sa.Column("field_name", sa.VARCHAR(length=128), nullable=False),
sa.Column("before_value", json_type, nullable=True),
sa.Column("after_value", json_type, nullable=True),
sa.Column("ai_snapshot", json_type, nullable=True),
sa.Column("ai_confidence", sa.Float(), nullable=True),
sa.Column("reason_candidates", json_type, nullable=True),
sa.Column("reason_code", sa.VARCHAR(length=64), nullable=True),
sa.Column("reason_text", sa.Text(), nullable=True),
sa.Column("review_status", sa.VARCHAR(length=16), nullable=False, server_default="pending"),
sa.Column("reviewed_by", sa.BigInteger(), nullable=True),
sa.Column("reviewed_at", sa.TIMESTAMP(), nullable=True),
sa.Column("source_title", sa.VARCHAR(length=500), nullable=True),
sa.Column("object_title", sa.VARCHAR(length=500), nullable=True),
sa.Column("auto_assigned", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("provider", sa.VARCHAR(length=32), nullable=True),
sa.Column("model_name", sa.VARCHAR(length=128), nullable=True),
sa.Column("prompt_version", sa.VARCHAR(length=64), nullable=True),
sa.Column("schema_version", sa.VARCHAR(length=64), nullable=True),
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()")),
sa.UniqueConstraint(
"tenant_id",
"source_type",
"source_id",
"object_type",
"object_id",
"field_name",
name="uq_feedback_center_records_source_object_field",
),
)
if not _table_exists(bind, "feedback_center_record_revisions"):
op.create_table(
"feedback_center_record_revisions",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("record_id", sa.Integer(), sa.ForeignKey("feedback_center_records.id", ondelete="CASCADE"), nullable=False),
sa.Column("revision_no", sa.Integer(), nullable=False, server_default="1"),
sa.Column("before_snapshot", json_type, nullable=False),
sa.Column("after_snapshot", json_type, nullable=False),
sa.Column("reason_code", sa.VARCHAR(length=64), nullable=True),
sa.Column("reason_text", sa.Text(), nullable=True),
sa.Column("review_status", sa.VARCHAR(length=16), nullable=False, server_default="reviewed"),
sa.Column("reviewed_by", sa.BigInteger(), nullable=True),
sa.Column("reviewed_at", sa.TIMESTAMP(), nullable=True),
sa.Column("change_summary", sa.Text(), nullable=True),
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()")),
)
if not _index_exists(bind, "feedback_center_records", "ix_feedback_center_records_tenant_project_status"):
op.create_index(
"ix_feedback_center_records_tenant_project_status",
"feedback_center_records",
["tenant_id", "project_id", "review_status"],
unique=False,
)
if not _index_exists(bind, "feedback_center_records", "ix_feedback_center_records_tenant_source"):
op.create_index(
"ix_feedback_center_records_tenant_source",
"feedback_center_records",
["tenant_id", "source_type", "source_id"],
unique=False,
)
if not _index_exists(bind, "feedback_center_records", "ix_feedback_center_records_reason_code"):
op.create_index(
"ix_feedback_center_records_reason_code",
"feedback_center_records",
["reason_code"],
unique=False,
)
if not _index_exists(bind, "feedback_center_record_revisions", "ix_feedback_center_record_revisions_tenant_record"):
op.create_index(
"ix_feedback_center_record_revisions_tenant_record",
"feedback_center_record_revisions",
["tenant_id", "record_id"],
unique=False,
)
_backfill_from_qa_feedback(bind)
_backfill_from_signal_feedback(bind)
_backfill_from_task_overrides(bind)
def downgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "feedback_center_record_revisions"):
if _index_exists(bind, "feedback_center_record_revisions", "ix_feedback_center_record_revisions_tenant_record"):
op.drop_index(
"ix_feedback_center_record_revisions_tenant_record",
table_name="feedback_center_record_revisions",
)
op.drop_table("feedback_center_record_revisions")
if _table_exists(bind, "feedback_center_records"):
for index_name in (
"ix_feedback_center_records_reason_code",
"ix_feedback_center_records_tenant_source",
"ix_feedback_center_records_tenant_project_status",
):
if _index_exists(bind, "feedback_center_records", index_name):
op.drop_index(index_name, table_name="feedback_center_records")
op.drop_table("feedback_center_records")

View File

@ -0,0 +1,135 @@
"""add external qa followup state fields
Revision ID: 20260327_add_external_qa_followup_state
Revises: 20260327_cleanup_model_routing_legacy_names
Create Date: 2026-03-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 = "20260327_add_external_qa_followup_state"
down_revision = "20260327_cleanup_model_routing_legacy_names"
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)
try:
columns = inspector.get_columns(table_name)
except Exception:
return False
return any(str(col.get("name")) == column_name for col in columns)
def _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
try:
indexes = inspector.get_indexes(table_name)
except Exception:
return False
return any(idx.get("name") == index_name for idx in indexes)
def _create_index_if_not_exists(
bind,
index_name: str,
table_name: str,
columns: list[str],
*,
unique: bool = False,
) -> None:
if not _index_exists(bind, table_name, index_name):
op.create_index(index_name, table_name, columns, unique=unique)
def _drop_index_if_exists(bind, index_name: str, table_name: str) -> None:
if _index_exists(bind, table_name, index_name):
op.drop_index(index_name, table_name=table_name)
def upgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "qa_sessions"):
if not _column_exists(bind, "qa_sessions", "external_conversation_key"):
with op.batch_alter_table("qa_sessions") as batch_op:
batch_op.add_column(sa.Column("external_conversation_key", sa.VARCHAR(length=160), nullable=True))
_create_index_if_not_exists(
bind,
"ix_qa_sessions_external_conversation_key",
"qa_sessions",
["external_conversation_key"],
)
_create_index_if_not_exists(
bind,
"uq_qa_sessions_tenant_external_conversation_key",
"qa_sessions",
["tenant_id", "external_conversation_key"],
unique=True,
)
if _table_exists(bind, "qa_session_states"):
if not _column_exists(bind, "qa_session_states", "pending_clarification"):
with op.batch_alter_table("qa_session_states") as batch_op:
batch_op.add_column(sa.Column("pending_clarification", _json_type(bind), nullable=True))
if not _column_exists(bind, "qa_session_states", "pending_clarification_expires_at"):
with op.batch_alter_table("qa_session_states") as batch_op:
batch_op.add_column(sa.Column("pending_clarification_expires_at", sa.TIMESTAMP(), nullable=True))
_create_index_if_not_exists(
bind,
"ix_qa_session_states_pending_clarification_expires_at",
"qa_session_states",
["pending_clarification_expires_at"],
)
def downgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "qa_session_states"):
_drop_index_if_exists(
bind,
"ix_qa_session_states_pending_clarification_expires_at",
"qa_session_states",
)
if _column_exists(bind, "qa_session_states", "pending_clarification_expires_at") or _column_exists(
bind, "qa_session_states", "pending_clarification"
):
with op.batch_alter_table("qa_session_states") as batch_op:
if _column_exists(bind, "qa_session_states", "pending_clarification_expires_at"):
batch_op.drop_column("pending_clarification_expires_at")
if _column_exists(bind, "qa_session_states", "pending_clarification"):
batch_op.drop_column("pending_clarification")
if _table_exists(bind, "qa_sessions"):
_drop_index_if_exists(
bind,
"uq_qa_sessions_tenant_external_conversation_key",
"qa_sessions",
)
_drop_index_if_exists(
bind,
"ix_qa_sessions_external_conversation_key",
"qa_sessions",
)
if _column_exists(bind, "qa_sessions", "external_conversation_key"):
with op.batch_alter_table("qa_sessions") as batch_op:
batch_op.drop_column("external_conversation_key")

View File

@ -0,0 +1,116 @@
"""add qa record followup anchor fields
Revision ID: 20260327_add_qa_record_followup_anchor_fields
Revises: 20260327_add_external_qa_followup_state
Create Date: 2026-03-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 = "20260327_add_qa_record_followup_anchor_fields"
down_revision = "20260327_add_external_qa_followup_state"
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)
try:
columns = inspector.get_columns(table_name)
except Exception:
return False
return any(str(col.get("name")) == column_name for col in columns)
def _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
try:
indexes = inspector.get_indexes(table_name)
except Exception:
return False
return any(idx.get("name") == index_name for idx in indexes)
def _create_index_if_not_exists(
bind,
index_name: str,
table_name: str,
columns: list[str],
*,
unique: bool = False,
) -> None:
if not _index_exists(bind, table_name, index_name):
op.create_index(index_name, table_name, columns, unique=unique)
def _drop_index_if_exists(bind, index_name: str, table_name: str) -> None:
if _index_exists(bind, table_name, index_name):
op.drop_index(index_name, table_name=table_name)
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "qa_records"):
return
columns = [
("source_message_id", sa.VARCHAR(length=128), True),
("source_chat_id", sa.VARCHAR(length=128), True),
("source_sender_open_id", sa.VARCHAR(length=128), True),
("reply_to_message_id", sa.VARCHAR(length=128), True),
("root_message_id", sa.VARCHAR(length=128), True),
("conversation_scope_key", sa.VARCHAR(length=160), True),
("anchor_kind", sa.VARCHAR(length=32), True),
("anchor_ref", sa.VARCHAR(length=128), True),
("routing_snapshot", _json_type(bind), True),
]
for name, column_type, nullable in columns:
if _column_exists(bind, "qa_records", name):
continue
with op.batch_alter_table("qa_records") as batch_op:
batch_op.add_column(sa.Column(name, column_type, nullable=nullable))
_create_index_if_not_exists(bind, "ix_qa_records_source_message_id", "qa_records", ["source_message_id"])
_create_index_if_not_exists(bind, "ix_qa_records_conversation_scope_key", "qa_records", ["conversation_scope_key"])
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "qa_records"):
return
_drop_index_if_exists(bind, "ix_qa_records_conversation_scope_key", "qa_records")
_drop_index_if_exists(bind, "ix_qa_records_source_message_id", "qa_records")
for name in (
"routing_snapshot",
"anchor_ref",
"anchor_kind",
"conversation_scope_key",
"root_message_id",
"reply_to_message_id",
"source_sender_open_id",
"source_chat_id",
"source_message_id",
):
if not _column_exists(bind, "qa_records", name):
continue
with op.batch_alter_table("qa_records") as batch_op:
batch_op.drop_column(name)

View File

@ -0,0 +1,587 @@
"""add reply iteration and training pipeline tables
Revision ID: 20260327_add_reply_iteration_training_tables
Revises: 20260325_add_feishu_reply_outbox_sent_message_id, 20260327_add_qa_record_followup_anchor_fields
Create Date: 2026-03-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 = "20260327_add_reply_iteration_training_tables"
down_revision = (
"20260325_add_feishu_reply_outbox_sent_message_id",
"20260327_add_qa_record_followup_anchor_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)
try:
columns = inspector.get_columns(table_name)
except Exception:
return False
return any(str(column.get("name")) == column_name for column in columns)
def _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
try:
indexes = inspector.get_indexes(table_name)
except Exception:
return False
return any(str(index.get("name")) == index_name for index in indexes)
def _create_index_if_missing(bind, table_name: str, index_name: str, columns: list[str], *, unique: bool = False) -> None:
if not _index_exists(bind, table_name, index_name):
op.create_index(index_name, table_name, columns, unique=unique)
def _drop_index_if_exists(bind, table_name: str, index_name: str) -> None:
if _index_exists(bind, table_name, index_name):
op.drop_index(index_name, table_name=table_name)
def _base_columns() -> list[sa.Column]:
return [
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
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()),
]
def _create_quality_assets(bind) -> None:
if _table_exists(bind, "quality_assets"):
return
op.create_table(
"quality_assets",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("project_id", sa.BigInteger(), nullable=True),
sa.Column("asset_type", sa.VARCHAR(length=16), nullable=False),
sa.Column("status", sa.VARCHAR(length=16), nullable=False, server_default="approved"),
sa.Column("source_feedback_record_id", sa.Integer(), sa.ForeignKey("feedback_center_records.id", ondelete="SET NULL"), nullable=True),
sa.Column("qa_record_id", sa.Integer(), sa.ForeignKey("qa_records.id", ondelete="SET NULL"), nullable=True),
sa.Column("ai_call_log_id", sa.Integer(), nullable=True),
sa.Column("prompt_key", sa.VARCHAR(length=128), nullable=True),
sa.Column("workflow_type", sa.VARCHAR(length=64), nullable=True),
sa.Column("asked_via", sa.VARCHAR(length=32), nullable=True),
sa.Column("planned_tier", sa.VARCHAR(length=16), nullable=True),
sa.Column("executed_tier", sa.VARCHAR(length=16), nullable=True),
sa.Column("route_source", sa.VARCHAR(length=32), nullable=True),
sa.Column("route_rule_name", sa.VARCHAR(length=255), nullable=True),
sa.Column("question_text", sa.Text(), nullable=False),
sa.Column("answer_text", sa.Text(), nullable=False),
sa.Column("corrected_answer", sa.Text(), nullable=True),
sa.Column("source_snapshot", _json_type(bind), nullable=True),
sa.Column("tags", _json_type(bind), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("approved_by", sa.BigInteger(), nullable=True),
sa.Column("approved_at", sa.VARCHAR(length=64), nullable=True),
*_base_columns(),
)
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_tenant_id", ["tenant_id"])
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_project_id", ["project_id"])
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_asset_type", ["asset_type"])
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_status", ["status"])
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_source_feedback_record_id", ["source_feedback_record_id"])
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_qa_record_id", ["qa_record_id"])
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_ai_call_log_id", ["ai_call_log_id"])
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_prompt_key", ["prompt_key"])
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_workflow_type", ["workflow_type"])
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_asked_via", ["asked_via"])
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_planned_tier", ["planned_tier"])
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_executed_tier", ["executed_tier"])
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_approved_by", ["approved_by"])
def _create_quality_asset_pairs(bind) -> None:
if _table_exists(bind, "quality_asset_pairs"):
return
op.create_table(
"quality_asset_pairs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("project_id", sa.BigInteger(), nullable=True),
sa.Column("pair_type", sa.VARCHAR(length=24), nullable=False, server_default="preference"),
sa.Column("status", sa.VARCHAR(length=16), nullable=False, server_default="approved"),
sa.Column("chosen_asset_id", sa.Integer(), sa.ForeignKey("quality_assets.id", ondelete="CASCADE"), nullable=False),
sa.Column("rejected_asset_id", sa.Integer(), sa.ForeignKey("quality_assets.id", ondelete="CASCADE"), nullable=False),
sa.Column("reason_text", sa.Text(), nullable=True),
sa.Column("approved_by", sa.BigInteger(), nullable=True),
sa.Column("approved_at", sa.VARCHAR(length=64), nullable=True),
*_base_columns(),
)
_create_index_if_missing(bind, "quality_asset_pairs", "ix_quality_asset_pairs_tenant_id", ["tenant_id"])
_create_index_if_missing(bind, "quality_asset_pairs", "ix_quality_asset_pairs_project_id", ["project_id"])
_create_index_if_missing(bind, "quality_asset_pairs", "ix_quality_asset_pairs_pair_type", ["pair_type"])
_create_index_if_missing(bind, "quality_asset_pairs", "ix_quality_asset_pairs_status", ["status"])
_create_index_if_missing(bind, "quality_asset_pairs", "ix_quality_asset_pairs_chosen_asset_id", ["chosen_asset_id"])
_create_index_if_missing(bind, "quality_asset_pairs", "ix_quality_asset_pairs_rejected_asset_id", ["rejected_asset_id"])
_create_index_if_missing(bind, "quality_asset_pairs", "ix_quality_asset_pairs_approved_by", ["approved_by"])
def _create_judge_runs(bind) -> None:
if _table_exists(bind, "judge_runs"):
return
op.create_table(
"judge_runs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("project_id", sa.BigInteger(), nullable=True),
sa.Column("asset_id", sa.Integer(), sa.ForeignKey("quality_assets.id", ondelete="CASCADE"), nullable=True),
sa.Column("pair_id", sa.Integer(), sa.ForeignKey("quality_asset_pairs.id", ondelete="CASCADE"), nullable=True),
sa.Column("prompt_key", sa.VARCHAR(length=128), nullable=True),
sa.Column("workflow_type", sa.VARCHAR(length=64), nullable=True),
sa.Column("asked_via", sa.VARCHAR(length=32), nullable=True),
sa.Column("planned_tier", sa.VARCHAR(length=16), nullable=True),
sa.Column("executed_tier", sa.VARCHAR(length=16), nullable=True),
sa.Column("route_rule_name", sa.VARCHAR(length=255), nullable=True),
sa.Column("judge_status", sa.VARCHAR(length=16), nullable=False, server_default="completed"),
sa.Column("bucket_key", sa.VARCHAR(length=255), nullable=False),
sa.Column("rule_score", sa.Float(), nullable=True),
sa.Column("llm_score", sa.Float(), nullable=True),
sa.Column("overall_score", sa.Float(), nullable=True),
sa.Column("findings", _json_type(bind), nullable=True),
sa.Column("llm_evidence", _json_type(bind), nullable=True),
sa.Column("metadata_json", _json_type(bind), nullable=True),
*_base_columns(),
)
for index_name, columns in (
("ix_judge_runs_tenant_id", ["tenant_id"]),
("ix_judge_runs_project_id", ["project_id"]),
("ix_judge_runs_asset_id", ["asset_id"]),
("ix_judge_runs_pair_id", ["pair_id"]),
("ix_judge_runs_prompt_key", ["prompt_key"]),
("ix_judge_runs_workflow_type", ["workflow_type"]),
("ix_judge_runs_asked_via", ["asked_via"]),
("ix_judge_runs_planned_tier", ["planned_tier"]),
("ix_judge_runs_executed_tier", ["executed_tier"]),
("ix_judge_runs_judge_status", ["judge_status"]),
("ix_judge_runs_bucket_key", ["bucket_key"]),
):
_create_index_if_missing(bind, "judge_runs", index_name, columns)
def _create_optimizer_candidates(bind) -> None:
if _table_exists(bind, "optimizer_candidates"):
return
op.create_table(
"optimizer_candidates",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("project_id", sa.BigInteger(), nullable=True),
sa.Column("judge_run_id", sa.Integer(), sa.ForeignKey("judge_runs.id", ondelete="CASCADE"), nullable=False),
sa.Column("candidate_type", sa.VARCHAR(length=32), nullable=False),
sa.Column("target_type", sa.VARCHAR(length=32), nullable=False),
sa.Column("target_ref", sa.VARCHAR(length=255), nullable=False),
sa.Column("status", sa.VARCHAR(length=24), nullable=False, server_default="draft"),
sa.Column("suggestion", _json_type(bind), nullable=True),
sa.Column("evidence", _json_type(bind), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
*_base_columns(),
)
for index_name, columns in (
("ix_optimizer_candidates_tenant_id", ["tenant_id"]),
("ix_optimizer_candidates_project_id", ["project_id"]),
("ix_optimizer_candidates_judge_run_id", ["judge_run_id"]),
("ix_optimizer_candidates_candidate_type", ["candidate_type"]),
("ix_optimizer_candidates_target_type", ["target_type"]),
("ix_optimizer_candidates_target_ref", ["target_ref"]),
("ix_optimizer_candidates_status", ["status"]),
):
_create_index_if_missing(bind, "optimizer_candidates", index_name, columns)
def _create_prompt_releases(bind) -> None:
if _table_exists(bind, "prompt_releases"):
return
op.create_table(
"prompt_releases",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("prompt_key", sa.VARCHAR(length=128), nullable=False),
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
sa.Column("body_snapshot", sa.Text(), nullable=False),
sa.Column("status", sa.VARCHAR(length=16), nullable=False, server_default="draft"),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("created_by", sa.BigInteger(), nullable=True),
sa.Column("activated_by", sa.BigInteger(), nullable=True),
sa.Column("activated_at", sa.VARCHAR(length=64), nullable=True),
*_base_columns(),
sa.UniqueConstraint("tenant_id", "prompt_key", "version", name="uq_prompt_releases_tenant_key_version"),
)
for index_name, columns in (
("ix_prompt_releases_tenant_id", ["tenant_id"]),
("ix_prompt_releases_prompt_key", ["prompt_key"]),
("ix_prompt_releases_status", ["status"]),
("ix_prompt_releases_created_by", ["created_by"]),
("ix_prompt_releases_activated_by", ["activated_by"]),
):
_create_index_if_missing(bind, "prompt_releases", index_name, columns)
def _create_training_datasets(bind) -> None:
if _table_exists(bind, "training_datasets"):
return
op.create_table(
"training_datasets",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("project_id", sa.BigInteger(), nullable=True),
sa.Column("dataset_type", sa.VARCHAR(length=16), nullable=False),
sa.Column("version", sa.VARCHAR(length=64), nullable=False),
sa.Column("status", sa.VARCHAR(length=16), nullable=False, server_default="ready"),
sa.Column("sample_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("selection_rules", _json_type(bind), nullable=True),
sa.Column("channel_scope", _json_type(bind), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("created_by", sa.BigInteger(), nullable=True),
*_base_columns(),
sa.UniqueConstraint("tenant_id", "dataset_type", "version", name="uq_training_datasets_tenant_type_version"),
)
for index_name, columns in (
("ix_training_datasets_tenant_id", ["tenant_id"]),
("ix_training_datasets_project_id", ["project_id"]),
("ix_training_datasets_dataset_type", ["dataset_type"]),
("ix_training_datasets_version", ["version"]),
("ix_training_datasets_status", ["status"]),
("ix_training_datasets_created_by", ["created_by"]),
):
_create_index_if_missing(bind, "training_datasets", index_name, columns)
def _create_training_dataset_items(bind) -> None:
if _table_exists(bind, "training_dataset_items"):
return
op.create_table(
"training_dataset_items",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("dataset_id", sa.Integer(), sa.ForeignKey("training_datasets.id", ondelete="CASCADE"), nullable=False),
sa.Column("quality_asset_id", sa.Integer(), sa.ForeignKey("quality_assets.id", ondelete="CASCADE"), nullable=True),
sa.Column("quality_asset_pair_id", sa.Integer(), sa.ForeignKey("quality_asset_pairs.id", ondelete="CASCADE"), nullable=True),
sa.Column("redacted_snapshot", _json_type(bind), nullable=True),
*_base_columns(),
)
for index_name, columns in (
("ix_training_dataset_items_tenant_id", ["tenant_id"]),
("ix_training_dataset_items_dataset_id", ["dataset_id"]),
("ix_training_dataset_items_quality_asset_id", ["quality_asset_id"]),
("ix_training_dataset_items_quality_asset_pair_id", ["quality_asset_pair_id"]),
):
_create_index_if_missing(bind, "training_dataset_items", index_name, columns)
def _create_training_jobs(bind) -> None:
if _table_exists(bind, "training_jobs"):
return
op.create_table(
"training_jobs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("project_id", sa.BigInteger(), nullable=True),
sa.Column("dataset_id", sa.Integer(), sa.ForeignKey("training_datasets.id", ondelete="CASCADE"), nullable=False),
sa.Column("job_type", sa.VARCHAR(length=16), nullable=False),
sa.Column("base_model_family", sa.VARCHAR(length=64), nullable=False),
sa.Column("target_slot", sa.VARCHAR(length=16), nullable=False),
sa.Column("status", sa.VARCHAR(length=16), nullable=False, server_default="queued"),
sa.Column("provider_backend", sa.VARCHAR(length=64), nullable=False),
sa.Column("artifact_ref", sa.VARCHAR(length=255), nullable=True),
sa.Column("metrics_json", _json_type(bind), nullable=True),
sa.Column("created_by", sa.BigInteger(), nullable=True),
*_base_columns(),
)
for index_name, columns in (
("ix_training_jobs_tenant_id", ["tenant_id"]),
("ix_training_jobs_project_id", ["project_id"]),
("ix_training_jobs_dataset_id", ["dataset_id"]),
("ix_training_jobs_job_type", ["job_type"]),
("ix_training_jobs_base_model_family", ["base_model_family"]),
("ix_training_jobs_target_slot", ["target_slot"]),
("ix_training_jobs_status", ["status"]),
("ix_training_jobs_created_by", ["created_by"]),
):
_create_index_if_missing(bind, "training_jobs", index_name, columns)
def _create_training_job_runs(bind) -> None:
if _table_exists(bind, "training_job_runs"):
return
op.create_table(
"training_job_runs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("training_job_id", sa.Integer(), sa.ForeignKey("training_jobs.id", ondelete="CASCADE"), nullable=False),
sa.Column("status", sa.VARCHAR(length=16), nullable=False, server_default="queued"),
sa.Column("provider_run_ref", sa.VARCHAR(length=255), nullable=True),
sa.Column("metrics_json", _json_type(bind), nullable=True),
*_base_columns(),
)
for index_name, columns in (
("ix_training_job_runs_tenant_id", ["tenant_id"]),
("ix_training_job_runs_training_job_id", ["training_job_id"]),
("ix_training_job_runs_status", ["status"]),
):
_create_index_if_missing(bind, "training_job_runs", index_name, columns)
def _create_training_evaluations(bind) -> None:
if _table_exists(bind, "training_evaluations"):
return
op.create_table(
"training_evaluations",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("training_job_id", sa.Integer(), sa.ForeignKey("training_jobs.id", ondelete="CASCADE"), nullable=False),
sa.Column("evaluation_type", sa.VARCHAR(length=32), nullable=False),
sa.Column("status", sa.VARCHAR(length=16), nullable=False, server_default="pending"),
sa.Column("score", sa.Float(), nullable=True),
sa.Column("evaluation_summary", _json_type(bind), nullable=True),
*_base_columns(),
)
for index_name, columns in (
("ix_training_evaluations_tenant_id", ["tenant_id"]),
("ix_training_evaluations_training_job_id", ["training_job_id"]),
("ix_training_evaluations_evaluation_type", ["evaluation_type"]),
("ix_training_evaluations_status", ["status"]),
):
_create_index_if_missing(bind, "training_evaluations", index_name, columns)
def _create_trained_model_artifacts(bind) -> None:
if _table_exists(bind, "trained_model_artifacts"):
return
op.create_table(
"trained_model_artifacts",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("training_job_id", sa.Integer(), sa.ForeignKey("training_jobs.id", ondelete="CASCADE"), nullable=False),
sa.Column("artifact_ref", sa.VARCHAR(length=255), nullable=False),
sa.Column("base_model_family", sa.VARCHAR(length=64), nullable=False),
sa.Column("target_slot", sa.VARCHAR(length=16), nullable=False),
sa.Column("status", sa.VARCHAR(length=16), nullable=False, server_default="pending_eval"),
sa.Column("metrics_json", _json_type(bind), nullable=True),
*_base_columns(),
)
for index_name, columns in (
("ix_trained_model_artifacts_tenant_id", ["tenant_id"]),
("ix_trained_model_artifacts_training_job_id", ["training_job_id"]),
("ix_trained_model_artifacts_artifact_ref", ["artifact_ref"]),
("ix_trained_model_artifacts_base_model_family", ["base_model_family"]),
("ix_trained_model_artifacts_target_slot", ["target_slot"]),
("ix_trained_model_artifacts_status", ["status"]),
):
_create_index_if_missing(bind, "trained_model_artifacts", index_name, columns)
def _create_trained_model_deployments(bind) -> None:
if _table_exists(bind, "trained_model_deployments"):
return
op.create_table(
"trained_model_deployments",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("artifact_id", sa.Integer(), sa.ForeignKey("trained_model_artifacts.id", ondelete="CASCADE"), nullable=False),
sa.Column("deployment_status", sa.VARCHAR(length=16), nullable=False, server_default="candidate"),
sa.Column("bound_tier", sa.VARCHAR(length=16), nullable=False),
sa.Column("effective_scope", _json_type(bind), nullable=True),
sa.Column("decision", sa.VARCHAR(length=24), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("decided_by", sa.BigInteger(), nullable=True),
sa.Column("decided_at", sa.VARCHAR(length=64), nullable=True),
*_base_columns(),
)
for index_name, columns in (
("ix_trained_model_deployments_tenant_id", ["tenant_id"]),
("ix_trained_model_deployments_artifact_id", ["artifact_id"]),
("ix_trained_model_deployments_deployment_status", ["deployment_status"]),
("ix_trained_model_deployments_bound_tier", ["bound_tier"]),
("ix_trained_model_deployments_decision", ["decision"]),
("ix_trained_model_deployments_decided_by", ["decided_by"]),
):
_create_index_if_missing(bind, "trained_model_deployments", index_name, columns)
def _add_experiment_columns(bind) -> None:
if not _table_exists(bind, "experiments"):
return
additions = [
("experiment_type", sa.VARCHAR(length=64), True),
("baseline_release_id", sa.Integer(), True),
("candidate_release_id", sa.Integer(), True),
("scope_json", _json_type(bind), True),
("result_summary", _json_type(bind), True),
("decision", sa.VARCHAR(length=24), True),
]
for column_name, column_type, nullable in additions:
if _column_exists(bind, "experiments", column_name):
continue
with op.batch_alter_table("experiments") as batch_op:
batch_op.add_column(sa.Column(column_name, column_type, nullable=nullable))
for index_name, columns in (
("ix_experiments_experiment_type", ["experiment_type"]),
("ix_experiments_baseline_release_id", ["baseline_release_id"]),
("ix_experiments_candidate_release_id", ["candidate_release_id"]),
("ix_experiments_decision", ["decision"]),
):
_create_index_if_missing(bind, "experiments", index_name, columns)
def upgrade() -> None:
bind = op.get_bind()
_create_quality_assets(bind)
_create_quality_asset_pairs(bind)
_create_judge_runs(bind)
_create_optimizer_candidates(bind)
_create_prompt_releases(bind)
_create_training_datasets(bind)
_create_training_dataset_items(bind)
_create_training_jobs(bind)
_create_training_job_runs(bind)
_create_training_evaluations(bind)
_create_trained_model_artifacts(bind)
_create_trained_model_deployments(bind)
_add_experiment_columns(bind)
def downgrade() -> None:
bind = op.get_bind()
for index_name in (
"ix_experiments_decision",
"ix_experiments_candidate_release_id",
"ix_experiments_baseline_release_id",
"ix_experiments_experiment_type",
):
_drop_index_if_exists(bind, "experiments", index_name)
if _table_exists(bind, "experiments"):
for column_name in (
"decision",
"result_summary",
"scope_json",
"candidate_release_id",
"baseline_release_id",
"experiment_type",
):
if _column_exists(bind, "experiments", column_name):
with op.batch_alter_table("experiments") as batch_op:
batch_op.drop_column(column_name)
for table_name, index_names in (
("trained_model_deployments", [
"ix_trained_model_deployments_decided_by",
"ix_trained_model_deployments_decision",
"ix_trained_model_deployments_bound_tier",
"ix_trained_model_deployments_deployment_status",
"ix_trained_model_deployments_artifact_id",
"ix_trained_model_deployments_tenant_id",
]),
("trained_model_artifacts", [
"ix_trained_model_artifacts_status",
"ix_trained_model_artifacts_target_slot",
"ix_trained_model_artifacts_base_model_family",
"ix_trained_model_artifacts_artifact_ref",
"ix_trained_model_artifacts_training_job_id",
"ix_trained_model_artifacts_tenant_id",
]),
("training_evaluations", [
"ix_training_evaluations_status",
"ix_training_evaluations_evaluation_type",
"ix_training_evaluations_training_job_id",
"ix_training_evaluations_tenant_id",
]),
("training_job_runs", [
"ix_training_job_runs_status",
"ix_training_job_runs_training_job_id",
"ix_training_job_runs_tenant_id",
]),
("training_jobs", [
"ix_training_jobs_created_by",
"ix_training_jobs_status",
"ix_training_jobs_target_slot",
"ix_training_jobs_base_model_family",
"ix_training_jobs_job_type",
"ix_training_jobs_dataset_id",
"ix_training_jobs_project_id",
"ix_training_jobs_tenant_id",
]),
("training_dataset_items", [
"ix_training_dataset_items_quality_asset_pair_id",
"ix_training_dataset_items_quality_asset_id",
"ix_training_dataset_items_dataset_id",
"ix_training_dataset_items_tenant_id",
]),
("training_datasets", [
"ix_training_datasets_created_by",
"ix_training_datasets_status",
"ix_training_datasets_version",
"ix_training_datasets_dataset_type",
"ix_training_datasets_project_id",
"ix_training_datasets_tenant_id",
]),
("prompt_releases", [
"ix_prompt_releases_activated_by",
"ix_prompt_releases_created_by",
"ix_prompt_releases_status",
"ix_prompt_releases_prompt_key",
"ix_prompt_releases_tenant_id",
]),
("optimizer_candidates", [
"ix_optimizer_candidates_status",
"ix_optimizer_candidates_target_ref",
"ix_optimizer_candidates_target_type",
"ix_optimizer_candidates_candidate_type",
"ix_optimizer_candidates_judge_run_id",
"ix_optimizer_candidates_project_id",
"ix_optimizer_candidates_tenant_id",
]),
("judge_runs", [
"ix_judge_runs_bucket_key",
"ix_judge_runs_judge_status",
"ix_judge_runs_executed_tier",
"ix_judge_runs_planned_tier",
"ix_judge_runs_asked_via",
"ix_judge_runs_workflow_type",
"ix_judge_runs_prompt_key",
"ix_judge_runs_pair_id",
"ix_judge_runs_asset_id",
"ix_judge_runs_project_id",
"ix_judge_runs_tenant_id",
]),
("quality_asset_pairs", [
"ix_quality_asset_pairs_approved_by",
"ix_quality_asset_pairs_rejected_asset_id",
"ix_quality_asset_pairs_chosen_asset_id",
"ix_quality_asset_pairs_status",
"ix_quality_asset_pairs_pair_type",
"ix_quality_asset_pairs_project_id",
"ix_quality_asset_pairs_tenant_id",
]),
("quality_assets", [
"ix_quality_assets_approved_by",
"ix_quality_assets_executed_tier",
"ix_quality_assets_planned_tier",
"ix_quality_assets_asked_via",
"ix_quality_assets_workflow_type",
"ix_quality_assets_prompt_key",
"ix_quality_assets_ai_call_log_id",
"ix_quality_assets_qa_record_id",
"ix_quality_assets_source_feedback_record_id",
"ix_quality_assets_status",
"ix_quality_assets_asset_type",
"ix_quality_assets_project_id",
"ix_quality_assets_tenant_id",
]),
):
for index_name in index_names:
_drop_index_if_exists(bind, table_name, index_name)
if _table_exists(bind, table_name):
op.drop_table(table_name)

View File

@ -0,0 +1,150 @@
"""cleanup legacy model routing function names
Revision ID: 20260327_cleanup_model_routing_legacy_names
Revises: 20260326_add_ai_call_log_routing_fields
Create Date: 2026-03-27
"""
from __future__ import annotations
from copy import deepcopy
from typing import Any
from alembic import op
import sqlalchemy as sa
revision = "20260327_cleanup_model_routing_legacy_names"
down_revision = "20260326_add_ai_call_log_routing_fields"
branch_labels = None
depends_on = None
_RENAME_KEYS = {
"project_draft_analysis_glm47_fallback": "ai_pmo_signal_title_refine",
"adaptive_rag_answer_deep_signal_batch_analysis": "ai_pmo_signal_batch_analysis",
}
_REMOVE_KEYS = {
"project_draft_charter_glm47_fallback",
"project_draft_signal_glm47_fallback",
"project_draft_task_chain_glm47_fallback",
}
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _integration_table(bind) -> sa.Table:
metadata = sa.MetaData()
return sa.Table(
"integrations",
metadata,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("type", sa.String(length=100)),
sa.Column("config", sa.JSON()),
)
def _safe_dict(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
try:
return deepcopy(value)
except Exception:
return dict(value)
return {}
def _rewrite_model_routing_config(config: Any) -> dict[str, Any]:
payload = _safe_dict(config)
if not payload:
return {}
mappings = payload.get("mappings")
if not isinstance(mappings, dict):
payload["mappings"] = {}
return payload
normalized: dict[str, Any] = {}
for key, value in mappings.items():
name = str(key or "").strip().lower()
if not name:
continue
normalized[name] = deepcopy(value)
for old_key, new_key in _RENAME_KEYS.items():
if old_key not in normalized:
continue
if new_key not in normalized:
normalized[new_key] = normalized[old_key]
normalized.pop(old_key, None)
for key in _REMOVE_KEYS:
normalized.pop(key, None)
payload["mappings"] = normalized
return payload
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "integrations"):
return
table = _integration_table(bind)
rows = (
bind.execute(
sa.select(table.c.id, table.c.config).where(table.c.type == "model_routing")
)
.mappings()
.all()
)
for row in rows:
row_id = int(row.get("id") or 0)
rewritten = _rewrite_model_routing_config(row.get("config"))
if not rewritten:
continue
if rewritten == row.get("config"):
continue
bind.execute(
table.update().where(table.c.id == row_id).values(config=rewritten)
)
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "integrations"):
return
table = _integration_table(bind)
reverse_rename = {new_key: old_key for old_key, new_key in _RENAME_KEYS.items()}
rows = (
bind.execute(
sa.select(table.c.id, table.c.config).where(table.c.type == "model_routing")
)
.mappings()
.all()
)
for row in rows:
row_id = int(row.get("id") or 0)
payload = _safe_dict(row.get("config"))
mappings = payload.get("mappings")
if not isinstance(mappings, dict):
continue
normalized: dict[str, Any] = {}
for key, value in mappings.items():
name = str(key or "").strip().lower()
if not name:
continue
normalized[name] = deepcopy(value)
for new_key, old_key in reverse_rename.items():
if new_key not in normalized:
continue
if old_key not in normalized:
normalized[old_key] = normalized[new_key]
normalized.pop(new_key, None)
payload["mappings"] = normalized
if payload == row.get("config"):
continue
bind.execute(
table.update().where(table.c.id == row_id).values(config=payload)
)

View File

@ -0,0 +1,83 @@
"""add qa record runtime grounding fields
Revision ID: 20260331_add_qa_record_runtime_fields
Revises: 20260327_add_reply_iteration_training_tables
Create Date: 2026-03-31
"""
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 = "20260331_add_qa_record_runtime_fields"
down_revision = "20260327_add_reply_iteration_training_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:
inspector = sa.inspect(bind)
try:
columns = inspector.get_columns(table_name)
except Exception:
return False
return any(str(column.get("name")) == column_name for column in columns)
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "qa_records"):
return
with op.batch_alter_table("qa_records") as batch_op:
if not _column_exists(bind, "qa_records", "answer_mode"):
batch_op.add_column(sa.Column("answer_mode", sa.String(length=32), nullable=True))
if not _column_exists(bind, "qa_records", "grounding_status"):
batch_op.add_column(sa.Column("grounding_status", sa.String(length=32), nullable=True))
if not _column_exists(bind, "qa_records", "policy_version"):
batch_op.add_column(sa.Column("policy_version", sa.String(length=160), nullable=True))
if not _column_exists(bind, "qa_records", "citations"):
batch_op.add_column(sa.Column("citations", _json_type(bind), nullable=True))
if not _column_exists(bind, "qa_records", "sources_used"):
batch_op.add_column(sa.Column("sources_used", _json_type(bind), nullable=True))
if not _column_exists(bind, "qa_records", "evidence_pack"):
batch_op.add_column(sa.Column("evidence_pack", _json_type(bind), nullable=True))
if not _column_exists(bind, "qa_records", "tool_trace"):
batch_op.add_column(sa.Column("tool_trace", _json_type(bind), nullable=True))
if not _column_exists(bind, "qa_records", "retrieval_metrics"):
batch_op.add_column(sa.Column("retrieval_metrics", _json_type(bind), nullable=True))
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "qa_records"):
return
with op.batch_alter_table("qa_records") as batch_op:
for column_name in (
"retrieval_metrics",
"tool_trace",
"evidence_pack",
"sources_used",
"citations",
"policy_version",
"grounding_status",
"answer_mode",
):
if _column_exists(bind, "qa_records", column_name):
batch_op.drop_column(column_name)

View File

@ -0,0 +1,298 @@
"""add mounted ai runtime tables
Revision ID: 20260405_add_mounted_ai_runtime_tables
Revises: 20260331_add_qa_record_runtime_fields
Create Date: 2026-04-05
"""
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 = "20260405_add_mounted_ai_runtime_tables"
down_revision = "20260331_add_qa_record_runtime_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 _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
try:
indexes = inspector.get_indexes(table_name)
except Exception:
return False
return any(str(index.get("name")) == index_name for index in indexes)
def _create_index_if_missing(bind, table_name: str, index_name: str, columns: list[str], *, unique: bool = False) -> None:
if not _index_exists(bind, table_name, index_name):
op.create_index(index_name, table_name, columns, unique=unique)
def _base_columns() -> list[sa.Column]:
return [
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
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()),
]
def _create_mounted_topic_workspaces(bind) -> None:
if _table_exists(bind, "mounted_topic_workspaces"):
return
op.create_table(
"mounted_topic_workspaces",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("topic_key", sa.VARCHAR(length=128), nullable=False),
sa.Column("source_system", sa.VARCHAR(length=32), nullable=False),
sa.Column("channel_ref", sa.VARCHAR(length=128), nullable=True),
sa.Column("thread_key", sa.VARCHAR(length=255), nullable=True),
sa.Column("root_message_id", sa.VARCHAR(length=128), nullable=True),
sa.Column("mode", sa.VARCHAR(length=24), nullable=False, server_default="chat"),
sa.Column("status", sa.VARCHAR(length=24), nullable=False, server_default="active"),
sa.Column("facts_json", _json_type(bind), nullable=True),
sa.Column("goals_json", _json_type(bind), nullable=True),
sa.Column("constraints_json", _json_type(bind), nullable=True),
sa.Column("options_json", _json_type(bind), nullable=True),
sa.Column("open_questions", _json_type(bind), nullable=True),
sa.Column("pending_actions", _json_type(bind), nullable=True),
sa.Column("bound_entities", _json_type(bind), nullable=True),
sa.Column("readiness_score", sa.Float(), nullable=True, server_default="0"),
sa.Column("last_message_id", sa.VARCHAR(length=128), nullable=True),
sa.Column("last_triggered_at", sa.TIMESTAMP(), nullable=True),
sa.Column("metadata_json", _json_type(bind), nullable=True),
*_base_columns(),
sa.UniqueConstraint(
"tenant_id",
"source_system",
"topic_key",
name="uq_mounted_topic_workspace_source_topic",
),
)
for index_name, columns in (
("ix_mounted_topic_workspaces_tenant_id", ["tenant_id"]),
("ix_mounted_topic_workspaces_topic_key", ["topic_key"]),
("ix_mounted_topic_workspaces_source_system", ["source_system"]),
("ix_mounted_topic_workspaces_channel_ref", ["channel_ref"]),
("ix_mounted_topic_workspaces_thread_key", ["thread_key"]),
("ix_mounted_topic_workspaces_root_message_id", ["root_message_id"]),
("ix_mounted_topic_workspaces_mode", ["mode"]),
("ix_mounted_topic_workspaces_status", ["status"]),
("ix_mounted_topic_workspaces_last_message_id", ["last_message_id"]),
):
_create_index_if_missing(bind, "mounted_topic_workspaces", index_name, columns)
def _create_mounted_draft_workspaces(bind) -> None:
if _table_exists(bind, "mounted_draft_workspaces"):
return
op.create_table(
"mounted_draft_workspaces",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column(
"topic_workspace_id",
sa.Integer(),
sa.ForeignKey("mounted_topic_workspaces.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("state", sa.VARCHAR(length=24), nullable=False, server_default="collecting"),
sa.Column("draft_version", sa.Integer(), nullable=False, server_default="0"),
sa.Column("requirement_card", _json_type(bind), nullable=True),
sa.Column("draft_payload", _json_type(bind), nullable=True),
sa.Column("required_confirmations", _json_type(bind), nullable=True),
sa.Column("last_ready_at", sa.TIMESTAMP(), nullable=True),
sa.Column("confirm_message_id", sa.VARCHAR(length=128), nullable=True),
*_base_columns(),
sa.UniqueConstraint(
"tenant_id",
"topic_workspace_id",
name="uq_mounted_draft_workspace_topic",
),
)
for index_name, columns in (
("ix_mounted_draft_workspaces_tenant_id", ["tenant_id"]),
("ix_mounted_draft_workspaces_topic_workspace_id", ["topic_workspace_id"]),
("ix_mounted_draft_workspaces_state", ["state"]),
("ix_mounted_draft_workspaces_confirm_message_id", ["confirm_message_id"]),
):
_create_index_if_missing(bind, "mounted_draft_workspaces", index_name, columns)
def _create_mounted_conversation_runs(bind) -> None:
if _table_exists(bind, "mounted_conversation_runs"):
return
op.create_table(
"mounted_conversation_runs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("trace_id", sa.VARCHAR(length=64), nullable=False),
sa.Column(
"topic_workspace_id",
sa.Integer(),
sa.ForeignKey("mounted_topic_workspaces.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("source_system", sa.VARCHAR(length=32), nullable=False),
sa.Column("source_message_id", sa.VARCHAR(length=128), nullable=True),
sa.Column("mode_before", sa.VARCHAR(length=24), nullable=True),
sa.Column("mode_after", sa.VARCHAR(length=24), nullable=True),
sa.Column("active_agent", sa.VARCHAR(length=64), nullable=True),
sa.Column("status", sa.VARCHAR(length=24), nullable=False, server_default="queued"),
sa.Column("trigger_reason", sa.VARCHAR(length=128), nullable=True),
sa.Column("context_layers_hit", _json_type(bind), nullable=True),
sa.Column("tool_calls", _json_type(bind), nullable=True),
sa.Column("final_reply", sa.Text(), nullable=True),
sa.Column("execution_outcome", _json_type(bind), nullable=True),
sa.Column("error_text", sa.Text(), nullable=True),
sa.Column("started_at", sa.TIMESTAMP(), nullable=True),
sa.Column("completed_at", sa.TIMESTAMP(), nullable=True),
*_base_columns(),
sa.UniqueConstraint("tenant_id", "trace_id", name="uq_mounted_conversation_run_trace"),
)
for index_name, columns in (
("ix_mounted_conversation_runs_tenant_id", ["tenant_id"]),
("ix_mounted_conversation_runs_trace_id", ["trace_id"]),
("ix_mounted_conversation_runs_topic_workspace_id", ["topic_workspace_id"]),
("ix_mounted_conversation_runs_source_system", ["source_system"]),
("ix_mounted_conversation_runs_source_message_id", ["source_message_id"]),
("ix_mounted_conversation_runs_mode_before", ["mode_before"]),
("ix_mounted_conversation_runs_mode_after", ["mode_after"]),
("ix_mounted_conversation_runs_active_agent", ["active_agent"]),
("ix_mounted_conversation_runs_status", ["status"]),
("ix_mounted_conversation_runs_trigger_reason", ["trigger_reason"]),
):
_create_index_if_missing(bind, "mounted_conversation_runs", index_name, columns)
def _create_mounted_agent_traces(bind) -> None:
if _table_exists(bind, "mounted_agent_traces"):
return
op.create_table(
"mounted_agent_traces",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column(
"conversation_run_id",
sa.Integer(),
sa.ForeignKey("mounted_conversation_runs.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("agent_name", sa.VARCHAR(length=64), nullable=False),
sa.Column("status", sa.VARCHAR(length=24), nullable=False, server_default="completed"),
sa.Column("latency_ms", sa.Integer(), nullable=True),
sa.Column("input_summary", _json_type(bind), nullable=True),
sa.Column("output_summary", _json_type(bind), nullable=True),
sa.Column("tool_trace", _json_type(bind), nullable=True),
sa.Column("failure_reason", sa.Text(), nullable=True),
*_base_columns(),
)
for index_name, columns in (
("ix_mounted_agent_traces_tenant_id", ["tenant_id"]),
("ix_mounted_agent_traces_conversation_run_id", ["conversation_run_id"]),
("ix_mounted_agent_traces_agent_name", ["agent_name"]),
("ix_mounted_agent_traces_status", ["status"]),
):
_create_index_if_missing(bind, "mounted_agent_traces", index_name, columns)
def _create_mounted_source_activations(bind) -> None:
if _table_exists(bind, "mounted_source_activations"):
return
op.create_table(
"mounted_source_activations",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("source_system", sa.VARCHAR(length=32), nullable=False),
sa.Column("scope_type", sa.VARCHAR(length=24), nullable=False, server_default="tenant"),
sa.Column("scope_ref", sa.VARCHAR(length=128), nullable=False),
sa.Column("status", sa.VARCHAR(length=24), nullable=False, server_default="active"),
sa.Column("cutover_mode", sa.VARCHAR(length=24), nullable=False, server_default="shadow"),
sa.Column("auto_reply_enabled", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("metadata_json", _json_type(bind), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("last_seen_at", sa.TIMESTAMP(), nullable=True),
*_base_columns(),
sa.UniqueConstraint(
"tenant_id",
"source_system",
"scope_type",
"scope_ref",
name="uq_mounted_source_activation_scope",
),
)
for index_name, columns in (
("ix_mounted_source_activations_tenant_id", ["tenant_id"]),
("ix_mounted_source_activations_source_system", ["source_system"]),
("ix_mounted_source_activations_scope_type", ["scope_type"]),
("ix_mounted_source_activations_scope_ref", ["scope_ref"]),
("ix_mounted_source_activations_status", ["status"]),
("ix_mounted_source_activations_cutover_mode", ["cutover_mode"]),
):
_create_index_if_missing(bind, "mounted_source_activations", index_name, columns)
def _create_mounted_tool_capability_policies(bind) -> None:
if _table_exists(bind, "mounted_tool_capability_policies"):
return
op.create_table(
"mounted_tool_capability_policies",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tool_name", sa.VARCHAR(length=128), nullable=False),
sa.Column("risk_level", sa.VARCHAR(length=24), nullable=False, server_default="medium"),
sa.Column("confirm_required", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("auto_execute_allowed", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("source_scope", _json_type(bind), nullable=True),
sa.Column("tenant_scope", _json_type(bind), nullable=True),
sa.Column("policy_metadata", _json_type(bind), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
*_base_columns(),
sa.UniqueConstraint(
"tenant_id",
"tool_name",
name="uq_mounted_tool_capability_policy_tool",
),
)
for index_name, columns in (
("ix_mounted_tool_capability_policies_tenant_id", ["tenant_id"]),
("ix_mounted_tool_capability_policies_tool_name", ["tool_name"]),
("ix_mounted_tool_capability_policies_risk_level", ["risk_level"]),
("ix_mounted_tool_capability_policies_is_active", ["is_active"]),
):
_create_index_if_missing(bind, "mounted_tool_capability_policies", index_name, columns)
def upgrade() -> None:
bind = op.get_bind()
_create_mounted_topic_workspaces(bind)
_create_mounted_draft_workspaces(bind)
_create_mounted_conversation_runs(bind)
_create_mounted_agent_traces(bind)
_create_mounted_source_activations(bind)
_create_mounted_tool_capability_policies(bind)
def downgrade() -> None:
bind = op.get_bind()
for table_name in (
"mounted_tool_capability_policies",
"mounted_source_activations",
"mounted_agent_traces",
"mounted_conversation_runs",
"mounted_draft_workspaces",
"mounted_topic_workspaces",
):
if _table_exists(bind, table_name):
op.drop_table(table_name)

View File

@ -0,0 +1,84 @@
"""add mounted runtime plan state fields
Revision ID: 20260405_add_mounted_runtime_plan_state_fields
Revises: 20260405_add_mounted_ai_runtime_tables
Create Date: 2026-04-05
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260405_add_mounted_runtime_plan_state_fields"
down_revision = "20260405_add_mounted_ai_runtime_tables"
branch_labels = None
depends_on = None
def _column_exists(bind, table_name: str, column_name: str) -> bool:
inspector = sa.inspect(bind)
try:
columns = inspector.get_columns(table_name)
except Exception:
return False
return any(str(column.get("name")) == column_name for column in columns)
def _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
try:
indexes = inspector.get_indexes(table_name)
except Exception:
return False
return any(str(index.get("name")) == index_name for index in indexes)
def upgrade() -> None:
bind = op.get_bind()
if not _column_exists(bind, "mounted_topic_workspaces", "conversation_scope_key"):
op.add_column(
"mounted_topic_workspaces",
sa.Column("conversation_scope_key", sa.VARCHAR(length=160), nullable=True),
)
if not _column_exists(bind, "mounted_topic_workspaces", "plan_status"):
op.add_column(
"mounted_topic_workspaces",
sa.Column("plan_status", sa.VARCHAR(length=32), nullable=False, server_default="idle"),
)
if not _index_exists(bind, "mounted_topic_workspaces", "ix_mounted_topic_workspaces_conversation_scope_key"):
op.create_index(
"ix_mounted_topic_workspaces_conversation_scope_key",
"mounted_topic_workspaces",
["conversation_scope_key"],
)
if not _index_exists(bind, "mounted_topic_workspaces", "ix_mounted_topic_workspaces_plan_status"):
op.create_index(
"ix_mounted_topic_workspaces_plan_status",
"mounted_topic_workspaces",
["plan_status"],
)
op.execute(
sa.text(
"""
UPDATE mounted_topic_workspaces
SET plan_status = COALESCE(NULLIF(plan_status, ''), 'idle')
"""
)
)
def downgrade() -> None:
bind = op.get_bind()
if _index_exists(bind, "mounted_topic_workspaces", "ix_mounted_topic_workspaces_plan_status"):
op.drop_index("ix_mounted_topic_workspaces_plan_status", table_name="mounted_topic_workspaces")
if _index_exists(bind, "mounted_topic_workspaces", "ix_mounted_topic_workspaces_conversation_scope_key"):
op.drop_index("ix_mounted_topic_workspaces_conversation_scope_key", table_name="mounted_topic_workspaces")
if _column_exists(bind, "mounted_topic_workspaces", "plan_status"):
op.drop_column("mounted_topic_workspaces", "plan_status")
if _column_exists(bind, "mounted_topic_workspaces", "conversation_scope_key"):
op.drop_column("mounted_topic_workspaces", "conversation_scope_key")

View File

@ -0,0 +1,116 @@
"""add mounted runtime active draft fields
Revision ID: 20260406_add_mounted_runtime_active_draft_fields
Revises: 20260405_add_mounted_runtime_plan_state_fields
Create Date: 2026-04-06
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260406_add_mounted_runtime_active_draft_fields"
down_revision = "20260405_add_mounted_runtime_plan_state_fields"
branch_labels = None
depends_on = None
def _column_exists(bind, table_name: str, column_name: str) -> bool:
inspector = sa.inspect(bind)
try:
columns = inspector.get_columns(table_name)
except Exception:
return False
return any(str(column.get("name")) == column_name for column in columns)
def _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
try:
indexes = inspector.get_indexes(table_name)
except Exception:
return False
return any(str(index.get("name")) == index_name for index in indexes)
def upgrade() -> None:
bind = op.get_bind()
if not _column_exists(bind, "mounted_draft_workspaces", "active_work_item_id"):
op.add_column(
"mounted_draft_workspaces",
sa.Column("active_work_item_id", sa.BigInteger(), nullable=True),
)
if not _column_exists(bind, "mounted_draft_workspaces", "latest_source_message_id"):
op.add_column(
"mounted_draft_workspaces",
sa.Column("latest_source_message_id", sa.VARCHAR(length=128), nullable=True),
)
if not _column_exists(bind, "mounted_draft_workspaces", "latest_reply_message_id"):
op.add_column(
"mounted_draft_workspaces",
sa.Column("latest_reply_message_id", sa.VARCHAR(length=128), nullable=True),
)
if not _index_exists(bind, "mounted_draft_workspaces", "ix_mounted_draft_workspaces_active_work_item_id"):
op.create_index(
"ix_mounted_draft_workspaces_active_work_item_id",
"mounted_draft_workspaces",
["active_work_item_id"],
)
if not _index_exists(bind, "mounted_draft_workspaces", "ix_mounted_draft_workspaces_latest_source_message_id"):
op.create_index(
"ix_mounted_draft_workspaces_latest_source_message_id",
"mounted_draft_workspaces",
["latest_source_message_id"],
)
if not _index_exists(bind, "mounted_draft_workspaces", "ix_mounted_draft_workspaces_latest_reply_message_id"):
op.create_index(
"ix_mounted_draft_workspaces_latest_reply_message_id",
"mounted_draft_workspaces",
["latest_reply_message_id"],
)
op.execute(
sa.text(
"""
UPDATE mounted_draft_workspaces AS draft
SET latest_source_message_id = COALESCE(
NULLIF(draft.latest_source_message_id, ''),
(
SELECT topic.last_message_id
FROM mounted_topic_workspaces AS topic
WHERE topic.id = draft.topic_workspace_id
)
)
WHERE COALESCE(NULLIF(draft.latest_source_message_id, ''), '') = ''
"""
)
)
def downgrade() -> None:
bind = op.get_bind()
if _index_exists(bind, "mounted_draft_workspaces", "ix_mounted_draft_workspaces_latest_reply_message_id"):
op.drop_index(
"ix_mounted_draft_workspaces_latest_reply_message_id",
table_name="mounted_draft_workspaces",
)
if _index_exists(bind, "mounted_draft_workspaces", "ix_mounted_draft_workspaces_latest_source_message_id"):
op.drop_index(
"ix_mounted_draft_workspaces_latest_source_message_id",
table_name="mounted_draft_workspaces",
)
if _index_exists(bind, "mounted_draft_workspaces", "ix_mounted_draft_workspaces_active_work_item_id"):
op.drop_index(
"ix_mounted_draft_workspaces_active_work_item_id",
table_name="mounted_draft_workspaces",
)
if _column_exists(bind, "mounted_draft_workspaces", "latest_reply_message_id"):
op.drop_column("mounted_draft_workspaces", "latest_reply_message_id")
if _column_exists(bind, "mounted_draft_workspaces", "latest_source_message_id"):
op.drop_column("mounted_draft_workspaces", "latest_source_message_id")
if _column_exists(bind, "mounted_draft_workspaces", "active_work_item_id"):
op.drop_column("mounted_draft_workspaces", "active_work_item_id")

View File

@ -0,0 +1,95 @@
"""rename control plane status column
Revision ID: 20260413_rename_control_plane_status
Revises: 20260406_add_mounted_runtime_active_draft_fields
Create Date: 2026-04-13
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260413_rename_control_plane_status"
down_revision = "20260406_add_mounted_runtime_active_draft_fields"
branch_labels = None
depends_on = None
TABLE_NAME = "ai_orchestration_runs"
NEW_COLUMN = "control_plane_status"
NEW_INDEX = "ix_ai_orchestration_runs_control_plane_status"
def _legacy_column() -> str:
return "".join(("open", "claw", "_status"))
def _legacy_index() -> str:
return f"ix_ai_orchestration_runs_{_legacy_column()}"
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _has_column(bind, table_name: str, column_name: str) -> bool:
inspector = sa.inspect(bind)
return any(str(col.get("name") or "") == column_name for col in inspector.get_columns(table_name))
def _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
return any(str(idx.get("name") or "") == index_name for idx in inspector.get_indexes(table_name))
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, TABLE_NAME):
return
legacy_column = _legacy_column()
legacy_index = _legacy_index()
has_legacy = _has_column(bind, TABLE_NAME, legacy_column)
has_new = _has_column(bind, TABLE_NAME, NEW_COLUMN)
if has_legacy and not has_new:
op.alter_column(
TABLE_NAME,
legacy_column,
new_column_name=NEW_COLUMN,
existing_type=sa.String(length=20),
existing_nullable=True,
)
if _index_exists(bind, TABLE_NAME, legacy_index):
op.drop_index(legacy_index, table_name=TABLE_NAME)
if _has_column(bind, TABLE_NAME, NEW_COLUMN) and not _index_exists(bind, TABLE_NAME, NEW_INDEX):
op.create_index(NEW_INDEX, TABLE_NAME, [NEW_COLUMN], unique=False)
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, TABLE_NAME):
return
legacy_column = _legacy_column()
legacy_index = _legacy_index()
has_legacy = _has_column(bind, TABLE_NAME, legacy_column)
has_new = _has_column(bind, TABLE_NAME, NEW_COLUMN)
if _index_exists(bind, TABLE_NAME, NEW_INDEX):
op.drop_index(NEW_INDEX, table_name=TABLE_NAME)
if has_new and not has_legacy:
op.alter_column(
TABLE_NAME,
NEW_COLUMN,
new_column_name=legacy_column,
existing_type=sa.String(length=20),
existing_nullable=True,
)
if _has_column(bind, TABLE_NAME, legacy_column) and not _index_exists(bind, TABLE_NAME, legacy_index):
op.create_index(legacy_index, TABLE_NAME, [legacy_column], unique=False)

View File

@ -0,0 +1,98 @@
"""add query route memories
Revision ID: 20260415_add_query_route_memories
Revises: 20260413_rename_control_plane_status
Create Date: 2026-04-15
"""
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 = "20260415_add_query_route_memories"
down_revision = "20260413_rename_control_plane_status"
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, "query_route_memories"):
return
op.create_table(
"query_route_memories",
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("qa_record_id", sa.Integer(), sa.ForeignKey("qa_records.id", ondelete="CASCADE"), nullable=False),
sa.Column("project_id", sa.BigInteger(), nullable=True),
sa.Column("question_text", sa.Text(), nullable=False),
sa.Column("route_mode_predicted", sa.String(length=32), nullable=False),
sa.Column("route_mode_effective", sa.String(length=32), nullable=False),
sa.Column("decision_origin", sa.String(length=64), nullable=True),
sa.Column("reply_contract", _json_type(bind), nullable=True),
sa.Column("probe_snapshot", _json_type(bind), nullable=True),
sa.Column("query_profile", _json_type(bind), nullable=True),
sa.Column("feedback_outcome", _json_type(bind), nullable=True),
sa.Column("question_embedding", _json_type(bind), nullable=True),
sa.UniqueConstraint(
"tenant_id",
"qa_record_id",
name="uq_query_route_memories_tenant_qa_record",
),
)
op.create_index("ix_query_route_memories_tenant_id", "query_route_memories", ["tenant_id"], unique=False)
op.create_index("ix_query_route_memories_qa_record_id", "query_route_memories", ["qa_record_id"], unique=False)
op.create_index("ix_query_route_memories_project_id", "query_route_memories", ["project_id"], unique=False)
op.create_index(
"ix_query_route_memories_route_mode_predicted",
"query_route_memories",
["route_mode_predicted"],
unique=False,
)
op.create_index(
"ix_query_route_memories_route_mode_effective",
"query_route_memories",
["route_mode_effective"],
unique=False,
)
op.create_index(
"ix_query_route_memories_decision_origin",
"query_route_memories",
["decision_origin"],
unique=False,
)
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "query_route_memories"):
return
for index_name in (
"ix_query_route_memories_decision_origin",
"ix_query_route_memories_route_mode_effective",
"ix_query_route_memories_route_mode_predicted",
"ix_query_route_memories_project_id",
"ix_query_route_memories_qa_record_id",
"ix_query_route_memories_tenant_id",
):
op.drop_index(index_name, table_name="query_route_memories")
op.drop_table("query_route_memories")

View File

@ -0,0 +1,138 @@
"""add semantic facts
Revision ID: 20260415_add_semantic_facts
Revises: 20260415_add_query_route_memories
Create Date: 2026-04-15
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260415_add_semantic_facts"
down_revision = "20260415_add_query_route_memories"
branch_labels = None
depends_on = None
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, "semantic_facts"):
op.create_table(
"semantic_facts",
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("entity_type", sa.String(length=32), nullable=False),
sa.Column("entity_id", sa.BigInteger(), nullable=False),
sa.Column("fact_namespace", sa.String(length=64), nullable=False),
sa.Column("fact_key", sa.String(length=64), nullable=False),
sa.Column("fact_value_text", sa.Text(), nullable=False),
sa.Column("fact_value_norm", sa.String(length=255), nullable=False),
sa.Column("value_type", sa.String(length=32), nullable=False),
sa.Column("confidence", sa.Float(), nullable=False, server_default="0"),
sa.Column("evidence_source_type", sa.String(length=32), nullable=True),
sa.Column("evidence_source_id", sa.BigInteger(), nullable=True),
sa.Column("evidence_excerpt", sa.Text(), nullable=True),
sa.Column("extract_version", sa.String(length=64), nullable=True),
sa.Column("freshness_at", sa.String(length=64), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.UniqueConstraint(
"tenant_id",
"entity_type",
"entity_id",
"fact_key",
"fact_value_norm",
name="uq_semantic_facts_tenant_entity_key_norm",
),
)
op.create_index("ix_semantic_facts_tenant_id", "semantic_facts", ["tenant_id"], unique=False)
op.create_index("ix_semantic_facts_entity_type", "semantic_facts", ["entity_type"], unique=False)
op.create_index("ix_semantic_facts_entity_id", "semantic_facts", ["entity_id"], unique=False)
op.create_index("ix_semantic_facts_fact_namespace", "semantic_facts", ["fact_namespace"], unique=False)
op.create_index("ix_semantic_facts_fact_key", "semantic_facts", ["fact_key"], unique=False)
op.create_index("ix_semantic_facts_fact_value_norm", "semantic_facts", ["fact_value_norm"], unique=False)
op.create_index("ix_semantic_facts_is_active", "semantic_facts", ["is_active"], unique=False)
if not _table_exists(bind, "semantic_fact_extraction_runs"):
op.create_table(
"semantic_fact_extraction_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("entity_type", sa.String(length=32), nullable=False),
sa.Column("entity_id", sa.BigInteger(), nullable=False),
sa.Column("trigger_type", sa.String(length=32), nullable=False),
sa.Column("status", sa.String(length=32), nullable=False),
sa.Column("input_fingerprint", sa.String(length=64), nullable=True),
sa.Column("facts_written", sa.Integer(), nullable=False, server_default="0"),
sa.Column("failure_reason", sa.Text(), nullable=True),
sa.Column("extract_version", sa.String(length=64), nullable=True),
sa.Column("started_at", sa.String(length=64), nullable=True),
sa.Column("finished_at", sa.String(length=64), nullable=True),
)
op.create_index(
"ix_semantic_fact_extraction_runs_tenant_id",
"semantic_fact_extraction_runs",
["tenant_id"],
unique=False,
)
op.create_index(
"ix_semantic_fact_extraction_runs_entity_type",
"semantic_fact_extraction_runs",
["entity_type"],
unique=False,
)
op.create_index(
"ix_semantic_fact_extraction_runs_entity_id",
"semantic_fact_extraction_runs",
["entity_id"],
unique=False,
)
op.create_index(
"ix_semantic_fact_extraction_runs_status",
"semantic_fact_extraction_runs",
["status"],
unique=False,
)
op.create_index(
"ix_semantic_fact_extraction_runs_input_fingerprint",
"semantic_fact_extraction_runs",
["input_fingerprint"],
unique=False,
)
def downgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "semantic_fact_extraction_runs"):
for index_name in (
"ix_semantic_fact_extraction_runs_input_fingerprint",
"ix_semantic_fact_extraction_runs_status",
"ix_semantic_fact_extraction_runs_entity_id",
"ix_semantic_fact_extraction_runs_entity_type",
"ix_semantic_fact_extraction_runs_tenant_id",
):
op.drop_index(index_name, table_name="semantic_fact_extraction_runs")
op.drop_table("semantic_fact_extraction_runs")
if _table_exists(bind, "semantic_facts"):
for index_name in (
"ix_semantic_facts_is_active",
"ix_semantic_facts_fact_value_norm",
"ix_semantic_facts_fact_key",
"ix_semantic_facts_fact_namespace",
"ix_semantic_facts_entity_id",
"ix_semantic_facts_entity_type",
"ix_semantic_facts_tenant_id",
):
op.drop_index(index_name, table_name="semantic_facts")
op.drop_table("semantic_facts")

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)

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