Compare commits
No commits in common. "master" and "main" have entirely different histories.
56
.env.example
|
|
@ -1,6 +1,3 @@
|
|||
# 唯一配置文件,backend/.env 已移除
|
||||
# 所有环境变量在此统一管理
|
||||
|
||||
# Docker Compose Runtime Secrets / Config
|
||||
POSTGRES_DB=pmflow
|
||||
POSTGRES_USER=pmflow
|
||||
|
|
@ -11,48 +8,35 @@ DB_PORT=5435
|
|||
REDIS_PORT=6379
|
||||
BACKEND_PORT=8000
|
||||
FRONTEND_PORT=5174
|
||||
FRONTEND_PROD_PORT=5175
|
||||
PMFLOW_EXPECTED_DB_DIALECT=postgresql
|
||||
|
||||
# Backend Runtime
|
||||
CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174
|
||||
LLM_PROVIDER=dashscope
|
||||
LLM_FALLBACK_PROVIDER=dashscope
|
||||
CONTROL_PLANE_ROUTING_ENABLED=true
|
||||
CONTROL_PLANE_ROUTING_RULES_PATH=backend/.control_plane/pmflow-routing-rules.json
|
||||
CONTROL_PLANE_AGENTS_DEFAULT_TIMEOUT_SECONDS=120
|
||||
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=
|
||||
LLM_PROVIDER=openai
|
||||
LLM_FALLBACK_PROVIDER=ollama
|
||||
OPENAI_BASE_URL=http://openclaw:18789/v1
|
||||
OPENAI_API_KEY=
|
||||
ZHIPU_API_KEY=
|
||||
OPENCLAW_ROUTING_ENABLED=true
|
||||
OPENCLAW_ROUTING_RULES_PATH=/app/backend/.openclaw/pmflow-routing-rules.json
|
||||
OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS=120
|
||||
LLM_MIXED_ROUTING_ENABLED=false
|
||||
LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true
|
||||
LLM_ROUTING_LOW_PROVIDER=openai
|
||||
LLM_ROUTING_LOW_MODEL=zai/glm-4-flash
|
||||
LLM_ROUTING_HIGH_PROVIDER=openai
|
||||
LLM_ROUTING_HIGH_MODEL=minimax/MiniMax-M2.5
|
||||
LLM_ROUTING_SAFETY_PROVIDER=openai
|
||||
LLM_ROUTING_SAFETY_MODEL=deepseek/deepseek-chat
|
||||
LLM_DIRECT_EMERGENCY_ENABLED=false
|
||||
RAG_DEEP_RETRIEVAL_STEPS=5
|
||||
RAG_DEEP_VECTOR_TOP_K=12
|
||||
RAG_DEEP_CONTEXT_BLOCKS=16
|
||||
RAG_DEEP_ANSWER_MAX_TOKENS=1400
|
||||
GLM_API_KEY=
|
||||
DEEPSEEK_API_KEY=
|
||||
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
|
||||
|
||||
# GitHub App platform connection.
|
||||
# SaaS/hosted PMFlow configures this once. End users only install the app and select repositories in GitHub.
|
||||
GITHUB_APP_ID=
|
||||
GITHUB_APP_SLUG=
|
||||
GITHUB_APP_INSTALL_URL=
|
||||
GITHUB_APP_CALLBACK_URL=
|
||||
GITHUB_APP_WEBHOOK_SECRET=
|
||||
# Prefer a secret-mounted PEM file in production. Host path: ./secrets/github-app-private-key.pem
|
||||
GITHUB_APP_PRIVATE_KEY_PATH=/app/secrets/github-app-private-key.pem
|
||||
GITHUB_APP_PRIVATE_KEY=
|
||||
|
|
|
|||
|
|
@ -1,40 +0,0 @@
|
|||
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
|
||||
|
|
@ -12,6 +12,7 @@ on:
|
|||
- "backend/app/main.py"
|
||||
- "backend/evals/feishu_replay_eval.py"
|
||||
- "backend/evals/golden_feishu_replay_v1.jsonl"
|
||||
- ".openclaw/pmflow-routing-rules.json"
|
||||
- "backend/.openclaw/pmflow-routing-rules.json"
|
||||
- "scripts/openclaw_routing_lint.py"
|
||||
- ".github/workflows/feishu-replay-gate.yml"
|
||||
|
|
@ -20,6 +21,7 @@ on:
|
|||
paths:
|
||||
- "backend/evals/feishu_replay_eval.py"
|
||||
- "backend/evals/golden_feishu_replay_v1.jsonl"
|
||||
- ".openclaw/pmflow-routing-rules.json"
|
||||
- "backend/.openclaw/pmflow-routing-rules.json"
|
||||
- "scripts/openclaw_routing_lint.py"
|
||||
- ".github/workflows/feishu-replay-gate.yml"
|
||||
|
|
@ -64,6 +66,7 @@ jobs:
|
|||
|
||||
- name: Lint OpenClaw routing rules
|
||||
run: |
|
||||
python scripts/openclaw_routing_lint.py --input .openclaw/pmflow-routing-rules.json
|
||||
python scripts/openclaw_routing_lint.py --input backend/.openclaw/pmflow-routing-rules.json
|
||||
|
||||
- name: Run replay eval
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ lerna-debug.log*
|
|||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
.worktrees/
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
|
|
@ -68,19 +67,3 @@ Pixelle-Video/
|
|||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
secrets/
|
||||
|
||||
# Playwright browser profiles
|
||||
frontend/.playwright-profiles/
|
||||
.playwright-cli/
|
||||
frontend/.playwright-cli/
|
||||
frontend/output/
|
||||
output/
|
||||
.tmp-feishu-*.png
|
||||
task_instance_*.xlsx
|
||||
yesterday_tasks_*.csv
|
||||
yesterday_tasks_*.xlsx
|
||||
工单数据_*.xlsx
|
||||
工单全字段报表_*.xlsx
|
||||
tmp_short_confirm_redesign_summary.html
|
||||
ai.rtf
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
"version": 1,
|
||||
"setupCompletedAt": "2026-03-26T16:00:06.817Z"
|
||||
"onboardingCompletedAt": "2026-03-04T07:50:38.457Z"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
{"type":"click","text":"C. Tab 分对象类型\n \n 概览 | 项目 | 任务 | 信号 | 文档 | 人员 | 宪章 | 发布 | 复盘\n \n 当前 tab facts 数\n 失败数\n 最后抽取\n \n 当前对象类型的列表和详情\n \n \n \n \n 方案 C\n 对象边界最清楚,但你要横向看整体积压和失败会来回切 tab,运营效率最低。","choice":"c","id":null,"timestamp":1776240598958}
|
||||
{"type":"click","text":"C. Tab 分对象类型\n \n 概览 | 项目 | 任务 | 信号 | 文档 | 人员 | 宪章 | 发布 | 复盘\n \n 当前 tab facts 数\n 失败数\n 最后抽取\n \n 当前对象类型的列表和详情\n \n \n \n \n 方案 C\n 对象边界最清楚,但你要横向看整体积压和失败会来回切 tab,运营效率最低。","choice":"c","id":null,"timestamp":1776241280184}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"reason":"idle timeout","timestamp":1776243098908}
|
||||
|
|
@ -1 +0,0 @@
|
|||
95325
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
<h2>Semantic Facts 状态页设计稿</h2>
|
||||
<p class="subtitle">先确认页面信息架构和操作区布局。后端统一按“提交后自动入队 + 失活旧 facts + 手动重抽入口”设计。</p>
|
||||
|
||||
<div class="cards">
|
||||
<div class="card" data-choice="a" onclick="toggleSelect(this)">
|
||||
<div class="card-image">
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">A. 总览台 + 队列栏 + 对象表 + 详情抽屉</div>
|
||||
<div class="mockup-body">
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:10px;">
|
||||
<div class="placeholder" style="height:68px;">对象总数</div>
|
||||
<div class="placeholder" style="height:68px;">active facts</div>
|
||||
<div class="placeholder" style="height:68px;">失败队列</div>
|
||||
<div class="placeholder" style="height:68px;">积压时长</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1.2fr 0.8fr;gap:10px;margin-bottom:10px;">
|
||||
<div class="placeholder" style="height:92px;">按对象类型统计:项目 / 任务 / 信号 / 文档 / 人员 / 宪章 / 发布 / 复盘</div>
|
||||
<div class="placeholder" style="height:92px;">抽取队列状态:pending / running / retry / dead_letter</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 0.55fr;gap:10px;">
|
||||
<div class="placeholder" style="height:170px;">可搜索对象表:对象类型 / 标题 / facts 条数 / 最后抽取 / 队列状态 / 操作</div>
|
||||
<div class="placeholder" style="height:170px;">右侧详情抽屉:facts 列表 / 证据 / 最近 run / 重抽按钮</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3>方案 A</h3>
|
||||
<p>一页看全:先看风险和积压,再搜对象,再在右侧展开 facts 详情。我推荐这个,最适合排查“哪个对象没抽上、为什么失败、要不要重跑”。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" data-choice="b" onclick="toggleSelect(this)">
|
||||
<div class="card-image">
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">B. 双栏工作台</div>
|
||||
<div class="mockup-body">
|
||||
<div style="display:grid;grid-template-columns:0.9fr 1.1fr;gap:10px;">
|
||||
<div>
|
||||
<div class="placeholder" style="height:78px;margin-bottom:10px;">队列状态总览</div>
|
||||
<div class="placeholder" style="height:194px;">对象列表 + 类型筛选 + 搜索</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="placeholder" style="height:88px;margin-bottom:10px;">当前对象摘要:facts 数 / 最后抽取 / 最近失败</div>
|
||||
<div class="placeholder" style="height:184px;">facts 明细、run 时间线、手动重抽</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3>方案 B</h3>
|
||||
<p>偏“工作台”操作感,适合长时间盯一个对象排障;但全局统计和横向比较会弱一点。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" data-choice="c" onclick="toggleSelect(this)">
|
||||
<div class="card-image">
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">C. Tab 分对象类型</div>
|
||||
<div class="mockup-body">
|
||||
<div class="mock-nav" style="margin-bottom:10px;">概览 | 项目 | 任务 | 信号 | 文档 | 人员 | 宪章 | 发布 | 复盘</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:10px;">
|
||||
<div class="placeholder" style="height:76px;">当前 tab facts 数</div>
|
||||
<div class="placeholder" style="height:76px;">失败数</div>
|
||||
<div class="placeholder" style="height:76px;">最后抽取</div>
|
||||
</div>
|
||||
<div class="placeholder" style="height:190px;">当前对象类型的列表和详情</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3>方案 C</h3>
|
||||
<p>对象边界最清楚,但你要横向看整体积压和失败会来回切 tab,运营效率最低。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pros-cons" style="margin-top:18px;">
|
||||
<div class="pros">
|
||||
<h4>我推荐 A 的原因</h4>
|
||||
<ul>
|
||||
<li>既能看全局积压,也能快速钻到单对象 facts。</li>
|
||||
<li>和现有治理子页风格更一致,适合放到智能配置中心。</li>
|
||||
<li>后面要加 bulk backfill、失败重放、对象覆盖率都比较自然。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="cons">
|
||||
<h4>设计约束</h4>
|
||||
<ul>
|
||||
<li>这轮“需求”按宪章(charter)展示,不含运行中 draft。</li>
|
||||
<li>队列状态会直接暴露 pending / retry / dead_letter,不做粉饰。</li>
|
||||
<li>对象详情默认展示 active facts,历史失活 facts 放二级查看。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
<!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>
|
||||
|
Before Width: | Height: | Size: 35 KiB |
|
|
@ -1,7 +0,0 @@
|
|||
当前未接入实时数据,以下基于训练数据(截止2024年)。
|
||||
|
||||
[行业动态]
|
||||
近期AI行业呈现以下核心趋势:
|
||||
1. **大模型竞争白热化**:OpenAI、Anthropic、Google等厂商持续迭代模型(如GPT-4o、Claude 3.5),上下文窗口与推理能力显著提升,价格战促使API成本下降。
|
||||
2. **Agent(智能体)爆发**:行业重心从单一对话转向具备自主规划、工具调用能力的Agent,自动化解决复杂工作流成为热点。
|
||||
3. **垂直领域落地加速**:医疗、法律、HR等专业场景的AI应用从“尝鲜”转向“实效”,
|
||||
|
|
@ -1 +0,0 @@
|
|||
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
|
||||
|
Before Width: | Height: | Size: 64 KiB |
|
|
@ -1,28 +0,0 @@
|
|||
<!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>
|
||||
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
|
@ -1 +0,0 @@
|
|||
<!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>
|
||||
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
|
@ -1,25 +0,0 @@
|
|||
<!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>
|
||||
|
Before Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
|
@ -1,313 +0,0 @@
|
|||
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())
|
||||
|
|
@ -1,330 +0,0 @@
|
|||
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())
|
||||
|
Before Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 285 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
|
@ -1,291 +0,0 @@
|
|||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models import AICallLog, AIOrchestrationRun, AIProcessingLog, FeishuReplyOutbox
|
||||
|
||||
BASE = "http://127.0.0.1:8000"
|
||||
CHAT_ID = "oc_c487d87563e48c2f9566ccd5a758212c"
|
||||
SENDER = "P1验收机器人"
|
||||
BANNED_PREFIXES = ["让我先", "让我尝试", "我来看看", "我先查", "让我看看", "我需要先"]
|
||||
|
||||
|
||||
def login_token() -> str:
|
||||
data = urllib.parse.urlencode({"username": "admin", "password": "admin123"}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{BASE}/auth/login",
|
||||
data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
return str(payload.get("access_token") or "")
|
||||
|
||||
|
||||
def post_capture(token: str, message: str, source_message_id: str) -> dict[str, Any]:
|
||||
payload = {
|
||||
"group_chat_id": CHAT_ID,
|
||||
"message_content": message,
|
||||
"sender": SENDER,
|
||||
"feishu_message_id": source_message_id,
|
||||
}
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{BASE}/ai-steward/ai-steward/signal/capture-feishu",
|
||||
data=body,
|
||||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
text = resp.read().decode("utf-8")
|
||||
try:
|
||||
return json.loads(text)
|
||||
except Exception:
|
||||
return {"raw": text}
|
||||
|
||||
|
||||
def detect_prompt_scene(prompt: str) -> Optional[str]:
|
||||
p = str(prompt or "")
|
||||
if "你是 PMFlow 的团队协作助手。" in p:
|
||||
return "member_workload"
|
||||
if "你是 PMFlow 的市场情报助手。" in p:
|
||||
return "external_trend"
|
||||
if "用户消息同时包含问题和需求。" in p:
|
||||
return "mixed"
|
||||
if "你是 PMFlow 的 AI 项目管理官。" in p:
|
||||
return "ai_pmo"
|
||||
if "用户通过飞书直接提问。" in p:
|
||||
return "feishu_direct"
|
||||
if "你是 PMFlow 的 RAG 问答助手" in p:
|
||||
return "default"
|
||||
return None
|
||||
|
||||
|
||||
def infer_scene_from_reply(reply: str) -> str:
|
||||
t = str(reply or "")
|
||||
if "[回答]" in t and "[已收到需求]" in t:
|
||||
return "mixed"
|
||||
if "[行业动态]" in t and "[建议]" in t:
|
||||
return "external_trend"
|
||||
if ("负载" in t or "工作量" in t) and "%" in t and "项目列表" not in t:
|
||||
return "member_workload"
|
||||
if "完成率" in t:
|
||||
return "default"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def has_thinking_leak(text: str) -> bool:
|
||||
for line in str(text or "").splitlines():
|
||||
s = line.strip()
|
||||
if not s:
|
||||
continue
|
||||
if any(s.startswith(prefix) for prefix in BANNED_PREFIXES):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def fetch_max_ai_call_id(db) -> int:
|
||||
row = (await db.execute(select(func.max(AICallLog.id)))).scalar()
|
||||
return int(row or 0)
|
||||
|
||||
|
||||
async def wait_run(db, source_message_id: str, timeout_sec: int = 720) -> Optional[AIOrchestrationRun]:
|
||||
deadline = time.time() + timeout_sec
|
||||
last = None
|
||||
while time.time() < deadline:
|
||||
row = (
|
||||
await db.execute(
|
||||
select(AIOrchestrationRun)
|
||||
.where(AIOrchestrationRun.source_message_id == source_message_id)
|
||||
.order_by(AIOrchestrationRun.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is not None:
|
||||
last = row
|
||||
st = str(row.status or "").lower()
|
||||
if st in {"succeeded", "failed", "cancelled"}:
|
||||
return row
|
||||
await asyncio.sleep(2)
|
||||
return last
|
||||
|
||||
|
||||
async def wait_outbox(db, source_message_id: str, run_status: str, timeout_sec: int = 300) -> list[FeishuReplyOutbox]:
|
||||
deadline = time.time() + timeout_sec
|
||||
expected = 2 if run_status == "succeeded" else 1
|
||||
latest: list[FeishuReplyOutbox] = []
|
||||
while time.time() < deadline:
|
||||
latest = (
|
||||
await db.execute(
|
||||
select(FeishuReplyOutbox)
|
||||
.where(FeishuReplyOutbox.source_message_id == source_message_id)
|
||||
.order_by(FeishuReplyOutbox.id.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
sent = [r for r in latest if str(r.status or "") == "sent"]
|
||||
if len(sent) >= expected:
|
||||
return latest
|
||||
await asyncio.sleep(2)
|
||||
return latest
|
||||
|
||||
|
||||
async def fetch_processing(db, source_message_id: str) -> Optional[AIProcessingLog]:
|
||||
return (
|
||||
await db.execute(
|
||||
select(AIProcessingLog)
|
||||
.where(AIProcessingLog.feishu_message_id == source_message_id)
|
||||
.order_by(AIProcessingLog.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
async def fetch_prompt_scene(db, min_call_id: int, keys: list[str]) -> tuple[str, list[dict[str, Any]]]:
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(AICallLog)
|
||||
.where(
|
||||
AICallLog.id > min_call_id,
|
||||
AICallLog.function_name.in_(["adaptive_rag_answer_normal", "adaptive_rag_answer_deep"]),
|
||||
)
|
||||
.order_by(AICallLog.id.asc())
|
||||
.limit(200)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
lowered = [k.lower() for k in keys if k]
|
||||
picked = "unknown"
|
||||
candidates: list[dict[str, Any]] = []
|
||||
|
||||
for row in rows:
|
||||
prompt = str(row.prompt or "")
|
||||
pl = prompt.lower()
|
||||
if lowered and not any(k in pl for k in lowered):
|
||||
continue
|
||||
scene = detect_prompt_scene(prompt)
|
||||
candidates.append(
|
||||
{
|
||||
"id": int(row.id),
|
||||
"scene": scene or "unknown",
|
||||
"function": str(row.function_name or ""),
|
||||
"created_at": str(row.created_at or ""),
|
||||
}
|
||||
)
|
||||
if scene:
|
||||
picked = scene
|
||||
|
||||
if picked == "unknown" and candidates:
|
||||
picked = str(candidates[-1].get("scene") or "unknown")
|
||||
|
||||
return picked, candidates
|
||||
|
||||
|
||||
def evaluate(idx: int, actual_scene: str, final_reply: str, run_status: str) -> tuple[bool, str]:
|
||||
leak = has_thinking_leak(final_reply)
|
||||
if idx == 1:
|
||||
ok = actual_scene == "member_workload" and "%" in final_reply and "项目列表" not in final_reply and not leak
|
||||
return ok, "member_workload+百分比+无项目列表"
|
||||
if idx == 2:
|
||||
sec = "[行业动态]" in final_reply and ("[对项目影响]" in final_reply or "[对项目的影响]" in final_reply) and "[建议]" in final_reply
|
||||
src = "未接入实时数据" in final_reply or "训练数据" in final_reply or "数据来源" in final_reply
|
||||
ok = actual_scene == "external_trend" and sec and src and not leak
|
||||
return ok, "external_trend+三段输出+来源限制"
|
||||
if idx == 3:
|
||||
mixed = "[回答]" in final_reply and "[已收到需求]" in final_reply
|
||||
prog = "进行中" in final_reply
|
||||
ok = actual_scene == "mixed" and run_status == "succeeded" and mixed and prog and not leak
|
||||
return ok, "mixed+创建成功+进行中数量"
|
||||
if idx == 4:
|
||||
ok = actual_scene == "default" and "完成率" in final_reply and "%" in final_reply and not leak
|
||||
return ok, "default+项目状态完成率"
|
||||
if idx == 5:
|
||||
ok = actual_scene == "external_trend" and not leak
|
||||
return ok, "external_trend+无思考外漏"
|
||||
return False, "unknown"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--idx", type=int, required=True)
|
||||
parser.add_argument("--message", type=str, required=True)
|
||||
parser.add_argument("--keys", type=str, default="")
|
||||
parser.add_argument("--prefix", type=str, default="real-scene-p1-manual")
|
||||
args = parser.parse_args()
|
||||
|
||||
keys = [k.strip() for k in str(args.keys).split("|") if k.strip()]
|
||||
source_message_id = f"{args.prefix}-{args.idx}-{int(time.time())}"
|
||||
|
||||
token = login_token()
|
||||
if not token:
|
||||
raise RuntimeError("token empty")
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
before_call_id = await fetch_max_ai_call_id(db)
|
||||
|
||||
capture = post_capture(token, args.message, source_message_id)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
run_row = await wait_run(db, source_message_id)
|
||||
run_status = str(getattr(run_row, "status", "missing") or "missing") if run_row is not None else "missing"
|
||||
outbox_rows = await wait_outbox(db, source_message_id, run_status)
|
||||
processing = await fetch_processing(db, source_message_id)
|
||||
prompt_scene, candidates = await fetch_prompt_scene(db, before_call_id, keys)
|
||||
|
||||
sent_rows = [r for r in outbox_rows if str(r.status or "") == "sent"]
|
||||
ack = str(sent_rows[0].message_text or "").strip() if sent_rows else ""
|
||||
final_reply = str(sent_rows[-1].message_text or "").strip() if sent_rows else ""
|
||||
if not final_reply:
|
||||
final_reply = str(capture.get("direct_reply_text") or "").strip()
|
||||
|
||||
inferred_scene = infer_scene_from_reply(final_reply)
|
||||
actual_scene = prompt_scene if prompt_scene != "unknown" else inferred_scene
|
||||
passed, pass_rule = evaluate(args.idx, actual_scene, final_reply, run_status)
|
||||
|
||||
payload = {
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"idx": args.idx,
|
||||
"source_message_id": source_message_id,
|
||||
"message": args.message,
|
||||
"capture_status": str(capture.get("status") or ""),
|
||||
"run_status": run_status,
|
||||
"ack": ack,
|
||||
"final_reply": final_reply,
|
||||
"prompt_scene": prompt_scene,
|
||||
"inferred_scene": inferred_scene,
|
||||
"actual_scene": actual_scene,
|
||||
"thinking_leak": has_thinking_leak(final_reply),
|
||||
"has_capacity_warning": "⚠ 产能提示" in final_reply,
|
||||
"processing": {
|
||||
"route": str(getattr(processing, "route", "") or ""),
|
||||
"intent": str(getattr(processing, "intent", "") or ""),
|
||||
"status": str(getattr(processing, "processing_status", "") or ""),
|
||||
"duration_ms": int(getattr(processing, "duration_ms", 0) or 0),
|
||||
"error_message": str(getattr(processing, "error_message", "") or ""),
|
||||
"reply_source": str(getattr(processing, "reply_source", "") or ""),
|
||||
},
|
||||
"outbox": [
|
||||
{
|
||||
"id": int(r.id),
|
||||
"status": str(r.status or ""),
|
||||
"attempts": int(r.attempts or 0),
|
||||
"created_at": str(r.created_at or ""),
|
||||
"text": str(r.message_text or ""),
|
||||
}
|
||||
for r in outbox_rows
|
||||
],
|
||||
"call_log_candidates": candidates,
|
||||
"pass": passed,
|
||||
"pass_rule": pass_rule,
|
||||
}
|
||||
print(json.dumps(payload, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
218
AGENTS.md
|
|
@ -2,16 +2,7 @@
|
|||
|
||||
你是一位资深的全栈工程师,精通 Python 3.11+、FastAPI、SQLAlchemy 2.0、LangChain、React 18、TypeScript、shadcn/ui、Tailwind CSS。
|
||||
|
||||
你正在为 **PMFlow** 开发代码:这是一款 AI 原生、人员能力驱动的项目执行操作系统,首版面向个人开发者、一人公司、半技术产品创作者与 1-10 人 AI 产品小团队,采用 `solo-first, team-ready` 方向。
|
||||
|
||||
---
|
||||
|
||||
## USER.md 边界
|
||||
|
||||
- 本项目内用户真源以 `USER.md` 为准;若 `~/.codex/AGENTS.md` 与本项目 `USER.md` 冲突,以 `USER.md` 为准。
|
||||
- `USER.md` 承载本项目内的用户协作偏好、判断优先级、已拍板决策、反复踩坑与暂不处理问题。
|
||||
- 工程硬约束、系统边界、安全规则与验证要求仍以本文件和更外层提示为准。
|
||||
- 执行时先满足硬约束,再在不违背硬约束的前提下尽量贴合 `USER.md`。
|
||||
你正在为 **PMFlow** 开发代码:这是一款 AI 原生、人员能力驱动的项目管理操作系统,专为 10-50 人 AI 开发团队打造。
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -25,12 +16,7 @@
|
|||
|
||||
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/`
|
||||
- 禁止直接硬编码模型名称
|
||||
4. **AI 能力规范**:所有 AI 能力必须封装在独立的 service 层,使用 LangChain 实现,Prompt 放在 `./backend/app/config/ai_prompts.py` 独立管理,禁止硬编码。
|
||||
|
||||
5. **AI 调用规范**:所有 AI 调用必须:
|
||||
- 记录日志到 `ai_call_logs` 表
|
||||
|
|
@ -84,203 +70,3 @@ frontend/ # React 前端
|
|||
## 执行要求
|
||||
|
||||
根据用户的具体需求,生成高质量、可直接运行的代码。
|
||||
|
||||
- 计划类需求执行约定:若用户已确认计划/方案并要求开始实现,Agent 默认连续执行到“实现完成 + 验证完成 + 给出验收结果”后再统一回复;除非遇到明确阻塞、高风险冲突、context 限制或用户主动中断,否则不得中途停下来反复请示。
|
||||
|
||||
---
|
||||
|
||||
## 8. 服务边界约束与 AI 系统宪章(禁止越界)
|
||||
|
||||
- 数据库写操作必须通过 Alembic migration,禁止直接 `ALTER TABLE`
|
||||
- 新增接口必须遵循响应包络:`{code, message, data}`,不得裸返回
|
||||
- 禁止在 API 层写业务逻辑,业务逻辑归属 `services/`
|
||||
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
|
||||
- MCP 模板接入中心只允许保存模板参数、命令预览、runtime probe 与“本机已执行”确认状态;后端 `backend_direct` 模板可由 PMFlow backend 直接校验/调用(包括远程 `streamable_http` MCP 与内建 `backend_adapter` runtime),本地 `stdio` 模板只能走 PMFlow Local Bridge 或兼容 MCP host;禁止从服务端直接写宿主机 `~/.codex/config.toml`、禁止替客户执行本机命令,GitHub 类模板只允许保存 env var 名称或占位符,不得落明文 token。
|
||||
- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState`
|
||||
- 前端身份边界必须与真实写接口一致:`/space`、真实 `/project-launch`、`/me`、项目主链 standalone 与空间管理/平台运营入口必须登录;客户空间管理入口看当前 workspace role,平台运营入口看 `platform_context.capabilities`,禁止把客户空间 `OWNER/REVIEWER` 当成 PMFlow 平台管理员;`/project-launch?demo=1` 或 `?dataSource=demo` 是唯一匿名启动台演示入口,且只能使用本地 mock/demo,不得调用真实写接口。
|
||||
- 真实 `/project-launch` 的 AI 草案生成优先走任务化链路:`POST /pmflow-launch/draft-jobs` 创建生成任务,前端轮询 `GET /pmflow-launch/draft-jobs/{job_id}`;“补充一句”必须提交用户真实补充并生成新 revision,旧运行中 job 只能标记为 `superseded/cancelled` 或忽略其结果,禁止用前端硬编码补充、UI 暂停动画或旧同步请求冒充可暂停/续写的 AI 调用。
|
||||
- Project Launch、需求结构化、任务拆解等保护型结构化 AI 场景的超时必须来自显式场景运行策略并写入 `routing_meta`,包括 `scene_timeout_policy_id / input_bucket / provider_timeout_seconds / job_timeout_seconds / large_input_policy`;底层 transport 只消费该合同,不得靠散落常量或函数名私自决定。`structured_timeout` 必须作为模型超时单独归因和展示,禁止与 JSON 无效、空响应混成笼统“结构化失败”。
|
||||
- Project Launch 草案生成与项目主链“发送并提取”变更抽取的 `max_tokens / repair_max_tokens / temperature / response_mode / foreground_wait_seconds / provider_timeout_seconds / job_timeout_seconds / max_retries / large_input_policy` 必须来自 Function Runtime Profile active 发布版本;draft 只能在平台 AI Ops 的业务场景路由里编辑、校验、真实 smoke 和发布,运行时不得读取未发布 draft,也不得把散落常量作为唯一真源。`provider_timeout_seconds` 必须控制上游 HTTP client timeout,`job_timeout_seconds` 必须控制外层任务/调用超时失败,`max_retries` 必须控制 provider transient retry budget,`large_input_policy=preprocess_then_generate` 必须在 large 输入桶进入有界材料压缩后再渲染 prompt,`foreground_wait_seconds` 必须返回给前端作为前台等待窗口。
|
||||
- 用户可见错误必须使用产品语义分类,禁止直接展示 Axios/FastAPI 原始错误(如 `Request failed with status code 401`、`Could not validate credentials`、traceback);401/无 token 必须表达为登录/会话问题,pending intent 只能恢复输入,禁止登录后自动重放真实写请求。
|
||||
- 用户可见核心业务内容零兜底:AI 失败、证据不足、结构化失败时必须显式失败或标记待确认,禁止用默认句、模板句、stub 冒充成功。
|
||||
- PMFlow AI 用户侧入口必须遵守“双脑结构”:Space AI 是跨项目注意力、想法孵化、待确认聚合、AI 进行中监控和入口路由的 Operating Partner;项目级 AI 副驾是绑定 ProjectTruthState、ChangeSet、Evidence、ExecutionContext 与 ReleaseSignals 的上下文操作副驾。禁止把两者做成无对象锚点的泛聊天;Space AI 不得直接写 Project Memory、提交 ChangeSet 或发起 Execution Bridge handoff;项目副驾不得做跨项目经营排序、平台 provider/model/prompt 诊断或绕过确认链。
|
||||
- Space AI Concierge / 用户侧 PMFlow AI 必须把用户原始问题与结构化 `surface_context` 分开传递,只回答 PMFlow 产品/操作知识、当前 tenant/project/space 状态、下一步指导与带来源的外部公开信息;系统代码、数据库表、prompt key、trace、函数名、模型供应商、内部路由、技术架构与架构实现必须被 `PMFlowAIDisclosureState -> protected_internal_refusal.v1 -> AnswerVerifier` 主链阻断或清洗,返回 payload 禁止暴露 raw source label、tool trace 或底层异常,内部诊断只能走显式 `/platform/*` 入口。
|
||||
- `/space/pmflow-ai` 与兼容 `/space/ai-concierge/ask` 的用户侧主控必须先进入 `PMFlowAgentRuntimeService.run_space_turn()`,该服务只负责创建 durable `pmflow_agent_runs` 并委托 `PMFlowAIAgentRunService` 执行 `LLM Step Planner -> typed AgentToolRegistry tool -> Observation Ledger -> State Critic -> Terminal Answer State -> PMFlowAIAgentAnswerRuntime`。`pmflow_agent_runtime/loop.py`、legacy `AnswerArbitrator/NaturalAnswerComposer`、旧 Turn Runtime 和 CustomerAnswerPresentationService 只能作为 compatibility/tool/renderer 资产,禁止恢复为产品主控或最终答案来源。
|
||||
- 旧 `PMFlowAIConversationOrchestrator / SpaceAIExecutionContract / SpaceAITaskGraphService / pmflow_ai_runtime_kernel` 已从 Space 用户侧代码中删除;`PROMPT_PMFLOW_AI_ROUTE_PLANNER` 仅保留为 legacy/deprecated 资产,不得恢复为主控,也不得再拍板 `source_strategy / answer_contract / allowed_executors / answer_obligations` 或分派 analyzer 早返回。
|
||||
- `/space/ai-concierge/ask` 的 API 层只加载 context、结构化 `interaction_submission`、持久化和返回,不得绕过 Agent OS 主控。`interaction_submission` 只能折叠为 raw question、answers 和 supplement;`intent_hint / evidence_mode / primary_subject` 旧推导字段已删除,禁止从按钮、选项、question id 或关键词恢复。`route_plan / answer_contract / source_strategy / allowed_executors` 在 Space 用户侧只能作为兼容 payload 或离线解释字段,禁止再决定 action、来源、追问或最终 answer shape。
|
||||
- 项目查找、项目状态、风险、容量、MVP 等能力不再靠问句关键词或固定 `answer_contract` 进入专用链路;必须先由 TurnState 判断目标、对象锚点和证据缺口,再调用 `PMFlowAIEvidenceToolRegistry` 的白名单只读 evidence tool。普通项目清单/项目数量等 surface 可验证事实也只能作为 `inspect_surface_context` 工具结果进入 TurnState。Packet 6 后工具层只返回事实、来源、置信度、目标相关性和剩余缺口,不得生成 implication、judgement 或最终答案;`PMFlowAIEvidenceAbsorber` 是唯一把 evidence 转成 `fact / source / implication / relevance_to_goal / confidence`、`remaining_gaps` 和 `JudgementState` 的层;`CustomerAnswerPresentationService` 只消费 final TurnState/CustomerEvidencePacket 的 `answer_shape` 做 renderer,不得再按 quick intent、旧 route contract、packet 名称或 answer_type 场景枚举重判展示形态。旧项目查找/通用合成/展示模板早返回服务不得恢复。
|
||||
- 当前项目 `status_answer / execution_guidance` 必须先经过项目执行态覆盖门禁:`inspect_project_status` 必须读取 PMFlow OS truth-state 与 execution-summary,并同步输出 `ProjectOperatingEvidencePacket`,覆盖指挥台、项目工作台、决策与待确认、开发执行、测试质量、发布增长、团队层、项目记忆、风险阻塞和最近动态这些 PMFlow OS 全流程 facet;Packet R 后这些 facet 必须由独立 reader 消费对应 PMFlow OS 投影,禁止退回单函数拼字段或只读项目 `active`、项目数量、可见卡片状态生成推进建议。Packet S 后 `PMFlowAIEvidenceCoverageGate` 只能消费 `TurnState.evidence_state.structured_evidence.project_operating_evidence_packet(s)` 的 facet coverage profile,禁止再通过中文 finding 文案、关键词或固定短句判断 coverage。覆盖不足时必须先触发低风险只读 probe,不得直接合成最终答案或把系统缺口转成用户追问。Packet T 后最终合成必须生成并消费 `project_operating_answer_verification.v1`:`safe_next_actions` 才能进入正文/customer packet/canvas 的可推进动作,`blocked_actions` 只能进入不可直接做边界,`missing_or_partial_facets` 必须进入证据边界;禁止让模型或展示层绕过该 verification profile 自由生成完整推进方案。
|
||||
- `/space/ai-concierge/ask` 的 ActionSelector 只能根据 `PMFlowAITurnState.evidence_state.evidence_gaps`、`sufficiency_state` 和白名单只读 action 选择下一步;禁止按自然语言关键词、`looks_like_*`、业务枚举、旧 `answer_contract`、旧 `task_type` 或 executor 列表做运行时拍板。项目查找、项目状态、外部查询、MVP、风险、容量等能力只能通过 `PMFlowAIEvidenceToolRegistry` 包装成 evidence tool,不能早返回抢主控。
|
||||
- 项目状态、风险、容量和 MVP 工具仍必须遵守只读边界:只返回证据、依据、缺口和建议,不写 Project Memory、不提交 ChangeSet、不写 review decision、不发起执行、不发布、不自动派任务、不写 capacity override;对象范围由 TurnState 的 explicit/current page/reply/card/active workspace strong focus 决定,visible context 只能作为候选或证据范围。
|
||||
- 新增任何用户可见 PMFlow 能力时,如果用户会问“这个能力现在怎么样 / 下一步怎么办 / 能不能让 AI 处理 / 风险是什么”,必须优先同步加入 Agent OS typed tool 合同、planner/critic 行为、observation/artifact/verification 投影、Terminal Answer State/Answer Runtime、runtime golden behavior case、回归测试和文档;兼容 Turn Runtime 只作为旧链路适配。不得只新增 `goal_kind / answer_contract / analysis_task / business_frame / evidence_plan`、executor 枚举、关键词路由或 specialized analyzer;没有专用 tool 时也必须通过通用 evidence tool 返回有边界的判断、依据、缺口和下一步。
|
||||
- 用户侧 PMFlow AI 的回答生成和最终返回都必须经过 `Answer Runtime -> Answer Quality Gate -> CustomerAnswerPresentationService -> CustomerEvidencePacket -> Customer Payload Gate` 合同:最终合成前先形成 `PMFlowAIAnswerObjective / PMFlowAIAnswerPlan / PMFlowAIAnswerDraft` 与客户安全 `answer_runtime_packet`,再按 `strategic_judgement / status_answer / clarification / execution_guidance / fact_list / composite_answer / general_answer` 这些表达形态渲染。Renderer 只能作为 draft source,不做目标识别、来源选择、对象绑定、追问仲裁或 answer_type 重判;静态产品合同和保护拒答合同只能提供 `required_claims / customer_safe_capabilities / source_summary / limitations`,不得保存整段终局回答或要求全文相等;Answer Quality Gate 是唯一答案质量出口,必须把空 draft、内部运行时术语泄漏、计划语句回显、静态合同材料缺失写成 `PMFlowAIAnswerQualityFinding`,并对重写结果二次复检,仍失败时 fail-closed,禁止恢复 `_sanitize_state_answer` 这类 renderer 局部兜底;项目运行态 verification 等后处理约束必须先合入 draft 再过 gate,写入 `answer / customer_evidence_packet / answer_canvas` 的文本也必须先客户安全投影,禁止后处理绕过 gate;`CustomerAnswerPresentationService` 只能清洗、裁剪和承接 runtime 已生成的自然答案,不得在 `answer_canvas.direct_answer` 或 packet fallback 中二次生成 `结论/判断依据/综合判断/可执行建议/依据` 这类固定段落或 answer_type 前缀;Customer Payload Gate 必须移除 raw `answer_runtime_packet / pmflow_ai_turn_state / evidence_findings / route_plan / trace / tool_trace / debug`,历史归档也必须使用同一份 sanitized customer payload;Composer/Verifier 只能消费 final TurnState、Answer Runtime packet 和 customer-safe packet 的 `display_label/display_summary/business_values`,禁止把 `System Snapshot / User Snapshot / Project database / raw JSON / 字段名 / trace / prompt key` 直接喂给用户答案表达层,也禁止靠新增场景关键词或固定模板解决回答重复问题。
|
||||
- 用户侧 PMFlow AI 的终局表达材料必须先经过 `PMFlowAITerminalAnswerState`:所有可说内容进入 `Claim Ledger`,所有不可说/不可确认/不可执行边界进入 `Boundary Ledger`,所有尝试进入 `Attempt Ledger`,表达策略进入 `AnswerIntentPlan`。Verifier 的 `reason/internal_diagnostic` 只能作为内部诊断,不得进入用户 answer、limitations、CustomerEvidencePacket 或客户可见 terminal state;客户可见只允许消费 `customer_safe_summary/customer_safe_effect`。`AnswerIntentPlan` 只能从 Claim/Boundary Ledger 选择 lead claim/boundary,并输出 `answer_moves / composition_guidance / anti_template_rules / detail_level / structure_preference` 这类表达策略;它不得生成最终正文、不得按业务场景或 failure_type 选择固定 presenter、不得读用户问句关键词做路由,也不得强制固定“判断依据/证据边界/下一步”三段式。最终自然表达必须经通用 terminal customer answer composer 和 critic:composer 只消费 terminal ledgers + AnswerIntentPlan,critic 只检查空答、内部运行时术语、固定标题模板、重复句和 lead claim/boundary 丢失,不得新增场景化 Customer Failure Presenter。Quality fail-closed 与 state fallback 也必须重建干净 terminal state 再表达,禁止复用被失败 draft 污染的 `answer_draft_claim`,禁止返回固定“质量检查未通过/旧链路模板/项目运行证据不足”句。禁止把所有 blocked/fail-closed 默认解释成项目运行证据不足,禁止按 failure_type 写固定 presenter,禁止从 raw runtime step 或 verifier reason 直接生成用户正文。
|
||||
- 用户侧 PMFlow AI 的 verifier failure 必须输出 `verification_delta` 并声明 `retry_would_change_state`;runtime 只有在 retry 会改变 TurnState 或能执行新的只读 probe/read 时才允许继续重试。`next_action_hint=synthesize_answer` 不得单独制造重复合成循环,Reviser 也不得在 retry=false 时创建 system action gap。没有 state delta 的失败必须进入 terminal-state blocked payload,由 Claim/Boundary Ledger 提供客户安全材料。
|
||||
- 用户侧 PMFlow AI 回复必须同时返回客户安全的 `customer_interaction_context_packet` 与 `answer_process_packet`:前者只暴露 `visible_objects/current_focus_object` 这类业务对象锚点,用于“这个项目/刚才那个/它”的续问归位;后者只描述本轮真实执行过的产品化步骤,供前端逐步展示,并可带 `duration_ms / total_duration_ms` 说明真实耗时归因。禁止前端用固定五步、固定 active index 或后台 trace 冒充思考过程,禁止在这些 packet 中暴露表名、字段名、prompt key、函数名、trace 或模型供应商。
|
||||
- Packet U/V 后,展示层不得再根据 `answer_type / route_display / verification.ok` 自行生成“识别问题、读取证据、表达判断、检查展示”等固定过程步骤;`CustomerAnswerPresentationService.build_answer_process_packet()` 只能保留显式传入的 runtime/process steps,Space 前端只展示后端返回的真实 steps。来源展示也必须 fail-closed:后端不得在无来源时补 `PMFlow 上下文`,前端不得在 `sources` 为空时补 `个人空间/PMFlow 上下文`。
|
||||
- Space AI 多轮续问对象归位必须以 Session Object Ledger 为真源:每轮只从真实返回 payload 的客户安全对象生成 `current_focus_object / last_shown_objects / recent_objects`,写入 `qa_session_states.context_capsules`;下一轮只有弱指代且 TurnState 目标需要单对象 inspection/execution 时,才允许用 ledger 绑定 `primary_object(binding_strength=session_object_ledger)`。禁止用 transcript 词面、项目名关键词、唯一可见项目或前端按钮文案恢复对象;有可执行系统证据缺口时必须先低风险 read/probe,不能把系统缺口 label 当成用户追问。
|
||||
- Session Object Ledger 必须受 `object_binding_decision` 和 topic switch gate 约束:当 TurnState 判断本轮是新项目、新想法、新方向、无对象或拒绝上一轮焦点时,必须挂起旧 `current_focus_object`,禁止用 ledger 触发 `inspect_project_status / inspect_internal_projects`。工具证据进入 `known_facts` 前必须经过目标相关性过滤;无关项目证据只能进入缺口/审计,不得作为新想法判断依据。用户答案出口必须清除 `evidence_findings / TurnState / problem_frame` 等内部运行时术语。
|
||||
- Turn Runtime 不得因为 `can_answer_now=true` 跳过未执行的系统读证据动作;只要 TurnState 仍有可执行的非用户 `system_evidence_gap/freshness_gap`,必须先执行对应 read/probe,再合成或显式说明剩余缺口。新项目/新想法/开放方向必须用 `PMFlowAIThreadWorkspace` + `ResearchWorkspace.collected_inputs` 持久化线程 slot,后续轮次合并 `PMFlowAITurnDelta` 继续推进,禁止靠 transcript、关键词、按钮文案或场景枚举恢复上下文。
|
||||
- 开放方向/新想法在 `can_answer_with_assumptions=true` 且无强项目绑定时,最终回答必须消费 TurnState 与 workspace slot 给出受控初判、切口、风险、缺口和下一步,禁止因没有 external findings 直接退成“证据不足不能判断”。需要用户补充时,`interaction_request` 必须批量消费 workspace pending questions 与 user-actionable gaps 生成 stepper,能一次问完的关键缺口不得拆成多轮单点模板追问。
|
||||
- PMFlow ChangeSet 写入边界:`accepted / edited / deferred` 只表示候选项已被人工处理但尚未写入 Project Memory;只要 ChangeSet 未 committed 且 ChangeItem 未 committed/skipped,就必须允许继续 accept/edit/defer。真正锁定边界是用户明确执行 ChangeSet commit / 确认写入项目记忆之后。
|
||||
- PMFlow 审查与执行边界:团队层只做责任路由、角色收件箱、团队动态、审查状态、阻塞解释和跳转,不直接执行 ChangeSet commit 或写 Project Memory;开发执行只能针对 Project Memory commit 后形成的正式 Task 发起 Execution Bridge handoff,禁止从 `pending_review` ChangeItem 或候选 Task 直接分派人/agent 执行。
|
||||
- 业务语义、问答/执行归因与前台动作拍板必须优先走“对象/上下文绑定 -> `SemanticContextPacket` -> `SemanticDecision` -> gatekeeper”主链,禁止在 `backend/app` 运行时代码里恢复 `looks_like_*`、关键词仓或散落正则拍板。
|
||||
- `query.answer` 路由与外部查询规划不得在运行时代码里维护 `_INTERNAL_ROUTE_OBJECTS / _EXTERNAL_ROUTE_OBJECTS / _SEMANTIC_QUERY_OBJECT_TYPES` 一类对象词表,也不得新增 `*_probe_is_strong / *_route_probe / query_kind(weather/news/company/market/model)` 这类二次语义拍板。`query_profile / source_policy_hint / SemanticDecision` 只能作为上游结构化输入;若证据不足,只能显式返回 `uncertain/clarify`,禁止在执行层靠对象集合、`subject_focus`、`needs_freshness`、`referenced_objects` 再次裁决 internal vs external。
|
||||
- `llm_service` 运行时必须显式传入 `routing_meta`;禁止再通过函数名片段、hint 常量或模板词在底层推断 tier、结构化输出或 fallback 策略。
|
||||
- 显式 `reply/thread`、卡片动作和 `work_item_id` 属于更强锚点,必须优先于旧 session 和记忆续写。
|
||||
- 进入 `requirement_plan` / draft confirm 的线程必须留在对应主链,禁止掉回 `query.answer`;其中 draft confirm 只允许显式 `reply/thread`、卡片按钮或 `work_item_id` 进入,禁止无锚点按“同群/同人”回捞旧 pending draft。若同 scope 旧 plan/draft 已被判定污染,运行时必须先终止该 scope 内的 active topic/work item,再回退到 `query.answer`。选项协议回复必须复用单点结构化解析器,不得散落关键词/正则。
|
||||
- 已绑定 active requirement context 的线程里,显式 `退出需求模式` 必须先挂起当前 collecting/ready draft 对应的 requirement topic,再允许回到普通 `query.answer`;同一 `conversation_scope` 下的 topic 选择必须优先命中仍带 active draft 的 live requirement workspace,禁止被后续 idle query topic 抢走。
|
||||
- draft confirm 若命中显式 `reply/thread` 锚点,候选 mounted draft / pending draft 必须先通过该锚点精确命中;同群、同 scope、同人但锚点不匹配的历史草稿不得参与打分或回捞,避免 requirement-plan A/B/C 线程被旧 draft 劫持。
|
||||
- Feishu `requirement_plan` 首轮/追问回复若命中 `reply_template_key=requirement_plan_followup`,必须保留模板键并优先渲染交互卡片;卡片按钮回流必须复用 canonical Feishu `reply/thread` 上下文,禁止因 query renderer 吞模板键或按钮回流丢锚点而退回纯文本旧模板。
|
||||
- 冷启动 `WorkflowSemanticPlanner` / `pmflow_semantic_context_decision` 失败时,运行时只能降级到带 `semantic_planner_degraded=true` 审计标记的中性 `query.answer` 场景;后续优先重试 semantic backfill,显式 active requirement context / reply anchor 仍留在 requirement-plan 主链,无锚点事实问句禁止硬降级成 requirement consultation。
|
||||
- Feishu 显式 `reply/thread` 到机器人消息时,必须先通过 `feishu_reply_outbox.sent_message_id -> source_message_id` 归一化 canonical thread context,再进入后续主链。
|
||||
- 外部 freshness 问答的 follow-up 若锚定上一轮“未接入实时外部数据/无法确认最新结论”的 QA 回复,默认继续留在 `query.answer` 聊天链并先尝试实时外部检索;只有用户显式点明系统/项目/配置等内部对象并提出接入动作时,才允许落入内部接入/入库流程。
|
||||
- non-plan `query.answer` 必须先经过 `Query Route Gateway` 产出 `route_mode / decision_origin / reply_contract / probe_snapshot`,再进入 AdaptiveRAG;回复正文必须遵守显式来源合同(`内部知识库查询:` / `外部实时查询:`),`uncertain` 只允许 `external-first` 或澄清,禁止默认混合答案。
|
||||
- `route_mode=external` 后不得再直接把 DuckDuckGo HTML 抓取当主执行层;必须统一进入 `External Query Planner -> External Query Gateway -> External Provider Registry -> MCP adapter 优先 -> bounded fallback live search -> external answer verifier` 主链。external 来源过滤必须 fail-closed,禁止把 `Feishu history / doc / pmflow` 这类内部来源混回 `外部实时查询` 的来源摘要。
|
||||
- `freshness_required=true` 的行业/市场/趋势类外部查询必须执行近年证据门禁:planner 要前置当前年/上一年查询,live search 不能第一条旧结果即停止,gateway verifier 必须拒绝只有过期年份材料的证据包,禁止把 2024 白皮书类旧资料冒充 2026 当前趋势。
|
||||
- `query.answer` 的客户可见回复必须再经过 `Answer Presentation Policy`:客户视图只允许看到中文化 `display_label/display_summary`,禁止直接暴露 `raw_source_label`、`Project database / Semantic facts / Knowledge Graph / Feishu history` 等机器来源或内部术语。prompt context 也只能喂展示字段,不能再把原始英文来源标签直接喂给模型。
|
||||
- `PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT / PROMPT_CUSTOMER_ANSWER_COMPOSER / PROMPT_CUSTOMER_ANSWER_VERIFIER / PROMPT_RAG_QUERY_SEMANTICS / PROMPT_RAG_FOLLOWUP_ANCHOR_SEMANTICS / PROMPT_RAG_PENDING_CLARIFICATION_SELECTION_SEMANTICS` 必须运行时读取 Prompt 运营中心 active release,并在 `ai_call_logs.routing_meta` 写入 `prompt_key / prompt_release_id / prompt_source`;禁止恢复为直接硬编码模板调用且无 prompt 归因。`PROMPT_PMFLOW_AI_ROUTE_PLANNER` 只允许作为 legacy/deprecated prompt key 占位,不得在 Space 用户侧运行时调用。
|
||||
- internal `query.answer` 一旦命中 `route_mode=internal`,必须再走“`Operation Contract -> Target Resolver -> Query DSL Plan -> 白名单受控执行器 -> semantic facts 优先(可用时)-> bounded fallback retrieval -> answer verifier`”链路;禁止让 LLM 自由写 SQL、自由决定 join/表名,或在没有证据时直接给内部结论。
|
||||
- `MountedDraftWorkspace.requirement_card` 是 requirement-plan 唯一业务真源;assistant prose、topic facts、run 文案只可审计,不可反推业务字段。
|
||||
- `project_create.create / followup.confirm` 的任务拆解是从属子步骤:运行时必须先 low-first 结构化生成,必要时升级 high;仍失败则显式记为 `pending`,禁止冒充成功。
|
||||
- 运行时长清单、Feishu / requirement-plan / AdaptiveRAG 细则、已知边界与假设,统一以 `docs/architecture/RUNTIME_GUARDRAILS.md` 为准。
|
||||
|
||||
## 9. 新增代码的参考模式
|
||||
|
||||
- 新增后端接口:参考 `backend/app/api/sstc_api/` 的结构
|
||||
- 新增前端 API 调用:参考 `frontend/src/api/sstc.ts`
|
||||
- 新增数据库表:参考 `alembic/versions/` 最新 migration
|
||||
- 新增飞书事件处理:参考 `backend/app/services/integrations/feishu_integration_service/`
|
||||
- 更细的接口、页面与服务参考入口统一维护在 `docs/architecture/CODE_REFERENCE_INDEX.md`
|
||||
|
||||
## 10. 幂等与事务约束
|
||||
|
||||
- 所有入站事件必须经过 `ingest_events` 幂等去重
|
||||
- 飞书回复必须经过 `feishu_reply_outbox`,禁止同步直接发送
|
||||
- 跨表写操作必须在同一个数据库事务内完成
|
||||
|
||||
## 11. 已知 BOUNDARY(不要幻构行为)
|
||||
|
||||
- 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用
|
||||
- `agents.defaults.timeoutSeconds` 在 PMFlow 本地路由层 网关行为未验证;普通 LLM 调用仍以 `LLM_REQUEST_TIMEOUT` 为默认,已接入 Function Runtime Profile / 场景运行策略的保护型结构化调用以已发布合同里的显式 `provider_timeout_seconds` 为准。
|
||||
- World Model 静态层已接入 Redis 缓存(key: `wm:static:{tenant_id}:{project_id}`,TTL 600s)
|
||||
- 写操作后主动 invalidate,动态层不缓存
|
||||
|
||||
## 12. 前端组件约束
|
||||
|
||||
- `ui/` 目录下组件禁止修改,仅使用
|
||||
- `frontend/src/components/sstc/` 目录已模块化拆分,新增文件与职责如下:
|
||||
- `SSTCCharterStagePanel.tsx`:宪章阶段主面板(内容编辑、版本/状态展示)
|
||||
- `SSTCExecutionBoardPanel.tsx`:执行看板阶段主面板(执行视图聚合)
|
||||
- `SSTCReleaseCreateDialog.tsx`:发布创建弹窗
|
||||
- `SSTCReleaseStagePanel.tsx`:发布阶段主面板(发布列表与校验项)
|
||||
- `SSTCReviewCreateDialog.tsx`:复盘创建弹窗
|
||||
- `SSTCReviewStagePanel.tsx`:复盘阶段主面板(复盘内容与改进项)
|
||||
- `SSTCSignalStagePanel.tsx`:信号阶段主面板(信号录入/状态流转)
|
||||
- `SSTCTaskCreateDialog.tsx`:任务创建弹窗
|
||||
- `SSTCTaskStagePanel.tsx`:任务阶段主面板(任务列表、筛选、状态流转)
|
||||
- `sstcUtils.ts`:SSTC 跨阶段工具函数与转换逻辑
|
||||
- `useSSTCWorkspaceOrchestration.ts`:SSTC 工作区编排 Hook(状态聚合、事件编排、数据装配)
|
||||
- 修改前必须执行 `FIND_USAGE` 的组件/模块清单(强制):
|
||||
- `SSTCWorkspace.tsx`
|
||||
- `useSSTCWorkspaceOrchestration.ts`
|
||||
- `sstcUtils.ts`
|
||||
- `SSTCCharterStagePanel.tsx`
|
||||
- `SSTCSignalStagePanel.tsx`
|
||||
- `SSTCTaskStagePanel.tsx`
|
||||
- `SSTCExecutionBoardPanel.tsx`
|
||||
- `SSTCReleaseStagePanel.tsx`
|
||||
- `SSTCReviewStagePanel.tsx`
|
||||
- `SSTCReleaseCreateDialog.tsx`
|
||||
- `SSTCReviewCreateDialog.tsx`
|
||||
- `SSTCTaskCreateDialog.tsx`
|
||||
- 新增业务组件放入对应功能目录,禁止在 `pages/` 内写内联组件
|
||||
- 样式只用 Tailwind utility class,禁止内联 `style`
|
||||
|
||||
## 13. 测试与验证
|
||||
|
||||
- 后端改动:`python -m py_compile` 通过 + 相关单测通过
|
||||
- 后端代码或数据库迁移改动:在编译/测试通过后,必须继续执行当前环境对应的热部署/重启/重载步骤,确保变更真实生效;可用方式包括 `docker compose restart backend`、`scripts/docker_stack.sh restart|dev-restart`、`systemctl restart neural-sync`、`nginx -s reload` 等。若当前环境不是部署机、权限不足或不具备热部署条件,必须明确说明未执行原因与阻塞点,禁止默认跳过
|
||||
- 前端改动:`npm run build` 通过 + `tsc --noEmit` 零错误
|
||||
- 新增接口:必须在 `backend/tests/` 补充对应单测
|
||||
|
||||
## 14. 文档同步规则(强制)
|
||||
|
||||
每次任务完成后,若以下内容发生变化,必须同步更新对应文档(`CLAUDE.md`、`docs/architecture/ARCHITECTURE_INVENTORY.md`、`docs/architecture/RUNTIME_GUARDRAILS.md`、`docs/architecture/CODE_REFERENCE_INDEX.md`):
|
||||
|
||||
- 新增/删除/重命名文件或目录
|
||||
→ 更新 `CLAUDE.md` 第3-5节目录结构,并同步更新 `docs/architecture/ARCHITECTURE_INVENTORY.md`
|
||||
- 新增/修改服务边界或禁止操作
|
||||
→ 更新 `AGENTS.md` 第8节摘要规则,并同步更新 `docs/architecture/RUNTIME_GUARDRAILS.md`
|
||||
- 新增已知 `BOUNDARY` 或 `ASSUMPTION`
|
||||
→ 更新 `docs/architecture/RUNTIME_GUARDRAILS.md`
|
||||
- 新增/修改 API 接口
|
||||
→ 更新 `docs/architecture/CODE_REFERENCE_INDEX.md`,必要时同步 `CLAUDE.md` 摘要索引
|
||||
|
||||
文档更新必须在 `[Task:Done]` 之前完成。
|
||||
未更新文档的任务视为未完成。
|
||||
|
||||
## 15. 反迎合约束(强制)
|
||||
|
||||
- 若用户方案存在明显根本风险,必须先直接指出风险,再继续执行。
|
||||
- 若存在更优方案且后果并不显而易见,必须简短说明更优路径与取舍;用户确认坚持后可继续执行原方案。
|
||||
|
||||
## 16. Context 管理约定
|
||||
|
||||
- 大功能开发或结构性重构,单任务涉及文件超过5个时优先拆分为独立子任务;缺陷修复/回归修复允许围绕同一根因跨相关文件端到端完成
|
||||
- 单任务预估改动超过200行 → 拆分执行
|
||||
- 发生 context 不足时 → 停止并输出已完成部分
|
||||
声明 `[CONTEXT: LIMIT]`,不要截断输出
|
||||
|
||||
## 17. Codex 并行开发治理
|
||||
|
||||
- 多个 Codex 线程并行推进时,可以按 `Main Flow / Space / Account Settings / Shell Integration` 等大模块 lane 长期并行,但实际执行单位必须是 `Packet`,不得只以模糊“大模块”直接开工。
|
||||
- 每个 Packet 开始前必须有任务包,至少声明 `packet_id / module_lane / owner_thread / lane_owner / integration_owner / base_ref / read_scope / write_scope / forbidden_paths / shared_files / cross_lane_inputs / cross_lane_outputs / depends_on / merge_after / verification / docs_to_sync`。
|
||||
- 没有 `write_scope` 的任务不允许并行;`write_scope` 重叠的 Packet 必须串行。
|
||||
- 每轮并行最多一个 `contract / migration / router owner`;其他线程只能消费该 owner 已定义的稳定接口。
|
||||
- 大模块并行时,`Shell / Integration owner` 负责共享路由、导航、Provider、共享 API base、共享文档和最终合并;模块线程只能写自己的 owned files。
|
||||
- `Space` 线程只做个人空间聚合、草案箱、待确认摘要、AI 进行中摘要和跳转,不直接写 Project Memory、ChangeSet commit 或 Execution Bridge 回写。
|
||||
- `Account Settings` 线程只做个人资料、偏好、通知、账户壳和设置入口,不直接改项目主链、权限核心、AI 路由或 Space 聚合。
|
||||
- `Main Flow` 线程继续推进 PMFlow OS 主链,但不得顺手重做 Space 或个人中心。
|
||||
- 并行线程默认使用独立分支 `codex/<packet_id>`,并优先使用独立 git worktree;不得在同一脏工作区里让多个线程直接写同一批文件。
|
||||
- 一个 Packet 对应一个短生命周期分支;Packet 完成、验证通过、文档同步完成后必须形成可审查 commit。复杂 Packet 可在同一分支内做多个逻辑完整 checkpoint commit,但禁止提交半成品、失败验证或越界改动。
|
||||
- Codex 完成 Packet 后必须自行执行 commit gate 判定:满足 `auto_commit` 条件时应自行提交;命中 `hold_no_commit` 时不得提交并报告阻塞;命中 `ask_before_commit` 时先说明事实再等用户拍板。具体判定以 `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` 的 `Packet commit 协议` 为准。
|
||||
- 不允许在旧 Packet 分支上继续做新 Packet;新 Packet 必须从最新 integration base 或明确依赖分支重新开 `codex/<new_packet_id>`。
|
||||
- 合并前必须检查 `git diff --name-only`,确认没有写出 `write_scope`;越界改动必须拆回对应 Packet 或交给 integration owner。
|
||||
- 合并顺序固定为:Contract / migration → Backend service/API → Frontend API/store → UI/page → Docs/test/integration。
|
||||
- 每批并行开发必须指定一个 integration owner,负责共享文件合并、最终验证、文档同步和进度台账回写。
|
||||
|
||||
共享高冲突区默认同一时间只能一个 Packet 写:
|
||||
|
||||
- `backend/app/api/router.py`
|
||||
- `backend/app/models/__init__.py`
|
||||
- `backend/app/schemas/*.py` 中的跨域 schema
|
||||
- `backend/alembic/versions/*`
|
||||
- `frontend/src/router/index.tsx`
|
||||
- `frontend/src/main.tsx`
|
||||
- `frontend/src/api/base.ts`
|
||||
- `frontend/src/lib/api.ts`
|
||||
- `frontend/src/features/pmflow-os/components/PMFlowOsFrame.tsx`
|
||||
- `frontend/src/features/pmflow-os/components/MobileBottomNav.tsx`
|
||||
- `frontend/src/features/pmflow-os/store.tsx`
|
||||
- `CLAUDE.md`
|
||||
- `docs/architecture/CODE_REFERENCE_INDEX.md`
|
||||
- `docs/architecture/ARCHITECTURE_INVENTORY.md`
|
||||
- `docs/architecture/API_CONTRACTS.md`
|
||||
- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md`
|
||||
|
||||
若必须多人改共享文件,先由 integration owner 建立接口占位或合同,再让其它线程只改自己 owned 文件,最后由 integration owner 统一合并共享文件。
|
||||
|
||||
|
||||
## 安全约束(必须严格遵守)
|
||||
|
||||
### 🚫 绝对禁止的操作:
|
||||
- 禁止未确认的破坏性删除;只有在用户明确要求删除死代码/死文件、已先列出目标路径并说明后果、且用户再次确认后,才允许执行文件删除
|
||||
- 禁止执行带 -rf、--force、-y 参数的命令
|
||||
- 禁止 DROP TABLE、TRUNCATE 等数据库破坏性操作
|
||||
- 禁止修改 /etc、/sys、/boot 等系统目录
|
||||
|
||||
### ✅ 文件操作前必须:
|
||||
1. 仅在破坏性删除、不可逆改名或其它高风险文件操作前,先列出将要操作的文件路径
|
||||
2. 明确告知用户操作后果
|
||||
3. 等待用户输入"确认"后才能执行
|
||||
|
|
|
|||
|
|
@ -120,11 +120,11 @@ source venv/bin/activate
|
|||
pip install -r requirements.txt
|
||||
|
||||
# 配置环境变量
|
||||
cp /opt/neural-sync/.env.example /opt/neural-sync/.env
|
||||
nano /opt/neural-sync/.env
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
编辑项目根目录 `.env` 文件:
|
||||
编辑 `.env` 文件:
|
||||
|
||||
```env
|
||||
# 数据库 - PostgreSQL
|
||||
|
|
@ -227,7 +227,7 @@ Type=simple
|
|||
User=root
|
||||
WorkingDirectory=/opt/neural-sync/backend
|
||||
Environment="PATH=/opt/neural-sync/backend/venv/bin"
|
||||
EnvironmentFile=/opt/neural-sync/.env
|
||||
EnvironmentFile=/opt/neural-sync/backend/.env
|
||||
ExecStart=/opt/neural-sync/backend/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
|
|
|||
10
HEARTBEAT.md
|
|
@ -3,13 +3,3 @@
|
|||
# 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.
|
||||
|
||||
## 每日工单推送
|
||||
|
||||
- 触发时间:每天 08:30
|
||||
- 任务:查询当天工单并推送到飞书
|
||||
- 执行命令:
|
||||
```bash
|
||||
docker exec pmflow-db psql "postgresql://readonly:readonly%40%232025@173.1.1.32:5432/middle_platform" -c "SELECT task_code, task_name, excutor_name, status, start_time FROM plat_task_center.task_instance WHERE start_time >= CURRENT_DATE AND start_time < CURRENT_DATE + INTERVAL '1 day' AND ext3 IN ('90101', '901') ORDER BY start_time DESC LIMIT 50;" -x
|
||||
```
|
||||
- 目标用户:ou_9e1fefbcf4cc68a84f4a4ff7d1749048
|
||||
|
|
|
|||
|
|
@ -20,4 +20,4 @@ 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`.
|
||||
- For avatars, use a workspace-relative path like `avatars/openclaw.png`.
|
||||
|
|
|
|||
20
MEMORY.md
|
|
@ -1,20 +0,0 @@
|
|||
# Memory
|
||||
|
||||
## 飞书工单查询配置
|
||||
|
||||
当用户问"查询工单"、"导出工单"、"今天的工单"时,**立即执行以下命令查询外部数据库**:
|
||||
|
||||
### 执行命令(直接复制执行)
|
||||
```bash
|
||||
docker exec pmflow-db psql "postgresql://readonly:readonly%40%232025@173.1.1.32:5432/middle_platform" -c "SELECT task_code, task_name, excutor_name, status, start_time FROM plat_task_center.task_instance WHERE start_time >= CURRENT_DATE AND start_time < CURRENT_DATE + INTERVAL '1 day' AND ext3 IN ('90101', '901') ORDER BY start_time DESC LIMIT 50;" -x
|
||||
```
|
||||
|
||||
### 导出 Excel 命令
|
||||
```bash
|
||||
docker exec pmflow-db psql "postgresql://readonly:readonly%40%232025@173.1.1.32:5432/middle_platform" -c "COPY (SELECT * FROM plat_task_center.task_instance WHERE start_time >= CURRENT_DATE AND start_time < CURRENT_DATE + INTERVAL '1 day' AND ext3 IN ('90101', '901') ORDER BY start_time DESC) TO STDOUT WITH CSV HEADER" > /tmp/工单报表_$(date +%Y-%m-%d).csv && python3 -c "import pandas as pd; pd.read_csv('/tmp/工单报表_$(date +%Y-%m-%d).csv').to_excel('/tmp/工单报表_$(date +%Y-%m-%d).xlsx', index=False)"
|
||||
```
|
||||
|
||||
### ⚠️ 关键提醒
|
||||
- 这是 **外部数据库 middle_platform**,不是 PMFlow 数据库
|
||||
- 收到工单查询请求后 **立即执行命令**,不要只回复文字
|
||||
- 导出后告诉用户文件路径:`/tmp/工单报表_YYYY-MM-DD.xlsx`
|
||||
139
USER.md
|
|
@ -1,134 +1,17 @@
|
|||
# USER.md
|
||||
# USER.md - About Your Human
|
||||
|
||||
本文件是这个用户在本项目内的主真源。
|
||||
目标不是堆规则,而是让我在这个项目里越来越懂这个用户,稳定贴合其协作方式、判断方式、已拍板决策与反复踩过的坑。
|
||||
工程、系统、安全与验证硬约束仍以 `AGENTS.md` 和更外层提示为准;若 `~/.codex/AGENTS.md` 与本文件冲突,以本文件为准。
|
||||
未写进这里的内容,不默认视为本项目内已拍板决策或稳定偏好。
|
||||
_Learn about the person you're helping. Update this as you go._
|
||||
|
||||
## 协作偏好
|
||||
- **Name:**
|
||||
- **What to call them:**
|
||||
- **Pronouns:** _(optional)_
|
||||
- **Timezone:**
|
||||
- **Notes:**
|
||||
|
||||
### 长期稳定偏好
|
||||
## Context
|
||||
|
||||
- 默认先自己读项目、历史、日志、配置和运行痕迹;不要把本该我自己挖出来的东西再反问给用户。
|
||||
- 默认直接推进;只在高风险分岔、破坏性操作或根因不明时停下来确认。
|
||||
- 回答要直接、具体、贴当前对象,优先给证据链、调用链和真实落点;不要写成模板化汇报。
|
||||
- 不确定就明确说不确定,不要假装完整。
|
||||
- 已拍板方案不要反复推荐替代品;配置和偏好尽量收敛,别分散。
|
||||
- 前端默认视觉标准:品牌主色固定为 `#2463EB`;浅色界面默认以白色 + `#2463EB` 为主,不再回到泛紫、泛青的 AI 套板。
|
||||
- 前端默认交互和样式参考 `Linear` 的克制层级与节奏;所有用户可见内容默认使用中文,除非我明确要求保留英文。
|
||||
- PMFlow 原型图默认标准:统一使用浅色白底 + `#2463EB` 主色;整体交互密度、节奏、层级、边界处理默认参考 `Linear` 的产品气质,但不照搬品牌元素;原型图内所有用户可见内容、页面标题、按钮、状态、说明、标注默认全部使用中文;默认需要考虑移动端适配,原型设计时同步关注移动端信息层级、导航结构、单手操作路径、底部安全区、滚动区域与关键交互在小屏上的可用性。
|
||||
- PMFlow Logo 默认标准:以用户在本轮提供的品牌规范图为唯一 Logo 方向;主标识使用蓝色几何图标 + 深色 `PMFlow` 字标组合,图标主色沿用 `#2463EB`,字标使用深色中性黑灰;图标可独立用于应用图标、侧边栏、favicon、移动端入口和小尺寸场景;后续不要再重新发散生成其它 Logo 风格。
|
||||
- PMFlow 移动端底部导航默认标准:项目创建前(新项目启动台 / 首次进入 / 草案确认流程)不展示全局底部导航,只保留顶部上下文、单列主内容、底部主 CTA 和 AI 底部抽屉;项目创建后进入正式应用壳,底部导航固定为 5 项且不得漂移:`空间`、`指挥台`、`待确认`、`动态`、`我的`。`空间` 是所有项目/想法/草案的上层入口,`指挥台` 是当前项目主工作面,`待确认` 聚合 AI 生成但需用户拍板的事项,`动态` 聚合 AI 进展/回写/系统事件,`我的` 承载账户、设置和个人偏好。具体项目内的 `工作台 / 开发执行 / 发布增长 / 项目记忆` 不进入底部导航,默认通过项目内二级入口、分段切换或更多菜单进入。
|
||||
- PMFlow 同一功能原型一致性标准:同一功能一旦出现已确认原型或实现,后续再出图、改图或开发时必须先沿用该功能的既有定位、信息架构、导航壳、核心对象、状态标签和视觉语法;只能在此基础上新增状态、补充局部区域或做明确版本迭代,禁止把同一功能重新设计成另一套页面。若确实需要重构,必须先说明“旧版是什么、新版改什么、迁移影响是什么”,并得到明确确认。比如 `项目记忆` 必须区分为同一功能下的 `项目记忆主视图` 与 `项目记忆写入结果态`,二者应共享同一项目壳、对象分类、证据/确认/写入语义和右侧 AI 副驾规则,而不是两套互不相干的页面。
|
||||
- PMFlow 高保真原型默认不能按“桌面原型画布宽度”锁死主内容区。桌面保真不等于固定画布:必须同时考虑普通桌面、超宽屏和小屏三档。默认规则是:
|
||||
- 普通桌面宽度保持原型密度与节奏;
|
||||
- 超宽屏必须继续扩展主内容区,或设置明确且足够宽的内容上限(如更合理的 wide container),不能让右侧出现大面积无意义留白;
|
||||
- 小屏必须改为堆叠/抽屉/分段,不允许直接压缩桌面三栏。
|
||||
- 做 PMFlow 这类多栏产品时,默认先检查这三个点:`主内容是否被隐性画布宽度卡死`、`超宽屏是否吃满可用空间`、`右侧留白是否来自容器上限而不是业务布局本身`。
|
||||
- 当前产品原型与首版体验默认面向个人开发者与 `1-10` 人小团队,采用 `solo-first, team-ready` 方向;不要按 `10-50` 人重型团队工具去设计。
|
||||
- PMFlow 产品蓝图真源已沉淀到 `docs/PMFLOW_PRODUCT_BLUEPRINT.md`:PMFlow 是“对话驱动的 AI 产品执行操作系统”,不是聊天工具 + 项目管理工具,也不是 Jira 加 AI;后续产品/原型/实现默认围绕 `Project Thread -> Structured Extraction -> ChangeSet Review -> Project Memory -> Work Graph -> Execution Bridge -> Release/Growth Feedback` 主链推进。
|
||||
- 团队方向已拍板为“AI-native team operating system”:单人版解决“一个人如何像团队一样推进”,团队版解决“小团队如何围绕 AI 维护的项目真相层,通过异步审查和人机混编执行协作”;不要退回传统多人维护任务表、开会同步、手动填状态的项目管理范式。
|
||||
- PMFlow AI 可用状态已拍板为“双层表达”:用户端默认只表达客户可理解的业务影响、可继续动作和保护规则,不暴露供应商、路由、模型槽位、连接测试、trace、函数名或平台诊断;平台人员进入用户端也先看客户视图,只有显式平台诊断入口才看具体原因和 CTA。`/platform/*` 是平台 AI Ops 控制面,默认展示供应商、路由、诊断、模型槽位、失败样本和对应处理动作。
|
||||
- PMFlow 产品推进执行标准已沉淀到 `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md`:后续每次 PMFlow 产品推进任务完成后,Codex 默认要自己更新该文档的进度台账;新增/修改入口、API、Schema、边界时同步更新 `CLAUDE.md`、`ARCHITECTURE_INVENTORY.md`、`CODE_REFERENCE_INDEX.md`、`API_CONTRACTS.md` 或 `RUNTIME_GUARDRAILS.md`。新开线程继续产品推进时,必须先读 `USER.md`、产品蓝图、执行标准、`CLAUDE.md` 和代码索引,不得从头重想方案。
|
||||
- 原型图与首版视觉交付默认只做浅色主题,不要求同时输出暗色模式;暗色模式属于后续产品实现能力,不作为当前原型标准。
|
||||
- 后续只要我在本项目里让你“出原型图 / 重画原型 / 生成产品视觉稿”,若我没有额外说明,就自动沿用这套原型图标准,不要再回到深色、紫色系、英文 UI 或泛后台风格。
|
||||
_(What do they care about? What projects are they working on? What annoys them? What makes them laugh? Build this over time.)_
|
||||
|
||||
### 学习与更新方式
|
||||
---
|
||||
|
||||
- 会话里要持续吸收用户纠正,不要让用户反复教同一件事。
|
||||
- 用户纠正某次排障或分析方式时,要吸收背后的通用思路,迁移到后续问题中;不要只把反馈当成这一次案例的补丁。
|
||||
- 学到的是方法论,不是死模板:后续先找最小对照/反证,再分层定位根因并验证;但要按具体问题调整,不要机械套步骤。
|
||||
- 反复出现、会长期影响判断的偏好,要主动沉淀到本文件。
|
||||
- 一次性情绪、临时波动和不影响后续决策的内容,不要频繁落盘。
|
||||
- 非必要不要更新真源文件;只有新信息足够稳定、能减少误判或能减少用户重复纠正时,才值得更新。
|
||||
|
||||
## 判断优先级
|
||||
|
||||
### 什么时候才准问用户
|
||||
|
||||
- 先自己查。能从代码、配置、历史、日志里读出来的,别问用户。
|
||||
- 只有这几种情况才停下来:
|
||||
- 要做破坏性操作
|
||||
- 方案真的分叉了
|
||||
- 根因没搞清,继续改大概率越改越歪
|
||||
- 问的时候别铺垫,直接说:已经查到什么、卡在哪、要用户拍哪个板。
|
||||
|
||||
### 改错后的默认动作
|
||||
|
||||
- 错了就指出具体错点,别演。
|
||||
- 能直接补就直接补,补完再说原因、影响和验证。
|
||||
- 有扩散风险先止血,再继续修。
|
||||
- 别把一次纠错搞成新的流程负担。
|
||||
|
||||
### 冲突时怎么取舍
|
||||
|
||||
- 先查主链、上下文、路由和证据,别上来就改 prompt、补词表、补正则。
|
||||
- 显式 `reply/thread`、卡片动作和当前 requirement context 优先于旧 session 和记忆续写。
|
||||
- 已经进入 `requirement_plan` / draft confirm 的线程,后续必须留在对应主链,不要掉回普通 QA。
|
||||
- AI 架构类回答优先讲当前调用链、入口、路由和模型网关,不要退回通用技术栈摘要。
|
||||
- 证据不足时直接说不确定,不要用模板化确定性答案补满。
|
||||
- 已经定过的方案别来回摇。
|
||||
|
||||
## 已拍板技术决策
|
||||
|
||||
- 后端:FastAPI + Python 3.11+ + SQLAlchemy 2.x + Alembic
|
||||
- 前端:React 18 + TypeScript + Vite
|
||||
- UI / 样式:shadcn/ui 风格组件层 + Radix UI + Tailwind CSS
|
||||
- 数据与缓存:PostgreSQL + pgvector + Redis
|
||||
- 鉴权:OAuth2PasswordBearer + JWT
|
||||
- 部署:Docker Compose
|
||||
- 验证基线:后端 `python -m py_compile` + 相关单测;前端 `npm run build` + `tsc --noEmit`
|
||||
- AI 路由对外名称:PMFlow 本地路由层
|
||||
- AI 路由内部主名:`control_plane`
|
||||
- 路由规则真源:`backend/.control_plane/pmflow-routing-rules.json`
|
||||
- 模型策略:业务代码不要硬编码模型名;provider / tier / model 由路由规则决定
|
||||
- Prompt 策略:新增或修改运行时 LLM prompt 不得直接写死在业务服务/API 中;必须先进入 Prompt 运营中心资产合同(`ai_prompts.py` 默认模板 + `ai_prompt_registry.py` 注册 + active `prompt_releases` 运行时读取),并在 `ai_call_logs` 写入 `prompt_key / prompt_release_id / prompt_source` 归因。历史 `needs_migration` 存量只能作为待迁移清单,不得作为新增写法。
|
||||
- 历史路由命名已废弃,不再把旧名字当现行架构,也不再建议回退
|
||||
- 以上栈默认不再主动建议替代方案,除非用户明确要求比较或迁移
|
||||
|
||||
## 反复踩过的坑 / 不要再做
|
||||
|
||||
- 把“AI 架构”答成通用技术栈总览,而不是当前调用链、入口、路由和模型网关。
|
||||
- 证据不足时还给模板化的确定性答案。
|
||||
- 短协议回复掉回通用 QA,不按当前 `thread` 和上一轮选项解释。
|
||||
- 飞书连续追问时继续沿着旧上下文跑,不先尊重显式 `reply/thread`。
|
||||
- 用户纠错后不先纠正当前主题,还带着旧主题继续扩写。
|
||||
- 遇到链路问题先补 prompt、关键词、正则,而不是先查 routing、context、session、gatekeeper。
|
||||
- 用 fallback、默认句、stub、模板话术冒充成功。
|
||||
- 把本该直答的问题拖进过深推理、官样文章或汇报体输出。
|
||||
- 做高保真原型时把页面按“桌面截图尺寸”做成隐性固定画布,导致超宽屏右侧出现大面积未使用留白;以后默认按“桌面保真 + 超宽屏扩展 + 小屏重排”三档检查,不再只盯原型图单一尺寸。
|
||||
|
||||
## 前端快回路与线程治理
|
||||
|
||||
### 前端任务分级
|
||||
|
||||
- `L1 微调类`:文案、字重、边框、间距、颜色、单元素位置。默认只允许 focused live 页确认或快验证,不默认跑 full build。
|
||||
- `L2 视觉逻辑类`:响应式、跨屏对齐、布局算法、曲线路径、动画节奏。允许 `build + 多视口截图`,但不夹带无关文案和样式修补。
|
||||
- `L3 结构治理类`:拆组件、拆路由、拆包、改 compose、改启动链路。必须单独线程处理,不和单页 comment 修复混做。
|
||||
|
||||
### 线程是否继续的判断规则
|
||||
|
||||
- 同一对象、同一路径、同一验证面,优先留在原线程直接做完。
|
||||
- 满足任两条时,优先新开线程继续:需求从分析切到实现;目标文件集合明显变化;验证方式变化;用户开始要求“完整方案/全仓整治/顺手再修几个点”;同一核心文件重复读取;线程已经发生 context compaction;已经拖过 15 分钟还没形成第一个闭环。
|
||||
- 当前改动已经能独立闭环时,不顺手带 unrelated fix;尤其不要把 `L1 微调` 混进 `L2 视觉逻辑` 或 `L3 结构治理`。
|
||||
|
||||
### 强制止损阈值
|
||||
|
||||
- 同一线程出现 `context_compacted` 后,如果任务仍未闭环,优先建议切新线程;出现两次 compaction,不再继续混合任务。
|
||||
- 从用户发起到首次实际代码编辑超过 `8` 分钟,必须先说明卡点,不继续闷头探索。
|
||||
- 同一核心文件读取超过 `2` 次,必须先总结当前判断,再决定是否继续读。
|
||||
- 同一轮工具调用超过 `20` 次但仍没有形成首个有效闭环,必须收口范围或切线程。
|
||||
|
||||
### 前端验证预算
|
||||
|
||||
- `L1 微调类`:优先 `verify:login-fast` 或 focused screenshot,不默认跑 `npm run build`。
|
||||
- `L2 视觉逻辑类`:优先 `verify:login-responsive` 或 `capture:login:matrix`,验证目标是跨尺寸一致,不是单视口碰巧正确。
|
||||
- `L3 结构治理类`:独立线程里跑 `typecheck + build + focused smoke`,不拿页面 comment 回合承载。
|
||||
|
||||
## 当前知道但暂不处理的项目问题
|
||||
|
||||
- SQLite 已废弃,不作为当前运行时或正式技术方案;但少量历史测试夹具仍沿用 SQLite 内存库。
|
||||
- `backend/tests/test_training_pipeline_service.py` 目前仍有历史 SQLite 测试夹具下的 `JSONB` 建表失败问题,这不是本轮改名引入的,需要单独清理测试基座。
|
||||
- `backend/app/schemas/recipe.py` 与 `backend/app/schemas/skill.py` 仍有 Pydantic V2 的 class-based config deprecation 警告。
|
||||
- 当前运行环境里的 `lark_oapi` / `websockets` 仍有上游 deprecation 警告,后续需要统一升级或兼容处理。
|
||||
- Project Launch 失败页需要后续补产品化排查能力:展示本次请求耗时、后端 trace id、后端是否稍后成功生成草案,并提供“恢复最近草案”入口,用于定位前端超时/代理断开但后端实际成功的情况;当前先记录,不实现。
|
||||
The more you know, the better you can help. But remember — you're learning about a person, not building a dossier. Respect the difference.
|
||||
|
|
|
|||
|
|
@ -1,177 +0,0 @@
|
|||
{
|
||||
"version": "v3",
|
||||
"updated_at": "2026-03-26T21:30:00+08:00",
|
||||
"source_of_truth": "control_plane",
|
||||
"description": "PMFlow 双层统一路由:调用层仅暴露 low/high,真实模型名只保留在配置真源。",
|
||||
"provider_defaults": {
|
||||
"deepseek": {
|
||||
"name": "DeepSeek",
|
||||
"base_url": "https://api.deepseek.com/v1",
|
||||
"model": "deepseek-chat",
|
||||
"api_env": "DEEPSEEK_API_KEY"
|
||||
},
|
||||
"dashscope": {
|
||||
"name": "Qwen (DashScope)",
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"model": "qwen-plus",
|
||||
"api_env": "DASHSCOPE_API_KEY"
|
||||
},
|
||||
"openai": {
|
||||
"name": "OpenAI",
|
||||
"base_url": "https://api.edgefn.net/v1",
|
||||
"model": "GLM-5",
|
||||
"api_env": "OPENAI_API_KEY"
|
||||
},
|
||||
"zhipu": {
|
||||
"name": "Zhipu BigModel",
|
||||
"base_url": "https://open.bigmodel.cn/api/paas/v4",
|
||||
"model": "glm-5.1",
|
||||
"api_env": "ZHIPU_API_KEY"
|
||||
},
|
||||
"ollama": {
|
||||
"name": "Ollama",
|
||||
"base_url": "http://127.0.0.1:11434",
|
||||
"model": "llama3:latest",
|
||||
"api_env": ""
|
||||
}
|
||||
},
|
||||
"tier_defaults": {
|
||||
"low": {
|
||||
"provider": "dashscope",
|
||||
"model": "qwen-plus",
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"api_env": "DASHSCOPE_API_KEY"
|
||||
},
|
||||
"high": {
|
||||
"provider": "openai",
|
||||
"model": "GLM-5",
|
||||
"base_url": "https://api.edgefn.net/v1",
|
||||
"api_env": "OPENAI_API_KEY"
|
||||
}
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"name": "engine_high_intent_complex_exact",
|
||||
"enabled": true,
|
||||
"priority": 320,
|
||||
"tier": "high",
|
||||
"provider": "openai",
|
||||
"model": "GLM-5",
|
||||
"api_env": "OPENAI_API_KEY",
|
||||
"function_exact": "classify_intent_complex"
|
||||
},
|
||||
{
|
||||
"name": "engine_low_pmflow_ai_operating_mission",
|
||||
"enabled": true,
|
||||
"priority": 312,
|
||||
"tier": "low",
|
||||
"provider": "dashscope",
|
||||
"model": "qwen-plus",
|
||||
"api_env": "DASHSCOPE_API_KEY",
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"function_exact": "pmflow_ai_operating_mission"
|
||||
},
|
||||
{
|
||||
"name": "engine_low_semantic_context_exact",
|
||||
"enabled": true,
|
||||
"priority": 310,
|
||||
"tier": "low",
|
||||
"provider": "dashscope",
|
||||
"model": "qwen-plus",
|
||||
"api_env": "DASHSCOPE_API_KEY",
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"function_exact": "pmflow_semantic_context_decision"
|
||||
},
|
||||
{
|
||||
"name": "engine_high_semantic_context_exact",
|
||||
"enabled": true,
|
||||
"priority": 305,
|
||||
"tier": "high",
|
||||
"provider": "openai",
|
||||
"model": "GLM-5",
|
||||
"api_env": "OPENAI_API_KEY",
|
||||
"function_exact": "pmflow_semantic_context_decision"
|
||||
},
|
||||
{
|
||||
"name": "engine_low_execute_glm_flash",
|
||||
"enabled": true,
|
||||
"priority": 300,
|
||||
"tier": "low",
|
||||
"provider": "dashscope",
|
||||
"model": "qwen-plus",
|
||||
"api_env": "DASHSCOPE_API_KEY",
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"match_any": [
|
||||
"feishu_level_classification",
|
||||
"feishu_signal_extraction",
|
||||
"rag_intent_classification",
|
||||
"classify_intent",
|
||||
"ai_pmo_signal_title_refine",
|
||||
"feishu_humanized_reply",
|
||||
"extract_context_insights"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "engine_high_reasoning_glm5",
|
||||
"enabled": true,
|
||||
"priority": 220,
|
||||
"tier": "high",
|
||||
"provider": "openai",
|
||||
"model": "GLM-5",
|
||||
"api_env": "OPENAI_API_KEY",
|
||||
"match_any": [
|
||||
"adaptive_rag_answer",
|
||||
"classify_intent_complex",
|
||||
"project_from_text_analysis",
|
||||
"project_from_text_signal",
|
||||
"project_from_text_charter",
|
||||
"project_from_text_task_chain",
|
||||
"project_requirement_extraction",
|
||||
"feishu_project_prefill",
|
||||
"feishu_task_orchestration",
|
||||
"generate_task_breakdown",
|
||||
"generate_charter_from_signal",
|
||||
"decompose_tasks_from_charter",
|
||||
"recommend_assignees_for_task",
|
||||
"evaluate_model_requirement",
|
||||
"calculate_alignment_score",
|
||||
"detect_cognitive_conflicts",
|
||||
"ai_pmo_signal_batch_analysis",
|
||||
"rag_db_project_semantic_retrieval",
|
||||
"workload_optimize_prompt",
|
||||
"analyze_cost_roi",
|
||||
"signal_planner_analyze"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "engine_high_extended_reasoning",
|
||||
"enabled": true,
|
||||
"priority": 200,
|
||||
"tier": "high",
|
||||
"provider": "openai",
|
||||
"model": "GLM-5",
|
||||
"api_env": "OPENAI_API_KEY",
|
||||
"match_any": [
|
||||
"analyze_food_calories",
|
||||
"feishu_new_project_brief",
|
||||
"feishu_project_signal_classification",
|
||||
"sstc_ai_breakdown",
|
||||
"sstc_charter_localize_zh",
|
||||
"sstc_charter_prefill",
|
||||
"skill_score_update",
|
||||
"test_engine_connection"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "engine_low_fallback",
|
||||
"enabled": true,
|
||||
"priority": 50,
|
||||
"tier": "low",
|
||||
"provider": "dashscope",
|
||||
"model": "qwen-plus",
|
||||
"api_env": "DASHSCOPE_API_KEY",
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"match_all": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -13,7 +13,6 @@ 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
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ if config.config_file_name is not None:
|
|||
|
||||
# Set the database URL from settings
|
||||
# Replace async driver with sync driver for migrations
|
||||
db_url = settings.database_url.replace("postgresql+asyncpg", "postgresql+psycopg")
|
||||
db_url = settings.database_url.replace("sqlite+aiosqlite", "sqlite")
|
||||
db_url = db_url.replace("postgresql+asyncpg", "postgresql+psycopg")
|
||||
config.set_main_option("sqlalchemy.url", db_url)
|
||||
|
||||
# add your model's MetaData object here
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ def upgrade() -> None:
|
|||
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("openclaw_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"),
|
||||
|
|
@ -89,7 +89,7 @@ def upgrade() -> None:
|
|||
("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_openclaw_status", ["openclaw_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)
|
||||
|
|
@ -100,7 +100,7 @@ def downgrade() -> None:
|
|||
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_openclaw_status",
|
||||
"ix_ai_orchestration_runs_planner_status",
|
||||
"ix_ai_orchestration_runs_status",
|
||||
"ix_ai_orchestration_runs_response_mode",
|
||||
|
|
|
|||
|
|
@ -1,111 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
"""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)
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
"""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"))
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
"""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)
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,520 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
"""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)
|
||||
|
|
@ -1,587 +0,0 @@
|
|||
"""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)
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
"""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)
|
||||
)
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
"""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)
|
||||
|
|
@ -1,298 +0,0 @@
|
|||
"""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)
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
"""add mounted runtime active draft fields
|
||||
|
||||
Revision ID: 20260406_add_mounted_runtime_active_draft_fields
|
||||
Revises: 20260405_add_mounted_runtime_plan_state_fields
|
||||
Create Date: 2026-04-06
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260406_add_mounted_runtime_active_draft_fields"
|
||||
down_revision = "20260405_add_mounted_runtime_plan_state_fields"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
try:
|
||||
columns = inspector.get_columns(table_name)
|
||||
except Exception:
|
||||
return False
|
||||
return any(str(column.get("name")) == column_name for column in columns)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
try:
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
except Exception:
|
||||
return False
|
||||
return any(str(index.get("name")) == index_name for index in indexes)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if not _column_exists(bind, "mounted_draft_workspaces", "active_work_item_id"):
|
||||
op.add_column(
|
||||
"mounted_draft_workspaces",
|
||||
sa.Column("active_work_item_id", sa.BigInteger(), nullable=True),
|
||||
)
|
||||
if not _column_exists(bind, "mounted_draft_workspaces", "latest_source_message_id"):
|
||||
op.add_column(
|
||||
"mounted_draft_workspaces",
|
||||
sa.Column("latest_source_message_id", sa.VARCHAR(length=128), nullable=True),
|
||||
)
|
||||
if not _column_exists(bind, "mounted_draft_workspaces", "latest_reply_message_id"):
|
||||
op.add_column(
|
||||
"mounted_draft_workspaces",
|
||||
sa.Column("latest_reply_message_id", sa.VARCHAR(length=128), nullable=True),
|
||||
)
|
||||
|
||||
if not _index_exists(bind, "mounted_draft_workspaces", "ix_mounted_draft_workspaces_active_work_item_id"):
|
||||
op.create_index(
|
||||
"ix_mounted_draft_workspaces_active_work_item_id",
|
||||
"mounted_draft_workspaces",
|
||||
["active_work_item_id"],
|
||||
)
|
||||
if not _index_exists(bind, "mounted_draft_workspaces", "ix_mounted_draft_workspaces_latest_source_message_id"):
|
||||
op.create_index(
|
||||
"ix_mounted_draft_workspaces_latest_source_message_id",
|
||||
"mounted_draft_workspaces",
|
||||
["latest_source_message_id"],
|
||||
)
|
||||
if not _index_exists(bind, "mounted_draft_workspaces", "ix_mounted_draft_workspaces_latest_reply_message_id"):
|
||||
op.create_index(
|
||||
"ix_mounted_draft_workspaces_latest_reply_message_id",
|
||||
"mounted_draft_workspaces",
|
||||
["latest_reply_message_id"],
|
||||
)
|
||||
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE mounted_draft_workspaces AS draft
|
||||
SET latest_source_message_id = COALESCE(
|
||||
NULLIF(draft.latest_source_message_id, ''),
|
||||
(
|
||||
SELECT topic.last_message_id
|
||||
FROM mounted_topic_workspaces AS topic
|
||||
WHERE topic.id = draft.topic_workspace_id
|
||||
)
|
||||
)
|
||||
WHERE COALESCE(NULLIF(draft.latest_source_message_id, ''), '') = ''
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _index_exists(bind, "mounted_draft_workspaces", "ix_mounted_draft_workspaces_latest_reply_message_id"):
|
||||
op.drop_index(
|
||||
"ix_mounted_draft_workspaces_latest_reply_message_id",
|
||||
table_name="mounted_draft_workspaces",
|
||||
)
|
||||
if _index_exists(bind, "mounted_draft_workspaces", "ix_mounted_draft_workspaces_latest_source_message_id"):
|
||||
op.drop_index(
|
||||
"ix_mounted_draft_workspaces_latest_source_message_id",
|
||||
table_name="mounted_draft_workspaces",
|
||||
)
|
||||
if _index_exists(bind, "mounted_draft_workspaces", "ix_mounted_draft_workspaces_active_work_item_id"):
|
||||
op.drop_index(
|
||||
"ix_mounted_draft_workspaces_active_work_item_id",
|
||||
table_name="mounted_draft_workspaces",
|
||||
)
|
||||
if _column_exists(bind, "mounted_draft_workspaces", "latest_reply_message_id"):
|
||||
op.drop_column("mounted_draft_workspaces", "latest_reply_message_id")
|
||||
if _column_exists(bind, "mounted_draft_workspaces", "latest_source_message_id"):
|
||||
op.drop_column("mounted_draft_workspaces", "latest_source_message_id")
|
||||
if _column_exists(bind, "mounted_draft_workspaces", "active_work_item_id"):
|
||||
op.drop_column("mounted_draft_workspaces", "active_work_item_id")
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
"""rename control plane status column
|
||||
|
||||
Revision ID: 20260413_rename_control_plane_status
|
||||
Revises: 20260406_add_mounted_runtime_active_draft_fields
|
||||
Create Date: 2026-04-13
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260413_rename_control_plane_status"
|
||||
down_revision = "20260406_add_mounted_runtime_active_draft_fields"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
TABLE_NAME = "ai_orchestration_runs"
|
||||
NEW_COLUMN = "control_plane_status"
|
||||
NEW_INDEX = "ix_ai_orchestration_runs_control_plane_status"
|
||||
|
||||
|
||||
def _legacy_column() -> str:
|
||||
return "".join(("open", "claw", "_status"))
|
||||
|
||||
|
||||
def _legacy_index() -> str:
|
||||
return f"ix_ai_orchestration_runs_{_legacy_column()}"
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _has_column(bind, table_name: str, column_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
return any(str(col.get("name") or "") == column_name for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
return any(str(idx.get("name") or "") == index_name for idx in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, TABLE_NAME):
|
||||
return
|
||||
|
||||
legacy_column = _legacy_column()
|
||||
legacy_index = _legacy_index()
|
||||
has_legacy = _has_column(bind, TABLE_NAME, legacy_column)
|
||||
has_new = _has_column(bind, TABLE_NAME, NEW_COLUMN)
|
||||
|
||||
if has_legacy and not has_new:
|
||||
op.alter_column(
|
||||
TABLE_NAME,
|
||||
legacy_column,
|
||||
new_column_name=NEW_COLUMN,
|
||||
existing_type=sa.String(length=20),
|
||||
existing_nullable=True,
|
||||
)
|
||||
|
||||
if _index_exists(bind, TABLE_NAME, legacy_index):
|
||||
op.drop_index(legacy_index, table_name=TABLE_NAME)
|
||||
if _has_column(bind, TABLE_NAME, NEW_COLUMN) and not _index_exists(bind, TABLE_NAME, NEW_INDEX):
|
||||
op.create_index(NEW_INDEX, TABLE_NAME, [NEW_COLUMN], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, TABLE_NAME):
|
||||
return
|
||||
|
||||
legacy_column = _legacy_column()
|
||||
legacy_index = _legacy_index()
|
||||
has_legacy = _has_column(bind, TABLE_NAME, legacy_column)
|
||||
has_new = _has_column(bind, TABLE_NAME, NEW_COLUMN)
|
||||
|
||||
if _index_exists(bind, TABLE_NAME, NEW_INDEX):
|
||||
op.drop_index(NEW_INDEX, table_name=TABLE_NAME)
|
||||
|
||||
if has_new and not has_legacy:
|
||||
op.alter_column(
|
||||
TABLE_NAME,
|
||||
NEW_COLUMN,
|
||||
new_column_name=legacy_column,
|
||||
existing_type=sa.String(length=20),
|
||||
existing_nullable=True,
|
||||
)
|
||||
|
||||
if _has_column(bind, TABLE_NAME, legacy_column) and not _index_exists(bind, TABLE_NAME, legacy_index):
|
||||
op.create_index(legacy_index, TABLE_NAME, [legacy_column], unique=False)
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
"""add query route memories
|
||||
|
||||
Revision ID: 20260415_add_query_route_memories
|
||||
Revises: 20260413_rename_control_plane_status
|
||||
Create Date: 2026-04-15
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260415_add_query_route_memories"
|
||||
down_revision = "20260413_rename_control_plane_status"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "query_route_memories"):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
"query_route_memories",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("qa_record_id", sa.Integer(), sa.ForeignKey("qa_records.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("question_text", sa.Text(), nullable=False),
|
||||
sa.Column("route_mode_predicted", sa.String(length=32), nullable=False),
|
||||
sa.Column("route_mode_effective", sa.String(length=32), nullable=False),
|
||||
sa.Column("decision_origin", sa.String(length=64), nullable=True),
|
||||
sa.Column("reply_contract", _json_type(bind), nullable=True),
|
||||
sa.Column("probe_snapshot", _json_type(bind), nullable=True),
|
||||
sa.Column("query_profile", _json_type(bind), nullable=True),
|
||||
sa.Column("feedback_outcome", _json_type(bind), nullable=True),
|
||||
sa.Column("question_embedding", _json_type(bind), nullable=True),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"qa_record_id",
|
||||
name="uq_query_route_memories_tenant_qa_record",
|
||||
),
|
||||
)
|
||||
op.create_index("ix_query_route_memories_tenant_id", "query_route_memories", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_query_route_memories_qa_record_id", "query_route_memories", ["qa_record_id"], unique=False)
|
||||
op.create_index("ix_query_route_memories_project_id", "query_route_memories", ["project_id"], unique=False)
|
||||
op.create_index(
|
||||
"ix_query_route_memories_route_mode_predicted",
|
||||
"query_route_memories",
|
||||
["route_mode_predicted"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_query_route_memories_route_mode_effective",
|
||||
"query_route_memories",
|
||||
["route_mode_effective"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_query_route_memories_decision_origin",
|
||||
"query_route_memories",
|
||||
["decision_origin"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "query_route_memories"):
|
||||
return
|
||||
|
||||
for index_name in (
|
||||
"ix_query_route_memories_decision_origin",
|
||||
"ix_query_route_memories_route_mode_effective",
|
||||
"ix_query_route_memories_route_mode_predicted",
|
||||
"ix_query_route_memories_project_id",
|
||||
"ix_query_route_memories_qa_record_id",
|
||||
"ix_query_route_memories_tenant_id",
|
||||
):
|
||||
op.drop_index(index_name, table_name="query_route_memories")
|
||||
op.drop_table("query_route_memories")
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
"""add semantic facts
|
||||
|
||||
Revision ID: 20260415_add_semantic_facts
|
||||
Revises: 20260415_add_query_route_memories
|
||||
Create Date: 2026-04-15
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260415_add_semantic_facts"
|
||||
down_revision = "20260415_add_query_route_memories"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "semantic_facts"):
|
||||
op.create_table(
|
||||
"semantic_facts",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("entity_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("entity_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("fact_namespace", sa.String(length=64), nullable=False),
|
||||
sa.Column("fact_key", sa.String(length=64), nullable=False),
|
||||
sa.Column("fact_value_text", sa.Text(), nullable=False),
|
||||
sa.Column("fact_value_norm", sa.String(length=255), nullable=False),
|
||||
sa.Column("value_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("confidence", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("evidence_source_type", sa.String(length=32), nullable=True),
|
||||
sa.Column("evidence_source_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("evidence_excerpt", sa.Text(), nullable=True),
|
||||
sa.Column("extract_version", sa.String(length=64), nullable=True),
|
||||
sa.Column("freshness_at", sa.String(length=64), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"entity_type",
|
||||
"entity_id",
|
||||
"fact_key",
|
||||
"fact_value_norm",
|
||||
name="uq_semantic_facts_tenant_entity_key_norm",
|
||||
),
|
||||
)
|
||||
op.create_index("ix_semantic_facts_tenant_id", "semantic_facts", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_semantic_facts_entity_type", "semantic_facts", ["entity_type"], unique=False)
|
||||
op.create_index("ix_semantic_facts_entity_id", "semantic_facts", ["entity_id"], unique=False)
|
||||
op.create_index("ix_semantic_facts_fact_namespace", "semantic_facts", ["fact_namespace"], unique=False)
|
||||
op.create_index("ix_semantic_facts_fact_key", "semantic_facts", ["fact_key"], unique=False)
|
||||
op.create_index("ix_semantic_facts_fact_value_norm", "semantic_facts", ["fact_value_norm"], unique=False)
|
||||
op.create_index("ix_semantic_facts_is_active", "semantic_facts", ["is_active"], unique=False)
|
||||
|
||||
if not _table_exists(bind, "semantic_fact_extraction_runs"):
|
||||
op.create_table(
|
||||
"semantic_fact_extraction_runs",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("entity_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("entity_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("trigger_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False),
|
||||
sa.Column("input_fingerprint", sa.String(length=64), nullable=True),
|
||||
sa.Column("facts_written", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("failure_reason", sa.Text(), nullable=True),
|
||||
sa.Column("extract_version", sa.String(length=64), nullable=True),
|
||||
sa.Column("started_at", sa.String(length=64), nullable=True),
|
||||
sa.Column("finished_at", sa.String(length=64), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_semantic_fact_extraction_runs_tenant_id",
|
||||
"semantic_fact_extraction_runs",
|
||||
["tenant_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_semantic_fact_extraction_runs_entity_type",
|
||||
"semantic_fact_extraction_runs",
|
||||
["entity_type"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_semantic_fact_extraction_runs_entity_id",
|
||||
"semantic_fact_extraction_runs",
|
||||
["entity_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_semantic_fact_extraction_runs_status",
|
||||
"semantic_fact_extraction_runs",
|
||||
["status"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_semantic_fact_extraction_runs_input_fingerprint",
|
||||
"semantic_fact_extraction_runs",
|
||||
["input_fingerprint"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "semantic_fact_extraction_runs"):
|
||||
for index_name in (
|
||||
"ix_semantic_fact_extraction_runs_input_fingerprint",
|
||||
"ix_semantic_fact_extraction_runs_status",
|
||||
"ix_semantic_fact_extraction_runs_entity_id",
|
||||
"ix_semantic_fact_extraction_runs_entity_type",
|
||||
"ix_semantic_fact_extraction_runs_tenant_id",
|
||||
):
|
||||
op.drop_index(index_name, table_name="semantic_fact_extraction_runs")
|
||||
op.drop_table("semantic_fact_extraction_runs")
|
||||
|
||||
if _table_exists(bind, "semantic_facts"):
|
||||
for index_name in (
|
||||
"ix_semantic_facts_is_active",
|
||||
"ix_semantic_facts_fact_value_norm",
|
||||
"ix_semantic_facts_fact_key",
|
||||
"ix_semantic_facts_fact_namespace",
|
||||
"ix_semantic_facts_entity_id",
|
||||
"ix_semantic_facts_entity_type",
|
||||
"ix_semantic_facts_tenant_id",
|
||||
):
|
||||
op.drop_index(index_name, table_name="semantic_facts")
|
||||
op.drop_table("semantic_facts")
|
||||
|
|
@ -1,496 +0,0 @@
|
|||
"""add ai project control tables
|
||||
|
||||
Revision ID: 20260421_add_ai_project_control_tables
|
||||
Revises: 20260415_add_semantic_facts
|
||||
Create Date: 2026-04-21
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260421_add_ai_project_control_tables"
|
||||
down_revision = "20260415_add_semantic_facts"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _seed_model_catalog(bind) -> None:
|
||||
table = sa.table(
|
||||
"model_catalog_items",
|
||||
sa.column("tenant_id", sa.BigInteger()),
|
||||
sa.column("provider", sa.String()),
|
||||
sa.column("model_key", sa.String()),
|
||||
sa.column("display_name", sa.String()),
|
||||
sa.column("version", sa.String()),
|
||||
sa.column("modalities", _json_type(bind)),
|
||||
sa.column("capability_tags", _json_type(bind)),
|
||||
sa.column("strengths", _json_type(bind)),
|
||||
sa.column("limitations", _json_type(bind)),
|
||||
sa.column("compliance_status", sa.String()),
|
||||
sa.column("filing_status", sa.String()),
|
||||
sa.column("input_cost_per_mtok", sa.Numeric(12, 4)),
|
||||
sa.column("output_cost_per_mtok", sa.Numeric(12, 4)),
|
||||
sa.column("official_docs_url", sa.Text()),
|
||||
sa.column("is_active", sa.Boolean()),
|
||||
)
|
||||
op.bulk_insert(
|
||||
table,
|
||||
[
|
||||
{
|
||||
"tenant_id": 1,
|
||||
"provider": "阿里云",
|
||||
"model_key": "qwen-plus",
|
||||
"display_name": "通义千问 Plus",
|
||||
"version": "latest",
|
||||
"modalities": ["text", "image"],
|
||||
"capability_tags": ["general", "rag", "reasoning", "agent", "code"],
|
||||
"strengths": ["中文通用能力强", "RAG 适配稳定", "工具调用成熟"],
|
||||
"limitations": ["不适合视频生成"],
|
||||
"compliance_status": "ready",
|
||||
"filing_status": "filed",
|
||||
"input_cost_per_mtok": 0.8,
|
||||
"output_cost_per_mtok": 2.0,
|
||||
"official_docs_url": "https://help.aliyun.com/zh/model-studio/",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"tenant_id": 1,
|
||||
"provider": "火山引擎",
|
||||
"model_key": "doubao-pro",
|
||||
"display_name": "豆包 Pro",
|
||||
"version": "latest",
|
||||
"modalities": ["text", "image"],
|
||||
"capability_tags": ["general", "rag", "reasoning", "multimodal"],
|
||||
"strengths": ["长文本稳", "中文体验好", "成本适中"],
|
||||
"limitations": ["代码生成弱于专用模型"],
|
||||
"compliance_status": "ready",
|
||||
"filing_status": "filed",
|
||||
"input_cost_per_mtok": 0.8,
|
||||
"output_cost_per_mtok": 2.0,
|
||||
"official_docs_url": "https://www.volcengine.com/product/doubao",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"tenant_id": 1,
|
||||
"provider": "百度智能云",
|
||||
"model_key": "ernie-4.0-turbo",
|
||||
"display_name": "文心一言 4.0 Turbo",
|
||||
"version": "latest",
|
||||
"modalities": ["text", "image"],
|
||||
"capability_tags": ["general", "rag", "reasoning", "multimodal"],
|
||||
"strengths": ["企业落地常见", "多模态均衡"],
|
||||
"limitations": ["综合成本偏高"],
|
||||
"compliance_status": "ready",
|
||||
"filing_status": "filed",
|
||||
"input_cost_per_mtok": 1.2,
|
||||
"output_cost_per_mtok": 3.2,
|
||||
"official_docs_url": "https://cloud.baidu.com/product/wenxinworkshop",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"tenant_id": 1,
|
||||
"provider": "DeepSeek",
|
||||
"model_key": "deepseek-chat",
|
||||
"display_name": "DeepSeek Chat",
|
||||
"version": "latest",
|
||||
"modalities": ["text"],
|
||||
"capability_tags": ["general", "reasoning", "code", "agent"],
|
||||
"strengths": ["代码与推理性价比高", "适合技术团队"],
|
||||
"limitations": ["需要留意合规和交付边界"],
|
||||
"compliance_status": "review_required",
|
||||
"filing_status": "unknown",
|
||||
"input_cost_per_mtok": 0.27,
|
||||
"output_cost_per_mtok": 1.1,
|
||||
"official_docs_url": "https://platform.deepseek.com/",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"tenant_id": 1,
|
||||
"provider": "Moonshot AI",
|
||||
"model_key": "kimi-k2",
|
||||
"display_name": "Kimi K2",
|
||||
"version": "latest",
|
||||
"modalities": ["text"],
|
||||
"capability_tags": ["general", "long_context", "rag", "reasoning"],
|
||||
"strengths": ["长上下文强", "资料理解稳定"],
|
||||
"limitations": ["对多模态场景支持有限"],
|
||||
"compliance_status": "ready",
|
||||
"filing_status": "filed",
|
||||
"input_cost_per_mtok": 1.0,
|
||||
"output_cost_per_mtok": 3.0,
|
||||
"official_docs_url": "https://platform.moonshot.cn/",
|
||||
"is_active": True,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
json_type = _json_type(bind)
|
||||
|
||||
if not _table_exists(bind, "model_catalog_items"):
|
||||
op.create_table(
|
||||
"model_catalog_items",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("provider", sa.String(length=120), nullable=False),
|
||||
sa.Column("model_key", sa.String(length=120), nullable=False),
|
||||
sa.Column("display_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("version", sa.String(length=120), nullable=False),
|
||||
sa.Column("modalities", json_type, nullable=False),
|
||||
sa.Column("capability_tags", json_type, nullable=False),
|
||||
sa.Column("strengths", json_type, nullable=False),
|
||||
sa.Column("limitations", json_type, nullable=False),
|
||||
sa.Column("compliance_status", sa.String(length=50), nullable=False, server_default="review_required"),
|
||||
sa.Column("filing_status", sa.String(length=50), nullable=False, server_default="unknown"),
|
||||
sa.Column("input_cost_per_mtok", sa.DECIMAL(12, 4), nullable=True),
|
||||
sa.Column("output_cost_per_mtok", sa.DECIMAL(12, 4), nullable=True),
|
||||
sa.Column("official_docs_url", sa.Text(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"provider",
|
||||
"model_key",
|
||||
"version",
|
||||
name="uq_model_catalog_items_tenant_provider_key_version",
|
||||
),
|
||||
)
|
||||
op.create_index("ix_model_catalog_items_tenant_id", "model_catalog_items", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_model_catalog_items_provider", "model_catalog_items", ["provider"], unique=False)
|
||||
op.create_index("ix_model_catalog_items_model_key", "model_catalog_items", ["model_key"], unique=False)
|
||||
op.create_index("ix_model_catalog_items_is_active", "model_catalog_items", ["is_active"], unique=False)
|
||||
_seed_model_catalog(bind)
|
||||
|
||||
table_specs = {
|
||||
"project_model_bindings": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
|
||||
sa.Column("model_catalog_item_id", sa.BigInteger(), sa.ForeignKey("model_catalog_items.id"), nullable=False),
|
||||
sa.Column("selected_by_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.Column("selection_reason", json_type, nullable=False),
|
||||
sa.Column("estimated_daily_tokens", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("estimated_monthly_cost_usd", sa.DECIMAL(12, 2), nullable=True),
|
||||
sa.Column("active_from", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("archived_at", sa.TIMESTAMP(), nullable=True),
|
||||
],
|
||||
"model_recommendation_snapshots": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
|
||||
sa.Column("recommendation_scope", sa.String(length=32), nullable=False, server_default="charter"),
|
||||
sa.Column("snapshot_input", json_type, nullable=False),
|
||||
sa.Column("recommendations", json_type, nullable=False),
|
||||
sa.Column("top_model_catalog_item_id", sa.BigInteger(), sa.ForeignKey("model_catalog_items.id"), nullable=True),
|
||||
sa.Column("generated_by", sa.String(length=32), nullable=False, server_default="rule_engine"),
|
||||
],
|
||||
"model_usage_events": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("project_model_binding_id", sa.BigInteger(), sa.ForeignKey("project_model_bindings.id"), nullable=True),
|
||||
sa.Column("source_type", sa.String(length=32), nullable=False, server_default="manual"),
|
||||
sa.Column("event_key", sa.String(length=255), nullable=True),
|
||||
sa.Column("input_tokens", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("output_tokens", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("cost_usd", sa.DECIMAL(12, 4), nullable=False, server_default="0"),
|
||||
sa.Column("event_payload", json_type, nullable=False),
|
||||
sa.Column("occurred_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.UniqueConstraint("tenant_id", "event_key", name="uq_model_usage_events_tenant_event_key"),
|
||||
],
|
||||
"model_eval_runs": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
|
||||
sa.Column("project_model_binding_id", sa.BigInteger(), sa.ForeignKey("project_model_bindings.id"), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="pending"),
|
||||
sa.Column("success_criteria", json_type, nullable=False),
|
||||
sa.Column("test_cases", json_type, nullable=False),
|
||||
sa.Column("summary", json_type, nullable=False),
|
||||
sa.Column("score", sa.DECIMAL(5, 2), nullable=True),
|
||||
],
|
||||
"prompt_packages": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=False),
|
||||
sa.Column("task_id", sa.BigInteger(), sa.ForeignKey("tasks.id"), nullable=True),
|
||||
sa.Column("project_model_binding_id", sa.BigInteger(), sa.ForeignKey("project_model_bindings.id"), nullable=True),
|
||||
sa.Column("package_kind", sa.String(length=32), nullable=False, server_default="development"),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.Column("package_text", sa.Text(), nullable=False),
|
||||
sa.Column("package_payload", json_type, nullable=False),
|
||||
sa.Column("generated_from", json_type, nullable=False),
|
||||
],
|
||||
"dev_workspace_bindings": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("workspace_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("repo_root", sa.Text(), nullable=True),
|
||||
sa.Column("editor_type", sa.String(length=32), nullable=False, server_default="vscode"),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.Column("last_seen_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.UniqueConstraint("tenant_id", "workspace_key", name="uq_dev_workspace_bindings_tenant_workspace_key"),
|
||||
],
|
||||
"repo_bindings": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("provider", sa.String(length=32), nullable=False, server_default="github"),
|
||||
sa.Column("external_repo_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("owner", sa.String(length=255), nullable=False),
|
||||
sa.Column("repo_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("default_branch", sa.String(length=255), nullable=False, server_default="main"),
|
||||
sa.Column("installation_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.Column("webhook_secret_hint", sa.String(length=255), nullable=True),
|
||||
sa.UniqueConstraint("tenant_id", "provider", "owner", "repo_name", name="uq_repo_bindings_tenant_provider_repo"),
|
||||
],
|
||||
"task_code_links": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("task_id", sa.BigInteger(), sa.ForeignKey("tasks.id"), nullable=False),
|
||||
sa.Column("repo_binding_id", sa.BigInteger(), sa.ForeignKey("repo_bindings.id"), nullable=False),
|
||||
sa.Column("branch_name", sa.String(length=255), nullable=True),
|
||||
sa.Column("pr_number", sa.Integer(), nullable=True),
|
||||
sa.Column("last_commit_sha", sa.String(length=255), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="linked"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"task_id",
|
||||
"repo_binding_id",
|
||||
"branch_name",
|
||||
name="uq_task_code_links_tenant_task_repo_branch",
|
||||
),
|
||||
],
|
||||
"progress_events": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("task_id", sa.BigInteger(), sa.ForeignKey("tasks.id"), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True),
|
||||
sa.Column("repo_binding_id", sa.BigInteger(), sa.ForeignKey("repo_bindings.id"), nullable=True),
|
||||
sa.Column("workspace_binding_id", sa.BigInteger(), sa.ForeignKey("dev_workspace_bindings.id"), nullable=True),
|
||||
sa.Column("event_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("source", sa.String(length=32), nullable=False, server_default="manual"),
|
||||
sa.Column("derived_task_status", sa.String(length=32), nullable=True),
|
||||
sa.Column("summary", sa.Text(), nullable=True),
|
||||
sa.Column("payload", json_type, nullable=False),
|
||||
sa.Column("occurred_at", sa.TIMESTAMP(), nullable=True),
|
||||
],
|
||||
"dev_sessions": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("workspace_binding_id", sa.BigInteger(), sa.ForeignKey("dev_workspace_bindings.id"), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.Column("started_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("heartbeat_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("ended_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("session_payload", json_type, nullable=False),
|
||||
],
|
||||
"release_validation_runs": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
|
||||
sa.Column("release_node_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
|
||||
sa.Column("project_model_binding_id", sa.BigInteger(), sa.ForeignKey("project_model_bindings.id"), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="pending"),
|
||||
sa.Column("score", sa.DECIMAL(5, 2), nullable=True),
|
||||
sa.Column("key_metrics", json_type, nullable=False),
|
||||
sa.Column("risk_items", json_type, nullable=False),
|
||||
sa.Column("rollback_plan", json_type, nullable=False),
|
||||
sa.Column("validation_summary", json_type, nullable=False),
|
||||
],
|
||||
"feedback_ingests": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
|
||||
sa.Column("ingested_by_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True),
|
||||
sa.Column("source_type", sa.String(length=32), nullable=False, server_default="manual"),
|
||||
sa.Column("title", sa.String(length=255), nullable=True),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("metrics", json_type, nullable=False),
|
||||
sa.Column("samples", json_type, nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
],
|
||||
"iteration_recommendations": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
|
||||
sa.Column("feedback_ingest_id", sa.BigInteger(), sa.ForeignKey("feedback_ingests.id"), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="draft"),
|
||||
sa.Column("priority", sa.String(length=32), nullable=False, server_default="medium"),
|
||||
sa.Column("recommendation_summary", json_type, nullable=False),
|
||||
sa.Column("proposed_charter_patch", json_type, nullable=False),
|
||||
sa.Column("proposed_tasks", json_type, nullable=False),
|
||||
],
|
||||
"revenue_events": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("source_type", sa.String(length=32), nullable=False, server_default="manual"),
|
||||
sa.Column("currency", sa.String(length=16), nullable=False, server_default="USD"),
|
||||
sa.Column("amount_usd", sa.DECIMAL(12, 2), nullable=False, server_default="0"),
|
||||
sa.Column("external_ref", sa.String(length=255), nullable=True),
|
||||
sa.Column("event_payload", json_type, nullable=False),
|
||||
sa.Column("occurred_at", sa.TIMESTAMP(), nullable=True),
|
||||
],
|
||||
"roi_snapshots": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("range_key", sa.String(length=32), nullable=False, server_default="monthly"),
|
||||
sa.Column("window_start", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("window_end", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("total_revenue_usd", sa.DECIMAL(12, 2), nullable=False, server_default="0"),
|
||||
sa.Column("total_cost_usd", sa.DECIMAL(12, 2), nullable=False, server_default="0"),
|
||||
sa.Column("roi_ratio", sa.DECIMAL(12, 4), nullable=True),
|
||||
sa.Column("snapshot_payload", json_type, nullable=False),
|
||||
],
|
||||
"compliance_check_runs": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
|
||||
sa.Column("release_node_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
|
||||
sa.Column("project_model_binding_id", sa.BigInteger(), sa.ForeignKey("project_model_bindings.id"), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="pending"),
|
||||
sa.Column("score", sa.DECIMAL(5, 2), nullable=True),
|
||||
sa.Column("checklist", json_type, nullable=False),
|
||||
sa.Column("issues", json_type, nullable=False),
|
||||
sa.Column("summary", json_type, nullable=False),
|
||||
],
|
||||
"evidence_packs": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("compliance_check_run_id", sa.BigInteger(), sa.ForeignKey("compliance_check_runs.id"), nullable=True),
|
||||
sa.Column("package_kind", sa.String(length=32), nullable=False, server_default="compliance_release"),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="draft"),
|
||||
sa.Column("content", json_type, nullable=False),
|
||||
sa.Column("export_payload", json_type, nullable=False),
|
||||
],
|
||||
}
|
||||
|
||||
for table_name, columns in table_specs.items():
|
||||
if _table_exists(bind, table_name):
|
||||
continue
|
||||
op.create_table(table_name, *columns)
|
||||
op.create_index(f"ix_{table_name}_tenant_id", table_name, ["tenant_id"], unique=False)
|
||||
|
||||
extra_indexes = {
|
||||
"project_model_bindings": ["project_id", "charter_id", "model_catalog_item_id", "status"],
|
||||
"model_recommendation_snapshots": ["project_id", "charter_id"],
|
||||
"model_usage_events": ["project_id", "project_model_binding_id", "source_type", "occurred_at"],
|
||||
"model_eval_runs": ["project_id", "charter_id", "project_model_binding_id", "status"],
|
||||
"prompt_packages": ["project_id", "charter_id", "task_id", "project_model_binding_id", "status"],
|
||||
"dev_workspace_bindings": ["project_id", "user_id", "status"],
|
||||
"repo_bindings": ["project_id", "provider", "status"],
|
||||
"task_code_links": ["task_id", "repo_binding_id", "pr_number", "status"],
|
||||
"progress_events": ["task_id", "project_id", "user_id", "repo_binding_id", "workspace_binding_id", "event_type", "source", "occurred_at"],
|
||||
"dev_sessions": ["project_id", "user_id", "workspace_binding_id", "status"],
|
||||
"release_validation_runs": ["project_id", "charter_id", "release_node_id", "project_model_binding_id", "status"],
|
||||
"feedback_ingests": ["project_id", "charter_id", "ingested_by_user_id", "source_type", "status"],
|
||||
"iteration_recommendations": ["project_id", "charter_id", "feedback_ingest_id", "status", "priority"],
|
||||
"revenue_events": ["project_id", "source_type", "external_ref", "occurred_at"],
|
||||
"roi_snapshots": ["project_id", "range_key"],
|
||||
"compliance_check_runs": ["project_id", "charter_id", "release_node_id", "project_model_binding_id", "status"],
|
||||
"evidence_packs": ["project_id", "compliance_check_run_id", "package_kind", "status"],
|
||||
}
|
||||
for table_name, columns in extra_indexes.items():
|
||||
for column_name in columns:
|
||||
op.create_index(f"ix_{table_name}_{column_name}", table_name, [column_name], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
tables = [
|
||||
"evidence_packs",
|
||||
"compliance_check_runs",
|
||||
"roi_snapshots",
|
||||
"revenue_events",
|
||||
"iteration_recommendations",
|
||||
"feedback_ingests",
|
||||
"release_validation_runs",
|
||||
"dev_sessions",
|
||||
"progress_events",
|
||||
"task_code_links",
|
||||
"repo_bindings",
|
||||
"dev_workspace_bindings",
|
||||
"prompt_packages",
|
||||
"model_eval_runs",
|
||||
"model_usage_events",
|
||||
"model_recommendation_snapshots",
|
||||
"project_model_bindings",
|
||||
"model_catalog_items",
|
||||
]
|
||||
for table_name in tables:
|
||||
if not _table_exists(bind, table_name):
|
||||
continue
|
||||
for index in sa.inspect(bind).get_indexes(table_name):
|
||||
op.drop_index(index["name"], table_name=table_name)
|
||||
op.drop_table(table_name)
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
"""finalize v1 productization
|
||||
|
||||
Revision ID: 20260421_finalize_v1_productization
|
||||
Revises: 20260421_add_ai_project_control_tables
|
||||
Create Date: 2026-04-21
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260421_finalize_v1_productization"
|
||||
down_revision = "20260421_add_ai_project_control_tables"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
if not _table_exists(bind, table_name):
|
||||
return False
|
||||
return any(column.get("name") == column_name for column in sa.inspect(bind).get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
json_type = _json_type(bind)
|
||||
|
||||
if _table_exists(bind, "users") and _column_exists(bind, "users", "role"):
|
||||
op.execute("UPDATE users SET role = 'OWNER' WHERE UPPER(role) = 'BOSS'")
|
||||
op.execute("UPDATE users SET role = 'REVIEWER' WHERE UPPER(role) = 'ADMIN'")
|
||||
op.execute("UPDATE users SET role = 'BUILDER' WHERE UPPER(role) IN ('AI', 'FE', 'BE')")
|
||||
|
||||
if _table_exists(bind, "revenue_events") and not _column_exists(bind, "revenue_events", "category"):
|
||||
op.add_column(
|
||||
"revenue_events",
|
||||
sa.Column("category", sa.String(length=32), nullable=False, server_default="other"),
|
||||
)
|
||||
op.create_index("ix_revenue_events_category", "revenue_events", ["category"], unique=False)
|
||||
|
||||
if _table_exists(bind, "repo_bindings") and not _column_exists(bind, "repo_bindings", "installation_account_login"):
|
||||
op.add_column(
|
||||
"repo_bindings",
|
||||
sa.Column("installation_account_login", sa.String(length=255), nullable=True),
|
||||
)
|
||||
|
||||
if _table_exists(bind, "progress_events") and not _column_exists(bind, "progress_events", "delivery_id"):
|
||||
op.add_column("progress_events", sa.Column("delivery_id", sa.String(length=255), nullable=True))
|
||||
op.create_index("ix_progress_events_delivery_id", "progress_events", ["delivery_id"], unique=False)
|
||||
if _table_exists(bind, "progress_events") and not _column_exists(bind, "progress_events", "event_source_id"):
|
||||
op.add_column("progress_events", sa.Column("event_source_id", sa.String(length=255), nullable=True))
|
||||
op.create_index("ix_progress_events_event_source_id", "progress_events", ["event_source_id"], unique=False)
|
||||
|
||||
if not _table_exists(bind, "github_app_installations"):
|
||||
op.create_table(
|
||||
"github_app_installations",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("installation_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("account_login", sa.String(length=255), nullable=False),
|
||||
sa.Column("account_type", sa.String(length=64), nullable=True),
|
||||
sa.Column("app_slug", sa.String(length=255), nullable=True),
|
||||
sa.Column("target_type", sa.String(length=64), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.Column("permissions", json_type, nullable=False),
|
||||
sa.Column("events", json_type, nullable=False),
|
||||
sa.Column("metadata", json_type, nullable=False),
|
||||
sa.Column("last_synced_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.UniqueConstraint("tenant_id", "installation_id", name="uq_github_app_installations_tenant_installation"),
|
||||
)
|
||||
op.create_index("ix_github_app_installations_tenant_id", "github_app_installations", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_github_app_installations_installation_id", "github_app_installations", ["installation_id"], unique=False)
|
||||
op.create_index("ix_github_app_installations_account_login", "github_app_installations", ["account_login"], unique=False)
|
||||
op.create_index("ix_github_app_installations_status", "github_app_installations", ["status"], unique=False)
|
||||
|
||||
if not _table_exists(bind, "github_app_repositories"):
|
||||
op.create_table(
|
||||
"github_app_repositories",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("installation_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("external_repo_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("owner", sa.String(length=255), nullable=False),
|
||||
sa.Column("repo_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("full_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("default_branch", sa.String(length=255), nullable=True),
|
||||
sa.Column("is_private", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("permissions", json_type, nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.Column("last_synced_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"installation_id",
|
||||
"external_repo_id",
|
||||
name="uq_github_app_repositories_repo",
|
||||
),
|
||||
)
|
||||
op.create_index("ix_github_app_repositories_tenant_id", "github_app_repositories", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_github_app_repositories_installation_id", "github_app_repositories", ["installation_id"], unique=False)
|
||||
op.create_index("ix_github_app_repositories_external_repo_id", "github_app_repositories", ["external_repo_id"], unique=False)
|
||||
op.create_index("ix_github_app_repositories_owner", "github_app_repositories", ["owner"], unique=False)
|
||||
op.create_index("ix_github_app_repositories_repo_name", "github_app_repositories", ["repo_name"], unique=False)
|
||||
op.create_index("ix_github_app_repositories_full_name", "github_app_repositories", ["full_name"], unique=False)
|
||||
op.create_index("ix_github_app_repositories_status", "github_app_repositories", ["status"], unique=False)
|
||||
|
||||
if not _table_exists(bind, "github_webhook_deliveries"):
|
||||
op.create_table(
|
||||
"github_webhook_deliveries",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("delivery_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("event_type", sa.String(length=64), nullable=False),
|
||||
sa.Column("repo_binding_id", sa.BigInteger(), sa.ForeignKey("repo_bindings.id"), nullable=True),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=True),
|
||||
sa.Column("installation_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="received"),
|
||||
sa.Column("failure_reason", sa.Text(), nullable=True),
|
||||
sa.Column("payload", json_type, nullable=False),
|
||||
sa.Column("received_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.UniqueConstraint("tenant_id", "delivery_id", name="uq_github_webhook_deliveries_tenant_delivery"),
|
||||
)
|
||||
op.create_index("ix_github_webhook_deliveries_tenant_id", "github_webhook_deliveries", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_github_webhook_deliveries_delivery_id", "github_webhook_deliveries", ["delivery_id"], unique=False)
|
||||
op.create_index("ix_github_webhook_deliveries_event_type", "github_webhook_deliveries", ["event_type"], unique=False)
|
||||
op.create_index("ix_github_webhook_deliveries_repo_binding_id", "github_webhook_deliveries", ["repo_binding_id"], unique=False)
|
||||
op.create_index("ix_github_webhook_deliveries_project_id", "github_webhook_deliveries", ["project_id"], unique=False)
|
||||
op.create_index("ix_github_webhook_deliveries_installation_id", "github_webhook_deliveries", ["installation_id"], unique=False)
|
||||
op.create_index("ix_github_webhook_deliveries_status", "github_webhook_deliveries", ["status"], unique=False)
|
||||
op.create_index("ix_github_webhook_deliveries_received_at", "github_webhook_deliveries", ["received_at"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if _table_exists(bind, "github_webhook_deliveries"):
|
||||
op.drop_table("github_webhook_deliveries")
|
||||
if _table_exists(bind, "github_app_repositories"):
|
||||
op.drop_table("github_app_repositories")
|
||||
if _table_exists(bind, "github_app_installations"):
|
||||
op.drop_table("github_app_installations")
|
||||
|
||||
if _table_exists(bind, "progress_events") and _column_exists(bind, "progress_events", "event_source_id"):
|
||||
op.drop_index("ix_progress_events_event_source_id", table_name="progress_events")
|
||||
op.drop_column("progress_events", "event_source_id")
|
||||
if _table_exists(bind, "progress_events") and _column_exists(bind, "progress_events", "delivery_id"):
|
||||
op.drop_index("ix_progress_events_delivery_id", table_name="progress_events")
|
||||
op.drop_column("progress_events", "delivery_id")
|
||||
|
||||
if _table_exists(bind, "repo_bindings") and _column_exists(bind, "repo_bindings", "installation_account_login"):
|
||||
op.drop_column("repo_bindings", "installation_account_login")
|
||||
|
||||
if _table_exists(bind, "revenue_events") and _column_exists(bind, "revenue_events", "category"):
|
||||
op.drop_index("ix_revenue_events_category", table_name="revenue_events")
|
||||
op.drop_column("revenue_events", "category")
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
"""add pmflow launch tables
|
||||
|
||||
Revision ID: 20260425_add_pmflow_launch_tables
|
||||
Revises: 20260421_finalize_v1_productization
|
||||
Create Date: 2026-04-25
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260425_add_pmflow_launch_tables"
|
||||
down_revision = "20260421_finalize_v1_productization"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
json_type = _json_type(bind)
|
||||
|
||||
if not _table_exists(bind, "pmflow_launch_drafts"):
|
||||
op.create_table(
|
||||
"pmflow_launch_drafts",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("draft_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("input_text", sa.Text(), nullable=False),
|
||||
sa.Column("input_type", sa.String(length=32), nullable=False, server_default="text"),
|
||||
sa.Column("source", sa.String(length=32), nullable=False, server_default="web"),
|
||||
sa.Column("attachments", json_type, nullable=False),
|
||||
sa.Column("draft_payload", json_type, nullable=False),
|
||||
sa.Column("edited_payload", json_type, nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="draft"),
|
||||
sa.Column("confidence", sa.Float(), nullable=True),
|
||||
sa.Column("warnings", json_type, nullable=False),
|
||||
sa.Column("confirmed_project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=True),
|
||||
sa.Column("expires_at", sa.TIMESTAMP(), nullable=False),
|
||||
sa.Column("confirmed_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("confirm_request_hash", sa.String(length=128), nullable=True),
|
||||
sa.UniqueConstraint("tenant_id", "draft_id", name="uq_pmflow_launch_drafts_tenant_draft_id"),
|
||||
)
|
||||
op.create_index("ix_pmflow_launch_drafts_tenant_id", "pmflow_launch_drafts", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_pmflow_launch_drafts_user_id", "pmflow_launch_drafts", ["user_id"], unique=False)
|
||||
op.create_index("ix_pmflow_launch_drafts_draft_id", "pmflow_launch_drafts", ["draft_id"], unique=False)
|
||||
op.create_index("ix_pmflow_launch_drafts_status", "pmflow_launch_drafts", ["status"], unique=False)
|
||||
op.create_index(
|
||||
"ix_pmflow_launch_drafts_confirmed_project_id",
|
||||
"pmflow_launch_drafts",
|
||||
["confirmed_project_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index("ix_pmflow_launch_drafts_expires_at", "pmflow_launch_drafts", ["expires_at"], unique=False)
|
||||
|
||||
if not _table_exists(bind, "pmflow_threads"):
|
||||
op.create_table(
|
||||
"pmflow_threads",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("launch_draft_id", sa.BigInteger(), sa.ForeignKey("pmflow_launch_drafts.id"), nullable=True),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("objective", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.Column("source", sa.String(length=32), nullable=False, server_default="project_launch"),
|
||||
sa.UniqueConstraint("tenant_id", "project_id", "source", name="uq_pmflow_threads_tenant_project_source"),
|
||||
)
|
||||
op.create_index("ix_pmflow_threads_tenant_id", "pmflow_threads", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_pmflow_threads_project_id", "pmflow_threads", ["project_id"], unique=False)
|
||||
op.create_index("ix_pmflow_threads_launch_draft_id", "pmflow_threads", ["launch_draft_id"], unique=False)
|
||||
op.create_index("ix_pmflow_threads_status", "pmflow_threads", ["status"], unique=False)
|
||||
op.create_index("ix_pmflow_threads_source", "pmflow_threads", ["source"], unique=False)
|
||||
|
||||
if not _table_exists(bind, "pmflow_thread_messages"):
|
||||
op.create_table(
|
||||
"pmflow_thread_messages",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("thread_id", sa.BigInteger(), sa.ForeignKey("pmflow_threads.id"), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("launch_draft_id", sa.BigInteger(), sa.ForeignKey("pmflow_launch_drafts.id"), nullable=True),
|
||||
sa.Column("role", sa.String(length=32), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("source", sa.String(length=32), nullable=False, server_default="project_launch"),
|
||||
sa.Column("attachments", json_type, nullable=False),
|
||||
sa.Column("message_metadata", json_type, nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="sent"),
|
||||
)
|
||||
op.create_index("ix_pmflow_thread_messages_tenant_id", "pmflow_thread_messages", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_pmflow_thread_messages_thread_id", "pmflow_thread_messages", ["thread_id"], unique=False)
|
||||
op.create_index("ix_pmflow_thread_messages_project_id", "pmflow_thread_messages", ["project_id"], unique=False)
|
||||
op.create_index(
|
||||
"ix_pmflow_thread_messages_launch_draft_id",
|
||||
"pmflow_thread_messages",
|
||||
["launch_draft_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index("ix_pmflow_thread_messages_status", "pmflow_thread_messages", ["status"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
for index_name, table_name in (
|
||||
("ix_pmflow_thread_messages_status", "pmflow_thread_messages"),
|
||||
("ix_pmflow_thread_messages_launch_draft_id", "pmflow_thread_messages"),
|
||||
("ix_pmflow_thread_messages_project_id", "pmflow_thread_messages"),
|
||||
("ix_pmflow_thread_messages_thread_id", "pmflow_thread_messages"),
|
||||
("ix_pmflow_thread_messages_tenant_id", "pmflow_thread_messages"),
|
||||
("ix_pmflow_threads_source", "pmflow_threads"),
|
||||
("ix_pmflow_threads_status", "pmflow_threads"),
|
||||
("ix_pmflow_threads_launch_draft_id", "pmflow_threads"),
|
||||
("ix_pmflow_threads_project_id", "pmflow_threads"),
|
||||
("ix_pmflow_threads_tenant_id", "pmflow_threads"),
|
||||
("ix_pmflow_launch_drafts_expires_at", "pmflow_launch_drafts"),
|
||||
("ix_pmflow_launch_drafts_confirmed_project_id", "pmflow_launch_drafts"),
|
||||
("ix_pmflow_launch_drafts_status", "pmflow_launch_drafts"),
|
||||
("ix_pmflow_launch_drafts_draft_id", "pmflow_launch_drafts"),
|
||||
("ix_pmflow_launch_drafts_user_id", "pmflow_launch_drafts"),
|
||||
("ix_pmflow_launch_drafts_tenant_id", "pmflow_launch_drafts"),
|
||||
):
|
||||
if _table_exists(bind, table_name):
|
||||
try:
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
except Exception:
|
||||
pass
|
||||
for table_name in ("pmflow_thread_messages", "pmflow_threads", "pmflow_launch_drafts"):
|
||||
if _table_exists(bind, table_name):
|
||||
op.drop_table(table_name)
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
"""add pmflow change review tables
|
||||
|
||||
Revision ID: 20260426_add_pmflow_change_review_tables
|
||||
Revises: 20260425_add_pmflow_launch_tables
|
||||
Create Date: 2026-04-26
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260426_add_pmflow_change_review_tables"
|
||||
down_revision = "20260425_add_pmflow_launch_tables"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
json_type = _json_type(bind)
|
||||
|
||||
if not _table_exists(bind, "pmflow_change_sets"):
|
||||
op.create_table(
|
||||
"pmflow_change_sets",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("thread_id", sa.BigInteger(), sa.ForeignKey("pmflow_threads.id"), nullable=False),
|
||||
sa.Column("source_message_id", sa.BigInteger(), sa.ForeignKey("pmflow_thread_messages.id"), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="open"),
|
||||
sa.Column("summary", sa.Text(), nullable=False),
|
||||
sa.Column("headline", sa.String(length=255), nullable=False),
|
||||
sa.Column("extraction_scope", sa.String(length=64), nullable=False, server_default="latest_message"),
|
||||
sa.Column("routing_meta", json_type, nullable=False),
|
||||
sa.Column("llm_call_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("created_by", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("reviewed_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("committed_at", sa.TIMESTAMP(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_pmflow_change_sets_tenant_id", "pmflow_change_sets", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_pmflow_change_sets_project_id", "pmflow_change_sets", ["project_id"], unique=False)
|
||||
op.create_index("ix_pmflow_change_sets_thread_id", "pmflow_change_sets", ["thread_id"], unique=False)
|
||||
op.create_index("ix_pmflow_change_sets_source_message_id", "pmflow_change_sets", ["source_message_id"], unique=False)
|
||||
op.create_index("ix_pmflow_change_sets_status", "pmflow_change_sets", ["status"], unique=False)
|
||||
op.create_index("ix_pmflow_change_sets_created_by", "pmflow_change_sets", ["created_by"], unique=False)
|
||||
|
||||
if not _table_exists(bind, "pmflow_change_items"):
|
||||
op.create_table(
|
||||
"pmflow_change_items",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("change_set_id", sa.BigInteger(), sa.ForeignKey("pmflow_change_sets.id"), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("group", sa.String(length=32), nullable=False),
|
||||
sa.Column("object_type", sa.String(length=64), nullable=False),
|
||||
sa.Column("object_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("summary", sa.Text(), nullable=False),
|
||||
sa.Column("diff", sa.Text(), nullable=False),
|
||||
sa.Column("payload", json_type, nullable=False),
|
||||
sa.Column("edited_payload", json_type, nullable=True),
|
||||
sa.Column("confidence", sa.Float(), nullable=True),
|
||||
sa.Column("risk_level", sa.String(length=16), nullable=False, server_default="medium"),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="pending_review"),
|
||||
sa.Column("reviewed_at", sa.TIMESTAMP(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_pmflow_change_items_tenant_id", "pmflow_change_items", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_pmflow_change_items_change_set_id", "pmflow_change_items", ["change_set_id"], unique=False)
|
||||
op.create_index("ix_pmflow_change_items_project_id", "pmflow_change_items", ["project_id"], unique=False)
|
||||
op.create_index("ix_pmflow_change_items_status", "pmflow_change_items", ["status"], unique=False)
|
||||
|
||||
if not _table_exists(bind, "pmflow_approvals"):
|
||||
op.create_table(
|
||||
"pmflow_approvals",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("change_set_id", sa.BigInteger(), sa.ForeignKey("pmflow_change_sets.id"), nullable=False),
|
||||
sa.Column("change_item_id", sa.BigInteger(), sa.ForeignKey("pmflow_change_items.id"), nullable=False),
|
||||
sa.Column("actor_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("action", sa.String(length=32), nullable=False),
|
||||
sa.Column("decision", sa.String(length=32), nullable=False),
|
||||
sa.Column("before_status", sa.String(length=32), nullable=False),
|
||||
sa.Column("after_status", sa.String(length=32), nullable=False),
|
||||
sa.Column("before_payload", json_type, nullable=False),
|
||||
sa.Column("after_payload", json_type, nullable=False),
|
||||
sa.Column("reason", sa.Text(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_pmflow_approvals_tenant_id", "pmflow_approvals", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_pmflow_approvals_project_id", "pmflow_approvals", ["project_id"], unique=False)
|
||||
op.create_index("ix_pmflow_approvals_change_set_id", "pmflow_approvals", ["change_set_id"], unique=False)
|
||||
op.create_index("ix_pmflow_approvals_change_item_id", "pmflow_approvals", ["change_item_id"], unique=False)
|
||||
op.create_index("ix_pmflow_approvals_actor_user_id", "pmflow_approvals", ["actor_user_id"], unique=False)
|
||||
|
||||
if not _table_exists(bind, "pmflow_evidence_links"):
|
||||
op.create_table(
|
||||
"pmflow_evidence_links",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("change_set_id", sa.BigInteger(), sa.ForeignKey("pmflow_change_sets.id"), nullable=False),
|
||||
sa.Column("change_item_id", sa.BigInteger(), sa.ForeignKey("pmflow_change_items.id"), nullable=True),
|
||||
sa.Column("source_type", sa.String(length=64), nullable=False),
|
||||
sa.Column("source_id", sa.String(length=128), nullable=False),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("excerpt", sa.Text(), nullable=False),
|
||||
sa.Column("display_label", sa.String(length=255), nullable=False),
|
||||
sa.Column("evidence_metadata", json_type, nullable=False),
|
||||
)
|
||||
op.create_index("ix_pmflow_evidence_links_tenant_id", "pmflow_evidence_links", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_pmflow_evidence_links_project_id", "pmflow_evidence_links", ["project_id"], unique=False)
|
||||
op.create_index("ix_pmflow_evidence_links_change_set_id", "pmflow_evidence_links", ["change_set_id"], unique=False)
|
||||
op.create_index("ix_pmflow_evidence_links_change_item_id", "pmflow_evidence_links", ["change_item_id"], unique=False)
|
||||
op.create_index("ix_pmflow_evidence_links_source_type", "pmflow_evidence_links", ["source_type"], unique=False)
|
||||
op.create_index("ix_pmflow_evidence_links_source_id", "pmflow_evidence_links", ["source_id"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
for index_name, table_name in (
|
||||
("ix_pmflow_evidence_links_source_id", "pmflow_evidence_links"),
|
||||
("ix_pmflow_evidence_links_source_type", "pmflow_evidence_links"),
|
||||
("ix_pmflow_evidence_links_change_item_id", "pmflow_evidence_links"),
|
||||
("ix_pmflow_evidence_links_change_set_id", "pmflow_evidence_links"),
|
||||
("ix_pmflow_evidence_links_project_id", "pmflow_evidence_links"),
|
||||
("ix_pmflow_evidence_links_tenant_id", "pmflow_evidence_links"),
|
||||
("ix_pmflow_approvals_actor_user_id", "pmflow_approvals"),
|
||||
("ix_pmflow_approvals_change_item_id", "pmflow_approvals"),
|
||||
("ix_pmflow_approvals_change_set_id", "pmflow_approvals"),
|
||||
("ix_pmflow_approvals_project_id", "pmflow_approvals"),
|
||||
("ix_pmflow_approvals_tenant_id", "pmflow_approvals"),
|
||||
("ix_pmflow_change_items_status", "pmflow_change_items"),
|
||||
("ix_pmflow_change_items_project_id", "pmflow_change_items"),
|
||||
("ix_pmflow_change_items_change_set_id", "pmflow_change_items"),
|
||||
("ix_pmflow_change_items_tenant_id", "pmflow_change_items"),
|
||||
("ix_pmflow_change_sets_created_by", "pmflow_change_sets"),
|
||||
("ix_pmflow_change_sets_status", "pmflow_change_sets"),
|
||||
("ix_pmflow_change_sets_source_message_id", "pmflow_change_sets"),
|
||||
("ix_pmflow_change_sets_thread_id", "pmflow_change_sets"),
|
||||
("ix_pmflow_change_sets_project_id", "pmflow_change_sets"),
|
||||
("ix_pmflow_change_sets_tenant_id", "pmflow_change_sets"),
|
||||
):
|
||||
if _table_exists(bind, table_name):
|
||||
try:
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
except Exception:
|
||||
pass
|
||||
for table_name in ("pmflow_evidence_links", "pmflow_approvals", "pmflow_change_items", "pmflow_change_sets"):
|
||||
if _table_exists(bind, table_name):
|
||||
op.drop_table(table_name)
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
"""add human capacity overrides
|
||||
|
||||
Revision ID: 20260427_add_human_capacity_overrides
|
||||
Revises: 20260426_add_pmflow_change_review_tables
|
||||
Create Date: 2026-04-27
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260427_add_human_capacity_overrides"
|
||||
down_revision = "20260426_add_pmflow_change_review_tables"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
json_type = _json_type(bind)
|
||||
|
||||
if not _table_exists(bind, "human_capacity_overrides"):
|
||||
op.create_table(
|
||||
"human_capacity_overrides",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("date", sa.Date(), nullable=False),
|
||||
sa.Column("override_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("added_minutes", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("overtime_type", sa.String(length=32), nullable=True),
|
||||
sa.Column("max_planned_utilization", sa.Float(), nullable=True),
|
||||
sa.Column("work_type_bias", json_type, nullable=False),
|
||||
sa.Column("high_risk_limit", sa.Integer(), nullable=True),
|
||||
sa.Column("reason", sa.Text(), nullable=False),
|
||||
sa.Column("expires_at", sa.TIMESTAMP(), nullable=False),
|
||||
sa.Column("created_by", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
)
|
||||
op.create_index("ix_human_capacity_overrides_tenant_id", "human_capacity_overrides", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_human_capacity_overrides_project_date", "human_capacity_overrides", ["project_id", "date"], unique=False)
|
||||
op.create_index("ix_human_capacity_overrides_user_date", "human_capacity_overrides", ["user_id", "date"], unique=False)
|
||||
op.create_index("ix_human_capacity_overrides_created_by", "human_capacity_overrides", ["created_by"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "human_capacity_overrides"):
|
||||
op.drop_index("ix_human_capacity_overrides_created_by", table_name="human_capacity_overrides")
|
||||
op.drop_index("ix_human_capacity_overrides_user_date", table_name="human_capacity_overrides")
|
||||
op.drop_index("ix_human_capacity_overrides_project_date", table_name="human_capacity_overrides")
|
||||
op.drop_index("ix_human_capacity_overrides_tenant_id", table_name="human_capacity_overrides")
|
||||
op.drop_table("human_capacity_overrides")
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
"""add phone auth identity
|
||||
|
||||
Revision ID: 20260428_add_phone_auth_identity
|
||||
Revises: 20260428_repair_admin_yangyu_workspace_state
|
||||
Create Date: 2026-04-28
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260428_add_phone_auth_identity"
|
||||
down_revision = "20260428_repair_admin_yangyu_workspace_state"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
if not _table_exists(bind, table_name):
|
||||
return False
|
||||
return any(column["name"] == column_name for column in sa.inspect(bind).get_columns(table_name))
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
if not _table_exists(bind, table_name):
|
||||
return False
|
||||
return any(index["name"] == index_name for index in sa.inspect(bind).get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if _table_exists(bind, "users"):
|
||||
for column in (
|
||||
sa.Column("phone_country_code", sa.String(length=8), nullable=True),
|
||||
sa.Column("phone_number", sa.String(length=32), nullable=True),
|
||||
sa.Column("phone_e164", sa.String(length=32), nullable=True),
|
||||
sa.Column("phone_verified_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("last_login_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("login_method", sa.String(length=32), nullable=True),
|
||||
):
|
||||
if not _column_exists(bind, "users", column.name):
|
||||
op.add_column("users", column)
|
||||
if not _index_exists(bind, "users", "ix_users_phone_e164"):
|
||||
op.create_index("ix_users_phone_e164", "users", ["phone_e164"], unique=True)
|
||||
|
||||
if _table_exists(bind, "tenant_invitations"):
|
||||
for column in (
|
||||
sa.Column("phone_country_code", sa.String(length=8), nullable=True),
|
||||
sa.Column("phone_number", sa.String(length=32), nullable=True),
|
||||
sa.Column("phone_e164", sa.String(length=32), nullable=True),
|
||||
):
|
||||
if not _column_exists(bind, "tenant_invitations", column.name):
|
||||
op.add_column("tenant_invitations", column)
|
||||
if not _index_exists(bind, "tenant_invitations", "ix_tenant_invitations_phone_e164"):
|
||||
op.create_index("ix_tenant_invitations_phone_e164", "tenant_invitations", ["phone_e164"], unique=False)
|
||||
if bind.dialect.name == "postgresql" and not _index_exists(
|
||||
bind,
|
||||
"tenant_invitations",
|
||||
"uq_tenant_invitations_pending_phone",
|
||||
):
|
||||
op.execute(
|
||||
"""
|
||||
CREATE UNIQUE INDEX uq_tenant_invitations_pending_phone
|
||||
ON tenant_invitations (tenant_id, phone_e164)
|
||||
WHERE status = 'pending' AND phone_e164 IS NOT NULL
|
||||
"""
|
||||
)
|
||||
|
||||
if not _table_exists(bind, "phone_verification_codes"):
|
||||
op.create_table(
|
||||
"phone_verification_codes",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("phone_e164", sa.String(length=32), nullable=False, index=True),
|
||||
sa.Column("purpose", sa.String(length=32), nullable=False, index=True),
|
||||
sa.Column("code_hash", sa.String(length=128), nullable=False),
|
||||
sa.Column("expires_at", sa.TIMESTAMP(), nullable=False),
|
||||
sa.Column("consumed_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("request_ip", sa.String(length=64), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_phone_verification_codes_phone_purpose_created",
|
||||
"phone_verification_codes",
|
||||
["phone_e164", "purpose", "created_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "phone_verification_codes"):
|
||||
if _index_exists(bind, "phone_verification_codes", "ix_phone_verification_codes_phone_purpose_created"):
|
||||
op.drop_index("ix_phone_verification_codes_phone_purpose_created", table_name="phone_verification_codes")
|
||||
op.drop_table("phone_verification_codes")
|
||||
if _table_exists(bind, "tenant_invitations"):
|
||||
if bind.dialect.name == "postgresql" and _index_exists(bind, "tenant_invitations", "uq_tenant_invitations_pending_phone"):
|
||||
op.drop_index("uq_tenant_invitations_pending_phone", table_name="tenant_invitations")
|
||||
if _index_exists(bind, "tenant_invitations", "ix_tenant_invitations_phone_e164"):
|
||||
op.drop_index("ix_tenant_invitations_phone_e164", table_name="tenant_invitations")
|
||||
for column_name in ("phone_e164", "phone_number", "phone_country_code"):
|
||||
if _column_exists(bind, "tenant_invitations", column_name):
|
||||
op.drop_column("tenant_invitations", column_name)
|
||||
if _table_exists(bind, "users"):
|
||||
if _index_exists(bind, "users", "ix_users_phone_e164"):
|
||||
op.drop_index("ix_users_phone_e164", table_name="users")
|
||||
for column_name in (
|
||||
"login_method",
|
||||
"last_login_at",
|
||||
"phone_verified_at",
|
||||
"phone_e164",
|
||||
"phone_number",
|
||||
"phone_country_code",
|
||||
):
|
||||
if _column_exists(bind, "users", column_name):
|
||||
op.drop_column("users", column_name)
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
"""add platform memberships
|
||||
|
||||
Revision ID: 20260428_add_platform_memberships
|
||||
Revises: 20260428_add_phone_auth_identity
|
||||
Create Date: 2026-04-28
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260428_add_platform_memberships"
|
||||
down_revision = "20260428_add_phone_auth_identity"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
if not _table_exists(bind, table_name):
|
||||
return False
|
||||
return any(index["name"] == index_name for index in sa.inspect(bind).get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "platform_memberships"):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
"platform_memberships",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("role", sa.String(length=50), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.UniqueConstraint("user_id", "role", name="uq_platform_memberships_user_role"),
|
||||
)
|
||||
op.create_index("ix_platform_memberships_user_id", "platform_memberships", ["user_id"], unique=False)
|
||||
op.create_index("ix_platform_memberships_status", "platform_memberships", ["status"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "platform_memberships"):
|
||||
return
|
||||
if _index_exists(bind, "platform_memberships", "ix_platform_memberships_status"):
|
||||
op.drop_index("ix_platform_memberships_status", table_name="platform_memberships")
|
||||
if _index_exists(bind, "platform_memberships", "ix_platform_memberships_user_id"):
|
||||
op.drop_index("ix_platform_memberships_user_id", table_name="platform_memberships")
|
||||
op.drop_table("platform_memberships")
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
"""add saved idea enrichment fields
|
||||
|
||||
Revision ID: 20260428_add_saved_idea_enrichment
|
||||
Revises: 20260428_add_saved_ideas
|
||||
Create Date: 2026-04-28
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260428_add_saved_idea_enrichment"
|
||||
down_revision = "20260428_add_saved_ideas"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
return any(column["name"] == column_name for column in sa.inspect(bind).get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "saved_ideas"):
|
||||
return
|
||||
if not _column_exists(bind, "saved_ideas", "enrichment_status"):
|
||||
op.add_column(
|
||||
"saved_ideas",
|
||||
sa.Column("enrichment_status", sa.String(length=32), nullable=False, server_default="baseline"),
|
||||
)
|
||||
if not _column_exists(bind, "saved_ideas", "enrichment_payload"):
|
||||
op.add_column("saved_ideas", sa.Column("enrichment_payload", _json_type(bind), nullable=True))
|
||||
if not _column_exists(bind, "saved_ideas", "enrichment_error"):
|
||||
op.add_column("saved_ideas", sa.Column("enrichment_error", sa.Text(), nullable=True))
|
||||
if not _column_exists(bind, "saved_ideas", "enriched_at"):
|
||||
op.add_column("saved_ideas", sa.Column("enriched_at", sa.TIMESTAMP(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "saved_ideas"):
|
||||
return
|
||||
for column_name in ("enriched_at", "enrichment_error", "enrichment_payload", "enrichment_status"):
|
||||
if _column_exists(bind, "saved_ideas", column_name):
|
||||
op.drop_column("saved_ideas", column_name)
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
"""add saved ideas
|
||||
|
||||
Revision ID: 20260428_add_saved_ideas
|
||||
Revises: 20260428_add_platform_memberships
|
||||
Create Date: 2026-04-28
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260428_add_saved_ideas"
|
||||
down_revision = "20260428_add_platform_memberships"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "saved_ideas"):
|
||||
return
|
||||
|
||||
json_type = _json_type(bind)
|
||||
op.create_table(
|
||||
"saved_ideas",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("idea_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("raw_text", sa.Text(), nullable=False),
|
||||
sa.Column("summary", sa.Text(), nullable=False),
|
||||
sa.Column("source_type", sa.String(length=32), nullable=False, server_default="project_launch"),
|
||||
sa.Column("source_ref", sa.String(length=255), nullable=True),
|
||||
sa.Column("input_mode", sa.String(length=32), nullable=False, server_default="idea"),
|
||||
sa.Column("attachments", json_type, nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="saved"),
|
||||
sa.Column("ai_summary", sa.Text(), nullable=True),
|
||||
sa.Column("missing_questions", json_type, nullable=False),
|
||||
sa.Column("readiness_score", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("launch_draft_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("promoted_project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=True),
|
||||
sa.Column("promoted_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("archived_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.UniqueConstraint("tenant_id", "idea_id", name="uq_saved_ideas_tenant_idea_id"),
|
||||
)
|
||||
op.create_index("ix_saved_ideas_tenant_id", "saved_ideas", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_saved_ideas_user_id", "saved_ideas", ["user_id"], unique=False)
|
||||
op.create_index("ix_saved_ideas_idea_id", "saved_ideas", ["idea_id"], unique=False)
|
||||
op.create_index("ix_saved_ideas_source_type", "saved_ideas", ["source_type"], unique=False)
|
||||
op.create_index("ix_saved_ideas_status", "saved_ideas", ["status"], unique=False)
|
||||
op.create_index("ix_saved_ideas_launch_draft_id", "saved_ideas", ["launch_draft_id"], unique=False)
|
||||
op.create_index("ix_saved_ideas_promoted_project_id", "saved_ideas", ["promoted_project_id"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "saved_ideas"):
|
||||
return
|
||||
for index_name in (
|
||||
"ix_saved_ideas_promoted_project_id",
|
||||
"ix_saved_ideas_launch_draft_id",
|
||||
"ix_saved_ideas_status",
|
||||
"ix_saved_ideas_source_type",
|
||||
"ix_saved_ideas_idea_id",
|
||||
"ix_saved_ideas_user_id",
|
||||
"ix_saved_ideas_tenant_id",
|
||||
):
|
||||
op.drop_index(index_name, table_name="saved_ideas")
|
||||
op.drop_table("saved_ideas")
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
"""add workspace memberships
|
||||
|
||||
Revision ID: 20260428_add_workspace_memberships
|
||||
Revises: 20260427_add_human_capacity_overrides
|
||||
Create Date: 2026-04-28
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260428_add_workspace_memberships"
|
||||
down_revision = "20260427_add_human_capacity_overrides"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
if not _table_exists(bind, table_name):
|
||||
return False
|
||||
return any(column["name"] == column_name for column in sa.inspect(bind).get_columns(table_name))
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
if not _table_exists(bind, table_name):
|
||||
return False
|
||||
return any(index["name"] == index_name for index in sa.inspect(bind).get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
json_type = _json_type(bind)
|
||||
|
||||
if _table_exists(bind, "tenants"):
|
||||
if not _column_exists(bind, "tenants", "workspace_type"):
|
||||
op.add_column("tenants", sa.Column("workspace_type", sa.String(length=32), nullable=False, server_default="team"))
|
||||
if not _column_exists(bind, "tenants", "slug"):
|
||||
op.add_column("tenants", sa.Column("slug", sa.String(length=120), nullable=True))
|
||||
if not _column_exists(bind, "tenants", "owner_user_id"):
|
||||
op.add_column("tenants", sa.Column("owner_user_id", sa.BigInteger(), nullable=True))
|
||||
if not _column_exists(bind, "tenants", "status"):
|
||||
op.add_column("tenants", sa.Column("status", sa.String(length=32), nullable=False, server_default="active"))
|
||||
|
||||
if not _table_exists(bind, "tenant_memberships"):
|
||||
op.create_table(
|
||||
"tenant_memberships",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, index=True),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("role", sa.String(length=50), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.Column("is_personal_owner", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.UniqueConstraint("tenant_id", "user_id", name="uq_tenant_memberships_tenant_user"),
|
||||
)
|
||||
op.create_index("ix_tenant_memberships_tenant_status", "tenant_memberships", ["tenant_id", "status"], unique=False)
|
||||
|
||||
if not _table_exists(bind, "tenant_invitations"):
|
||||
op.create_table(
|
||||
"tenant_invitations",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, index=True),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("email", sa.String(length=255), nullable=False, index=True),
|
||||
sa.Column("role", sa.String(length=50), nullable=False),
|
||||
sa.Column("project_ids", json_type, nullable=False),
|
||||
sa.Column("token_hash", sa.String(length=128), nullable=False, unique=True, index=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="pending"),
|
||||
sa.Column("expires_at", sa.TIMESTAMP(), nullable=False),
|
||||
sa.Column("invited_by_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("accepted_by_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True),
|
||||
)
|
||||
op.create_index("ix_tenant_invitations_tenant_status", "tenant_invitations", ["tenant_id", "status"], unique=False)
|
||||
|
||||
# Backfill current single-workspace users into membership rows.
|
||||
if _table_exists(bind, "users") and _table_exists(bind, "tenant_memberships"):
|
||||
if bind.dialect.name == "postgresql":
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO tenant_memberships (tenant_id, user_id, role, status, is_personal_owner, created_at, updated_at)
|
||||
SELECT users.tenant_id, users.id, users.role, 'active', false, NOW(), NOW()
|
||||
FROM users
|
||||
WHERE users.tenant_id IS NOT NULL
|
||||
ON CONFLICT ON CONSTRAINT uq_tenant_memberships_tenant_user DO NOTHING
|
||||
"""
|
||||
)
|
||||
else:
|
||||
op.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO tenant_memberships (tenant_id, user_id, role, status, is_personal_owner, created_at, updated_at)
|
||||
SELECT users.tenant_id, users.id, users.role, 'active', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM users
|
||||
WHERE users.tenant_id IS NOT NULL
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "tenant_invitations"):
|
||||
if _index_exists(bind, "tenant_invitations", "ix_tenant_invitations_tenant_status"):
|
||||
op.drop_index("ix_tenant_invitations_tenant_status", table_name="tenant_invitations")
|
||||
op.drop_table("tenant_invitations")
|
||||
if _table_exists(bind, "tenant_memberships"):
|
||||
if _index_exists(bind, "tenant_memberships", "ix_tenant_memberships_tenant_status"):
|
||||
op.drop_index("ix_tenant_memberships_tenant_status", table_name="tenant_memberships")
|
||||
op.drop_table("tenant_memberships")
|
||||
if _table_exists(bind, "tenants"):
|
||||
for column_name in ("status", "owner_user_id", "slug", "workspace_type"):
|
||||
if _column_exists(bind, "tenants", column_name):
|
||||
op.drop_column("tenants", column_name)
|
||||
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
"""repair admin and yangyu workspace state
|
||||
|
||||
Revision ID: 20260428_repair_admin_yangyu_workspace_state
|
||||
Revises: 20260428_add_workspace_memberships
|
||||
Create Date: 2026-04-28
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "20260428_repair_admin_yangyu_workspace_state"
|
||||
down_revision = "20260428_add_workspace_memberships"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name != "postgresql":
|
||||
return
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO tenants (id, name, domain, workspace_type, slug, owner_user_id, status, created_at, updated_at)
|
||||
SELECT
|
||||
1,
|
||||
'PMFlow 旧团队空间',
|
||||
NULL,
|
||||
'team',
|
||||
'legacy-team',
|
||||
admin_user.id,
|
||||
'active',
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM users AS admin_user
|
||||
WHERE admin_user.username = 'admin'
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET
|
||||
workspace_type = 'team',
|
||||
status = 'active',
|
||||
slug = COALESCE(tenants.slug, EXCLUDED.slug),
|
||||
owner_user_id = COALESCE(tenants.owner_user_id, EXCLUDED.owner_user_id),
|
||||
updated_at = NOW()
|
||||
"""
|
||||
)
|
||||
op.execute("SELECT setval('tenants_id_seq', GREATEST((SELECT COALESCE(MAX(id), 1) FROM tenants), 1), true)")
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO tenant_memberships (tenant_id, user_id, role, status, is_personal_owner, created_at, updated_at)
|
||||
SELECT 1, users.id, 'OWNER', 'active', false, NOW(), NOW()
|
||||
FROM users
|
||||
WHERE users.username = 'admin'
|
||||
ON CONFLICT ON CONSTRAINT uq_tenant_memberships_tenant_user DO UPDATE
|
||||
SET role = 'OWNER', status = 'active', is_personal_owner = false, updated_at = NOW()
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO tenant_memberships (tenant_id, user_id, role, status, is_personal_owner, created_at, updated_at)
|
||||
SELECT 1, users.id, 'BUILDER', 'active', false, NOW(), NOW()
|
||||
FROM users
|
||||
WHERE users.username = 'yangyu'
|
||||
ON CONFLICT ON CONSTRAINT uq_tenant_memberships_tenant_user DO UPDATE
|
||||
SET role = 'BUILDER', status = 'active', is_personal_owner = false, updated_at = NOW()
|
||||
"""
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
WITH personal_workspace AS (
|
||||
SELECT users.id AS user_id, tenant_memberships.tenant_id
|
||||
FROM users
|
||||
JOIN tenant_memberships ON tenant_memberships.user_id = users.id
|
||||
JOIN tenants ON tenants.id = tenant_memberships.tenant_id
|
||||
WHERE
|
||||
users.username = 'admin'
|
||||
AND tenants.workspace_type = 'personal'
|
||||
AND tenant_memberships.status = 'active'
|
||||
AND tenant_memberships.is_personal_owner IS TRUE
|
||||
ORDER BY tenants.id ASC
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE users
|
||||
SET tenant_id = personal_workspace.tenant_id, role = 'OWNER', updated_at = NOW()
|
||||
FROM personal_workspace
|
||||
WHERE users.id = personal_workspace.user_id
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
WITH personal_workspace AS (
|
||||
SELECT users.id AS user_id, tenant_memberships.tenant_id
|
||||
FROM users
|
||||
JOIN tenant_memberships ON tenant_memberships.user_id = users.id
|
||||
JOIN tenants ON tenants.id = tenant_memberships.tenant_id
|
||||
WHERE
|
||||
users.username = 'yangyu'
|
||||
AND tenants.workspace_type = 'personal'
|
||||
AND tenant_memberships.status = 'active'
|
||||
AND tenant_memberships.is_personal_owner IS TRUE
|
||||
ORDER BY tenants.id ASC
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE users
|
||||
SET tenant_id = personal_workspace.tenant_id, role = 'OWNER', updated_at = NOW()
|
||||
FROM personal_workspace
|
||||
WHERE users.id = personal_workspace.user_id
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Data repair is intentionally not reverted; rolling it back would reintroduce
|
||||
# the broken active workspace pointers.
|
||||
return
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
"""add ai call log provider payloads
|
||||
|
||||
Revision ID: 20260429_add_ai_call_log_provider_payloads
|
||||
Revises: 20260429_add_prompt_release_checks
|
||||
Create Date: 2026-04-29
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260429_add_ai_call_log_provider_payloads"
|
||||
down_revision = "20260429_add_prompt_release_checks"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(str(item.get("name") or "") == column_name for item in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
table_name = "ai_call_logs"
|
||||
if not _table_exists(bind, table_name):
|
||||
return
|
||||
|
||||
for column_name in (
|
||||
"provider_request_payload",
|
||||
"provider_response_payload",
|
||||
"provider_attempts",
|
||||
):
|
||||
if not _column_exists(bind, table_name, column_name):
|
||||
op.add_column(table_name, sa.Column(column_name, _json_type(bind), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
table_name = "ai_call_logs"
|
||||
if not _table_exists(bind, table_name):
|
||||
return
|
||||
|
||||
for column_name in (
|
||||
"provider_attempts",
|
||||
"provider_response_payload",
|
||||
"provider_request_payload",
|
||||
):
|
||||
if _column_exists(bind, table_name, column_name):
|
||||
op.drop_column(table_name, column_name)
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
"""add ai call trace fields
|
||||
|
||||
Revision ID: 20260429_add_ai_call_trace_fields
|
||||
Revises: 20260428_add_saved_idea_enrichment
|
||||
Create Date: 2026-04-29
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260429_add_ai_call_trace_fields"
|
||||
down_revision = "20260428_add_saved_idea_enrichment"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(str(item.get("name") or "") == column_name for item in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
table_name = "ai_call_logs"
|
||||
if not _table_exists(bind, table_name):
|
||||
return
|
||||
|
||||
columns = [
|
||||
("trace_id", sa.Column("trace_id", sa.String(length=80), nullable=True)),
|
||||
("provider", sa.Column("provider", sa.String(length=64), nullable=True)),
|
||||
("model", sa.Column("model", sa.String(length=120), nullable=True)),
|
||||
("latency_ms", sa.Column("latency_ms", sa.Integer(), nullable=True)),
|
||||
("routing_meta", sa.Column("routing_meta", _json_type(bind), nullable=True)),
|
||||
]
|
||||
for column_name, column in columns:
|
||||
if not _column_exists(bind, table_name, column_name):
|
||||
op.add_column(table_name, column)
|
||||
|
||||
existing_indexes = {index["name"] for index in sa.inspect(bind).get_indexes(table_name)}
|
||||
for index_name, column_names in (
|
||||
("ix_ai_call_logs_trace_id", ["trace_id"]),
|
||||
("ix_ai_call_logs_provider", ["provider"]),
|
||||
("ix_ai_call_logs_model", ["model"]),
|
||||
):
|
||||
if index_name not in existing_indexes:
|
||||
op.create_index(index_name, table_name, column_names, unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
table_name = "ai_call_logs"
|
||||
if not _table_exists(bind, table_name):
|
||||
return
|
||||
|
||||
existing_indexes = {index["name"] for index in sa.inspect(bind).get_indexes(table_name)}
|
||||
for index_name in ("ix_ai_call_logs_model", "ix_ai_call_logs_provider", "ix_ai_call_logs_trace_id"):
|
||||
if index_name in existing_indexes:
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
for column_name in ("routing_meta", "latency_ms", "model", "provider", "trace_id"):
|
||||
if _column_exists(bind, table_name, column_name):
|
||||
op.drop_column(table_name, column_name)
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
"""add prompt quality linkage
|
||||
|
||||
Revision ID: 20260429_add_prompt_quality_linkage
|
||||
Revises: 20260429_add_prompt_release_checks
|
||||
Create Date: 2026-04-29
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260429_add_prompt_quality_linkage"
|
||||
down_revision = "20260429_add_prompt_release_checks"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(str(item.get("name") or "") == column_name for item in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
if not _table_exists(bind, table_name):
|
||||
return False
|
||||
return index_name in {str(index.get("name") or "") for index in sa.inspect(bind).get_indexes(table_name)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
table_name = "ai_call_logs"
|
||||
if _table_exists(bind, table_name):
|
||||
for column_name, column in (
|
||||
("prompt_key", sa.Column("prompt_key", sa.String(length=128), nullable=True)),
|
||||
("prompt_release_id", sa.Column("prompt_release_id", sa.Integer(), nullable=True)),
|
||||
("prompt_source", sa.Column("prompt_source", sa.String(length=32), nullable=True)),
|
||||
):
|
||||
if not _column_exists(bind, table_name, column_name):
|
||||
op.add_column(table_name, column)
|
||||
for index_name, column_names in (
|
||||
("ix_ai_call_logs_prompt_key", ["prompt_key"]),
|
||||
("ix_ai_call_logs_prompt_release_id", ["prompt_release_id"]),
|
||||
("ix_ai_call_logs_prompt_source", ["prompt_source"]),
|
||||
):
|
||||
if not _index_exists(bind, table_name, index_name):
|
||||
op.create_index(index_name, table_name, column_names, unique=False)
|
||||
|
||||
if not _table_exists(bind, "prompt_golden_cases"):
|
||||
op.create_table(
|
||||
"prompt_golden_cases",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("prompt_key", sa.String(length=128), nullable=False),
|
||||
sa.Column("case_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("status", sa.String(length=24), nullable=False, server_default="active"),
|
||||
sa.Column("input_payload", _json_type(bind), nullable=True),
|
||||
sa.Column("expected_contract", _json_type(bind), nullable=True),
|
||||
sa.Column("expected_contains", sa.Text(), nullable=True),
|
||||
sa.Column("source", sa.String(length=48), nullable=False, server_default="manual"),
|
||||
sa.Column("source_ai_call_log_id", sa.Integer(), nullable=True),
|
||||
sa.Column("created_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
for index_name, column_names in (
|
||||
("ix_prompt_golden_cases_tenant_id", ["tenant_id"]),
|
||||
("ix_prompt_golden_cases_prompt_key", ["prompt_key"]),
|
||||
("ix_prompt_golden_cases_status", ["status"]),
|
||||
("ix_prompt_golden_cases_source", ["source"]),
|
||||
("ix_prompt_golden_cases_source_ai_call_log_id", ["source_ai_call_log_id"]),
|
||||
("ix_prompt_golden_cases_created_by", ["created_by"]),
|
||||
):
|
||||
op.create_index(index_name, "prompt_golden_cases", column_names, unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "prompt_golden_cases"):
|
||||
for index_name in (
|
||||
"ix_prompt_golden_cases_created_by",
|
||||
"ix_prompt_golden_cases_source_ai_call_log_id",
|
||||
"ix_prompt_golden_cases_source",
|
||||
"ix_prompt_golden_cases_status",
|
||||
"ix_prompt_golden_cases_prompt_key",
|
||||
"ix_prompt_golden_cases_tenant_id",
|
||||
):
|
||||
if _index_exists(bind, "prompt_golden_cases", index_name):
|
||||
op.drop_index(index_name, table_name="prompt_golden_cases")
|
||||
op.drop_table("prompt_golden_cases")
|
||||
|
||||
table_name = "ai_call_logs"
|
||||
if _table_exists(bind, table_name):
|
||||
for index_name in (
|
||||
"ix_ai_call_logs_prompt_source",
|
||||
"ix_ai_call_logs_prompt_release_id",
|
||||
"ix_ai_call_logs_prompt_key",
|
||||
):
|
||||
if _index_exists(bind, table_name, index_name):
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
for column_name in ("prompt_source", "prompt_release_id", "prompt_key"):
|
||||
if _column_exists(bind, table_name, column_name):
|
||||
op.drop_column(table_name, column_name)
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
"""add prompt release checks
|
||||
|
||||
Revision ID: 20260429_add_prompt_release_checks
|
||||
Revises: 20260429_rename_glm_provider_to_dashscope
|
||||
Create Date: 2026-04-29
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260429_add_prompt_release_checks"
|
||||
down_revision = "20260429_rename_glm_provider_to_dashscope"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "prompt_release_checks"):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
"prompt_release_checks",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("prompt_key", sa.String(length=128), nullable=False),
|
||||
sa.Column("release_id", sa.Integer(), nullable=False),
|
||||
sa.Column("check_type", sa.String(length=48), nullable=False),
|
||||
sa.Column("status", sa.String(length=24), nullable=False),
|
||||
sa.Column("severity", sa.String(length=16), nullable=False, server_default="info"),
|
||||
sa.Column("summary", sa.Text(), nullable=False),
|
||||
sa.Column("details_json", _json_type(bind), nullable=True),
|
||||
sa.Column("created_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("completed_at", sa.String(length=64), nullable=True),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(["release_id"], ["prompt_releases.id"], ondelete="CASCADE"),
|
||||
)
|
||||
op.create_index("ix_prompt_release_checks_tenant_id", "prompt_release_checks", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_prompt_release_checks_prompt_key", "prompt_release_checks", ["prompt_key"], unique=False)
|
||||
op.create_index("ix_prompt_release_checks_release_id", "prompt_release_checks", ["release_id"], unique=False)
|
||||
op.create_index("ix_prompt_release_checks_check_type", "prompt_release_checks", ["check_type"], unique=False)
|
||||
op.create_index("ix_prompt_release_checks_status", "prompt_release_checks", ["status"], unique=False)
|
||||
op.create_index("ix_prompt_release_checks_severity", "prompt_release_checks", ["severity"], unique=False)
|
||||
op.create_index("ix_prompt_release_checks_created_by", "prompt_release_checks", ["created_by"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "prompt_release_checks"):
|
||||
return
|
||||
for index_name in (
|
||||
"ix_prompt_release_checks_created_by",
|
||||
"ix_prompt_release_checks_severity",
|
||||
"ix_prompt_release_checks_status",
|
||||
"ix_prompt_release_checks_check_type",
|
||||
"ix_prompt_release_checks_release_id",
|
||||
"ix_prompt_release_checks_prompt_key",
|
||||
"ix_prompt_release_checks_tenant_id",
|
||||
):
|
||||
op.drop_index(index_name, table_name="prompt_release_checks")
|
||||
op.drop_table("prompt_release_checks")
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
"""rename glm provider code to dashscope
|
||||
|
||||
Revision ID: 20260429_rename_glm_provider_to_dashscope
|
||||
Revises: 20260429_add_ai_call_trace_fields
|
||||
Create Date: 2026-04-29
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260429_rename_glm_provider_to_dashscope"
|
||||
down_revision = "20260429_add_ai_call_trace_fields"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(str(item.get("name") or "") == column_name for item in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _load_json(value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return json.loads(value)
|
||||
except Exception:
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def _replace_provider_code(value: Any) -> tuple[Any, bool]:
|
||||
changed = False
|
||||
if isinstance(value, dict):
|
||||
result: dict[Any, Any] = {}
|
||||
for raw_key, raw_value in value.items():
|
||||
key = "dashscope" if raw_key == "glm" else raw_key
|
||||
next_value, child_changed = _replace_provider_code(raw_value)
|
||||
result[key] = next_value
|
||||
changed = changed or child_changed or key != raw_key
|
||||
return result, changed
|
||||
if isinstance(value, list):
|
||||
result = []
|
||||
for item in value:
|
||||
next_value, child_changed = _replace_provider_code(item)
|
||||
result.append(next_value)
|
||||
changed = changed or child_changed
|
||||
return result, changed
|
||||
if value == "glm":
|
||||
return "dashscope", True
|
||||
return value, False
|
||||
|
||||
|
||||
def _migrate_json_table(bind, table_name: str, column_name: str) -> None:
|
||||
if not _table_exists(bind, table_name) or not _column_exists(bind, table_name, column_name):
|
||||
return
|
||||
table = sa.table(
|
||||
table_name,
|
||||
sa.column("id", sa.Integer()),
|
||||
sa.column(column_name, _json_type(bind)),
|
||||
)
|
||||
rows = bind.execute(sa.select(table.c.id, table.c[column_name])).all()
|
||||
for row_id, raw_value in rows:
|
||||
payload = _load_json(raw_value)
|
||||
next_payload, changed = _replace_provider_code(payload)
|
||||
if changed:
|
||||
bind.execute(
|
||||
table.update().where(table.c.id == row_id).values({column_name: next_payload})
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
_migrate_json_table(bind, "integrations", "config")
|
||||
_migrate_json_table(bind, "ai_call_logs", "routing_meta")
|
||||
|
||||
if _table_exists(bind, "ai_call_logs") and _column_exists(bind, "ai_call_logs", "provider"):
|
||||
op.execute("UPDATE ai_call_logs SET provider = 'dashscope' WHERE provider = 'glm'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Intentionally irreversible: glm was a misleading historical provider code.
|
||||
pass
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
"""add prompt replay runs
|
||||
|
||||
Revision ID: 20260430_add_prompt_replay_runs
|
||||
Revises: 20260429_add_prompt_quality_linkage
|
||||
Create Date: 2026-04-30
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260430_add_prompt_replay_runs"
|
||||
down_revision = "20260429_add_prompt_quality_linkage"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
if not _table_exists(bind, table_name):
|
||||
return False
|
||||
return index_name in {str(index.get("name") or "") for index in sa.inspect(bind).get_indexes(table_name)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "prompt_replay_runs"):
|
||||
op.create_table(
|
||||
"prompt_replay_runs",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("prompt_key", sa.String(length=128), nullable=False),
|
||||
sa.Column("release_id", sa.Integer(), nullable=False),
|
||||
sa.Column("baseline_release_id", sa.Integer(), nullable=True),
|
||||
sa.Column("run_status", sa.String(length=24), nullable=False, server_default="pending"),
|
||||
sa.Column("run_mode", sa.String(length=32), nullable=False, server_default="contract_smoke"),
|
||||
sa.Column("case_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("passed_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("failed_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("warning_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("score_delta", sa.Float(), nullable=True),
|
||||
sa.Column("summary", sa.Text(), nullable=True),
|
||||
sa.Column("risk_flags_json", _json_type(bind), nullable=True),
|
||||
sa.Column("created_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("started_at", sa.String(length=64), nullable=True),
|
||||
sa.Column("completed_at", sa.String(length=64), nullable=True),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(["release_id"], ["prompt_releases.id"], ondelete="CASCADE"),
|
||||
)
|
||||
for index_name, column_names in (
|
||||
("ix_prompt_replay_runs_tenant_id", ["tenant_id"]),
|
||||
("ix_prompt_replay_runs_prompt_key", ["prompt_key"]),
|
||||
("ix_prompt_replay_runs_release_id", ["release_id"]),
|
||||
("ix_prompt_replay_runs_baseline_release_id", ["baseline_release_id"]),
|
||||
("ix_prompt_replay_runs_run_status", ["run_status"]),
|
||||
("ix_prompt_replay_runs_run_mode", ["run_mode"]),
|
||||
("ix_prompt_replay_runs_created_by", ["created_by"]),
|
||||
):
|
||||
op.create_index(index_name, "prompt_replay_runs", column_names, unique=False)
|
||||
|
||||
if not _table_exists(bind, "prompt_replay_case_results"):
|
||||
op.create_table(
|
||||
"prompt_replay_case_results",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("run_id", sa.Integer(), nullable=False),
|
||||
sa.Column("golden_case_id", sa.Integer(), nullable=False),
|
||||
sa.Column("prompt_key", sa.String(length=128), nullable=False),
|
||||
sa.Column("release_id", sa.Integer(), nullable=False),
|
||||
sa.Column("baseline_output", sa.Text(), nullable=True),
|
||||
sa.Column("candidate_output", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.String(length=24), nullable=False),
|
||||
sa.Column("contract_result_json", _json_type(bind), nullable=True),
|
||||
sa.Column("diff_summary", sa.Text(), nullable=True),
|
||||
sa.Column("risk_flags_json", _json_type(bind), nullable=True),
|
||||
sa.Column("latency_ms", sa.Integer(), nullable=True),
|
||||
sa.Column("estimated_cost", sa.Float(), nullable=True),
|
||||
sa.Column("error_summary", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(["run_id"], ["prompt_replay_runs.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["golden_case_id"], ["prompt_golden_cases.id"], ondelete="CASCADE"),
|
||||
)
|
||||
for index_name, column_names in (
|
||||
("ix_prompt_replay_case_results_tenant_id", ["tenant_id"]),
|
||||
("ix_prompt_replay_case_results_run_id", ["run_id"]),
|
||||
("ix_prompt_replay_case_results_golden_case_id", ["golden_case_id"]),
|
||||
("ix_prompt_replay_case_results_prompt_key", ["prompt_key"]),
|
||||
("ix_prompt_replay_case_results_release_id", ["release_id"]),
|
||||
("ix_prompt_replay_case_results_status", ["status"]),
|
||||
):
|
||||
op.create_index(index_name, "prompt_replay_case_results", column_names, unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "prompt_replay_case_results"):
|
||||
for index_name in (
|
||||
"ix_prompt_replay_case_results_status",
|
||||
"ix_prompt_replay_case_results_release_id",
|
||||
"ix_prompt_replay_case_results_prompt_key",
|
||||
"ix_prompt_replay_case_results_golden_case_id",
|
||||
"ix_prompt_replay_case_results_run_id",
|
||||
"ix_prompt_replay_case_results_tenant_id",
|
||||
):
|
||||
if _index_exists(bind, "prompt_replay_case_results", index_name):
|
||||
op.drop_index(index_name, table_name="prompt_replay_case_results")
|
||||
op.drop_table("prompt_replay_case_results")
|
||||
if _table_exists(bind, "prompt_replay_runs"):
|
||||
for index_name in (
|
||||
"ix_prompt_replay_runs_created_by",
|
||||
"ix_prompt_replay_runs_run_mode",
|
||||
"ix_prompt_replay_runs_run_status",
|
||||
"ix_prompt_replay_runs_baseline_release_id",
|
||||
"ix_prompt_replay_runs_release_id",
|
||||
"ix_prompt_replay_runs_prompt_key",
|
||||
"ix_prompt_replay_runs_tenant_id",
|
||||
):
|
||||
if _index_exists(bind, "prompt_replay_runs", index_name):
|
||||
op.drop_index(index_name, table_name="prompt_replay_runs")
|
||||
op.drop_table("prompt_replay_runs")
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
"""extend prompt eval replay
|
||||
|
||||
Revision ID: 20260430_extend_prompt_eval_replay
|
||||
Revises: 20260430_add_prompt_replay_runs
|
||||
Create Date: 2026-04-30
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260430_extend_prompt_eval_replay"
|
||||
down_revision = "20260430_add_prompt_replay_runs"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
if not _table_exists(bind, table_name):
|
||||
return False
|
||||
return column_name in {str(column.get("name") or "") for column in sa.inspect(bind).get_columns(table_name)}
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
if not _table_exists(bind, table_name):
|
||||
return False
|
||||
return index_name in {str(index.get("name") or "") for index in sa.inspect(bind).get_indexes(table_name)}
|
||||
|
||||
|
||||
def _add_column_if_missing(bind, table_name: str, column: sa.Column) -> None:
|
||||
if not _column_exists(bind, table_name, str(column.name)):
|
||||
op.add_column(table_name, column)
|
||||
|
||||
|
||||
def _create_index_if_missing(bind, index_name: str, table_name: str, columns: list[str]) -> None:
|
||||
if not _index_exists(bind, table_name, index_name):
|
||||
op.create_index(index_name, table_name, columns, unique=False)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "prompt_golden_cases"):
|
||||
_add_column_if_missing(bind, "prompt_golden_cases", sa.Column("case_type", sa.String(length=32), nullable=False, server_default="regression"))
|
||||
_add_column_if_missing(bind, "prompt_golden_cases", sa.Column("priority", sa.String(length=24), nullable=False, server_default="blocking"))
|
||||
_add_column_if_missing(bind, "prompt_golden_cases", sa.Column("scenario_key", sa.String(length=128), nullable=True))
|
||||
_add_column_if_missing(bind, "prompt_golden_cases", sa.Column("tags", _json_type(bind), nullable=True))
|
||||
_add_column_if_missing(bind, "prompt_golden_cases", sa.Column("last_run_status", sa.String(length=24), nullable=True))
|
||||
_add_column_if_missing(bind, "prompt_golden_cases", sa.Column("last_failed_at", sa.String(length=64), nullable=True))
|
||||
_create_index_if_missing(bind, "ix_prompt_golden_cases_case_type", "prompt_golden_cases", ["case_type"])
|
||||
_create_index_if_missing(bind, "ix_prompt_golden_cases_priority", "prompt_golden_cases", ["priority"])
|
||||
_create_index_if_missing(bind, "ix_prompt_golden_cases_scenario_key", "prompt_golden_cases", ["scenario_key"])
|
||||
_create_index_if_missing(bind, "ix_prompt_golden_cases_last_run_status", "prompt_golden_cases", ["last_run_status"])
|
||||
|
||||
if _table_exists(bind, "prompt_replay_case_results"):
|
||||
_add_column_if_missing(bind, "prompt_replay_case_results", sa.Column("baseline_ai_call_log_id", sa.Integer(), nullable=True))
|
||||
_add_column_if_missing(bind, "prompt_replay_case_results", sa.Column("candidate_ai_call_log_id", sa.Integer(), nullable=True))
|
||||
_add_column_if_missing(bind, "prompt_replay_case_results", sa.Column("judge_score", sa.Float(), nullable=True))
|
||||
_add_column_if_missing(bind, "prompt_replay_case_results", sa.Column("judge_label", sa.String(length=32), nullable=True))
|
||||
_add_column_if_missing(bind, "prompt_replay_case_results", sa.Column("judge_reason", sa.Text(), nullable=True))
|
||||
_create_index_if_missing(bind, "ix_prompt_replay_case_results_judge_label", "prompt_replay_case_results", ["judge_label"])
|
||||
_create_index_if_missing(bind, "ix_prompt_replay_case_results_baseline_ai_call_log_id", "prompt_replay_case_results", ["baseline_ai_call_log_id"])
|
||||
_create_index_if_missing(bind, "ix_prompt_replay_case_results_candidate_ai_call_log_id", "prompt_replay_case_results", ["candidate_ai_call_log_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "prompt_replay_case_results"):
|
||||
for index_name in (
|
||||
"ix_prompt_replay_case_results_candidate_ai_call_log_id",
|
||||
"ix_prompt_replay_case_results_baseline_ai_call_log_id",
|
||||
"ix_prompt_replay_case_results_judge_label",
|
||||
):
|
||||
if _index_exists(bind, "prompt_replay_case_results", index_name):
|
||||
op.drop_index(index_name, table_name="prompt_replay_case_results")
|
||||
for column_name in ("judge_reason", "judge_label", "judge_score", "candidate_ai_call_log_id", "baseline_ai_call_log_id"):
|
||||
if _column_exists(bind, "prompt_replay_case_results", column_name):
|
||||
op.drop_column("prompt_replay_case_results", column_name)
|
||||
if _table_exists(bind, "prompt_golden_cases"):
|
||||
for index_name in (
|
||||
"ix_prompt_golden_cases_last_run_status",
|
||||
"ix_prompt_golden_cases_scenario_key",
|
||||
"ix_prompt_golden_cases_priority",
|
||||
"ix_prompt_golden_cases_case_type",
|
||||
):
|
||||
if _index_exists(bind, "prompt_golden_cases", index_name):
|
||||
op.drop_index(index_name, table_name="prompt_golden_cases")
|
||||
for column_name in ("last_failed_at", "last_run_status", "tags", "scenario_key", "priority", "case_type"):
|
||||
if _column_exists(bind, "prompt_golden_cases", column_name):
|
||||
op.drop_column("prompt_golden_cases", column_name)
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
"""add pmflow launch draft jobs
|
||||
|
||||
Revision ID: 20260501_add_pmflow_launch_draft_jobs
|
||||
Revises: 20260430_extend_prompt_eval_replay
|
||||
Create Date: 2026-05-01
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260501_add_pmflow_launch_draft_jobs"
|
||||
down_revision = "20260430_extend_prompt_eval_replay"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "pmflow_launch_draft_jobs"):
|
||||
return
|
||||
|
||||
json_type = _json_type(bind)
|
||||
op.create_table(
|
||||
"pmflow_launch_draft_jobs",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("job_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("revision_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("parent_draft_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("superseded_by_job_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("result_draft_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("input_text", sa.Text(), nullable=False),
|
||||
sa.Column("input_type", sa.String(length=32), nullable=False, server_default="text"),
|
||||
sa.Column("source", sa.String(length=32), nullable=False, server_default="web"),
|
||||
sa.Column("attachments", json_type, nullable=False),
|
||||
sa.Column("mode", sa.String(length=32), nullable=False, server_default="create"),
|
||||
sa.Column("revision_note", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="queued"),
|
||||
sa.Column("result_payload", json_type, nullable=True),
|
||||
sa.Column("changed_fields", json_type, nullable=False),
|
||||
sa.Column("error_code", sa.String(length=64), nullable=True),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("started_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("completed_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("expires_at", sa.TIMESTAMP(), nullable=False),
|
||||
sa.UniqueConstraint("tenant_id", "job_id", name="uq_pmflow_launch_draft_jobs_tenant_job_id"),
|
||||
sa.UniqueConstraint("tenant_id", "revision_id", name="uq_pmflow_launch_draft_jobs_tenant_revision_id"),
|
||||
)
|
||||
op.create_index("ix_pmflow_launch_draft_jobs_tenant_id", "pmflow_launch_draft_jobs", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_pmflow_launch_draft_jobs_user_id", "pmflow_launch_draft_jobs", ["user_id"], unique=False)
|
||||
op.create_index("ix_pmflow_launch_draft_jobs_job_id", "pmflow_launch_draft_jobs", ["job_id"], unique=False)
|
||||
op.create_index("ix_pmflow_launch_draft_jobs_revision_id", "pmflow_launch_draft_jobs", ["revision_id"], unique=False)
|
||||
op.create_index("ix_pmflow_launch_draft_jobs_parent_draft_id", "pmflow_launch_draft_jobs", ["parent_draft_id"], unique=False)
|
||||
op.create_index("ix_pmflow_launch_draft_jobs_superseded_by_job_id", "pmflow_launch_draft_jobs", ["superseded_by_job_id"], unique=False)
|
||||
op.create_index("ix_pmflow_launch_draft_jobs_result_draft_id", "pmflow_launch_draft_jobs", ["result_draft_id"], unique=False)
|
||||
op.create_index("ix_pmflow_launch_draft_jobs_status", "pmflow_launch_draft_jobs", ["status"], unique=False)
|
||||
op.create_index("ix_pmflow_launch_draft_jobs_expires_at", "pmflow_launch_draft_jobs", ["expires_at"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "pmflow_launch_draft_jobs"):
|
||||
return
|
||||
for index_name in (
|
||||
"ix_pmflow_launch_draft_jobs_expires_at",
|
||||
"ix_pmflow_launch_draft_jobs_status",
|
||||
"ix_pmflow_launch_draft_jobs_result_draft_id",
|
||||
"ix_pmflow_launch_draft_jobs_superseded_by_job_id",
|
||||
"ix_pmflow_launch_draft_jobs_parent_draft_id",
|
||||
"ix_pmflow_launch_draft_jobs_revision_id",
|
||||
"ix_pmflow_launch_draft_jobs_job_id",
|
||||
"ix_pmflow_launch_draft_jobs_user_id",
|
||||
"ix_pmflow_launch_draft_jobs_tenant_id",
|
||||
):
|
||||
op.drop_index(index_name, table_name="pmflow_launch_draft_jobs")
|
||||
op.drop_table("pmflow_launch_draft_jobs")
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
"""add pmflow ai feedback events
|
||||
|
||||
Revision ID: 20260503_add_pmflow_ai_feedback_events
|
||||
Revises: 20260501_add_pmflow_launch_draft_jobs
|
||||
Create Date: 2026-05-03
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260503_add_pmflow_ai_feedback_events"
|
||||
down_revision = "20260501_add_pmflow_launch_draft_jobs"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "pmflow_ai_feedback_events"):
|
||||
return
|
||||
|
||||
json_type = _json_type(bind)
|
||||
op.create_table(
|
||||
"pmflow_ai_feedback_events",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("thread_id", sa.BigInteger(), sa.ForeignKey("pmflow_threads.id"), nullable=True),
|
||||
sa.Column("message_id", sa.BigInteger(), sa.ForeignKey("pmflow_thread_messages.id"), nullable=True),
|
||||
sa.Column("change_set_id", sa.BigInteger(), sa.ForeignKey("pmflow_change_sets.id"), nullable=True),
|
||||
sa.Column("ai_call_log_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("actor_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("target_type", sa.String(length=64), nullable=False),
|
||||
sa.Column("target_id", sa.String(length=128), nullable=False),
|
||||
sa.Column("answer_kind", sa.String(length=64), nullable=False, server_default="chat_answer"),
|
||||
sa.Column("rating", sa.String(length=16), nullable=False),
|
||||
sa.Column("reason_tags", json_type, nullable=False),
|
||||
sa.Column("comment", sa.Text(), nullable=True),
|
||||
sa.Column("scene_key", sa.String(length=128), nullable=True),
|
||||
sa.Column("prompt_key", sa.String(length=128), nullable=True),
|
||||
sa.Column("prompt_release_id", sa.Integer(), nullable=True),
|
||||
sa.Column("runtime_profile_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("model_provider", sa.String(length=64), nullable=True),
|
||||
sa.Column("model_name", sa.String(length=120), nullable=True),
|
||||
sa.Column("feedback_metadata", json_type, nullable=False),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"project_id",
|
||||
"actor_user_id",
|
||||
"target_type",
|
||||
"target_id",
|
||||
name="uq_pmflow_ai_feedback_target_user",
|
||||
),
|
||||
)
|
||||
for column_name in (
|
||||
"tenant_id",
|
||||
"project_id",
|
||||
"thread_id",
|
||||
"message_id",
|
||||
"change_set_id",
|
||||
"ai_call_log_id",
|
||||
"actor_user_id",
|
||||
"target_type",
|
||||
"target_id",
|
||||
"answer_kind",
|
||||
"rating",
|
||||
"scene_key",
|
||||
"prompt_key",
|
||||
"prompt_release_id",
|
||||
"runtime_profile_id",
|
||||
):
|
||||
op.create_index(
|
||||
f"ix_pmflow_ai_feedback_events_{column_name}",
|
||||
"pmflow_ai_feedback_events",
|
||||
[column_name],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "pmflow_ai_feedback_events"):
|
||||
return
|
||||
for column_name in (
|
||||
"runtime_profile_id",
|
||||
"prompt_release_id",
|
||||
"prompt_key",
|
||||
"scene_key",
|
||||
"rating",
|
||||
"answer_kind",
|
||||
"target_id",
|
||||
"target_type",
|
||||
"actor_user_id",
|
||||
"ai_call_log_id",
|
||||
"change_set_id",
|
||||
"message_id",
|
||||
"thread_id",
|
||||
"project_id",
|
||||
"tenant_id",
|
||||
):
|
||||
op.drop_index(f"ix_pmflow_ai_feedback_events_{column_name}", table_name="pmflow_ai_feedback_events")
|
||||
op.drop_table("pmflow_ai_feedback_events")
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
"""add pmflow changeset committer
|
||||
|
||||
Revision ID: 20260505_add_pmflow_changeset_committer
|
||||
Revises: 20260503_add_pmflow_ai_feedback_events
|
||||
Create Date: 2026-05-05
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260505_add_pmflow_changeset_committer"
|
||||
down_revision = "20260503_add_pmflow_ai_feedback_events"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
if not sa.inspect(bind).has_table(table_name):
|
||||
return False
|
||||
return any(column["name"] == column_name for column in sa.inspect(bind).get_columns(table_name))
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
if not sa.inspect(bind).has_table(table_name):
|
||||
return False
|
||||
return any(index["name"] == index_name for index in sa.inspect(bind).get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _column_exists(bind, "pmflow_change_sets", "committed_by"):
|
||||
op.add_column("pmflow_change_sets", sa.Column("committed_by", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True))
|
||||
if not _index_exists(bind, "pmflow_change_sets", "ix_pmflow_change_sets_committed_by"):
|
||||
op.create_index("ix_pmflow_change_sets_committed_by", "pmflow_change_sets", ["committed_by"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _index_exists(bind, "pmflow_change_sets", "ix_pmflow_change_sets_committed_by"):
|
||||
op.drop_index("ix_pmflow_change_sets_committed_by", table_name="pmflow_change_sets")
|
||||
if _column_exists(bind, "pmflow_change_sets", "committed_by"):
|
||||
op.drop_column("pmflow_change_sets", "committed_by")
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
"""add execution runtime credentials
|
||||
|
||||
Revision ID: 20260506_add_execution_runtime_credentials
|
||||
Revises: 20260505_add_pmflow_changeset_committer
|
||||
Create Date: 2026-05-06
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260506_add_execution_runtime_credentials"
|
||||
down_revision = "20260505_add_pmflow_changeset_committer"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
if not _table_exists(bind, table_name):
|
||||
return False
|
||||
return any(index["name"] == index_name for index in sa.inspect(bind).get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
table_name = "pmflow_execution_runtime_credentials"
|
||||
if not _table_exists(bind, table_name):
|
||||
op.create_table(
|
||||
table_name,
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("key_id", sa.String(length=80), nullable=False),
|
||||
sa.Column("secret_hash", sa.String(length=128), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("task_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("prompt_package_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("repo_binding_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("runtime_target", sa.String(length=32), nullable=False),
|
||||
sa.Column("allowed_sources", _json_type(bind), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False),
|
||||
sa.Column("expires_at", sa.TIMESTAMP(), nullable=False),
|
||||
sa.Column("created_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("last_used_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("use_count", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
|
||||
sa.ForeignKeyConstraint(["project_id"], ["projects.id"]),
|
||||
sa.ForeignKeyConstraint(["prompt_package_id"], ["prompt_packages.id"]),
|
||||
sa.ForeignKeyConstraint(["repo_binding_id"], ["repo_bindings.id"]),
|
||||
sa.ForeignKeyConstraint(["task_id"], ["tasks.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("tenant_id", "key_id", name="uq_pmflow_runtime_credentials_tenant_key"),
|
||||
)
|
||||
|
||||
for column_name in (
|
||||
"tenant_id",
|
||||
"key_id",
|
||||
"project_id",
|
||||
"task_id",
|
||||
"prompt_package_id",
|
||||
"repo_binding_id",
|
||||
"runtime_target",
|
||||
"status",
|
||||
"expires_at",
|
||||
"created_by",
|
||||
):
|
||||
index_name = f"ix_{table_name}_{column_name}"
|
||||
if not _index_exists(bind, table_name, index_name):
|
||||
op.create_index(index_name, table_name, [column_name], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
table_name = "pmflow_execution_runtime_credentials"
|
||||
if _table_exists(bind, table_name):
|
||||
op.drop_table(table_name)
|
||||