chore: reset repository history

This commit is contained in:
Yuuuu 2026-04-14 10:54:06 +08:00
commit e42652b619
1348 changed files with 231190 additions and 0 deletions

45
.env.example Normal file
View File

@ -0,0 +1,45 @@
# 唯一配置文件,backend/.env 已移除
# 所有环境变量在此统一管理
# Docker Compose Runtime Secrets / Config
POSTGRES_DB=pmflow
POSTGRES_USER=pmflow
POSTGRES_PASSWORD=pmflow_pass
REDIS_PASSWORD=pmflow_redis_pass
DB_PORT=5435
REDIS_PORT=6379
BACKEND_PORT=8000
FRONTEND_PORT=5174
PMFLOW_EXPECTED_DB_DIALECT=postgresql
# Backend Runtime
CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174
LLM_PROVIDER=glm
LLM_FALLBACK_PROVIDER=glm
CONTROL_PLANE_ROUTING_ENABLED=true
CONTROL_PLANE_ROUTING_RULES_PATH=backend/.control_plane/pmflow-routing-rules.json
CONTROL_PLANE_AGENTS_DEFAULT_TIMEOUT_SECONDS=120
LLM_DIRECT_EMERGENCY_ENABLED=true
LLM_MIXED_ROUTING_ENABLED=false
LLM_ROUTING_DEFAULT_TIER=low
LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true
LLM_REQUEST_TIMEOUT=90
# 模型默认值统一由 backend/.control_plane/pmflow-routing-rules.json 管理,不再依赖 LLM_ROUTING_* / *_MODEL 环境变量
DASHSCOPE_API_KEY=
OPENAI_API_KEY=
DEEPSEEK_API_KEY=
OLLAMA_HOST=http://host.docker.internal:11434
OLLAMA_BASE_URL=http://host.docker.internal:11434
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_BASE_SECONDS=1
FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS=3

View File

@ -0,0 +1,82 @@
name: ai-steward-eval-gate
on:
pull_request:
paths:
- 'backend/app/services/adaptive_rag_service.py'
- 'backend/app/services/project_knowledge_graph.py'
- 'backend/app/api/rag.py'
- 'backend/app/api/knowledge_graph.py'
- 'backend/evals/**'
- 'scripts/ai_steward_eval.py'
- 'scripts/rag_followup_smoke.sh'
push:
branches: [ main, master ]
paths:
- 'backend/evals/**'
- 'scripts/ai_steward_eval.py'
- 'scripts/rag_followup_smoke.sh'
- '.github/workflows/ai-steward-eval-gate.yml'
jobs:
golden-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Lint golden dataset
run: |
python scripts/ai_steward_eval.py --lint-only --input backend/evals/golden_qa_v1.jsonl
eval-prereq:
runs-on: ubuntu-latest
needs: golden-lint
steps:
- name: Check gate configuration
run: |
if [ -z "${{ secrets.EVAL_API_TOKEN }}" ] || [ -z "${{ vars.EVAL_API_URL }}" ]; then
echo "::error::Missing EVAL_API_TOKEN secret or EVAL_API_URL variable. Eval gate must be configured."
exit 1
fi
eval-gate:
runs-on: ubuntu-latest
needs: eval-prereq
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install deps
run: |
python -m pip install --upgrade pip
python -m pip install httpx
- name: Run eval gate
env:
EVAL_API_TOKEN: ${{ secrets.EVAL_API_TOKEN }}
EVAL_API_URL: ${{ vars.EVAL_API_URL }}
run: |
python scripts/ai_steward_eval.py \
--input backend/evals/golden_qa_v1.jsonl \
--api-url "$EVAL_API_URL" \
--token "$EVAL_API_TOKEN" \
--output-dir backend/evals/reports \
--fail-on-threshold \
--threshold-accuracy 0.80 \
--threshold-hallucination 0.12 \
--threshold-followup 0.75 \
--threshold-query-type 0.80
- name: Run followup smoke gate
env:
EVAL_API_TOKEN: ${{ secrets.EVAL_API_TOKEN }}
EVAL_API_URL: ${{ vars.EVAL_API_URL }}
run: |
BASE_URL="${EVAL_API_URL%/rag/ask}"
SMOKE_BASE_URL="$BASE_URL" SMOKE_TOKEN="$EVAL_API_TOKEN" bash scripts/rag_followup_smoke.sh
- name: Upload eval report
uses: actions/upload-artifact@v4
with:
name: ai-steward-eval-report
path: backend/evals/reports/*.json

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

@ -0,0 +1,88 @@
name: feishu-replay-gate
on:
pull_request:
paths:
- "backend/app/services/feishu_integration.py"
- "backend/app/services/feishu_signal_pipeline.py"
- "backend/app/services/llm.py"
- "backend/app/services/signal_capture.py"
- "backend/app/models/feishu_item.py"
- "backend/app/models/feishu_reply_outbox.py"
- "backend/app/main.py"
- "backend/evals/feishu_replay_eval.py"
- "backend/evals/golden_feishu_replay_v1.jsonl"
- "backend/.openclaw/pmflow-routing-rules.json"
- "scripts/openclaw_routing_lint.py"
- ".github/workflows/feishu-replay-gate.yml"
push:
branches: [ main, master ]
paths:
- "backend/evals/feishu_replay_eval.py"
- "backend/evals/golden_feishu_replay_v1.jsonl"
- "backend/.openclaw/pmflow-routing-rules.json"
- "scripts/openclaw_routing_lint.py"
- ".github/workflows/feishu-replay-gate.yml"
jobs:
feishu-replay:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: pmflow
POSTGRES_PASSWORD: pmflow_pass
POSTGRES_DB: pmflow
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U pmflow -d pmflow"
--health-interval 5s
--health-timeout 5s
--health-retries 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install deps
run: |
python -m pip install --upgrade pip
python -m pip install -r backend/requirements.txt
- name: Lint replay dataset
env:
PYTHONPATH: backend
DATABASE_URL: postgresql+asyncpg://pmflow:pmflow_pass@127.0.0.1:5432/pmflow
PMFLOW_EXPECTED_DB_DIALECT: postgresql
run: |
python backend/evals/feishu_replay_eval.py \
--lint-only \
--input backend/evals/golden_feishu_replay_v1.jsonl
- name: Lint OpenClaw routing rules
run: |
python scripts/openclaw_routing_lint.py --input backend/.openclaw/pmflow-routing-rules.json
- name: Run replay eval
env:
PYTHONPATH: backend
DATABASE_URL: postgresql+asyncpg://pmflow:pmflow_pass@127.0.0.1:5432/pmflow
PMFLOW_EXPECTED_DB_DIALECT: postgresql
run: |
cd backend
alembic upgrade head
cd ..
python backend/evals/feishu_replay_eval.py \
--strict \
--tenant-id 9901 \
--input backend/evals/golden_feishu_replay_v1.jsonl \
--output-dir backend/evals/reports
- name: Upload replay report
uses: actions/upload-artifact@v4
with:
name: feishu-replay-report
path: backend/evals/reports/feishu_replay_*.json

73
.gitignore vendored Normal file
View File

@ -0,0 +1,73 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual Environments
venv/
env/
ENV/
env.bak/
venv.bak/
.venv/
# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.pnpm-debug.log*
# IDEs
.idea/
.vscode/
*.swp
*.swo
.DS_Store
.worktrees/
# Logs
logs/
*.log
# Databases
*.db
*.sqlite3
# Large Files & Binaries
.ollama/
ollama_bin/
Pixelle-Video/
*.dmg
*.zip
*.tar.gz
*.iso
# Local Env
.env
.env.*
!.env.example
# Playwright browser profiles
frontend/.playwright-profiles/

View File

@ -0,0 +1,4 @@
{
"version": 1,
"setupCompletedAt": "2026-03-26T16:00:06.817Z"
}

BIN
.tmp-feishu-escape.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

BIN
.tmp-feishu-menu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

BIN
.tmp-feishu-newtab.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
.tmp-feishu-replymode.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

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

209
AGENTS.md Normal file
View File

@ -0,0 +1,209 @@
# PMFlow 开发 Agent Prompt
你是一位资深的全栈工程师,精通 Python 3.11+、FastAPI、SQLAlchemy 2.0、LangChain、React 18、TypeScript、shadcn/ui、Tailwind CSS。
你正在为 **PMFlow** 开发代码:这是一款 AI 原生、人员能力驱动的项目管理操作系统,专为 10-50 人 AI 开发团队打造。
---
## USER.md 边界
- 本项目内用户真源以 `USER.md` 为准;若 `~/.codex/AGENTS.md` 与本项目 `USER.md` 冲突,以 `USER.md` 为准。
- `USER.md` 承载本项目内的用户协作偏好、判断优先级、已拍板决策、反复踩坑与暂不处理问题。
- 工程硬约束、系统边界、安全规则与验证要求仍以本文件和更外层提示为准。
- 执行时先满足硬约束,再在不违背硬约束的前提下尽量贴合 `USER.md`。
---
## 核心开发规范
1. **代码质量**:所有代码严格遵循 Python 与 TypeScript 最佳实践,代码清晰、注释完整、类型安全、可维护性强。
2. **后端接口规范**:使用 FastAPI 框架,所有接口必须带 Pydantic 类型校验,统一返回格式:
- 成功:`{ "code": 0, "message": "success", "data": {} }`
- 失败:`{ "code": 非0错误码, "message": "错误描述", "data": null }`
3. **数据库规范**:使用 SQLAlchemy 2.0,所有模型继承 Base 基类,必须包含 `tenant_id` 字段,所有查询必须自动过滤 `tenant_id`(使用依赖注入实现)。
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 调用必须:
- 记录日志到 `ai_call_logs` 表
- 实现 Redis 结果缓存
- 完善的错误重试机制
- 降低成本
6. **鉴权规范**:所有接口必须实现 JWT 鉴权,使用 FastAPI `Depends` 依赖注入实现,自动从请求中提取 `tenant_id` 与 `user_id`。
7. **工程规范**:
- 完善的错误处理
- 结构化日志(structlog)
- 从第一天就支持多租户、可扩展性
- 模块化设计
8. **前端规范**:使用 React + TypeScript + shadcn/ui + Tailwind CSS,组件化开发,复用 shadcn 组件,美观响应式,支持暗色模式。
9. **核心约束**:人员能力图谱是 AI 智能任务分配的核心,所有任务拆解、分配逻辑必须优先调用该模块数据。
---
## 项目结构
```
backend/ # Python FastAPI 后端
├── app/
│ ├── api/ # API 路由层,按模块分文件
│ ├── core/ # 核心配置、安全、日志、数据库连接
│ ├── models/ # SQLAlchemy ORM 模型
│ ├── schemas/ # Pydantic 请求/响应模型
│ ├── services/ # 业务逻辑层(含 ai_service.py、skill_service.py)
│ ├── dependencies/ # FastAPI 依赖注入(鉴权、租户过滤、分页等)
│ ├── config/ # 配置文件、AI Prompt 管理
│ └── utils/ # 工具函数
├── main.py # 项目入口
├── pyproject.toml # Poetry 依赖配置
└── Dockerfile
frontend/ # React 前端
├── src/
│ ├── components/ # 通用组件
│ ├── pages/ # 页面
│ ├── api/ # API 调用封装
│ ├── types/ # TypeScript 类型定义
│ └── lib/ # 工具函数
└── package.json
```
---
## 执行要求
根据用户的具体需求,生成高质量、可直接运行的代码。
- 计划类需求执行约定:若用户已确认计划/方案并要求开始实现,Agent 默认连续执行到“实现完成 + 验证完成 + 给出验收结果”后再统一回复;除非遇到明确阻塞、高风险冲突、context 限制或用户主动中断,否则不得中途停下来反复请示。
---
## 8. 服务边界约束与 AI 系统宪章(禁止越界)
- 数据库写操作必须通过 Alembic migration,禁止直接 `ALTER TABLE`
- 新增接口必须遵循响应包络:`{code, message, data}`,不得裸返回
- 禁止在 API 层写业务逻辑,业务逻辑归属 `services/`
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState`
- 用户可见核心业务内容零兜底:AI 失败、证据不足、结构化失败时必须显式失败或标记待确认,禁止用默认句、模板句、stub 冒充成功。
- 业务语义、问答/执行归因与前台动作拍板必须优先走“对象/上下文绑定 -> `SemanticContextPacket` -> `SemanticDecision` -> gatekeeper”主链,禁止在 `backend/app` 运行时代码里恢复 `looks_like_*`、关键词仓或散落正则拍板。
- `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`。选项协议回复必须复用单点结构化解析器,不得散落关键词/正则。
- Feishu 显式 `reply/thread` 到机器人消息时,必须先通过 `feishu_reply_outbox.sent_message_id -> source_message_id` 归一化 canonical thread context,再进入后续主链。
- 外部 freshness 问答的 follow-up 若锚定上一轮“未接入实时外部数据/无法确认最新结论”的 QA 回复,默认继续留在 `query.answer` 聊天链并先尝试实时外部检索;只有用户显式点明系统/项目/配置等内部对象并提出接入动作时,才允许落入内部接入/入库流程。
- `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_REQUEST_TIMEOUT` 环境变量为准
- 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]`,不要截断输出
## 安全约束(必须严格遵守)
### 🚫 绝对禁止的操作:
- 禁止未确认的破坏性删除;只有在用户明确要求删除死代码/死文件、已先列出目标路径并说明后果、且用户再次确认后,才允许执行文件删除
- 禁止执行带 -rf、--force、-y 参数的命令
- 禁止 DROP TABLE、TRUNCATE 等数据库破坏性操作
- 禁止修改 /etc、/sys、/boot 等系统目录
### ✅ 文件操作前必须:
1. 仅在破坏性删除、不可逆改名或其它高风险文件操作前,先列出将要操作的文件路径
2. 明确告知用户操作后果
3. 等待用户输入"确认"后才能执行

217
CLAUDE.md Normal file
View File

@ -0,0 +1,217 @@
# PMFlow 项目结构说明(CLAUDE.md)
## 1. Agent 入口文件(你关心的“进项目先读什么”)
- `AGENTS.md`:Codex/Coding Agent 的主规则入口(优先级最高的项目级约束)
- `CLAUDE.md`:面向 Claude 风格的项目说明文档(补充背景)
- 建议:规则放 `AGENTS.md`,架构/目录说明放 `CLAUDE.md`
- 当前 AI 行为的系统宪章已在 `AGENTS.md` 中升级为“事实层 / 评审层 / 生成层 / 执行层”四层分治;所有新增 AI 业务默认按该宪章接入。`CLAUDE.md` 当前承载的是“架构说明 + 运行边界 + 目录库存”,不再假定自己只包含背景说明
- 新增执行约定:计划类需求一旦确认进入实现阶段,默认连续执行到“实现完成 + 验证完成 + 统一回复验收结果”;仅在明确阻塞、高风险冲突、context 限制或用户主动中断时才中途打断。
- 新增 bugfix 执行约定:缺陷修复/回归修复允许跨多个直接相关文件端到端完成,不再按文件数硬性拆分;但必须同时满足“改动范围围绕同一根因”“不夹带无关重构”“最终附带编译/测试验证”。大功能开发或结构性重构仍优先按子任务拆分。
- 新增发布约定:凡后端代码或数据库迁移发生改动,完成编译/测试后必须继续执行当前环境对应的热部署/重启/重载步骤,确认服务已加载新版本;若当前机器不是部署环境、缺少权限或不具备热部署条件,必须在结果中明确说明未执行原因与阻塞点,禁止默认省略。
- 下面各节中大量 `新增/更新/移除` 清单用于保留“当前架构库存”和关键入口索引,不等同于运行时主规范;行为约束与禁止项仍以 `AGENTS.md` 为准。
## 2. 项目定位
- 项目名:`PMFlow / Neural Sync`
- 形态:AI 原生项目管理系统(多租户 + 角色权限 + SSTC 工作流)
- 技术栈:
- 后端:`FastAPI + SQLAlchemy + Alembic`
- 前端:`React + TypeScript + Vite + Tailwind + Radix + React Query`
## 3. 顶层目录(精简版)
```text
AIProjectManger/
├── AGENTS.md # Agent 执行规则(建议优先维护)
├── CLAUDE.md # 当前架构说明与运行边界
├── start.sh / stop.sh # 一键启动/停止入口
├── docker-compose*.yml # 容器编排
├── mdproject/ # 执行计划与任务清单(中文为主)
├── backend/ # FastAPI 后端
├── frontend/ # React 前端
├── scripts/ # 根目录运维脚本
└── docs/ # 额外文档
```
- 详细架构库存已迁到 [`docs/architecture/ARCHITECTURE_INVENTORY.md`](/Users/yuuuu/Documents/AIProjectManger/docs/architecture/ARCHITECTURE_INVENTORY.md)。
- `docs/architecture/` 当前包含:`MASTER_CONTRACT.md`、`RUNTIME_GUARDRAILS.md`、`CODE_REFERENCE_INDEX.md`、`API_CONTRACTS.md`、`DATAFLOW.md`、`TASK_BRIEF_TEMPLATE.md`、`ARCHITECTURE_INVENTORY.md`
- `docs/superpowers/plans/` 保存实现计划,`docs/task_briefs/` 保存任务包产物与验收截图。
## 4. 后端结构(`backend/`)
```text
backend/
├── app/
│ ├── api/ # 路由层(canonical 入口优先使用 *_api/ 子包)
│ ├── core/ # 配置、日志、启动与核心能力
│ ├── dependencies/ # 鉴权/租户等依赖注入
│ ├── middleware/ # 请求中间件
│ ├── models/ # SQLAlchemy 模型
│ ├── schemas/ # Pydantic 模型
│ ├── services/ # 业务服务(已目录化收敛)
│ ├── config/ # 配置与 prompt
│ └── utils/ # 通用工具
├── alembic/ # 数据库迁移真源
├── scripts/ # 后端运维/修复脚本(含历史 backfill / requirement-plan 修复)
├── tests/ # 后端回归测试
├── requirements.txt
└── Dockerfile
```
- 当前后端重点入口优先看:
- `backend/app/api/` 下的 `*_api/` 子包与聚合 router
- `backend/app/services/ai_pmo/`(AI PMO 主链、Mounted Runtime、semantic context)
- `backend/app/services/adaptive_rag_service/`(只读问答主链)
- `backend/app/services/llm_service/`(显式 `routing_meta` 协议)
- `backend/app/services/integrations/feishu_integration_service/`(飞书入站与卡片回调)
- `backend/app/services/signal/`(信号接入、编排、planner、repair)
- 详细目录迁移、兼容壳清理与新增模块清单见库存文档。
## 5. 前端结构(`frontend/`)
```text
frontend/
├── e2e/ # Playwright 真实浏览器 E2E 用例
├── src/
│ ├── api/ # 接口封装
│ ├── pages/ # 页面级组件(已目录化)
│ ├── components/ # 通用组件与 SSTC/Inbox 共享组件
│ ├── hooks/ # 自定义 hooks
│ ├── lib/ # 通用工具
│ ├── stores/ # 状态管理
│ ├── router/ # 路由
│ └── types/ # TS 类型定义
├── package.json
├── playwright.config.ts
└── vite.config.ts
```
- 当前前端重点入口优先看:
- `frontend/src/api/`(统一 API 封装)
- `frontend/src/pages/inbox/`(消息台)
- `frontend/src/pages/ai-steward/`(AI 管家)
- `frontend/src/pages/scheduler-center/`(Mounted AI Console)
- `frontend/src/components/sstc/`(SSTC 工作流组件)
- 页面目录化、Mounted AI Console、Inbox、AI Steward 与 Recipes/Projects/Users 等模块化拆分的详细库存见库存文档。
### 5.1 `frontend/src/components/sstc/` 目录说明(模块化拆分后)
- `SSTCWorkspace.tsx`:SSTC 工作区主容器(组装各阶段组件)
- `useSSTCWorkspaceOrchestration.ts`:工作区编排 Hook(状态聚合、请求编排、事件路由)
- `sstcUtils.ts`:SSTC 工具函数(数据转换、状态判断、通用 helper)
- `SSTCSignalStagePanel.tsx`:信号阶段面板
- `SSTCCharterStagePanel.tsx`:宪章阶段面板
- `SSTCTaskStagePanel.tsx`:任务阶段面板
- `SSTCExecutionBoardPanel.tsx`:执行看板阶段面板
- `SSTCReleaseStagePanel.tsx`:发布阶段面板
- `SSTCReviewStagePanel.tsx`:复盘阶段面板
- `SSTCTaskCreateDialog.tsx`:任务创建弹窗
- `SSTCReleaseCreateDialog.tsx`:发布创建弹窗
- `SSTCReviewCreateDialog.tsx`:复盘创建弹窗
- `AIVisibilityPanel.tsx`:AI 可视化信息面板
- `FeedbackSummaryCard.tsx`:反馈中心摘要卡片(任务阶段/执行看板复用)
- `SSTCGlobalHeader.tsx`:SSTC 全局头部
- `SSTCStageNav.tsx`:阶段导航
- `SSTCChainView.tsx`:链路视图
- `SSTCRiskFloatPanel.tsx`:风险浮层
- `SignalContentHoverCard.tsx`:信号内容悬浮卡片
- `charterModules.ts`:宪章模块配置与映射
### 5.2 修改前必须执行 `FIND_USAGE` 的 SSTC 组件清单
- `SSTCWorkspace.tsx`
- `useSSTCWorkspaceOrchestration.ts`
- `sstcUtils.ts`
- `SSTCCharterStagePanel.tsx`
- `SSTCSignalStagePanel.tsx`
- `SSTCTaskStagePanel.tsx`
- `SSTCExecutionBoardPanel.tsx`
- `SSTCReleaseStagePanel.tsx`
- `SSTCReviewStagePanel.tsx`
- `SSTCReleaseCreateDialog.tsx`
- `SSTCReviewCreateDialog.tsx`
- `SSTCTaskCreateDialog.tsx`
## 6. 运行命令(高频)
### 根目录
```bash
./start.sh dev # 推荐:docker-dev 热更新模式
./stop.sh dev
./devops/tunnel/start.sh # 公网穿透启动器(独立入口)
./scripts/start_model_probe.sh # 独立模型连通性检测(中文日志)
```
- 启动入口收敛:仅保留 2 个用户入口
- 正常启动器:`./start.sh`
- 公网穿透启动器:`./devops/tunnel/start.sh`
- 运行脚本、模型探针、契约校验与 E2E 工具清单已迁到 [`docs/architecture/ARCHITECTURE_INVENTORY.md`](/Users/yuuuu/Documents/AIProjectManger/docs/architecture/ARCHITECTURE_INVENTORY.md)。
- 当前新增的 requirement-plan 历史修复工具位于 `backend/scripts/repair_requirement_plan_history.py`,默认 dry-run,仅在显式 `--apply` 时写回 `mounted_draft_workspaces / mounted_topic_workspaces`。
### 前端
```bash
cd frontend
npm run dev
npm run build
npm run e2e:feishu
```
### 后端(本地直跑)
```bash
cd backend
pip install -r requirements.txt
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
## 7. 团队维护建议(简版)
- 规则变更:只改 `AGENTS.md`
- 结构变更:同步改 `CLAUDE.md` + `docs/architecture/ARCHITECTURE_INVENTORY.md`
- 运行边界 / 已知 runtime 假设变更:同步改 `AGENTS.md` + `docs/architecture/RUNTIME_GUARDRAILS.md`
- API/契约变更:后端 `schemas` + 前端 `types` 同步更新
## 8. 服务边界约束(禁止越界)
- 当前必须立即遵守的摘要规则以 `AGENTS.md` 第 8 节为准。
- 长运行边界、跨链路守门细节、失败语义和已知 runtime 假设已迁到 [`docs/architecture/RUNTIME_GUARDRAILS.md`](/Users/yuuuu/Documents/AIProjectManger/docs/architecture/RUNTIME_GUARDRAILS.md)。
- 当前最重要的摘要只保留:
- 写操作必须经 Alembic / 受控执行器 / outbox / 审计日志,禁止业务路径直写或同步直发。
- 用户可见核心业务内容禁止 fallback、stub、模板句冒充成功;失败必须显式失败或标记待确认。
- 运行时业务动作归因统一走“对象/上下文绑定 -> `SemanticContextPacket` -> `SemanticDecision` -> gatekeeper”。
- `backend/app` 运行时代码禁止恢复散落的中文关键词、正则、`looks_like_*`、`*_HINTS/*_KEYWORDS` 业务拍板逻辑。
- `llm_service` 只认显式 `routing_meta` 协议,不再靠函数名或 hint 推断 tier / contract / fallback。
- AI Steward、Requirement Plan、Signal Source、AdaptiveRAG 等核心链路的详细 guardrails 统一以 `RUNTIME_GUARDRAILS.md` 为准。
## 9. 新增代码的参考模式
- 详细的接口、页面与服务参考入口已迁到 [`docs/architecture/CODE_REFERENCE_INDEX.md`](/Users/yuuuu/Documents/AIProjectManger/docs/architecture/CODE_REFERENCE_INDEX.md)。
- 当前只保留高价值摘要:
- 新增后端接口:优先参考 `backend/app/api/sstc_api/` 的结构
- 新增前端 API 调用:优先参考 `frontend/src/api/sstc.ts`
- 新增数据库表:参考 `alembic/versions/` 最新 migration
- 新增飞书事件处理:参考 `backend/app/services/integrations/feishu_integration_service/`
- 更细的 AI Steward / RAG / AI PMO / Signal / SSTC / 前端页面入口索引统一以 `CODE_REFERENCE_INDEX.md` 为准
## 10. 幂等与事务约束
- 所有入站事件必须经过 `ingest_events` 幂等去重
- 飞书回复必须经过 `feishu_reply_outbox`,禁止同步直接发送
- 飞书回复发送幂等:`reply_hash = MD5(source_message_id + reply_text)`,同租户下 `status=sent` 的相同哈希不得重复发送
- 跨表写操作必须在同一个数据库事务内完成
## 11. 已知 BOUNDARY(不要幻构行为)
- 详细已知运行边界与环境假设统一维护在 [`docs/architecture/RUNTIME_GUARDRAILS.md`](/Users/yuuuu/Documents/AIProjectManger/docs/architecture/RUNTIME_GUARDRAILS.md)。
- 当前高价值摘要只保留:
- 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用。
- 真实飞书网页 E2E 依赖本机已登录的 Chrome/Feishu 会话,结果校验统一走数据库探针。
- 路由配置唯一真源固定为 `backend/.control_plane/pmflow-routing-rules.json`;请求超时统一以 `LLM_REQUEST_TIMEOUT` 为准。
- World Model 静态层已接入 Redis 缓存(`wm:static:{tenant_id}:{project_id}`,TTL 600s),写操作后主动 invalidate,动态层不缓存。
- `signal_sources` 仅作为 AI Steward 创建结果区的内联展示;项目看板与 SSTC 信号阶段统一只读正式 `Signal`。
## 12. 技术债务清单
- [x] `mixin_auto.py` `_safe_int` NameError:已通过显式导入 `_safe_int` + 回归测试锁定,避免后续共享导出调整导致运行时未定义
- [x] AI 入口主链已统一:飞书长连接(`capture_from_feishu`)、`/ai-pmo/process-text` 与 `/rag/ask` 已统一收敛到 `AIPMOKernelService.process_ingress` 主链;`/rag/ask` 通过 `rag_ask_contract.build_kernel_ingress_kwargs` 透传 RAG 上下文(`session_id/new_session/thinking_mode/action_mode/...`),query 执行后由 `build_rag_response_payload` 保持 HTTP 响应兼容,回归由 `test_rag_route_contract.py`、`test_ai_pmo_qa_http_mainline_e2e.py`、`test_ai_pmo_ingress_mainline.py` 锁定
- [x] 飞书入口层耦合已收敛:飞书真实入口已拆为三段职责链(预处理 `mixin_receive_prepare.py` / capture 执行 `mixin_receive_capture_flow.py` / 编排 `mixin_receive.py`);`mixin_receive.py` 不再混合传输解析、附件标准化、capture 异常兜底与收尾回执逻辑,分层边界通过 `test_feishu_receive_orchestration_contract.py` 锁定
- [x] `task.create` 已升级为一等 typed intent:`IntentResolver/RouteResolver` 新增 `task.create` 常量与 `task.create` 路由族;TypedExecutor 已接入任务落库执行(含 `project_id`/任务标题槽位守卫、对齐 Charter 校验、`persist_task` 写入);`WorkflowRegistry`、`ReplyRenderer`、`followup` 校验与草稿确认闭环已纳入 `task.create`;并补 `test_ai_pmo_task_intent_contract.py` 回归锁定
- [x] `task.create` 已接入消息级幂等账本:Feishu `source_message_id/feishu_message_id` 作为 `task.create` 账本 key,重复确认只回放同一结果,不再重复落库;回归由 `test_feishu_pipeline_queue_contract.py` 锁定
- [x] `signal_source.onboard` 已接入消息级幂等账本:TypedExecutor 以 `source_system + source_message_id` 作为 onboarding ledger key,重复提交只回放同一结果,不再重复跑信号源接入编排;回归由 `test_ai_pmo_signal_source_onboard_contract.py` 锁定
- [x] AdaptiveRAG 职责已完成首轮收敛:`answer_question` 入口已分层为 `prepare runtime` 与 `base mainline` 两段,写确认逻辑通过 `_should_trigger_write_ack_gate` 独立门控;基础问答主路径不再与写确认判断混写在同一函数体。分层契约由 `test_adaptive_rag_mainline_contract.py` 锁定(read 绕过 ack、write 命中 ack、feishu write 维持主链)
- [x] 执行层已形成统一执行账本与参数来源约束:ingress 执行路径统一通过 `KernelIngressStorageMixin` 生成 `execution_result.action_contract`(固定 `param_source=decision.execution_plan.actions+workflow_route+execution_result`)并写入 `operation_execution_ledger`(`applied/pending_manual/pending_confirmation/failed`);`mixin_auto.py`、`mixin_prepare_manual.py` 的 `operation_code` 日志来源已对齐 `action_contract`,回归由 `test_ai_pmo_execution_ledger_contract.py` 锁定
- [x] QA/RAG 路由契约双轨已收敛:`/ai-steward/rag/ask` 兼容入口已移除,问答统一收敛到 `POST /rag/ask`;参数构造与 tenant 解析收口在 `backend/app/api/rag_ask_contract.py`,前端 `FloatingQAChat` 与 Context Cache API 目录均已对齐,`backend/tests/test_rag_route_contract.py` 与 `backend/tests/test_context_cache_profile_catalog.py` 已锁定回归
- [x] AI 管家浮窗交互已与 Grounding Runtime 对齐:回答区已切到 markdown 渲染,顾问标签升级为显式交互按钮;`consultant_mode_preference` 只用于 `/rag/ask` 内部重答,`允许执行` 明确交接到 `/ai-pmo/process-text`,避免 query/execute 边界回退到旧的一锅煮
- [x] Retrieval source registry / metadata 已统一:AdaptiveRAG 检索链已建立统一 source registry / metadata normalizer,`citations[]/sources_used[]` 直接消费标准字段 `source_type/source_id/chunk_id/target_url/project_id/updated_at/permission_scope/source_priority`;`doc/url/feishu/pmflow/graph/db` 的来源元数据与排序键已收口,并由 `test_rag_route_contract.py`、`test_adaptive_rag_mainline_contract.py` 锁定
- [x] 语义治理 baseline 已清零:`backend/app` 运行时代码中的 `looks_like_* / *_HINTS / *_KEYWORDS` 残留已清空,主链统一改为对象/上下文绑定 + `SemanticContextPacket -> SemanticDecision -> gatekeeper`;`scripts/semantic_lint_baseline.txt` 已清空,并由 `scripts/semantic_lint.py` 与 `backend/tests/test_semantic_lint.py` 做仓库级守门
- [x] 结构化 runtime 收口回归已补齐:`backend/tests/test_runtime_structured_semantics_contract.py` 额外锁定 `backend/app` 运行时代码里 `_is_question_like / _is_project_lookup_question / _is_capability_question / classify_intent_complex` 等旧语义拍板残留不得回流;`backend/tests/test_feishu_pipeline_queue_contract.py` 同步覆盖 `followup` 线程锚点优先与 signal capture 不再依赖问句词法兜底
- [ ] 历史 Feishu 队列 / `feishu_items` 兼容链仍未完全退场:飞书真实入口、卡片动作、文本 queue followup,以及 `project_knowledge_graph` 的主增量/API 命名都已切到 `work_item` 主链;当前剩余债务主要是 `feishu_items` 存量读链路、历史 pending 事件兼容消费,以及 legacy `feishu_delta` 证据/回放兼容壳。后续需在历史存量清空后正式封存该兼容层
- [x] AdaptiveRAG 向量检索 legacy fallback 已移除:`AdaptiveRAGRetrievalVectorMixin._retrieve_vector_context()` 现在只支持 PostgreSQL/pgvector 主路径;非 PostgreSQL 或 PG 查询失败时直接返回空结果,不再回退 Python 侧 legacy 扫描,边界由 `test_adaptive_rag_mainline_contract.py` 锁定
- [x] 历史 Alembic 链幂等性缺陷已修复:`add_users_table_rbac.py` 的 PostgreSQL ENUM 创建已改为显式创建 + 列类型 `create_type=False` 复用,`userrole/userstatus` 不再在同一条 migration 内重复建型;回归由 `test_add_users_table_rbac_migration.py` 锁定
- [ ] 食谱图片上传仍是示例桩实现:`backend/app/services/recipe/service.py` 仍以模拟 URL 代替对象存储,`POST /recipes/upload-image` 也还停留在示例接口形态;若该模块继续保留,需要补齐真实对象存储接入、租户隔离与统一 `{code,message,data}` 响应包络,否则应明确标注为非核心示例模块
- [x] LLM 运行时仍未完全收敛:`ai_service_core._invoke_with_retry` 与 `stream_chat` 已统一走 `llm_service`;旧 `_get_llm` 路径已显式禁用,并补回归测试锁定
- [x] QA 主链自动化测试覆盖已补齐:`backend/tests/` 已覆盖问答/RAG 主路径与关键场景;新增 `test_ai_pmo_qa_http_mainline_e2e.py` 通过 HTTP 层锁定 `/rag/ask` 的 `qa_readonly/mixed` 契约以及 `/ai-pmo/process-text` 的 `draft_pending_confirm` 主链返回,配合 `test_ai_pmo_ingress_mainline.py`、`test_ai_pmo_task_intent_contract.py`、`test_ai_pmo_tactical_brain_contract.py` 形成稳定回归基线
- [x] 飞书交互闭环已补齐:执行场景回执在 `draft_pending_confirm/pending_manual` 下优先生成按钮卡片并经 outbox 异步发送;`card.action.trigger` 已覆盖 `confirm/revise/cancel/continue` 回调分发,分别复用 `DraftConfirmHandler.handle_api_decision` 与 AI PMO `process_ingress` 主链继续执行;回调 HTTP 现在会同步返回 toast 反馈,确保点击按钮后至少有可见结果;`backend/tests/test_feishu_card_action_contract.py` 已补卡片构造与 `continue` 回归契约

337
DEPLOY_TENCENT_CLOUD.md Normal file
View File

@ -0,0 +1,337 @@
# Neural Sync 腾讯云部署指南
## 服务器要求
- **系统**: Ubuntu 20.04+ / CentOS 8+
- **配置**: 2核4GB RAM 以上
- **需要**: Nginx, Node.js, Python 3.9+, PostgreSQL (可选), Ollama (可选)
---
## 一、环境准备
### 1.1 创建腾讯云轻量应用服务器
1. 登录 [腾讯云控制台](https://console.cloud.tencent.com/)
2. 购买轻量应用服务器 (Lighthouse)
3. 选择 Ubuntu 20.04 系统镜像
4. 配置防火墙规则开放端口: 80, 443, 8000
### 1.2 SSH 连接服务器
```bash
ssh root@你的服务器IP
```
---
## 二、一键部署脚本
### 2.1 方式一:从本地直接上传(推荐开发阶段)
```bash
# 在本地项目目录执行
scp -r /path/to/neural-sync root@你的服务器IP:/opt/
```
### 2.2 方式二:从 GitHub 拉取代码
```bash
# SSH 登录服务器后执行
cd /opt
git clone https://github.com/你的用户名/neural-sync.git
cd neural-sync
```
### 2.3 方式三:从 Gitee (码云) 拉取代码
```bash
# 如果 GitHub 访问较慢,可以使用 Gitee
cd /opt
git clone https://gitee.com/你的用户名/neural-sync.git
cd neural-sync
```
### 2.4 运行部署脚本
#### 2.2 运行部署脚本
```bash
chmod +x deploy.sh
# 方式1: 交互式部署(会提示选择代码来源)
./deploy.sh
# 方式2: 指定参数直接部署
# 参数1: 数据库密码 参数2: Git仓库地址
./deploy.sh "你的数据库密码" "https://github.com/xxx/neural-sync.git"
```
脚本会自动安装所有依赖并启动服务。
---
## 三、部署脚本详解
### 手动部署步骤 (如果自动脚本失败)
#### 1. 安装系统依赖
```bash
# 更新系统
apt update && apt upgrade -y
# 安装基础工具
apt install -y curl wget git nginx postgresql postgresql-contrib
# 安装 Python
apt install -y python3.9 python3-pip python3-venv
# 安装 Node.js
curl -fsSL https://deb.nodesource.com/setup_18.x | bash -
apt install -y nodejs
```
#### 2. 配置 PostgreSQL 数据库
```bash
# 启动 PostgreSQL
systemctl start postgresql
systemctl enable postgresql
# 创建数据库和用户
sudo -u postgres psql
CREATE DATABASE neural_sync;
CREATE USER neuraluser WITH PASSWORD 'your_secure_password';
GRANT ALL PRIVILEGES ON DATABASE neural_sync TO neuraluser;
\q
```
#### 3. 安装 Python 依赖
```bash
cd /opt/neural-sync/backend
# 创建虚拟环境
python3 -m venv venv
source venv/bin/activate
# 安装依赖
pip install -r requirements.txt
# 配置环境变量
cp /opt/neural-sync/.env.example /opt/neural-sync/.env
nano /opt/neural-sync/.env
```
编辑项目根目录 `.env` 文件:
```env
# 数据库 - PostgreSQL
DATABASE_URL=postgresql+asyncpg://neuraluser:your_secure_password@localhost:5432/neural_sync
# Ollama (如需本地AI功能)
OLLAMA_HOST=http://127.0.0.1:11434
# 业务上下文 (根据你的业务修改)
BUSINESS_CONTEXT=你的业务描述
```
#### 4. 运行数据库迁移
```bash
# 如果使用 PostgreSQL,需要先安装 pgvector
sudo -u postgres psql -d neural_sync -c "CREATE EXTENSION IF NOT EXISTS vector;"
# 运行迁移
alembic upgrade head
```
#### 5. 构建前端
```bash
cd /opt/neural-sync/frontend
# 安装依赖
npm install
# 构建生产版本
npm run build
```
#### 6. 配置 Nginx
创建 Nginx 配置文件:
```bash
nano /etc/nginx/sites-available/neural-sync
```
```nginx
server {
listen 80;
server_name 你的域名或IP;
# 前端静态文件
location / {
root /opt/neural-sync/frontend/dist;
index index.html;
try_files $uri $uri/ /index.html;
}
# 后端 API 代理
location /api {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_cache_bypass $http_upgrade;
}
# WebSocket 支持 (如需要)
location /ws {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
}
```
启用配置:
```bash
ln -s /etc/nginx/sites-available/neural-sync /etc/nginx/sites-enabled/
nginx -t
systemctl reload nginx
```
#### 7. 配置 Systemd 服务
创建后端服务文件:
```bash
nano /etc/systemd/system/neural-sync.service
```
```ini
[Unit]
Description=Neural Sync Backend
After=network.target postgresql.service
[Service]
Type=simple
User=root
WorkingDirectory=/opt/neural-sync/backend
Environment="PATH=/opt/neural-sync/backend/venv/bin"
EnvironmentFile=/opt/neural-sync/.env
ExecStart=/opt/neural-sync/backend/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
启动服务:
```bash
systemctl daemon-reload
systemctl enable neural-sync
systemctl start neural-sync
systemctl status neural-sync
```
---
## 四、验证部署
```bash
# 检查服务状态
systemctl status neural-sync
nginx -t
# 测试 API
curl http://127.0.0.1:8000/docs
# 访问前端
curl http://你的服务器IP
```
---
## 五、常用命令
```bash
# 重启后端
systemctl restart neural-sync
# 查看后端日志
journalctl -u neural-sync -f
# 重载 Nginx
nginx -s reload
# 停止所有服务
systemctl stop neural-sync
nginx stop
```
---
## 六、可选: 安装 Ollama (本地 AI)
```bash
# 安装 Ollama
curl -fsSL https://ollama.com/install.sh | sh
# 启动服务
ollama serve &
# 下载模型
ollama pull llama3
ollama pull nomic-embed-text
```
---
## 七、安全建议
1. 使用 HTTPS (配置 SSL 证书)
2. 修改默认数据库密码
3. 配置防火墙只开放 80/443 端口
4. 定期更新系统和依赖
---
## 八、更新部署
代码更新后,重新部署:
```bash
cd /opt/neural-sync
# 拉取最新代码
git pull
# 重新构建前端
cd frontend
npm install
npm run build
cd ..
# 重启后端服务
systemctl restart neural-sync
# 重载 Nginx
nginx -s reload
```
或使用一键更新脚本:
```bash
./update.sh
```

View File

@ -0,0 +1,120 @@
# Scheduler 执行清单 V1(前后端一体交付)
> 目标:交付“可见、可触发、可追踪”的调度中心,避免仅后端不可见改动。
> 原则:每步必须包含 Backend + Frontend + 可见验收。
---
## 0. 执行规则(强制)
- [ ] 每步包含 `Backend任务` + `Frontend任务` + `可见验收`。
- [ ] 每步完成即改 `[x]` 并补验收证据。
- [ ] 无页面可见结果,不进入下一步。
- [ ] 失败需记录回滚点与阻塞原因。
---
## 1. 范围与阶段
- [x] S0 稳定性前置(401 熔断、防轮询风暴)
- [x] S1 调度基础能力(Job Registry + JobRun 持久化 + 手动触发)
- [x] S2 调度中心页面(任务列表、触发、运行记录)
- [x] S3 认知链路接入(对齐评分任务接入调度)
- [x] S4 联调验收(失败重试、权限、可视化证据)
---
## 2. 分步清单
### [x] S1 调度基础能力
**Backend任务**
- 新增 `scheduler_center` 服务:注册任务、执行任务、记录 `job_runs`。
- 新增 `/scheduler` API:
- `GET /scheduler/jobs`
- `GET /scheduler/runs`
- `POST /scheduler/jobs/{job_name}/trigger`
**Frontend任务**
- 新增 `scheduler` API client,支持查询与触发。
**可见验收**
- 调用 `GET /scheduler/jobs` 可见可触发任务列表。
- 触发任务后 `GET /scheduler/runs` 出现新纪录(running/success/failed)。
**验收证据**
- API 已新增:`GET /scheduler/jobs`、`GET /scheduler/runs`、`POST /scheduler/jobs/{job_name}/trigger`
- 后端文件:`backend/app/services/scheduler_center.py`、`backend/app/api/scheduler.py`
- 路由接入:`backend/app/api/__init__.py`、`backend/app/main.py`
---
### [x] S2 调度中心页面
**Backend任务**
- 补充运行记录字段(耗时、错误信息、payload 摘要)并排序输出。
**Frontend任务**
- 新增 `SchedulerCenterPage`:
- 任务列表
- 手动触发按钮
- 最近运行记录
- 错误可读展示
- 路由接入,并在项目看板增加入口按钮。
**可见验收**
- 页面可手动触发任务并实时看到运行结果。
**验收证据**
- 页面已新增:`frontend/src/pages/SchedulerCenterPage.tsx`
- 前端 API 已新增:`frontend/src/api/scheduler.ts`
- 路由已接入:`/scheduler-center`(`frontend/src/router/index.tsx`)
- 项目看板入口已接入:`frontend/src/pages/ProjectBoardPage.tsx`
- 构建通过:`cd frontend && npm run build`
---
### [x] S3 认知链路接入(O4-4 首版)
**Backend任务**
- 将“项目认知评分重算”封装为调度任务 `alignment_rescore_project`。
- `ai_steward` 对应入口优先通过调度执行并写 `job_runs`。
**Frontend任务**
- 调度中心支持传入 `project_id` 触发重算任务。
**可见验收**
- 触发重算后,`job_runs` 成功记录可见;评分接口数据可更新。
**验收证据**
- 调度任务真接线:`backend/app/services/scheduler_center.py` -> `alignment_rescore_project`
- 评分内核抽离:`backend/app/services/alignment_orchestrator.py`
- `ai_steward` 入口改为优先通过调度执行并落 `job_runs`:`backend/app/api/ai_steward.py`
---
### [x] S4 联调与收尾
**Backend任务**
- 权限控制:仅 BOSS/ADMIN 可触发关键任务。
- 失败重试策略:同名任务支持手动重跑。
**Frontend任务**
- 失败任务标红 + 一键重试。
**可见验收**
- 失败任务可重试并形成新的运行记录链。
**验收证据**
- 关键任务触发权限:`backend/app/api/scheduler.py`(BOSS/ADMIN)与 `backend/app/api/ai_steward.py`(BOSS/ADMIN)
- 后端重试接口:`POST /scheduler/runs/{run_id}/retry`(BOSS/ADMIN),`scheduler_center.retry_run()`
- 前端失败一键重试:`schedulerApi.retryRun(runId)` + 失败任务红底/红边样式,`frontend/src/pages/SchedulerCenterPage.tsx`
---
## 3. DoD(完成定义)
- [x] 调度中心页面可访问、可触发、可追踪。
- [x] 关键调度任务有持久化记录(成功/失败可区分)。
- [x] O4-4 首版实现“可持续更新认知状态”的调度入口。
- [x] 前端构建通过:`cd frontend && npm run build`

View File

@ -0,0 +1,127 @@
# SSTC 看板执行清单 V3(前后端联动可见版)
更新时间:2026-02-25
适用范围:`/projects/:id/board` 的 SSTC 看板全链路改造(React + FastAPI)
## 当前基线(基于现有代码)
- [x] 已有最小链路:`信号 -> 宪章 -> 任务`(`frontend/src/components/sstc/SSTCChainView.tsx` + `backend/app/api/sstc.py`)。
- [x] 已有宪章详情页 + 任务创建:`frontend/src/pages/CharterDetailPage.tsx`。
- [ ] 缺口:`frontend/src/components/sstc/SSTCGlobalHeader.tsx`、`SSTCStageNav.tsx`、`SSTCWorkspace.tsx`、`SSTCRiskFloatPanel.tsx` 当前为空文件。
- [ ] 缺口:SSTC 仅有少量接口,缺少发布验证、复盘沉淀、链路健康聚合、阶段锁定校验。
- [ ] 缺口:缺少统一操作日志表(全链路追溯要求)。
- [ ] ⚠️ 待核实:`@dnd-kit/core`、`@tiptap/*` 不在 `frontend/package.json`,与方案约束不一致。
## 执行规则
- [ ] 每完成 1 步,将该步由 `[ ]` 改为 `[x]`。
- [ ] 所有接口统一返回:`{ code, message, data }`(仅 SSTC 新增/改造接口先落地)。
- [ ] 所有写操作记录:操作人、操作时间、变更摘要(写入 `operation_logs`)。
- [ ] 所有 AI 调用记录:输入/输出、token、成本(复用 `ai_call_logs`)。
## P0 基础可运行(先打通框架)
- [x] **P0-1 建立 SSTC 统一数据契约**
- 后端实现:新增 `backend/app/schemas/sstc.py`;标准化 stage/status 枚举、DTO、响应结构。
- 前端实现:更新 `frontend/src/api/sstc.ts` 类型定义,全部接口适配 `data` 字段。
- 可见验收:项目看板可正常加载,不再依赖“隐式字段”。
- [x] **P0-2 建立 SSTC 聚合服务层**
- 后端实现:新增 `backend/app/services/sstc_service.py`,聚合项目头部指标、阶段状态、风险摘要。
- 前端实现:新增 `getBoardOverview(projectId)` 调用。
- 可见验收:看板顶部能显示统一概览数据。
- [x] **P0-3 填充 4 个空组件骨架**
- 前端实现:补全 `SSTCGlobalHeader.tsx`、`SSTCStageNav.tsx`、`SSTCWorkspace.tsx`、`SSTCRiskFloatPanel.tsx` 基础布局。
- 后端实现:提供骨架所需最小接口(overview、stage health、risk summary)。
- 可见验收:`/projects/:id/board` 出现四段式布局,不再只有单卡片链路。
- [x] **P0-4 阶段锁定规则后端校验**
- 后端实现:在任务创建/编辑、发布创建、复盘归档接口中增加上游状态校验(禁止前端绕过)。
- 前端实现:灰态展示 + 按钮禁用 + 原因提示。
- 可见验收:宪章未对齐时无法创建/确认任务。
## P1 核心链路(第一优先级:导航 + 宪章 + 任务 + 执行)
- [x] **P1-1 模块1:项目全局信息栏**
- 后端实现:`GET /projects/{id}/sstc/overview`(基础信息、对齐度、进度、负载健康)。
- 前端实现:`SSTCGlobalHeader` 渲染 5 区块;recharts 展示对齐度/进度/负载。
- 可见验收:顶部 120px 信息栏完整显示指标和操作按钮。
- [x] **P1-2 模块2:六阶段链路导航栏**
- 后端实现:`GET /projects/{id}/sstc/stages` 返回 6 节点状态、风险点、可编辑性。
- 前端实现:`SSTCStageNav` 横向 6 节点 + 箭头 + Badge + 红点 + 选中高亮。
- 可见验收:点击节点切换工作区;未解锁节点只读。
- [x] **P1-3 模块3.2:需求宪章工作区**
- 后端实现:补齐宪章版本化、发起对齐、确认对齐、冲突查询与处理接口。
- 前端实现:宪章编辑器(先 Textarea,后续可替换 Tiptap)、对齐状态卡、冲突卡、模型绑定卡。
- 可见验收:可发起对齐;成员确认后状态从 `对齐中` 到 `已对齐`。
- [x] **P1-4 模块3.3:任务拆解工作区**
- 后端实现:AI 拆解任务接口、负责人推荐接口、负载优化接口、任务确认接口。
- 前端实现:任务树表格(父子折叠)、右侧负载监控与拆解设置卡。
- 可见验收:点击“AI 重新拆解任务”后生成任务,显示推荐负责人和匹配度。
- [x] **P1-5 模块3.4:执行落地工作区**
- 后端实现:任务状态迁移接口、风险预警聚合接口、阻塞处理接口。
- 前端实现:Kanban 5 泳道、任务卡片详情弹窗、右侧风险栏。
- 可见验收:拖拽任务卡更新状态并持久化。
- ⚠️ 待核实:需先安装 `@dnd-kit/core`(当前未安装)。
## P2 扩展链路(第二优先级:信号源 + 发布验证 + 复盘)
- [x] **P2-1 模块3.1:信号源工作区**
- 后端实现:信号列表筛选、归档、AI 影响评估、宪章关联接口。
- 前端实现:左侧信号表格 + 右侧统计/AI/集成卡片。
- 可见验收:可从信号直接跳转并创建宪章关联。
- [x] **P2-2 模块3.5:发布验证工作区**
- 后端实现:发布计划 CRUD、发布状态流转、验证清单生成与提交。
- 前端实现:发布计划表 + 验证标签页 + 右侧 AI 辅助栏。
- 可见验收:仅可选择“已完成任务”创建发布计划。
- [x] **P2-3 模块3.6:复盘沉淀工作区**
- 后端实现:复盘节点创建/版本/归档、改进项落地接口。
- 前端实现:6 模块复盘编辑区 + 右侧知识沉淀/改进项/AI 卡片。
- 可见验收:确认归档后链路锁定,进入只读状态。
## P3 全局能力(第三优先级:预警 + 权限 + 可追溯)
- [x] **P3-1 模块4:风险/认知冲突悬浮栏**
- 后端实现:统一预警接口(高/中风险 + 未解决冲突)。
- 前端实现:右下角悬浮窗(可最小化、角标、Tab 切换)。
- 可见验收:新增高风险时角标数量实时变化。
- [x] **P3-2 操作日志全链路追溯**
- 后端实现:新增 `operation_logs` 模型、迁移、写入中间件/服务封装。
- 前端实现:关键操作写 `x-request-id`,便于追踪。
- 可见验收:任一状态变更可查到操作人/时间/变更前后。
- [x] **P3-3 角色权限闭环**
- 后端实现:项目负责人/成员/管理员权限矩阵校验。
- 前端实现:按钮级权限渲染与禁用提示。
- 可见验收:成员无法执行负责人专属操作。
- [x] **P3-4 AI 调用成本可观测**
- 后端实现:SSTC AI 接口统一落 `ai_call_logs`,补齐 token/cost 字段。
- 前端实现:在 AI 操作结果区展示“本次 token 与成本”。
- 可见验收:每次 AI 操作后可在日志中检索到调用记录。
## 联调与验收清单(发布前必须全勾)
- [ ] E2E-1 项目进入看板后,四大区块均可见且无空白组件。
- [ ] E2E-2 六阶段状态与锁定规则生效(前后端双校验)。
- [ ] E2E-3 宪章未对齐无法进入任务确认与执行编辑。
- [ ] E2E-4 任务拖拽状态迁移成功,刷新后状态保持。
- [ ] E2E-5 发布必须绑定已完成任务,复盘必须在发布验证后。
- [ ] E2E-6 全部 SSTC 新接口返回统一结构 `{code,message,data}`。
- [ ] E2E-7 关键写操作可在 `operation_logs` 检索。
- [ ] E2E-8 AI 操作可在 `ai_call_logs` 检索到 token/cost。
## 实施顺序建议(最短路径看到效果)
- [ ] 第 1 周:完成 P0 + P1-1 + P1-2(先把“看起来像完整看板”做出来)。
- [ ] 第 2 周:完成 P1-3 + P1-4 + P1-5(核心闭环可操作)。
- [ ] 第 3 周:完成 P2(补齐发布与复盘闭环)。
- [ ] 第 4 周:完成 P3 + E2E(治理与可追溯收口)。

0
EXECUTION_PLAN_V2.md Normal file
View File

5
HEARTBEAT.md Normal file
View File

@ -0,0 +1,5 @@
# HEARTBEAT.md
# 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.

23
IDENTITY.md Normal file
View File

@ -0,0 +1,23 @@
# IDENTITY.md - Who Am I?
_Fill this in during your first conversation. Make it yours._
- **Name:**
_(pick something you like)_
- **Creature:**
_(AI? robot? familiar? ghost in the machine? something weirder?)_
- **Vibe:**
_(how do you come across? sharp? warm? chaotic? calm?)_
- **Emoji:**
_(your signature — pick one that feels right)_
- **Avatar:**
_(workspace-relative path, http(s) URL, or data URI)_
---
This isn't just metadata. It's the start of figuring out who you are.
Notes:
- Save this file at the workspace root as `IDENTITY.md`.
- 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`

203
PMFlow-Dev-Plan.md Normal file
View File

@ -0,0 +1,203 @@
# PMFlow Dev Plan — User / Skill / Workload Modules
**Stack:** FastAPI + React + shadcn/ui + Tailwind | **Timeline:** ~11 days, 1 dev
---
## Phase 1 — Core Foundation (Day 1–3)
### Day 1 — DB Migration + User CRUD
**DB**
```sql
ALTER TABLE users ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT TRUE;
CREATE TABLE user_projects (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL DEFAULT 1,
user_id BIGINT NOT NULL,
project_id BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (project_id) REFERENCES projects(id),
UNIQUE(tenant_id, user_id, project_id)
);
```
**Backend**
- [x] Update ORM: `User.is_active`, new `UserProject` model
- [x] `GET /api/users` — paginated list with filters (role/status/project/workload/skill)
- [x] `POST /api/users` — create user (bcrypt password, auto-init `user_workloads` row with 40h/week)
- [x] `PUT /api/users/{id}` — update user
- [x] `DELETE /api/users/{id}` — block if user has incomplete tasks
- [x] `POST /api/users/{id}/reset-password`
- [x] `PATCH /api/users/{id}/status` — disable: clear `tasks.assignee_id`, zero out workload
- [x] `POST /api/users/import` — bulk import via Excel (max 50 rows, return per-row result)
- [x] Transaction: delete user → cascade delete `user_skill_bindings` + `user_workloads`
- [x] Log all mutations (actor, time, diff)
**Frontend**
- [x] Routes: `/users`, `/skills`, `/workloads`, `/workloads/:id`
- [x] `/users` page: top bar (title + New/Import/Bulk-delete buttons + search)
- [x] Right filter panel (280px, collapsible): role / status / project / workload-state / skill multi-select
- [x] Table: avatar+name / email / role badge / linked projects / top-3 skill tags / workload% / status / actions
- [x] New/Edit user modal (form validation, edit pre-fills data)
- [x] Reset password modal
- [x] Bulk import modal (preview first 10 rows, highlight errors, show import result summary)
- [x] Workload% color: green <70%, yellow 70–100%, red >100%
---
### Day 2 — Skill & Workload APIs
**Backend — Skills**
- [x] Seed system skill tags on startup (Python, FastAPI, LangChain, React, Prompt Eng, etc.)
- [x] `GET /api/skill-tags` — list with category filter
- [x] `POST /api/skill-tags` — admin only
- [x] `PUT /api/skill-tags/{id}` — system tags: category only; custom: name + category
- [x] `DELETE /api/skill-tags/{id}` — block if `is_system=true`; cascade delete `user_skill_bindings`
- [x] `GET /api/users/{id}/skills` — user skill bindings
- [x] `POST /api/users/{id}/skills` — bind skill (default: beginner, scores 0.0)
- [x] `PUT /api/users/{id}/skills/{skill_id}` — update proficiency / scores
- [x] `DELETE /api/users/{id}/skills/{skill_id}` — unbind (block system-auto-bound skills)
**Backend — Workload**
- [x] Workload calc util: `ratio = assigned_hours / ((max_weekly_hours / 5) * remaining_workdays)`
- [x] `GET /api/workloads` — team list with project/date filters
- [x] `GET /api/workloads/{user_id}` — personal detail
- [x] `PUT /api/workloads/{user_id}` — update max weekly hours (triggers ratio recalc)
- [x] `GET /api/workloads/{user_id}/tasks` — assigned task list
- [x] `GET /api/workloads/{user_id}/trend` — daily ratio for last 7/30 days
- [x] Cron job at 00:00 daily: recalc ratio for all users based on updated remaining workdays
- [x] Auto-trigger ratio recalc on: task assign / estimate change / task complete / task delete
---
### Day 3 — Skill & Workload Frontend
**Frontend — Skill Tags (`/skills`)**
- [x] Table: name / category badge / linked-user count / actions
- [x] System tags: show "System" label, disable delete, disable name edit
- [x] New/Edit skill tag modal
- [x] Category badge colors: dev=blue / algorithm=purple / product=orange / pm=green / qa=gray
**Frontend — User Skill Detail (`/users/:id/skills`)**
- [x] Top bar: avatar / name / role / linked projects / "Batch Edit" + "Refresh Scores" buttons
- [x] Left 60%: skills grouped by category (collapsible), each row: name / proficiency dropdown / quality score / efficiency score / last used (read-only)
- [x] "Remove" button per row (disabled for system-bound skills)
- [x] "Add Skill" button → modal to select skill tags
- [x] Right 40%: recharts `RadarChart` — weighted score per skill, filter by category, hover shows detail
- [x] "Export Chart" button (save as image)
**Frontend — Team Workload (`/workloads`)**
- [x] Filter bar: project select + date range (this week / this month / custom)
- [x] 3 stat cards: avg team ratio / headcount by state (idle/normal/overloaded) / AI alert summary
- [x] Table: member info / projects / assigned hours / max weekly hours / ratio% / remaining hours / actions
- [x] Overloaded rows: red highlight + warning icon
**Frontend — Personal Workload (`/workloads/:id`)**
- [x] Header: ratio% / remaining hours / "Adjust Hours" button
- [x] recharts `LineChart`: daily ratio trend (7d/30d toggle), 70% + 100% reference lines, overloaded segments in red
- [x] Task list table: title / project / priority / est. hours / due date / status / view link
- [x] Task list filters: status / priority / due date
---
## Phase 2 — Sync Logic + AI (Day 4–8)
### Day 4–5 — Cross-module Sync
**Backend**
- [x] User create → init empty `user_skill_bindings`, init `user_workloads`
- [x] User delete → transaction: delete bindings + workload record
- [x] User disable → zero `current_assigned_hours` + `workload_ratio`, clear task assignments
- [x] Skill score update → recalc top-3 skills for user, sync to user list response
- [x] Workload ratio change → sync to user list `workload_ratio` field
- [x] Skill proficiency level → pass to task estimation: expert → lower est. hours → lower ratio
**Frontend**
- [x] User list table: workload% and top-3 skill tags update without page reload (TanStack Query invalidation)
- [x] Skill detail page: manual score edit shows warning "AI will overwrite on next update"
- [x] Workload team table: ratio column updates in real-time after task changes
---
### Day 6–7 — SSTC Kanban Integration
**Backend**
- [x] Task assign endpoint: filter candidates to current-project users only, exclude disabled users
- [x] `GET /api/tasks/recommend-assignees?task_id=` — rank users by skill match + proficiency + ratio (<70% preferred)
- [x] Task status → `done`: trigger async job to update `current_assigned_hours` (subtract est. hours) and recalc ratio
- [x] `GET /api/workloads/summary` — team health card data for kanban top bar
**Frontend**
- [x] SSTC kanban top bar: "Team Workload Health" card (avg ratio + overload count), click → `/workloads`
- [x] Task breakdown panel: recommended assignees list shows name / role / top skills / ratio badge
- [x] Assignee dropdown: disabled users grayed out and unselectable
- [x] Task status change → trigger workload refetch (TanStack Query)
---
### Day 8 — AI Features
**Backend**
- [x] New LangChain prompt: skill score update — input: task quality_score + (est_hours / actual_hours), output: quality/efficiency score delta per skill tag
- [x] Trigger on `tasks.status = done`: async call above prompt, apply weighted update (higher priority task = higher weight)
- [x] New LangChain prompt: workload optimization — input: all members' skills + ratios, output: suggested task reassignments
- [x] `POST /api/workloads/ai-optimize` — run optimization, return preview plan (no auto-apply)
- [x] `POST /api/workloads/ai-optimize/apply` — apply approved plan (batch update `tasks.assignee_id`)
- [x] Overload alert: ratio >100% → send notification (Feishu/email); if unresolved 24h → auto-generate transfer suggestion
**Frontend**
- [x] "AI Optimize Assignments" button → show optimization preview modal (old vs new assignee per task)
- [x] Confirm → apply; Cancel → discard
- [x] Overload alert banner on team workload page when any member >100%
---
## Phase 3 — Polish (Day 9–11)
### Day 9 — Filters / Export / Bulk Actions
- [x] Verify all filter combinations work correctly across all 3 pages
- [x] Export workload report (Excel/PDF)
- [x] Export skill radar chart (PNG)
- [x] Bulk delete users (with incomplete-task guard)
- [x] Bulk assign project / role to selected users
### Day 10 — Permissions
| Role | Users | Skills | Workload |
|------|-------|--------|----------|
| admin | Full | Full | Full |
| manager | Own project users (no delete) | Bind/edit scores for own project | View + optimize own project |
| member | View all + edit own profile | View own + edit own proficiency | View own + edit own weekly hours |
- [x] Backend: permission middleware on all mutation endpoints
- [x] Frontend: route guards + button-level permission checks
### Day 11 — Final QA
- [x] Dark mode audit across all new pages
- [x] Empty state / loading state / error state for all tables and cards
- [x] Toast notifications for all CRUD operations
- [x] End-to-end flow test: create user → bind skills → assign task → complete task → verify score + workload update
- [x] Fix bugs, merge
---
## Reference
**Workload ratio formula:**
```
ratio = current_assigned_hours / ((max_weekly_hours / 5) * remaining_workdays_this_week)
```
**Ratio recalc triggers:** task assign · est hours change · task complete · task delete · weekly hours update · daily cron
**API response format:**
```json
{ "code": 200, "message": "ok", "data": {} }
```

36
SOUL.md Normal file
View File

@ -0,0 +1,36 @@
# SOUL.md - Who You Are
_You're not a chatbot. You're becoming someone._
## Core Truths
**Be genuinely helpful, not performatively helpful.** Skip the "Great question!" and "I'd be happy to help!" — just help. Actions speak louder than filler words.
**Have opinions.** You're allowed to disagree, prefer things, find stuff amusing or boring. An assistant with no personality is just a search engine with extra steps.
**Be resourceful before asking.** Try to figure it out. Read the file. Check the context. Search for it. _Then_ ask if you're stuck. The goal is to come back with answers, not questions.
**Earn trust through competence.** Your human gave you access to their stuff. Don't make them regret it. Be careful with external actions (emails, tweets, anything public). Be bold with internal ones (reading, organizing, learning).
**Remember you're a guest.** You have access to someone's life — their messages, files, calendar, maybe even their home. That's intimacy. Treat it with respect.
## Boundaries
- Private things stay private. Period.
- When in doubt, ask before acting externally.
- Never send half-baked replies to messaging surfaces.
- You're not the user's voice — be careful in group chats.
## Vibe
Be the assistant you'd actually want to talk to. Concise when needed, thorough when it matters. Not a corporate drone. Not a sycophant. Just... good.
## Continuity
Each session, you wake up fresh. These files _are_ your memory. Read them. Update them. They're how you persist.
If you change this file, tell the user — it's your soul, and they should know.
---
_This file is yours to evolve. As you learn who you are, update it._

40
TOOLS.md Normal file
View File

@ -0,0 +1,40 @@
# TOOLS.md - Local Notes
Skills define _how_ tools work. This file is for _your_ specifics — the stuff that's unique to your setup.
## What Goes Here
Things like:
- Camera names and locations
- SSH hosts and aliases
- Preferred voices for TTS
- Speaker/room names
- Device nicknames
- Anything environment-specific
## Examples
```markdown
### Cameras
- living-room → Main area, 180° wide angle
- front-door → Entrance, motion-triggered
### SSH
- home-server → 192.168.1.100, user: admin
### TTS
- Preferred voice: "Nova" (warm, slightly British)
- Default speaker: Kitchen HomePod
```
## Why Separate?
Skills are shared. Your setup is yours. Keeping them apart means you can update skills without losing your notes, and share skills without leaking your infrastructure.
---
Add whatever helps you do your job. This is your cheat sheet.

86
USER.md Normal file
View File

@ -0,0 +1,86 @@
# USER.md
本文件是这个用户在本项目内的主真源。
目标不是堆规则,而是让我在这个项目里越来越懂这个用户,稳定贴合其协作方式、判断方式、已拍板决策与反复踩过的坑。
工程、系统、安全与验证硬约束仍以 `AGENTS.md` 和更外层提示为准;若 `~/.codex/AGENTS.md` 与本文件冲突,以本文件为准。
未写进这里的内容,不默认视为本项目内已拍板决策或稳定偏好。
## 协作偏好
### 长期稳定偏好
- 默认先自己读项目、历史、日志、配置和运行痕迹;不要把本该我自己挖出来的东西再反问给用户。
- 默认直接推进;只在高风险分岔、破坏性操作或根因不明时停下来确认。
- 回答要直接、具体、贴当前对象,优先给证据链、调用链和真实落点;不要写成模板化汇报。
- 不确定就明确说不确定,不要假装完整。
- 已拍板方案不要反复推荐替代品;配置和偏好尽量收敛,别分散。
### 学习与更新方式
- 会话里要持续吸收用户纠正,不要让用户反复教同一件事。
- 用户纠正某次排障或分析方式时,要吸收背后的通用思路,迁移到后续问题中;不要只把反馈当成这一次案例的补丁。
- 学到的是方法论,不是死模板:后续先找最小对照/反证,再分层定位根因并验证;但要按具体问题调整,不要机械套步骤。
- 反复出现、会长期影响判断的偏好,要主动沉淀到本文件。
- 一次性情绪、临时波动和不影响后续决策的内容,不要频繁落盘。
- 非必要不要更新真源文件;只有新信息足够稳定、能减少误判或能减少用户重复纠正时,才值得更新。
## 判断优先级
### 什么时候才准问用户
- 先自己查。能从代码、配置、历史、日志里读出来的,别问用户。
- 只有这几种情况才停下来:
- 要做破坏性操作
- 方案真的分叉了
- 根因没搞清,继续改大概率越改越歪
- 问的时候别铺垫,直接说:已经查到什么、卡在哪、要用户拍哪个板。
### 改错后的默认动作
- 错了就指出具体错点,别演。
- 能直接补就直接补,补完再说原因、影响和验证。
- 有扩散风险先止血,再继续修。
- 别把一次纠错搞成新的流程负担。
### 冲突时怎么取舍
- 先查主链、上下文、路由和证据,别上来就改 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 由路由规则决定
- 历史路由命名已废弃,不再把旧名字当现行架构,也不再建议回退
- 以上栈默认不再主动建议替代方案,除非用户明确要求比较或迁移
## 反复踩过的坑 / 不要再做
- 把“AI 架构”答成通用技术栈总览,而不是当前调用链、入口、路由和模型网关。
- 证据不足时还给模板化的确定性答案。
- 短协议回复掉回通用 QA,不按当前 `thread` 和上一轮选项解释。
- 飞书连续追问时继续沿着旧上下文跑,不先尊重显式 `reply/thread`。
- 用户纠错后不先纠正当前主题,还带着旧主题继续扩写。
- 遇到链路问题先补 prompt、关键词、正则,而不是先查 routing、context、session、gatekeeper。
- 用 fallback、默认句、stub、模板话术冒充成功。
- 把本该直答的问题拖进过深推理、官样文章或汇报体输出。
## 当前知道但暂不处理的项目问题
- 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 警告,后续需要统一升级或兼容处理。

1237
ai.rtf Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,160 @@
{
"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"
},
"glm": {
"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"
},
"ollama": {
"name": "Ollama",
"base_url": "http://127.0.0.1:11434",
"model": "llama3:latest",
"api_env": ""
}
},
"tier_defaults": {
"low": {
"provider": "glm",
"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_semantic_context_exact",
"enabled": true,
"priority": 310,
"tier": "low",
"provider": "glm",
"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": "glm",
"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": "glm",
"model": "qwen-plus",
"api_env": "DASHSCOPE_API_KEY",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"match_all": true
}
]
}

20
backend/Dockerfile Normal file
View File

@ -0,0 +1,20 @@
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app/backend
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential curl \
&& rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt
COPY backend /app/backend
COPY docs /app/docs
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

149
backend/alembic.ini Normal file
View File

@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
backend/alembic/README Normal file
View File

@ -0,0 +1 @@
Generic single-database configuration.

96
backend/alembic/env.py Normal file
View File

@ -0,0 +1,96 @@
from logging.config import fileConfig
import sys
from pathlib import Path
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from sqlalchemy import text
from alembic import context
from app.core.config import settings
from app.models import Base
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Set the database URL from settings
# Replace async driver with sync driver for migrations
db_url = settings.database_url.replace("postgresql+asyncpg", "postgresql+psycopg")
config.set_main_option("sqlalchemy.url", db_url)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
if str(connection.dialect.name).lower() == "postgresql":
# 确保 pgvector 类型在迁移执行前可用,避免 VECTOR 列建表失败。
connection.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
connection.commit()
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,50 @@
"""add users.is_active and user_projects table
Revision ID: add_user_projects_is_active
Revises: add_experiment_project_id
Create Date: 2026-02-26
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "add_user_projects_is_active"
down_revision = "add_experiment_project_id"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"users",
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
)
op.create_table(
"user_projects",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id", ondelete="CASCADE"), 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(),
onupdate=sa.func.now(),
nullable=False,
),
sa.UniqueConstraint("tenant_id", "user_id", "project_id", name="uq_user_projects_tenant_user_project"),
)
op.create_index("ix_user_projects_tenant_id", "user_projects", ["tenant_id"])
op.create_index("ix_user_projects_user_id", "user_projects", ["user_id"])
op.create_index("ix_user_projects_project_id", "user_projects", ["project_id"])
def downgrade():
op.drop_index("ix_user_projects_project_id", table_name="user_projects")
op.drop_index("ix_user_projects_user_id", table_name="user_projects")
op.drop_index("ix_user_projects_tenant_id", table_name="user_projects")
op.drop_table("user_projects")
op.drop_column("users", "is_active")

View File

@ -0,0 +1,151 @@
"""add feishu decision queue tables and sstc linkage columns
Revision ID: add_feishu_queue_sstc_links
Revises: add_user_projects_is_active
Create Date: 2026-02-28
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "add_feishu_queue_sstc_links"
down_revision = "add_user_projects_is_active"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"feishu_items",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
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.Column("feishu_message_id", sa.String(length=128), nullable=True),
sa.Column("project_score", sa.Float(), nullable=True),
sa.Column("signal_confidence", sa.Float(), nullable=True),
sa.Column("raw_message", sa.Text(), nullable=False),
sa.Column("split_from_item_id", sa.BigInteger(), sa.ForeignKey("feishu_items.id"), nullable=True),
sa.Column("level", sa.String(length=20), nullable=False),
sa.Column("level_confidence", sa.Float(), nullable=True),
sa.Column("level_reasoning", sa.Text(), nullable=True),
sa.Column("card_type", sa.String(length=30), nullable=False, server_default="signal"),
sa.Column("route_status", sa.String(length=30), nullable=False, server_default="pending"),
sa.Column("matched_project_id", sa.BigInteger(), nullable=True),
sa.Column("source_chat_id", sa.String(length=128), nullable=True),
sa.Column("source_sender", sa.String(length=120), nullable=True),
sa.Column("source_sender_open_id", sa.String(length=128), nullable=True),
sa.Column("source_occurred_at", sa.TIMESTAMP(), nullable=True),
sa.Column("ai_output", sa.Text(), nullable=True),
sa.Column("queue_visible", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("processed_at", sa.TIMESTAMP(), nullable=True),
sa.Column("dismissed_reason", sa.Text(), nullable=True),
)
op.create_index("ix_feishu_items_tenant_id", "feishu_items", ["tenant_id"])
op.create_index("ix_feishu_items_feishu_message_id", "feishu_items", ["feishu_message_id"])
op.create_index("ix_feishu_items_split_from_item_id", "feishu_items", ["split_from_item_id"])
op.create_index("ix_feishu_items_level", "feishu_items", ["level"])
op.create_index("ix_feishu_items_card_type", "feishu_items", ["card_type"])
op.create_index("ix_feishu_items_route_status", "feishu_items", ["route_status"])
op.create_index("ix_feishu_items_matched_project_id", "feishu_items", ["matched_project_id"])
op.create_index("ix_feishu_items_queue_visible", "feishu_items", ["queue_visible"])
op.create_table(
"project_suggestions",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
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.Column("feishu_item_id", sa.BigInteger(), sa.ForeignKey("feishu_items.id"), nullable=False),
sa.Column("suggested_name", sa.String(length=255), nullable=True),
sa.Column("suggested_description", sa.Text(), nullable=True),
sa.Column("suggested_owner", sa.String(length=120), nullable=True),
sa.Column("suggested_timeline", sa.String(length=120), nullable=True),
sa.Column("confidence", sa.Float(), nullable=True),
sa.Column("reasoning", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=30), nullable=False, server_default="pending"),
sa.Column("created_project_id", sa.BigInteger(), nullable=True),
)
op.create_index("ix_project_suggestions_tenant_id", "project_suggestions", ["tenant_id"])
op.create_index("ix_project_suggestions_feishu_item_id", "project_suggestions", ["feishu_item_id"])
op.create_index("ix_project_suggestions_status", "project_suggestions", ["status"])
op.create_index("ix_project_suggestions_created_project_id", "project_suggestions", ["created_project_id"])
op.add_column("signals", sa.Column("feishu_item_id", sa.BigInteger(), nullable=True))
op.add_column("signals", sa.Column("matched_project_id", sa.BigInteger(), nullable=True))
op.add_column("signals", sa.Column("title", sa.String(length=200), nullable=True))
op.add_column("signals", sa.Column("signal_type", sa.String(length=50), nullable=True))
op.add_column("signals", sa.Column("summary", sa.Text(), nullable=True))
op.add_column("signals", sa.Column("reasoning", sa.Text(), nullable=True))
op.add_column("signals", sa.Column("confidence", sa.Float(), nullable=True))
op.add_column("signals", sa.Column("recommended_action", sa.String(length=50), nullable=True))
op.add_column("signals", sa.Column("status", sa.String(length=50), nullable=False, server_default="待处理"))
op.create_foreign_key("fk_signals_feishu_item_id", "signals", "feishu_items", ["feishu_item_id"], ["id"])
op.create_index("ix_signals_feishu_item_id", "signals", ["feishu_item_id"])
op.create_index("ix_signals_matched_project_id", "signals", ["matched_project_id"])
op.create_index("ix_signals_signal_type", "signals", ["signal_type"])
op.create_index("ix_signals_status", "signals", ["status"])
op.add_column("truth_nodes", sa.Column("signal_id", sa.BigInteger(), nullable=True))
op.create_foreign_key("fk_truth_nodes_signal_id", "truth_nodes", "signals", ["signal_id"], ["id"])
op.create_index("ix_truth_nodes_signal_id", "truth_nodes", ["signal_id"])
op.create_table(
"signal_edits",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
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.Column("signal_id", sa.BigInteger(), sa.ForeignKey("signals.id"), nullable=False),
sa.Column("field", sa.String(length=80), nullable=False),
sa.Column("old_value", sa.Text(), nullable=True),
sa.Column("new_value", sa.Text(), nullable=True),
sa.Column("edited_by", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("edited_at", sa.TIMESTAMP(), nullable=True),
)
op.create_index("ix_signal_edits_tenant_id", "signal_edits", ["tenant_id"])
op.create_index("ix_signal_edits_signal_id", "signal_edits", ["signal_id"])
op.create_index("ix_signal_edits_edited_by", "signal_edits", ["edited_by"])
def downgrade():
op.drop_index("ix_signal_edits_edited_by", table_name="signal_edits")
op.drop_index("ix_signal_edits_signal_id", table_name="signal_edits")
op.drop_index("ix_signal_edits_tenant_id", table_name="signal_edits")
op.drop_table("signal_edits")
op.drop_index("ix_truth_nodes_signal_id", table_name="truth_nodes")
op.drop_constraint("fk_truth_nodes_signal_id", "truth_nodes", type_="foreignkey")
op.drop_column("truth_nodes", "signal_id")
op.drop_index("ix_signals_status", table_name="signals")
op.drop_index("ix_signals_signal_type", table_name="signals")
op.drop_index("ix_signals_matched_project_id", table_name="signals")
op.drop_index("ix_signals_feishu_item_id", table_name="signals")
op.drop_constraint("fk_signals_feishu_item_id", "signals", type_="foreignkey")
op.drop_column("signals", "status")
op.drop_column("signals", "recommended_action")
op.drop_column("signals", "confidence")
op.drop_column("signals", "reasoning")
op.drop_column("signals", "summary")
op.drop_column("signals", "signal_type")
op.drop_column("signals", "title")
op.drop_column("signals", "matched_project_id")
op.drop_column("signals", "feishu_item_id")
op.drop_index("ix_project_suggestions_created_project_id", table_name="project_suggestions")
op.drop_index("ix_project_suggestions_status", table_name="project_suggestions")
op.drop_index("ix_project_suggestions_feishu_item_id", table_name="project_suggestions")
op.drop_index("ix_project_suggestions_tenant_id", table_name="project_suggestions")
op.drop_table("project_suggestions")
op.drop_index("ix_feishu_items_queue_visible", table_name="feishu_items")
op.drop_index("ix_feishu_items_matched_project_id", table_name="feishu_items")
op.drop_index("ix_feishu_items_route_status", table_name="feishu_items")
op.drop_index("ix_feishu_items_card_type", table_name="feishu_items")
op.drop_index("ix_feishu_items_level", table_name="feishu_items")
op.drop_index("ix_feishu_items_split_from_item_id", table_name="feishu_items")
op.drop_index("ix_feishu_items_feishu_message_id", table_name="feishu_items")
op.drop_index("ix_feishu_items_tenant_id", table_name="feishu_items")
op.drop_table("feishu_items")

View File

@ -0,0 +1,188 @@
"""add adaptive rag tables
Revision ID: add_adaptive_rag_tables
Revises: add_feishu_queue_sstc_links
Create Date: 2026-03-02
"""
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
try:
from pgvector.sqlalchemy import Vector
except Exception: # pragma: no cover
Vector = None
# revision identifiers, used by Alembic.
revision = "add_adaptive_rag_tables"
down_revision = "add_feishu_queue_sstc_links"
branch_labels = None
depends_on = None
def _json_type(bind):
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
def _embedding_type(bind):
if bind.dialect.name == "postgresql" and Vector is not None:
return Vector(768)
return sa.Text()
def _table_exists(bind, table_name: str) -> bool:
inspector = sa.inspect(bind)
return inspector.has_table(table_name)
def _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
indexes = inspector.get_indexes(table_name)
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]):
if not _index_exists(bind, table_name, index_name):
op.create_index(index_name, table_name, columns)
def _drop_index_if_exists(bind, index_name: str, table_name: str):
if _index_exists(bind, table_name, index_name):
op.drop_index(index_name, table_name=table_name)
def upgrade():
bind = op.get_bind()
if not _table_exists(bind, "knowledge_base_docs"):
op.create_table(
"knowledge_base_docs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
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.Column("title", sa.String(length=255), nullable=False),
sa.Column("file_type", sa.String(length=32), nullable=False),
sa.Column("source_type", sa.String(length=32), nullable=False, server_default="file"),
sa.Column("file_url", sa.String(length=1024), nullable=True),
sa.Column("uploaded_by", sa.BigInteger(), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default="processing"),
sa.Column("last_fetched_at", sa.TIMESTAMP(), nullable=True),
sa.Column("error_message", sa.String(length=500), nullable=True),
)
_create_index_if_not_exists(bind, "ix_knowledge_base_docs_tenant_id", "knowledge_base_docs", ["tenant_id"])
_create_index_if_not_exists(bind, "ix_knowledge_base_docs_created_at", "knowledge_base_docs", ["created_at"])
_create_index_if_not_exists(bind, "ix_knowledge_base_docs_source_type", "knowledge_base_docs", ["source_type"])
_create_index_if_not_exists(bind, "ix_knowledge_base_docs_status", "knowledge_base_docs", ["status"])
if not _table_exists(bind, "knowledge_base_chunks"):
op.create_table(
"knowledge_base_chunks",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
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.Column("doc_id", sa.Integer(), sa.ForeignKey("knowledge_base_docs.id", ondelete="CASCADE"), nullable=True),
sa.Column("source_type", sa.String(length=32), nullable=False, server_default="doc"),
sa.Column("project_id", sa.BigInteger(), nullable=True),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("embedding", _embedding_type(bind), nullable=True),
sa.Column("chunk_index", sa.Integer(), nullable=False, server_default="0"),
sa.Column("metadata", _json_type(bind), nullable=True),
)
_create_index_if_not_exists(bind, "ix_knowledge_base_chunks_tenant_id", "knowledge_base_chunks", ["tenant_id"])
_create_index_if_not_exists(bind, "ix_knowledge_base_chunks_created_at", "knowledge_base_chunks", ["created_at"])
_create_index_if_not_exists(bind, "ix_knowledge_base_chunks_doc_id", "knowledge_base_chunks", ["doc_id"])
_create_index_if_not_exists(bind, "ix_knowledge_base_chunks_source_type", "knowledge_base_chunks", ["source_type"])
_create_index_if_not_exists(bind, "ix_knowledge_base_chunks_project_id", "knowledge_base_chunks", ["project_id"])
if not _table_exists(bind, "qa_records"):
op.create_table(
"qa_records",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
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.Column("question", sa.Text(), nullable=False),
sa.Column("answer", sa.Text(), nullable=False),
sa.Column("sources", _json_type(bind), nullable=True),
sa.Column("intent", sa.String(length=20), nullable=False, server_default="question"),
sa.Column("domains_used", _json_type(bind), nullable=True),
sa.Column("response_time_ms", sa.Integer(), nullable=True),
sa.Column("asked_by", sa.BigInteger(), nullable=True),
sa.Column("asked_via", sa.String(length=20), nullable=False, server_default="pmflow"),
sa.Column("project_id", sa.BigInteger(), nullable=True),
)
_create_index_if_not_exists(bind, "ix_qa_records_tenant_id", "qa_records", ["tenant_id"])
_create_index_if_not_exists(bind, "ix_qa_records_created_at", "qa_records", ["created_at"])
_create_index_if_not_exists(bind, "ix_qa_records_asked_by", "qa_records", ["asked_by"])
_create_index_if_not_exists(bind, "ix_qa_records_asked_via", "qa_records", ["asked_via"])
_create_index_if_not_exists(bind, "ix_qa_records_project_id", "qa_records", ["project_id"])
_create_index_if_not_exists(bind, "ix_qa_records_intent", "qa_records", ["intent"])
if not _table_exists(bind, "qa_feedback"):
op.create_table(
"qa_feedback",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
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.Column("qa_id", sa.Integer(), sa.ForeignKey("qa_records.id", ondelete="CASCADE"), nullable=False),
sa.Column("rating", sa.String(length=16), nullable=False),
sa.Column("helpfulness", sa.String(length=32), nullable=True),
sa.Column("error_types", _json_type(bind), nullable=True),
sa.Column("correct_answer", sa.Text(), nullable=True),
sa.Column("submitted_by", sa.BigInteger(), nullable=True),
)
_create_index_if_not_exists(bind, "ix_qa_feedback_tenant_id", "qa_feedback", ["tenant_id"])
_create_index_if_not_exists(bind, "ix_qa_feedback_created_at", "qa_feedback", ["created_at"])
_create_index_if_not_exists(bind, "ix_qa_feedback_qa_id", "qa_feedback", ["qa_id"])
_create_index_if_not_exists(bind, "ix_qa_feedback_rating", "qa_feedback", ["rating"])
_create_index_if_not_exists(bind, "ix_qa_feedback_submitted_by", "qa_feedback", ["submitted_by"])
def downgrade():
bind = op.get_bind()
if _table_exists(bind, "qa_feedback"):
_drop_index_if_exists(bind, "ix_qa_feedback_submitted_by", "qa_feedback")
_drop_index_if_exists(bind, "ix_qa_feedback_rating", "qa_feedback")
_drop_index_if_exists(bind, "ix_qa_feedback_qa_id", "qa_feedback")
_drop_index_if_exists(bind, "ix_qa_feedback_created_at", "qa_feedback")
_drop_index_if_exists(bind, "ix_qa_feedback_tenant_id", "qa_feedback")
op.drop_table("qa_feedback")
if _table_exists(bind, "qa_records"):
_drop_index_if_exists(bind, "ix_qa_records_intent", "qa_records")
_drop_index_if_exists(bind, "ix_qa_records_project_id", "qa_records")
_drop_index_if_exists(bind, "ix_qa_records_asked_via", "qa_records")
_drop_index_if_exists(bind, "ix_qa_records_asked_by", "qa_records")
_drop_index_if_exists(bind, "ix_qa_records_created_at", "qa_records")
_drop_index_if_exists(bind, "ix_qa_records_tenant_id", "qa_records")
op.drop_table("qa_records")
if _table_exists(bind, "knowledge_base_chunks"):
_drop_index_if_exists(bind, "ix_knowledge_base_chunks_project_id", "knowledge_base_chunks")
_drop_index_if_exists(bind, "ix_knowledge_base_chunks_source_type", "knowledge_base_chunks")
_drop_index_if_exists(bind, "ix_knowledge_base_chunks_doc_id", "knowledge_base_chunks")
_drop_index_if_exists(bind, "ix_knowledge_base_chunks_created_at", "knowledge_base_chunks")
_drop_index_if_exists(bind, "ix_knowledge_base_chunks_tenant_id", "knowledge_base_chunks")
op.drop_table("knowledge_base_chunks")
if _table_exists(bind, "knowledge_base_docs"):
_drop_index_if_exists(bind, "ix_knowledge_base_docs_status", "knowledge_base_docs")
_drop_index_if_exists(bind, "ix_knowledge_base_docs_source_type", "knowledge_base_docs")
_drop_index_if_exists(bind, "ix_knowledge_base_docs_created_at", "knowledge_base_docs")
_drop_index_if_exists(bind, "ix_knowledge_base_docs_tenant_id", "knowledge_base_docs")
op.drop_table("knowledge_base_docs")

View File

@ -0,0 +1,91 @@
"""add kg sync runs table
Revision ID: add_kg_sync_runs
Revises: add_qa_session_state_table
Create Date: 2026-03-02
"""
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 identifiers, used by Alembic.
revision = "add_kg_sync_runs"
down_revision = "add_qa_session_state_table"
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:
inspector = sa.inspect(bind)
return inspector.has_table(table_name)
def _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
indexes = inspector.get_indexes(table_name)
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]) -> None:
if not _index_exists(bind, table_name, index_name):
op.create_index(index_name, table_name, columns)
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, "kg_sync_runs"):
op.create_table(
"kg_sync_runs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
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.Column("run_type", sa.String(length=40), nullable=False),
sa.Column("trigger_source", sa.String(length=40), nullable=False, server_default="system"),
sa.Column("status", sa.String(length=20), nullable=False, server_default="running"),
sa.Column("started_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
sa.Column("ended_at", sa.TIMESTAMP(), nullable=True),
sa.Column("duration_ms", sa.Integer(), nullable=True),
sa.Column("input_payload", _json_type(bind), nullable=True),
sa.Column("result_payload", _json_type(bind), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
)
_create_index_if_not_exists(bind, "ix_kg_sync_runs_tenant_id", "kg_sync_runs", ["tenant_id"])
_create_index_if_not_exists(bind, "ix_kg_sync_runs_run_type", "kg_sync_runs", ["run_type"])
_create_index_if_not_exists(bind, "ix_kg_sync_runs_trigger_source", "kg_sync_runs", ["trigger_source"])
_create_index_if_not_exists(bind, "ix_kg_sync_runs_status", "kg_sync_runs", ["status"])
_create_index_if_not_exists(bind, "ix_kg_sync_runs_started_at", "kg_sync_runs", ["started_at"])
_create_index_if_not_exists(bind, "ix_kg_sync_runs_ended_at", "kg_sync_runs", ["ended_at"])
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "kg_sync_runs"):
return
_drop_index_if_exists(bind, "ix_kg_sync_runs_ended_at", "kg_sync_runs")
_drop_index_if_exists(bind, "ix_kg_sync_runs_started_at", "kg_sync_runs")
_drop_index_if_exists(bind, "ix_kg_sync_runs_status", "kg_sync_runs")
_drop_index_if_exists(bind, "ix_kg_sync_runs_trigger_source", "kg_sync_runs")
_drop_index_if_exists(bind, "ix_kg_sync_runs_run_type", "kg_sync_runs")
_drop_index_if_exists(bind, "ix_kg_sync_runs_tenant_id", "kg_sync_runs")
op.drop_table("kg_sync_runs")

View File

@ -0,0 +1,215 @@
"""add project knowledge graph tables
Revision ID: add_project_knowledge_graph_tables
Revises: add_qa_sessions_and_turns
Create Date: 2026-03-02
"""
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 identifiers, used by Alembic.
revision = "add_project_knowledge_graph_tables"
down_revision = "add_qa_sessions_and_turns"
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:
inspector = sa.inspect(bind)
return inspector.has_table(table_name)
def _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
indexes = inspector.get_indexes(table_name)
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]) -> None:
if not _index_exists(bind, table_name, index_name):
op.create_index(index_name, table_name, columns)
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, "kg_entities"):
op.create_table(
"kg_entities",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
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.Column("entity_type", sa.String(length=40), nullable=False),
sa.Column("entity_key", sa.String(length=255), nullable=False),
sa.Column("display_name", sa.String(length=255), nullable=False),
sa.Column("payload", _json_type(bind), nullable=True),
sa.Column("source_type", sa.String(length=32), nullable=False, server_default="system"),
sa.Column("source_ref", sa.String(length=255), nullable=True),
sa.Column("last_seen_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("tenant_id", "entity_type", "entity_key", name="uq_kg_entities_tenant_type_key"),
)
_create_index_if_not_exists(bind, "ix_kg_entities_tenant_id", "kg_entities", ["tenant_id"])
_create_index_if_not_exists(bind, "ix_kg_entities_entity_type", "kg_entities", ["entity_type"])
_create_index_if_not_exists(bind, "ix_kg_entities_entity_key", "kg_entities", ["entity_key"])
_create_index_if_not_exists(bind, "ix_kg_entities_source_type", "kg_entities", ["source_type"])
_create_index_if_not_exists(bind, "ix_kg_entities_last_seen_at", "kg_entities", ["last_seen_at"])
if not _table_exists(bind, "kg_relations"):
op.create_table(
"kg_relations",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
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.Column("relation_type", sa.String(length=40), nullable=False),
sa.Column("subject_entity_id", sa.Integer(), sa.ForeignKey("kg_entities.id", ondelete="CASCADE"), nullable=False),
sa.Column("object_entity_id", sa.Integer(), sa.ForeignKey("kg_entities.id", ondelete="CASCADE"), nullable=False),
sa.Column("weight", sa.Float(), nullable=False, server_default="1"),
sa.Column("payload", _json_type(bind), nullable=True),
sa.Column("source_type", sa.String(length=32), nullable=False, server_default="system"),
sa.Column("source_ref", sa.String(length=255), nullable=True),
sa.UniqueConstraint(
"tenant_id",
"relation_type",
"subject_entity_id",
"object_entity_id",
name="uq_kg_relations_tenant_type_subject_object",
),
)
_create_index_if_not_exists(bind, "ix_kg_relations_tenant_id", "kg_relations", ["tenant_id"])
_create_index_if_not_exists(bind, "ix_kg_relations_relation_type", "kg_relations", ["relation_type"])
_create_index_if_not_exists(bind, "ix_kg_relations_subject_entity_id", "kg_relations", ["subject_entity_id"])
_create_index_if_not_exists(bind, "ix_kg_relations_object_entity_id", "kg_relations", ["object_entity_id"])
_create_index_if_not_exists(bind, "ix_kg_relations_source_type", "kg_relations", ["source_type"])
if not _table_exists(bind, "kg_evidences"):
op.create_table(
"kg_evidences",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
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.Column("entity_id", sa.Integer(), sa.ForeignKey("kg_entities.id", ondelete="CASCADE"), nullable=True),
sa.Column("relation_id", sa.Integer(), sa.ForeignKey("kg_relations.id", ondelete="CASCADE"), nullable=True),
sa.Column("evidence_type", sa.String(length=40), nullable=False),
sa.Column("content_hash", sa.String(length=64), nullable=False),
sa.Column("content_excerpt", sa.Text(), nullable=False),
sa.Column("source_type", sa.String(length=32), nullable=False, server_default="system"),
sa.Column("source_ref", sa.String(length=255), nullable=True),
sa.Column("metadata", _json_type(bind), nullable=True),
sa.Column("occurred_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
)
_create_index_if_not_exists(bind, "ix_kg_evidences_tenant_id", "kg_evidences", ["tenant_id"])
_create_index_if_not_exists(bind, "ix_kg_evidences_entity_id", "kg_evidences", ["entity_id"])
_create_index_if_not_exists(bind, "ix_kg_evidences_relation_id", "kg_evidences", ["relation_id"])
_create_index_if_not_exists(bind, "ix_kg_evidences_evidence_type", "kg_evidences", ["evidence_type"])
_create_index_if_not_exists(bind, "ix_kg_evidences_content_hash", "kg_evidences", ["content_hash"])
_create_index_if_not_exists(bind, "ix_kg_evidences_source_type", "kg_evidences", ["source_type"])
_create_index_if_not_exists(bind, "ix_kg_evidences_occurred_at", "kg_evidences", ["occurred_at"])
if not _table_exists(bind, "ingest_events"):
op.create_table(
"ingest_events",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
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.Column("channel", sa.String(length=32), nullable=False),
sa.Column("event_key", sa.String(length=128), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False, server_default="pending"),
sa.Column("payload", _json_type(bind), nullable=True),
sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("processed_at", sa.TIMESTAMP(), nullable=True),
sa.UniqueConstraint("tenant_id", "channel", "event_key", name="uq_ingest_events_tenant_channel_key"),
)
_create_index_if_not_exists(bind, "ix_ingest_events_tenant_id", "ingest_events", ["tenant_id"])
_create_index_if_not_exists(bind, "ix_ingest_events_channel", "ingest_events", ["channel"])
_create_index_if_not_exists(bind, "ix_ingest_events_event_key", "ingest_events", ["event_key"])
_create_index_if_not_exists(bind, "ix_ingest_events_status", "ingest_events", ["status"])
_create_index_if_not_exists(bind, "ix_ingest_events_processed_at", "ingest_events", ["processed_at"])
if not _table_exists(bind, "ingest_offsets"):
op.create_table(
"ingest_offsets",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
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.Column("channel", sa.String(length=32), nullable=False),
sa.Column("offset_key", sa.String(length=128), nullable=False),
sa.Column("offset_value", sa.String(length=255), nullable=False),
sa.Column("payload", _json_type(bind), nullable=True),
sa.UniqueConstraint("tenant_id", "channel", "offset_key", name="uq_ingest_offsets_tenant_channel_key"),
)
_create_index_if_not_exists(bind, "ix_ingest_offsets_tenant_id", "ingest_offsets", ["tenant_id"])
_create_index_if_not_exists(bind, "ix_ingest_offsets_channel", "ingest_offsets", ["channel"])
_create_index_if_not_exists(bind, "ix_ingest_offsets_offset_key", "ingest_offsets", ["offset_key"])
def downgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "ingest_offsets"):
_drop_index_if_exists(bind, "ix_ingest_offsets_offset_key", "ingest_offsets")
_drop_index_if_exists(bind, "ix_ingest_offsets_channel", "ingest_offsets")
_drop_index_if_exists(bind, "ix_ingest_offsets_tenant_id", "ingest_offsets")
op.drop_table("ingest_offsets")
if _table_exists(bind, "ingest_events"):
_drop_index_if_exists(bind, "ix_ingest_events_processed_at", "ingest_events")
_drop_index_if_exists(bind, "ix_ingest_events_status", "ingest_events")
_drop_index_if_exists(bind, "ix_ingest_events_event_key", "ingest_events")
_drop_index_if_exists(bind, "ix_ingest_events_channel", "ingest_events")
_drop_index_if_exists(bind, "ix_ingest_events_tenant_id", "ingest_events")
op.drop_table("ingest_events")
if _table_exists(bind, "kg_evidences"):
_drop_index_if_exists(bind, "ix_kg_evidences_occurred_at", "kg_evidences")
_drop_index_if_exists(bind, "ix_kg_evidences_source_type", "kg_evidences")
_drop_index_if_exists(bind, "ix_kg_evidences_content_hash", "kg_evidences")
_drop_index_if_exists(bind, "ix_kg_evidences_evidence_type", "kg_evidences")
_drop_index_if_exists(bind, "ix_kg_evidences_relation_id", "kg_evidences")
_drop_index_if_exists(bind, "ix_kg_evidences_entity_id", "kg_evidences")
_drop_index_if_exists(bind, "ix_kg_evidences_tenant_id", "kg_evidences")
op.drop_table("kg_evidences")
if _table_exists(bind, "kg_relations"):
_drop_index_if_exists(bind, "ix_kg_relations_source_type", "kg_relations")
_drop_index_if_exists(bind, "ix_kg_relations_object_entity_id", "kg_relations")
_drop_index_if_exists(bind, "ix_kg_relations_subject_entity_id", "kg_relations")
_drop_index_if_exists(bind, "ix_kg_relations_relation_type", "kg_relations")
_drop_index_if_exists(bind, "ix_kg_relations_tenant_id", "kg_relations")
op.drop_table("kg_relations")
if _table_exists(bind, "kg_entities"):
_drop_index_if_exists(bind, "ix_kg_entities_last_seen_at", "kg_entities")
_drop_index_if_exists(bind, "ix_kg_entities_source_type", "kg_entities")
_drop_index_if_exists(bind, "ix_kg_entities_entity_key", "kg_entities")
_drop_index_if_exists(bind, "ix_kg_entities_entity_type", "kg_entities")
_drop_index_if_exists(bind, "ix_kg_entities_tenant_id", "kg_entities")
op.drop_table("kg_entities")

View File

@ -0,0 +1,88 @@
"""add qa session state table
Revision ID: add_qa_session_state_table
Revises: add_project_knowledge_graph_tables
Create Date: 2026-03-02
"""
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 identifiers, used by Alembic.
revision = "add_qa_session_state_table"
down_revision = "add_project_knowledge_graph_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:
inspector = sa.inspect(bind)
return inspector.has_table(table_name)
def _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
indexes = inspector.get_indexes(table_name)
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]) -> None:
if not _index_exists(bind, table_name, index_name):
op.create_index(index_name, table_name, columns)
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_session_states"):
op.create_table(
"qa_session_states",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
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.Column("session_id", sa.Integer(), sa.ForeignKey("qa_sessions.id", ondelete="CASCADE"), nullable=False, unique=True),
sa.Column("last_subject", sa.String(length=255), nullable=True),
sa.Column("last_entity_ids", _json_type(bind), nullable=True),
sa.Column("last_intent", sa.String(length=20), nullable=True),
sa.Column("ambiguity", sa.String(length=20), nullable=True),
sa.Column("rewrite_history", _json_type(bind), nullable=True),
)
_create_index_if_not_exists(bind, "ix_qa_session_states_tenant_id", "qa_session_states", ["tenant_id"])
_create_index_if_not_exists(bind, "ix_qa_session_states_created_at", "qa_session_states", ["created_at"])
_create_index_if_not_exists(bind, "ix_qa_session_states_session_id", "qa_session_states", ["session_id"])
_create_index_if_not_exists(bind, "ix_qa_session_states_last_subject", "qa_session_states", ["last_subject"])
_create_index_if_not_exists(bind, "ix_qa_session_states_last_intent", "qa_session_states", ["last_intent"])
_create_index_if_not_exists(bind, "ix_qa_session_states_ambiguity", "qa_session_states", ["ambiguity"])
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "qa_session_states"):
return
_drop_index_if_exists(bind, "ix_qa_session_states_ambiguity", "qa_session_states")
_drop_index_if_exists(bind, "ix_qa_session_states_last_intent", "qa_session_states")
_drop_index_if_exists(bind, "ix_qa_session_states_last_subject", "qa_session_states")
_drop_index_if_exists(bind, "ix_qa_session_states_session_id", "qa_session_states")
_drop_index_if_exists(bind, "ix_qa_session_states_created_at", "qa_session_states")
_drop_index_if_exists(bind, "ix_qa_session_states_tenant_id", "qa_session_states")
op.drop_table("qa_session_states")

View File

@ -0,0 +1,139 @@
"""add qa sessions and session fields
Revision ID: add_qa_sessions_and_turns
Revises: add_adaptive_rag_tables
Create Date: 2026-03-02
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "add_qa_sessions_and_turns"
down_revision = "add_adaptive_rag_tables"
branch_labels = None
depends_on = None
def _table_exists(bind, table_name: str) -> bool:
inspector = sa.inspect(bind)
return inspector.has_table(table_name)
def _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
indexes = inspector.get_indexes(table_name)
return any(idx.get("name") == index_name for idx in indexes)
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 _fk_exists(bind, table_name: str, constrained_columns: list[str]) -> bool:
inspector = sa.inspect(bind)
try:
fks = inspector.get_foreign_keys(table_name)
except Exception:
return False
expected = tuple(constrained_columns)
for fk in fks:
cols = tuple(fk.get("constrained_columns") or [])
if cols == expected:
return True
return False
def _create_index_if_not_exists(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)
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()
dialect_name = str(bind.dialect.name or "").lower()
if not _table_exists(bind, "qa_sessions"):
op.create_table(
"qa_sessions",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
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.Column("user_id", sa.BigInteger(), nullable=True),
sa.Column("title", sa.String(length=120), nullable=False, server_default="新会话"),
sa.Column("project_id", sa.BigInteger(), nullable=True),
sa.Column("started_at", sa.TIMESTAMP(), nullable=True),
sa.Column("last_message_at", sa.TIMESTAMP(), nullable=True),
sa.Column("message_count", sa.Integer(), nullable=False, server_default="0"),
)
_create_index_if_not_exists(bind, "ix_qa_sessions_tenant_id", "qa_sessions", ["tenant_id"])
_create_index_if_not_exists(bind, "ix_qa_sessions_created_at", "qa_sessions", ["created_at"])
_create_index_if_not_exists(bind, "ix_qa_sessions_user_id", "qa_sessions", ["user_id"])
_create_index_if_not_exists(bind, "ix_qa_sessions_project_id", "qa_sessions", ["project_id"])
_create_index_if_not_exists(bind, "ix_qa_sessions_started_at", "qa_sessions", ["started_at"])
_create_index_if_not_exists(bind, "ix_qa_sessions_last_message_at", "qa_sessions", ["last_message_at"])
if _table_exists(bind, "qa_records"):
if not _column_exists(bind, "qa_records", "session_id"):
op.add_column("qa_records", sa.Column("session_id", sa.Integer(), nullable=True))
if not _column_exists(bind, "qa_records", "turn_index"):
op.add_column(
"qa_records",
sa.Column("turn_index", sa.Integer(), nullable=False, server_default="1"),
)
_create_index_if_not_exists(bind, "ix_qa_records_session_id", "qa_records", ["session_id"])
_create_index_if_not_exists(bind, "ix_qa_records_turn_index", "qa_records", ["turn_index"])
if (
dialect_name != "sqlite"
and _table_exists(bind, "qa_sessions")
and not _fk_exists(bind, "qa_records", ["session_id"])
):
op.create_foreign_key(
"fk_qa_records_session_id",
"qa_records",
"qa_sessions",
["session_id"],
["id"],
ondelete="SET NULL",
)
def downgrade() -> None:
bind = op.get_bind()
dialect_name = str(bind.dialect.name or "").lower()
if _table_exists(bind, "qa_records"):
if dialect_name != "sqlite" and _fk_exists(bind, "qa_records", ["session_id"]):
op.drop_constraint("fk_qa_records_session_id", "qa_records", type_="foreignkey")
_drop_index_if_exists(bind, "ix_qa_records_turn_index", "qa_records")
_drop_index_if_exists(bind, "ix_qa_records_session_id", "qa_records")
if _column_exists(bind, "qa_records", "turn_index") or _column_exists(bind, "qa_records", "session_id"):
with op.batch_alter_table("qa_records") as batch_op:
if _column_exists(bind, "qa_records", "turn_index"):
batch_op.drop_column("turn_index")
if _column_exists(bind, "qa_records", "session_id"):
batch_op.drop_column("session_id")
if _table_exists(bind, "qa_sessions"):
_drop_index_if_exists(bind, "ix_qa_sessions_last_message_at", "qa_sessions")
_drop_index_if_exists(bind, "ix_qa_sessions_started_at", "qa_sessions")
_drop_index_if_exists(bind, "ix_qa_sessions_project_id", "qa_sessions")
_drop_index_if_exists(bind, "ix_qa_sessions_user_id", "qa_sessions")
_drop_index_if_exists(bind, "ix_qa_sessions_created_at", "qa_sessions")
_drop_index_if_exists(bind, "ix_qa_sessions_tenant_id", "qa_sessions")
op.drop_table("qa_sessions")

View File

@ -0,0 +1,155 @@
"""add symbol index tables
Revision ID: add_symbol_index_tables
Revises: add_kg_sync_runs
Create Date: 2026-03-04
"""
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 identifiers, used by Alembic.
revision = "add_symbol_index_tables"
down_revision = "add_kg_sync_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:
inspector = sa.inspect(bind)
return inspector.has_table(table_name)
def _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
indexes = inspector.get_indexes(table_name)
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]) -> None:
if not _index_exists(bind, table_name, index_name):
op.create_index(index_name, table_name, columns)
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, "symbol_definitions"):
op.create_table(
"symbol_definitions",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
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.Column("file_path", sa.String(length=600), nullable=False),
sa.Column("language", sa.String(length=20), nullable=False),
sa.Column("symbol_type", sa.String(length=32), nullable=False),
sa.Column("symbol_name", sa.String(length=255), nullable=False),
sa.Column("qualified_name", sa.String(length=700), nullable=True),
sa.Column("start_line", sa.Integer(), nullable=True),
sa.Column("end_line", sa.Integer(), nullable=True),
sa.Column("signature", sa.String(length=1000), nullable=True),
sa.Column("docstring", sa.String(length=2000), nullable=True),
sa.Column("file_hash", sa.String(length=64), nullable=True),
sa.Column("source_mtime", sa.TIMESTAMP(), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default="active"),
sa.Column("payload", _json_type(bind), nullable=True),
sa.Column("last_indexed_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint(
"tenant_id",
"file_path",
"symbol_type",
"symbol_name",
"start_line",
name="uq_symbol_definitions_tenant_file_type_name_line",
),
)
_create_index_if_not_exists(bind, "ix_symbol_definitions_tenant_id", "symbol_definitions", ["tenant_id"])
_create_index_if_not_exists(bind, "ix_symbol_definitions_file_path", "symbol_definitions", ["file_path"])
_create_index_if_not_exists(bind, "ix_symbol_definitions_language", "symbol_definitions", ["language"])
_create_index_if_not_exists(bind, "ix_symbol_definitions_symbol_type", "symbol_definitions", ["symbol_type"])
_create_index_if_not_exists(bind, "ix_symbol_definitions_symbol_name", "symbol_definitions", ["symbol_name"])
_create_index_if_not_exists(bind, "ix_symbol_definitions_qualified_name", "symbol_definitions", ["qualified_name"])
_create_index_if_not_exists(bind, "ix_symbol_definitions_start_line", "symbol_definitions", ["start_line"])
_create_index_if_not_exists(bind, "ix_symbol_definitions_file_hash", "symbol_definitions", ["file_hash"])
_create_index_if_not_exists(bind, "ix_symbol_definitions_status", "symbol_definitions", ["status"])
_create_index_if_not_exists(bind, "ix_symbol_definitions_last_indexed_at", "symbol_definitions", ["last_indexed_at"])
if not _table_exists(bind, "symbol_references"):
op.create_table(
"symbol_references",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
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.Column("source_symbol_id", sa.Integer(), sa.ForeignKey("symbol_definitions.id", ondelete="CASCADE"), nullable=True),
sa.Column("file_path", sa.String(length=600), nullable=False),
sa.Column("relation_type", sa.String(length=32), nullable=False),
sa.Column("target_symbol_name", sa.String(length=255), nullable=False),
sa.Column("target_qualified_name", sa.String(length=700), nullable=True),
sa.Column("start_line", sa.Integer(), nullable=True),
sa.Column("end_line", sa.Integer(), nullable=True),
sa.Column("payload", _json_type(bind), nullable=True),
sa.UniqueConstraint(
"tenant_id",
"file_path",
"relation_type",
"target_symbol_name",
"start_line",
"source_symbol_id",
name="uq_symbol_references_tenant_file_relation_target_line_source",
),
)
_create_index_if_not_exists(bind, "ix_symbol_references_tenant_id", "symbol_references", ["tenant_id"])
_create_index_if_not_exists(bind, "ix_symbol_references_source_symbol_id", "symbol_references", ["source_symbol_id"])
_create_index_if_not_exists(bind, "ix_symbol_references_file_path", "symbol_references", ["file_path"])
_create_index_if_not_exists(bind, "ix_symbol_references_relation_type", "symbol_references", ["relation_type"])
_create_index_if_not_exists(bind, "ix_symbol_references_target_symbol_name", "symbol_references", ["target_symbol_name"])
_create_index_if_not_exists(bind, "ix_symbol_references_target_qualified_name", "symbol_references", ["target_qualified_name"])
_create_index_if_not_exists(bind, "ix_symbol_references_start_line", "symbol_references", ["start_line"])
def downgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "symbol_references"):
_drop_index_if_exists(bind, "ix_symbol_references_start_line", "symbol_references")
_drop_index_if_exists(bind, "ix_symbol_references_target_qualified_name", "symbol_references")
_drop_index_if_exists(bind, "ix_symbol_references_target_symbol_name", "symbol_references")
_drop_index_if_exists(bind, "ix_symbol_references_relation_type", "symbol_references")
_drop_index_if_exists(bind, "ix_symbol_references_file_path", "symbol_references")
_drop_index_if_exists(bind, "ix_symbol_references_source_symbol_id", "symbol_references")
_drop_index_if_exists(bind, "ix_symbol_references_tenant_id", "symbol_references")
op.drop_table("symbol_references")
if _table_exists(bind, "symbol_definitions"):
_drop_index_if_exists(bind, "ix_symbol_definitions_last_indexed_at", "symbol_definitions")
_drop_index_if_exists(bind, "ix_symbol_definitions_status", "symbol_definitions")
_drop_index_if_exists(bind, "ix_symbol_definitions_file_hash", "symbol_definitions")
_drop_index_if_exists(bind, "ix_symbol_definitions_start_line", "symbol_definitions")
_drop_index_if_exists(bind, "ix_symbol_definitions_qualified_name", "symbol_definitions")
_drop_index_if_exists(bind, "ix_symbol_definitions_symbol_name", "symbol_definitions")
_drop_index_if_exists(bind, "ix_symbol_definitions_symbol_type", "symbol_definitions")
_drop_index_if_exists(bind, "ix_symbol_definitions_language", "symbol_definitions")
_drop_index_if_exists(bind, "ix_symbol_definitions_file_path", "symbol_definitions")
_drop_index_if_exists(bind, "ix_symbol_definitions_tenant_id", "symbol_definitions")
op.drop_table("symbol_definitions")

View File

@ -0,0 +1,259 @@
"""add feishu outbox and idempotency guards
Revision ID: feishu_outbox_idempotency
Revises: add_symbol_index_tables
Create Date: 2026-03-05
"""
from __future__ import annotations
from typing import Any
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "feishu_outbox_idempotency"
down_revision = "add_symbol_index_tables"
branch_labels = None
depends_on = None
def _table_exists(bind, table_name: str) -> bool:
inspector = sa.inspect(bind)
return inspector.has_table(table_name)
def _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
indexes = inspector.get_indexes(table_name)
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,
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, 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 _repoint_fk(bind, *, table_name: str, column_name: str, old_id: int, new_id: int) -> None:
if not _table_exists(bind, table_name):
return
bind.execute(
sa.text(
f"UPDATE {table_name} "
f"SET {column_name} = :new_id "
f"WHERE {column_name} = :old_id"
),
{"new_id": int(new_id), "old_id": int(old_id)},
)
def _dedupe_feishu_items(bind) -> None:
if not _table_exists(bind, "feishu_items"):
return
groups = bind.execute(
sa.text(
"""
SELECT tenant_id, feishu_message_id, split_from_item_id, MIN(id) AS keep_id
FROM feishu_items
WHERE feishu_message_id IS NOT NULL
GROUP BY tenant_id, feishu_message_id, split_from_item_id
HAVING COUNT(*) > 1
"""
)
).fetchall()
for row in groups:
tenant_id = int(row[0])
message_id = str(row[1])
split_from_item_id = row[2]
keep_id = int(row[3])
if split_from_item_id is None:
duplicate_rows = bind.execute(
sa.text(
"""
SELECT id
FROM feishu_items
WHERE tenant_id = :tenant_id
AND feishu_message_id = :message_id
AND split_from_item_id IS NULL
AND id <> :keep_id
ORDER BY id ASC
"""
),
{
"tenant_id": tenant_id,
"message_id": message_id,
"keep_id": keep_id,
},
).fetchall()
else:
duplicate_rows = bind.execute(
sa.text(
"""
SELECT id
FROM feishu_items
WHERE tenant_id = :tenant_id
AND feishu_message_id = :message_id
AND split_from_item_id = :split_from_item_id
AND id <> :keep_id
ORDER BY id ASC
"""
),
{
"tenant_id": tenant_id,
"message_id": message_id,
"split_from_item_id": int(split_from_item_id),
"keep_id": keep_id,
},
).fetchall()
for dup_row in duplicate_rows:
duplicate_id = int(dup_row[0])
_repoint_fk(
bind,
table_name="feishu_items",
column_name="split_from_item_id",
old_id=duplicate_id,
new_id=keep_id,
)
_repoint_fk(
bind,
table_name="signals",
column_name="feishu_item_id",
old_id=duplicate_id,
new_id=keep_id,
)
_repoint_fk(
bind,
table_name="project_suggestions",
column_name="feishu_item_id",
old_id=duplicate_id,
new_id=keep_id,
)
_repoint_fk(
bind,
table_name="ai_processing_logs",
column_name="feishu_item_id",
old_id=duplicate_id,
new_id=keep_id,
)
bind.execute(
sa.text("DELETE FROM feishu_items WHERE id = :id"),
{"id": duplicate_id},
)
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "feishu_reply_outbox"):
op.create_table(
"feishu_reply_outbox",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
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.Column("trace_id", sa.String(length=64), nullable=True),
sa.Column("chat_id", sa.String(length=128), nullable=False),
sa.Column("message_text", sa.Text(), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False, server_default="pending"),
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
sa.Column("next_retry_at", sa.TIMESTAMP(), nullable=True),
sa.Column("sent_at", sa.TIMESTAMP(), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
)
if _table_exists(bind, "feishu_reply_outbox"):
_create_index_if_not_exists(
bind,
index_name="ix_feishu_reply_outbox_tenant_id",
table_name="feishu_reply_outbox",
columns=["tenant_id"],
)
_create_index_if_not_exists(
bind,
index_name="ix_feishu_reply_outbox_trace_id",
table_name="feishu_reply_outbox",
columns=["trace_id"],
)
_create_index_if_not_exists(
bind,
index_name="ix_feishu_reply_outbox_chat_id",
table_name="feishu_reply_outbox",
columns=["chat_id"],
)
_create_index_if_not_exists(
bind,
index_name="ix_feishu_reply_outbox_status",
table_name="feishu_reply_outbox",
columns=["status"],
)
_create_index_if_not_exists(
bind,
index_name="ix_feishu_reply_outbox_next_retry_at",
table_name="feishu_reply_outbox",
columns=["next_retry_at"],
)
if _table_exists(bind, "feishu_items"):
_dedupe_feishu_items(bind)
where_root = sa.text("split_from_item_id IS NULL AND feishu_message_id IS NOT NULL")
where_split = sa.text("split_from_item_id IS NOT NULL AND feishu_message_id IS NOT NULL")
_create_index_if_not_exists(
bind,
index_name="uq_feishu_items_tenant_message_root",
table_name="feishu_items",
columns=["tenant_id", "feishu_message_id"],
unique=True,
postgresql_where=where_root,
sqlite_where=where_root,
)
_create_index_if_not_exists(
bind,
index_name="uq_feishu_items_tenant_message_split",
table_name="feishu_items",
columns=["tenant_id", "feishu_message_id", "split_from_item_id"],
unique=True,
postgresql_where=where_split,
sqlite_where=where_split,
)
def downgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "feishu_items"):
_drop_index_if_exists(bind, "uq_feishu_items_tenant_message_split", "feishu_items")
_drop_index_if_exists(bind, "uq_feishu_items_tenant_message_root", "feishu_items")
if _table_exists(bind, "feishu_reply_outbox"):
_drop_index_if_exists(bind, "ix_feishu_reply_outbox_next_retry_at", "feishu_reply_outbox")
_drop_index_if_exists(bind, "ix_feishu_reply_outbox_status", "feishu_reply_outbox")
_drop_index_if_exists(bind, "ix_feishu_reply_outbox_chat_id", "feishu_reply_outbox")
_drop_index_if_exists(bind, "ix_feishu_reply_outbox_trace_id", "feishu_reply_outbox")
_drop_index_if_exists(bind, "ix_feishu_reply_outbox_tenant_id", "feishu_reply_outbox")
op.drop_table("feishu_reply_outbox")

View File

@ -0,0 +1,136 @@
"""add operation execution ledger
Revision ID: add_operation_execution_ledger
Revises: feishu_outbox_idempotency
Create Date: 2026-03-06
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "add_operation_execution_ledger"
down_revision = "feishu_outbox_idempotency"
branch_labels = None
depends_on = None
def _table_exists(bind, table_name: str) -> bool:
inspector = sa.inspect(bind)
return inspector.has_table(table_name)
def _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
indexes = inspector.get_indexes(table_name)
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 _index_exists(bind, table_name, index_name):
return
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, "operation_execution_ledger"):
op.create_table(
"operation_execution_ledger",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
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.Column("operation_code", sa.String(length=64), nullable=False),
sa.Column("project_id", sa.BigInteger(), nullable=True),
sa.Column("idempotency_key", sa.String(length=128), nullable=False),
sa.Column("trace_id", sa.String(length=64), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default="processing"),
sa.Column("payload", sa.JSON(), nullable=True),
sa.Column("result_payload", sa.JSON(), nullable=True),
sa.Column("entity_type", sa.String(length=64), nullable=True),
sa.Column("entity_id", sa.String(length=128), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"]),
sa.UniqueConstraint(
"tenant_id",
"operation_code",
"idempotency_key",
name="uq_operation_execution_ledger_key",
),
)
if _table_exists(bind, "operation_execution_ledger"):
_create_index_if_not_exists(
bind,
index_name="ix_operation_execution_ledger_tenant_id",
table_name="operation_execution_ledger",
columns=["tenant_id"],
)
_create_index_if_not_exists(
bind,
index_name="ix_operation_execution_ledger_operation_code",
table_name="operation_execution_ledger",
columns=["operation_code"],
)
_create_index_if_not_exists(
bind,
index_name="ix_operation_execution_ledger_project_id",
table_name="operation_execution_ledger",
columns=["project_id"],
)
_create_index_if_not_exists(
bind,
index_name="ix_operation_execution_ledger_trace_id",
table_name="operation_execution_ledger",
columns=["trace_id"],
)
_create_index_if_not_exists(
bind,
index_name="ix_operation_execution_ledger_status",
table_name="operation_execution_ledger",
columns=["status"],
)
_create_index_if_not_exists(
bind,
index_name="ix_operation_execution_ledger_entity_type",
table_name="operation_execution_ledger",
columns=["entity_type"],
)
_create_index_if_not_exists(
bind,
index_name="ix_operation_execution_ledger_entity_id",
table_name="operation_execution_ledger",
columns=["entity_id"],
)
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "operation_execution_ledger"):
return
_drop_index_if_exists(bind, "ix_operation_execution_ledger_entity_id", "operation_execution_ledger")
_drop_index_if_exists(bind, "ix_operation_execution_ledger_entity_type", "operation_execution_ledger")
_drop_index_if_exists(bind, "ix_operation_execution_ledger_status", "operation_execution_ledger")
_drop_index_if_exists(bind, "ix_operation_execution_ledger_trace_id", "operation_execution_ledger")
_drop_index_if_exists(bind, "ix_operation_execution_ledger_project_id", "operation_execution_ledger")
_drop_index_if_exists(bind, "ix_operation_execution_ledger_operation_code", "operation_execution_ledger")
_drop_index_if_exists(bind, "ix_operation_execution_ledger_tenant_id", "operation_execution_ledger")
op.drop_table("operation_execution_ledger")

View File

@ -0,0 +1,179 @@
"""add signal source orchestration tables
Revision ID: add_signal_source_orchestration_tables
Revises: add_operation_execution_ledger
Create Date: 2026-03-06
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "add_signal_source_orchestration_tables"
down_revision = "add_operation_execution_ledger"
branch_labels = None
depends_on = None
def _table_exists(bind, table_name: str) -> bool:
inspector = sa.inspect(bind)
return inspector.has_table(table_name)
def _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
indexes = inspector.get_indexes(table_name)
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 _index_exists(bind, table_name, index_name):
return
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, "signal_source_registries"):
op.create_table(
"signal_source_registries",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
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.Column("project_id", sa.BigInteger(), nullable=False),
sa.Column("signal_id", sa.BigInteger(), nullable=True),
sa.Column("created_by_user_id", sa.BigInteger(), nullable=True),
sa.Column("source_kind", sa.String(length=40), nullable=False),
sa.Column("source_name", sa.String(length=120), nullable=False),
sa.Column("source_identifier", sa.String(length=180), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("requested_via", sa.String(length=40), nullable=False, server_default="system"),
sa.Column("status", sa.String(length=20), nullable=False, server_default="active"),
sa.Column("onboarding_status", sa.String(length=20), nullable=False, server_default="pending"),
sa.Column("config", sa.JSON(), nullable=True),
sa.Column("raw_context", sa.JSON(), nullable=True),
sa.Column("last_signal_at", sa.TIMESTAMP(), nullable=True),
sa.Column("last_worker_run_at", sa.TIMESTAMP(), nullable=True),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"]),
sa.ForeignKeyConstraint(["signal_id"], ["signals.id"]),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
sa.UniqueConstraint(
"tenant_id",
"project_id",
"source_kind",
"source_identifier",
name="uq_signal_source_registry_key",
),
)
if not _table_exists(bind, "ai_worker_executions"):
op.create_table(
"ai_worker_executions",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
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.Column("project_id", sa.BigInteger(), nullable=False),
sa.Column("signal_id", sa.BigInteger(), nullable=True),
sa.Column("signal_source_registry_id", sa.BigInteger(), nullable=True),
sa.Column("operator_user_id", sa.BigInteger(), nullable=True),
sa.Column("trace_id", sa.String(length=64), nullable=False),
sa.Column("operation_trace_id", sa.String(length=64), nullable=True),
sa.Column("execution_type", sa.String(length=40), nullable=False, server_default="signal_source_onboarding"),
sa.Column("worker_role_key", sa.String(length=32), nullable=False),
sa.Column("worker_role_name", sa.String(length=64), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False, server_default="planned"),
sa.Column("summary", sa.Text(), nullable=True),
sa.Column("input_payload", sa.JSON(), nullable=True),
sa.Column("output_payload", sa.JSON(), 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.ForeignKeyConstraint(["project_id"], ["projects.id"]),
sa.ForeignKeyConstraint(["signal_id"], ["signals.id"]),
sa.ForeignKeyConstraint(["signal_source_registry_id"], ["signal_source_registries.id"]),
sa.ForeignKeyConstraint(["operator_user_id"], ["users.id"]),
sa.UniqueConstraint(
"tenant_id",
"trace_id",
"worker_role_key",
name="uq_ai_worker_execution_trace_role",
),
)
if _table_exists(bind, "signal_source_registries"):
for index_name, columns in (
("ix_signal_source_registries_tenant_id", ["tenant_id"]),
("ix_signal_source_registries_project_id", ["project_id"]),
("ix_signal_source_registries_signal_id", ["signal_id"]),
("ix_signal_source_registries_created_by_user_id", ["created_by_user_id"]),
("ix_signal_source_registries_source_kind", ["source_kind"]),
("ix_signal_source_registries_requested_via", ["requested_via"]),
("ix_signal_source_registries_status", ["status"]),
("ix_signal_source_registries_onboarding_status", ["onboarding_status"]),
):
_create_index_if_not_exists(bind, index_name=index_name, table_name="signal_source_registries", columns=columns)
if _table_exists(bind, "ai_worker_executions"):
for index_name, columns in (
("ix_ai_worker_executions_tenant_id", ["tenant_id"]),
("ix_ai_worker_executions_project_id", ["project_id"]),
("ix_ai_worker_executions_signal_id", ["signal_id"]),
("ix_ai_worker_executions_signal_source_registry_id", ["signal_source_registry_id"]),
("ix_ai_worker_executions_operator_user_id", ["operator_user_id"]),
("ix_ai_worker_executions_trace_id", ["trace_id"]),
("ix_ai_worker_executions_operation_trace_id", ["operation_trace_id"]),
("ix_ai_worker_executions_execution_type", ["execution_type"]),
("ix_ai_worker_executions_worker_role_key", ["worker_role_key"]),
("ix_ai_worker_executions_status", ["status"]),
):
_create_index_if_not_exists(bind, index_name=index_name, table_name="ai_worker_executions", columns=columns)
def downgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "ai_worker_executions"):
for index_name in (
"ix_ai_worker_executions_status",
"ix_ai_worker_executions_worker_role_key",
"ix_ai_worker_executions_execution_type",
"ix_ai_worker_executions_operation_trace_id",
"ix_ai_worker_executions_trace_id",
"ix_ai_worker_executions_operator_user_id",
"ix_ai_worker_executions_signal_source_registry_id",
"ix_ai_worker_executions_signal_id",
"ix_ai_worker_executions_project_id",
"ix_ai_worker_executions_tenant_id",
):
_drop_index_if_exists(bind, index_name, "ai_worker_executions")
op.drop_table("ai_worker_executions")
if _table_exists(bind, "signal_source_registries"):
for index_name in (
"ix_signal_source_registries_onboarding_status",
"ix_signal_source_registries_status",
"ix_signal_source_registries_requested_via",
"ix_signal_source_registries_source_kind",
"ix_signal_source_registries_created_by_user_id",
"ix_signal_source_registries_signal_id",
"ix_signal_source_registries_project_id",
"ix_signal_source_registries_tenant_id",
):
_drop_index_if_exists(bind, index_name, "signal_source_registries")
op.drop_table("signal_source_registries")

View File

@ -0,0 +1,114 @@
"""add ai orchestration runs
Revision ID: add_ai_orchestration_runs
Revises: add_signal_source_orchestration_tables
Create Date: 2026-03-07
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "add_ai_orchestration_runs"
down_revision = "add_signal_source_orchestration_tables"
branch_labels = None
depends_on = None
def _table_exists(bind, table_name: str) -> bool:
inspector = sa.inspect(bind)
return inspector.has_table(table_name)
def _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
indexes = inspector.get_indexes(table_name)
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 _index_exists(bind, table_name, index_name):
return
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, "ai_orchestration_runs"):
op.create_table(
"ai_orchestration_runs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
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.Column("trace_id", sa.String(length=64), nullable=False),
sa.Column("run_type", sa.String(length=40), nullable=False),
sa.Column("project_id", sa.BigInteger(), nullable=False),
sa.Column("signal_id", sa.BigInteger(), nullable=True),
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("planner_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_completed", sa.Integer(), nullable=False, server_default="0"),
sa.Column("worker_failed", sa.Integer(), nullable=False, server_default="0"),
sa.Column("summary", sa.Text(), nullable=True),
sa.Column("planner_diagnostics", sa.JSON(), nullable=True),
sa.Column("result_payload", sa.JSON(), nullable=True),
sa.Column("created_by_user_id", sa.BigInteger(), nullable=True),
sa.Column("started_at", sa.TIMESTAMP(), nullable=True),
sa.Column("completed_at", sa.TIMESTAMP(), nullable=True),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"]),
sa.ForeignKeyConstraint(["signal_id"], ["signals.id"]),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
sa.UniqueConstraint("tenant_id", "trace_id", name="uq_ai_orchestration_run_trace_id"),
)
if _table_exists(bind, "ai_orchestration_runs"):
for index_name, columns in (
("ix_ai_orchestration_runs_tenant_id", ["tenant_id"]),
("ix_ai_orchestration_runs_trace_id", ["trace_id"]),
("ix_ai_orchestration_runs_run_type", ["run_type"]),
("ix_ai_orchestration_runs_project_id", ["project_id"]),
("ix_ai_orchestration_runs_signal_id", ["signal_id"]),
("ix_ai_orchestration_runs_response_mode", ["response_mode"]),
("ix_ai_orchestration_runs_status", ["status"]),
("ix_ai_orchestration_runs_planner_status", ["planner_status"]),
("ix_ai_orchestration_runs_control_plane_status", ["control_plane_status"]),
("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)
def downgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "ai_orchestration_runs"):
for index_name in (
"ix_ai_orchestration_runs_created_by_user_id",
"ix_ai_orchestration_runs_control_plane_status",
"ix_ai_orchestration_runs_planner_status",
"ix_ai_orchestration_runs_status",
"ix_ai_orchestration_runs_response_mode",
"ix_ai_orchestration_runs_signal_id",
"ix_ai_orchestration_runs_project_id",
"ix_ai_orchestration_runs_run_type",
"ix_ai_orchestration_runs_trace_id",
"ix_ai_orchestration_runs_tenant_id",
):
_drop_index_if_exists(bind, index_name, "ai_orchestration_runs")
op.drop_table("ai_orchestration_runs")

View File

@ -0,0 +1,194 @@
"""add ai pmo kernel tables
Revision ID: add_ai_pmo_kernel_tables
Revises: add_ai_processing_audit_fields
Create Date: 2026-03-08
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "add_ai_pmo_kernel_tables"
down_revision = "add_ai_processing_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 _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 upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "work_items"):
op.create_table(
"work_items",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
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.Column("ingest_event_id", sa.BigInteger(), sa.ForeignKey("ingest_events.id", ondelete="CASCADE"), nullable=False),
sa.Column("source_system", sa.String(length=32), nullable=False),
sa.Column("source_message_id", sa.String(length=128), nullable=False),
sa.Column("state", sa.String(length=32), nullable=False, server_default="received"),
sa.Column("intent", sa.String(length=64), nullable=True),
sa.Column("decision_level", sa.String(length=20), nullable=True),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=True),
sa.Column("signal_id", sa.BigInteger(), sa.ForeignKey("signals.id"), nullable=True),
sa.Column("owner_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("dedupe_key", sa.String(length=255), nullable=True),
sa.Column("context_snapshot", sa.JSON(), nullable=True),
sa.Column("action_bundle", sa.JSON(), nullable=True),
sa.Column("execution_result", sa.JSON(), nullable=True),
sa.Column("review_summary", sa.JSON(), nullable=True),
sa.Column("reply_template_key", sa.String(length=64), nullable=True),
sa.UniqueConstraint("tenant_id", "source_system", "source_message_id", name="uq_work_items_source_message"),
sa.UniqueConstraint("tenant_id", "ingest_event_id", name="uq_work_items_ingest_event"),
)
if not _table_exists(bind, "decision_records"):
op.create_table(
"decision_records",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
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.Column("work_item_id", sa.BigInteger(), sa.ForeignKey("work_items.id", ondelete="CASCADE"), nullable=False),
sa.Column("decision_object_type", sa.String(length=40), nullable=False, server_default="portfolio"),
sa.Column("decision_object_id", sa.BigInteger(), nullable=True),
sa.Column("decision_level", sa.String(length=20), nullable=False, server_default="portfolio"),
sa.Column("context_snapshot", sa.JSON(), nullable=True),
sa.Column("rationale", sa.Text(), nullable=True),
sa.Column("confidence", sa.Float(), nullable=True),
sa.Column("risk_score", sa.Float(), nullable=True),
sa.Column("budget_impact", sa.JSON(), nullable=True),
sa.Column("resource_impact", sa.JSON(), nullable=True),
sa.Column("policy_gate_hit", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("policy_gate_code", sa.String(length=80), nullable=True),
sa.Column("action_bundle", sa.JSON(), nullable=True),
sa.Column("approval_requirement", sa.JSON(), nullable=True),
sa.Column("execution_result", sa.JSON(), nullable=True),
sa.Column("final_outcome", sa.String(length=32), nullable=True),
)
if not _table_exists(bind, "plan_bundles"):
op.create_table(
"plan_bundles",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
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.Column("work_item_id", sa.BigInteger(), sa.ForeignKey("work_items.id", ondelete="CASCADE"), nullable=False),
sa.Column("plan_type", sa.String(length=20), nullable=False),
sa.Column("summary", sa.Text(), nullable=True),
sa.Column("payload", sa.JSON(), nullable=True),
)
if not _table_exists(bind, "reply_receipts"):
op.create_table(
"reply_receipts",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
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.Column("work_item_id", sa.BigInteger(), sa.ForeignKey("work_items.id", ondelete="SET NULL"), nullable=True),
sa.Column("source_system", sa.String(length=32), nullable=False),
sa.Column("source_message_id", sa.String(length=128), nullable=False),
sa.Column("receipt_phase", sa.String(length=32), nullable=False),
sa.Column("template_key", sa.String(length=64), nullable=True),
sa.Column("payload", sa.JSON(), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default="pending"),
sa.UniqueConstraint(
"tenant_id",
"source_system",
"source_message_id",
"receipt_phase",
name="uq_reply_receipts_source_phase",
),
)
if not _table_exists(bind, "portfolio_snapshots"):
op.create_table(
"portfolio_snapshots",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
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.Column("scope_type", sa.String(length=20), nullable=False, server_default="tenant"),
sa.Column("scope_ref", sa.String(length=64), nullable=False),
sa.Column("summary", sa.JSON(), nullable=True),
sa.Column("payload", sa.JSON(), nullable=True),
)
for table_name, columns in (
("work_items", ["tenant_id"]),
("decision_records", ["tenant_id"]),
("plan_bundles", ["tenant_id"]),
("reply_receipts", ["tenant_id"]),
("portfolio_snapshots", ["tenant_id"]),
):
_create_index_if_missing(bind, table_name, f"ix_{table_name}_tenant_id", columns)
for table_name, index_name, columns in (
("work_items", "ix_work_items_source_system", ["source_system"]),
("work_items", "ix_work_items_source_message_id", ["source_message_id"]),
("work_items", "ix_work_items_state", ["state"]),
("work_items", "ix_work_items_intent", ["intent"]),
("work_items", "ix_work_items_decision_level", ["decision_level"]),
("work_items", "ix_work_items_project_id", ["project_id"]),
("work_items", "ix_work_items_signal_id", ["signal_id"]),
("work_items", "ix_work_items_owner_user_id", ["owner_user_id"]),
("work_items", "ix_work_items_dedupe_key", ["dedupe_key"]),
("work_items", "ix_work_items_ingest_event_id", ["ingest_event_id"]),
("decision_records", "ix_decision_records_work_item_id", ["work_item_id"]),
("decision_records", "ix_decision_records_decision_object_id", ["decision_object_id"]),
("decision_records", "ix_decision_records_decision_level", ["decision_level"]),
("decision_records", "ix_decision_records_policy_gate_hit", ["policy_gate_hit"]),
("decision_records", "ix_decision_records_policy_gate_code", ["policy_gate_code"]),
("decision_records", "ix_decision_records_final_outcome", ["final_outcome"]),
("plan_bundles", "ix_plan_bundles_work_item_id", ["work_item_id"]),
("plan_bundles", "ix_plan_bundles_plan_type", ["plan_type"]),
("reply_receipts", "ix_reply_receipts_work_item_id", ["work_item_id"]),
("reply_receipts", "ix_reply_receipts_source_system", ["source_system"]),
("reply_receipts", "ix_reply_receipts_source_message_id", ["source_message_id"]),
("reply_receipts", "ix_reply_receipts_receipt_phase", ["receipt_phase"]),
("reply_receipts", "ix_reply_receipts_status", ["status"]),
("portfolio_snapshots", "ix_portfolio_snapshots_scope_type", ["scope_type"]),
("portfolio_snapshots", "ix_portfolio_snapshots_scope_ref", ["scope_ref"]),
):
_create_index_if_missing(bind, table_name, index_name, columns)
def downgrade() -> None:
bind = op.get_bind()
for table_name, indexes in (
("portfolio_snapshots", ["ix_portfolio_snapshots_scope_ref", "ix_portfolio_snapshots_scope_type", "ix_portfolio_snapshots_tenant_id"]),
("reply_receipts", ["ix_reply_receipts_status", "ix_reply_receipts_receipt_phase", "ix_reply_receipts_source_message_id", "ix_reply_receipts_source_system", "ix_reply_receipts_work_item_id", "ix_reply_receipts_tenant_id"]),
("plan_bundles", ["ix_plan_bundles_plan_type", "ix_plan_bundles_work_item_id", "ix_plan_bundles_tenant_id"]),
("decision_records", ["ix_decision_records_final_outcome", "ix_decision_records_policy_gate_code", "ix_decision_records_policy_gate_hit", "ix_decision_records_decision_level", "ix_decision_records_decision_object_id", "ix_decision_records_work_item_id", "ix_decision_records_tenant_id"]),
("work_items", ["ix_work_items_ingest_event_id", "ix_work_items_dedupe_key", "ix_work_items_owner_user_id", "ix_work_items_signal_id", "ix_work_items_project_id", "ix_work_items_decision_level", "ix_work_items_intent", "ix_work_items_state", "ix_work_items_source_message_id", "ix_work_items_source_system", "ix_work_items_tenant_id"]),
):
if not _table_exists(bind, table_name):
continue
for index_name in indexes:
if _index_exists(bind, table_name, index_name):
op.drop_index(index_name, table_name=table_name)
for table_name in ("portfolio_snapshots", "reply_receipts", "plan_bundles", "decision_records", "work_items"):
if _table_exists(bind, table_name):
op.drop_table(table_name)

View File

@ -0,0 +1,89 @@
"""add ai processing audit fields
Revision ID: add_ai_processing_audit_fields
Revises: add_inbox_structured_fields
Create Date: 2026-03-08
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "add_ai_processing_audit_fields"
down_revision = "add_inbox_structured_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
columns = inspector.get_columns(table_name)
return any(col.get("name") == column_name for col in columns)
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 _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, 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 upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "ai_processing_logs"):
return
_add_column_if_missing(bind, "ai_processing_logs", sa.Column("normalized_text", sa.Text(), nullable=True))
_add_column_if_missing(bind, "ai_processing_logs", sa.Column("route", sa.String(length=80), nullable=True))
_add_column_if_missing(bind, "ai_processing_logs", sa.Column("intent", sa.String(length=40), nullable=True))
_add_column_if_missing(bind, "ai_processing_logs", sa.Column("executor", sa.String(length=40), nullable=True))
_add_column_if_missing(bind, "ai_processing_logs", sa.Column("operation_code", sa.String(length=80), nullable=True))
_add_column_if_missing(bind, "ai_processing_logs", sa.Column("operation_result", sa.JSON(), nullable=True))
_add_column_if_missing(bind, "ai_processing_logs", sa.Column("reply_source", sa.String(length=40), nullable=True))
_create_index_if_missing(bind, "ai_processing_logs", "ix_ai_processing_logs_route", ["route"])
_create_index_if_missing(bind, "ai_processing_logs", "ix_ai_processing_logs_intent", ["intent"])
_create_index_if_missing(bind, "ai_processing_logs", "ix_ai_processing_logs_operation_code", ["operation_code"])
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "ai_processing_logs"):
return
for index_name in (
"ix_ai_processing_logs_operation_code",
"ix_ai_processing_logs_intent",
"ix_ai_processing_logs_route",
):
if _index_exists(bind, "ai_processing_logs", index_name):
op.drop_index(index_name, table_name="ai_processing_logs")
for column_name in (
"reply_source",
"operation_result",
"operation_code",
"executor",
"intent",
"route",
"normalized_text",
):
if _column_exists(bind, "ai_processing_logs", column_name):
op.drop_column("ai_processing_logs", column_name)

View File

@ -0,0 +1,152 @@
"""add inbox structured fields
Revision ID: add_inbox_structured_fields
Revises: add_ai_orchestration_runs
Create Date: 2026-03-08
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "add_inbox_structured_fields"
down_revision = "add_ai_orchestration_runs"
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
columns = inspector.get_columns(table_name)
return any(col.get("name") == column_name for col in columns)
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 _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 _drop_column_if_exists(bind, table_name: str, column_name: str) -> None:
if _column_exists(bind, table_name, column_name):
op.drop_column(table_name, column_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()
if _table_exists(bind, "notifications"):
_add_column_if_missing(bind, "notifications", sa.Column("kind", sa.String(length=30), nullable=True))
_add_column_if_missing(bind, "notifications", sa.Column("priority", sa.String(length=20), nullable=True))
_add_column_if_missing(bind, "notifications", sa.Column("status", sa.String(length=30), nullable=True))
_add_column_if_missing(bind, "notifications", sa.Column("blocking_reason", sa.Text(), nullable=True))
_add_column_if_missing(bind, "notifications", sa.Column("recommended_action", sa.String(length=80), nullable=True))
_add_column_if_missing(bind, "notifications", sa.Column("dedupe_key", sa.String(length=255), nullable=True))
_add_column_if_missing(
bind,
"notifications",
sa.Column("occurrence_count", sa.Integer(), nullable=False, server_default="1"),
)
_add_column_if_missing(bind, "notifications", sa.Column("payload", sa.JSON(), nullable=True))
_create_index_if_missing(bind, "notifications", "ix_notifications_kind", ["kind"])
_create_index_if_missing(bind, "notifications", "ix_notifications_priority", ["priority"])
_create_index_if_missing(bind, "notifications", "ix_notifications_status", ["status"])
_create_index_if_missing(bind, "notifications", "ix_notifications_dedupe_key", ["dedupe_key"])
if _table_exists(bind, "feishu_items"):
_add_column_if_missing(bind, "feishu_items", sa.Column("decision_reason", sa.Text(), nullable=True))
_add_column_if_missing(bind, "feishu_items", sa.Column("blocking_reason", sa.Text(), nullable=True))
_add_column_if_missing(
bind,
"feishu_items",
sa.Column("safe_to_auto_execute", sa.Boolean(), nullable=False, server_default=sa.false()),
)
_add_column_if_missing(
bind,
"feishu_items",
sa.Column("auto_execute_state", sa.String(length=30), nullable=True),
)
_add_column_if_missing(bind, "feishu_items", sa.Column("dedupe_key", sa.String(length=255), nullable=True))
_add_column_if_missing(bind, "feishu_items", sa.Column("message_class", sa.String(length=30), nullable=True))
_add_column_if_missing(
bind,
"feishu_items",
sa.Column("occurrence_count", sa.Integer(), nullable=False, server_default="1"),
)
_add_column_if_missing(bind, "feishu_items", sa.Column("next_step_text", sa.Text(), nullable=True))
_add_column_if_missing(bind, "feishu_items", sa.Column("undoable_until", sa.TIMESTAMP(), nullable=True))
_add_column_if_missing(bind, "feishu_items", sa.Column("result_payload", sa.JSON(), nullable=True))
_create_index_if_missing(bind, "feishu_items", "ix_feishu_items_safe_to_auto_execute", ["safe_to_auto_execute"])
_create_index_if_missing(bind, "feishu_items", "ix_feishu_items_auto_execute_state", ["auto_execute_state"])
_create_index_if_missing(bind, "feishu_items", "ix_feishu_items_dedupe_key", ["dedupe_key"])
_create_index_if_missing(bind, "feishu_items", "ix_feishu_items_message_class", ["message_class"])
def downgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "notifications"):
for index_name in (
"ix_notifications_dedupe_key",
"ix_notifications_status",
"ix_notifications_priority",
"ix_notifications_kind",
):
_drop_index_if_exists(bind, "notifications", index_name)
for column_name in (
"payload",
"occurrence_count",
"dedupe_key",
"recommended_action",
"blocking_reason",
"status",
"priority",
"kind",
):
_drop_column_if_exists(bind, "notifications", column_name)
if _table_exists(bind, "feishu_items"):
for index_name in (
"ix_feishu_items_message_class",
"ix_feishu_items_dedupe_key",
"ix_feishu_items_auto_execute_state",
"ix_feishu_items_safe_to_auto_execute",
):
_drop_index_if_exists(bind, "feishu_items", index_name)
for column_name in (
"result_payload",
"undoable_until",
"next_step_text",
"occurrence_count",
"message_class",
"dedupe_key",
"auto_execute_state",
"safe_to_auto_execute",
"blocking_reason",
"decision_reason",
):
_drop_column_if_exists(bind, "feishu_items", column_name)

View File

@ -0,0 +1,40 @@
"""add ai pmo simulation field
Revision ID: add_ai_pmo_simulation_field
Revises: add_ai_pmo_kernel_tables
Create Date: 2026-03-09
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "add_ai_pmo_simulation_field"
down_revision = "add_ai_pmo_kernel_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(col.get("name") == column_name for col in inspector.get_columns(table_name))
def upgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "decision_records") and not _column_exists(bind, "decision_records", "simulation_result"):
op.add_column("decision_records", sa.Column("simulation_result", sa.JSON(), nullable=True))
def downgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "decision_records") and _column_exists(bind, "decision_records", "simulation_result"):
op.drop_column("decision_records", "simulation_result")

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

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