chore: reset repository history
|
|
@ -0,0 +1,45 @@
|
|||
# 唯一配置文件,backend/.env 已移除
|
||||
# 所有环境变量在此统一管理
|
||||
|
||||
# Docker Compose Runtime Secrets / Config
|
||||
POSTGRES_DB=pmflow
|
||||
POSTGRES_USER=pmflow
|
||||
POSTGRES_PASSWORD=pmflow_pass
|
||||
REDIS_PASSWORD=pmflow_redis_pass
|
||||
|
||||
DB_PORT=5435
|
||||
REDIS_PORT=6379
|
||||
BACKEND_PORT=8000
|
||||
FRONTEND_PORT=5174
|
||||
PMFLOW_EXPECTED_DB_DIALECT=postgresql
|
||||
|
||||
# Backend Runtime
|
||||
CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174
|
||||
LLM_PROVIDER=glm
|
||||
LLM_FALLBACK_PROVIDER=glm
|
||||
CONTROL_PLANE_ROUTING_ENABLED=true
|
||||
CONTROL_PLANE_ROUTING_RULES_PATH=backend/.control_plane/pmflow-routing-rules.json
|
||||
CONTROL_PLANE_AGENTS_DEFAULT_TIMEOUT_SECONDS=120
|
||||
LLM_DIRECT_EMERGENCY_ENABLED=true
|
||||
LLM_MIXED_ROUTING_ENABLED=false
|
||||
LLM_ROUTING_DEFAULT_TIER=low
|
||||
LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true
|
||||
LLM_REQUEST_TIMEOUT=90
|
||||
|
||||
# 模型默认值统一由 backend/.control_plane/pmflow-routing-rules.json 管理,不再依赖 LLM_ROUTING_* / *_MODEL 环境变量
|
||||
DASHSCOPE_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
DEEPSEEK_API_KEY=
|
||||
OLLAMA_HOST=http://host.docker.internal:11434
|
||||
OLLAMA_BASE_URL=http://host.docker.internal:11434
|
||||
OLLAMA_MODEL=llama3:latest
|
||||
|
||||
# Adaptive RAG 深入思考模式
|
||||
RAG_DEEP_RETRIEVAL_STEPS=5
|
||||
RAG_DEEP_VECTOR_TOP_K=12
|
||||
RAG_DEEP_CONTEXT_BLOCKS=16
|
||||
RAG_DEEP_ANSWER_MAX_TOKENS=1400
|
||||
|
||||
# Feishu long connection retry policy (1s -> 2s -> 4s, max 3 attempts)
|
||||
FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS=1
|
||||
FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS=3
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
name: ai-steward-eval-gate
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'backend/app/services/adaptive_rag_service.py'
|
||||
- 'backend/app/services/project_knowledge_graph.py'
|
||||
- 'backend/app/api/rag.py'
|
||||
- 'backend/app/api/knowledge_graph.py'
|
||||
- 'backend/evals/**'
|
||||
- 'scripts/ai_steward_eval.py'
|
||||
- 'scripts/rag_followup_smoke.sh'
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
paths:
|
||||
- 'backend/evals/**'
|
||||
- 'scripts/ai_steward_eval.py'
|
||||
- 'scripts/rag_followup_smoke.sh'
|
||||
- '.github/workflows/ai-steward-eval-gate.yml'
|
||||
|
||||
jobs:
|
||||
golden-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Lint golden dataset
|
||||
run: |
|
||||
python scripts/ai_steward_eval.py --lint-only --input backend/evals/golden_qa_v1.jsonl
|
||||
|
||||
eval-prereq:
|
||||
runs-on: ubuntu-latest
|
||||
needs: golden-lint
|
||||
steps:
|
||||
- name: Check gate configuration
|
||||
run: |
|
||||
if [ -z "${{ secrets.EVAL_API_TOKEN }}" ] || [ -z "${{ vars.EVAL_API_URL }}" ]; then
|
||||
echo "::error::Missing EVAL_API_TOKEN secret or EVAL_API_URL variable. Eval gate must be configured."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
eval-gate:
|
||||
runs-on: ubuntu-latest
|
||||
needs: eval-prereq
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Install deps
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install httpx
|
||||
- name: Run eval gate
|
||||
env:
|
||||
EVAL_API_TOKEN: ${{ secrets.EVAL_API_TOKEN }}
|
||||
EVAL_API_URL: ${{ vars.EVAL_API_URL }}
|
||||
run: |
|
||||
python scripts/ai_steward_eval.py \
|
||||
--input backend/evals/golden_qa_v1.jsonl \
|
||||
--api-url "$EVAL_API_URL" \
|
||||
--token "$EVAL_API_TOKEN" \
|
||||
--output-dir backend/evals/reports \
|
||||
--fail-on-threshold \
|
||||
--threshold-accuracy 0.80 \
|
||||
--threshold-hallucination 0.12 \
|
||||
--threshold-followup 0.75 \
|
||||
--threshold-query-type 0.80
|
||||
- name: Run followup smoke gate
|
||||
env:
|
||||
EVAL_API_TOKEN: ${{ secrets.EVAL_API_TOKEN }}
|
||||
EVAL_API_URL: ${{ vars.EVAL_API_URL }}
|
||||
run: |
|
||||
BASE_URL="${EVAL_API_URL%/rag/ask}"
|
||||
SMOKE_BASE_URL="$BASE_URL" SMOKE_TOKEN="$EVAL_API_TOKEN" bash scripts/rag_followup_smoke.sh
|
||||
- name: Upload eval report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ai-steward-eval-report
|
||||
path: backend/evals/reports/*.json
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
name: contract-check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "backend/app/api/**"
|
||||
- "backend/app/schemas/**"
|
||||
- "backend/app/services/**"
|
||||
- "frontend/src/api/**"
|
||||
- "frontend/src/types/**"
|
||||
- "docs/architecture/**"
|
||||
- "scripts/check_contract_sync.sh"
|
||||
- "scripts/semantic_lint.py"
|
||||
- "scripts/semantic_lint_baseline.txt"
|
||||
- ".github/workflows/contract-check.yml"
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
paths:
|
||||
- "backend/app/api/**"
|
||||
- "backend/app/schemas/**"
|
||||
- "backend/app/services/**"
|
||||
- "frontend/src/api/**"
|
||||
- "frontend/src/types/**"
|
||||
- "docs/architecture/**"
|
||||
- "scripts/check_contract_sync.sh"
|
||||
- "scripts/semantic_lint.py"
|
||||
- "scripts/semantic_lint_baseline.txt"
|
||||
- ".github/workflows/contract-check.yml"
|
||||
|
||||
jobs:
|
||||
contract-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Run contract sync check
|
||||
run: |
|
||||
bash scripts/check_contract_sync.sh
|
||||
- name: Run semantic lint
|
||||
run: |
|
||||
python3 scripts/semantic_lint.py
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
name: feishu-replay-gate
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "backend/app/services/feishu_integration.py"
|
||||
- "backend/app/services/feishu_signal_pipeline.py"
|
||||
- "backend/app/services/llm.py"
|
||||
- "backend/app/services/signal_capture.py"
|
||||
- "backend/app/models/feishu_item.py"
|
||||
- "backend/app/models/feishu_reply_outbox.py"
|
||||
- "backend/app/main.py"
|
||||
- "backend/evals/feishu_replay_eval.py"
|
||||
- "backend/evals/golden_feishu_replay_v1.jsonl"
|
||||
- "backend/.openclaw/pmflow-routing-rules.json"
|
||||
- "scripts/openclaw_routing_lint.py"
|
||||
- ".github/workflows/feishu-replay-gate.yml"
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
paths:
|
||||
- "backend/evals/feishu_replay_eval.py"
|
||||
- "backend/evals/golden_feishu_replay_v1.jsonl"
|
||||
- "backend/.openclaw/pmflow-routing-rules.json"
|
||||
- "scripts/openclaw_routing_lint.py"
|
||||
- ".github/workflows/feishu-replay-gate.yml"
|
||||
|
||||
jobs:
|
||||
feishu-replay:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: pmflow
|
||||
POSTGRES_PASSWORD: pmflow_pass
|
||||
POSTGRES_DB: pmflow
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U pmflow -d pmflow"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install deps
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -r backend/requirements.txt
|
||||
|
||||
- name: Lint replay dataset
|
||||
env:
|
||||
PYTHONPATH: backend
|
||||
DATABASE_URL: postgresql+asyncpg://pmflow:pmflow_pass@127.0.0.1:5432/pmflow
|
||||
PMFLOW_EXPECTED_DB_DIALECT: postgresql
|
||||
run: |
|
||||
python backend/evals/feishu_replay_eval.py \
|
||||
--lint-only \
|
||||
--input backend/evals/golden_feishu_replay_v1.jsonl
|
||||
|
||||
- name: Lint OpenClaw routing rules
|
||||
run: |
|
||||
python scripts/openclaw_routing_lint.py --input backend/.openclaw/pmflow-routing-rules.json
|
||||
|
||||
- name: Run replay eval
|
||||
env:
|
||||
PYTHONPATH: backend
|
||||
DATABASE_URL: postgresql+asyncpg://pmflow:pmflow_pass@127.0.0.1:5432/pmflow
|
||||
PMFLOW_EXPECTED_DB_DIALECT: postgresql
|
||||
run: |
|
||||
cd backend
|
||||
alembic upgrade head
|
||||
cd ..
|
||||
python backend/evals/feishu_replay_eval.py \
|
||||
--strict \
|
||||
--tenant-id 9901 \
|
||||
--input backend/evals/golden_feishu_replay_v1.jsonl \
|
||||
--output-dir backend/evals/reports
|
||||
|
||||
- name: Upload replay report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: feishu-replay-report
|
||||
path: backend/evals/reports/feishu_replay_*.json
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Virtual Environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
.venv/
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# IDEs
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
.worktrees/
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Databases
|
||||
*.db
|
||||
*.sqlite3
|
||||
|
||||
# Large Files & Binaries
|
||||
.ollama/
|
||||
ollama_bin/
|
||||
Pixelle-Video/
|
||||
*.dmg
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.iso
|
||||
|
||||
# Local Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Playwright browser profiles
|
||||
frontend/.playwright-profiles/
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"version": 1,
|
||||
"setupCompletedAt": "2026-03-26T16:00:06.817Z"
|
||||
}
|
||||
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
|
@ -0,0 +1,31 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Case2 Feishu Reply</title>
|
||||
<style>
|
||||
body { font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif; background:#f5f7fb; margin:0; padding:24px; }
|
||||
.card { max-width: 980px; margin: 0 auto; background: #fff; border: 1px solid #e5eaf3; border-radius: 12px; padding: 20px; box-shadow: 0 4px 18px rgba(19,33,68,.06); }
|
||||
h1 { margin: 0 0 10px; font-size: 20px; }
|
||||
.meta { color:#4b5b7c; font-size:14px; margin-bottom: 12px; }
|
||||
pre { white-space: pre-wrap; word-break: break-word; line-height: 1.6; background:#f8faff; border:1px solid #e7edf8; border-radius:8px; padding:14px; font-size:14px; }
|
||||
.flag { margin-top: 10px; font-size: 14px; }
|
||||
.bad { color:#b42318; font-weight:600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Case2 真实飞书回复</h1>
|
||||
<div class="meta">source_message_id: real-scene-case2-1773913396 · outbox_id: 242 · run_id: 91</div>
|
||||
<pre>当前未接入实时数据,以下基于训练数据(截止2024年)。
|
||||
|
||||
[行业动态]
|
||||
近期AI行业呈现以下核心趋势:
|
||||
1. **大模型竞争白热化**:OpenAI、Anthropic、Google等厂商持续迭代模型(如GPT-4o、Claude 3.5),上下文窗口与推理能力显著提升,价格战促使API成本下降。
|
||||
2. **Agent(智能体)爆发**:行业重心从单一对话转向具备自主规划、工具调用能力的Agent,自动化解决复杂工作流成为热点。
|
||||
3. **垂直领域落地加速**:医疗、法律、HR等专业场景的AI应用从“尝鲜”转向“实效”,</pre>
|
||||
<div class="flag">[建议] 段检查:<span class="bad">未出现</span></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 35 KiB |
|
|
@ -0,0 +1,7 @@
|
|||
当前未接入实时数据,以下基于训练数据(截止2024年)。
|
||||
|
||||
[行业动态]
|
||||
近期AI行业呈现以下核心趋势:
|
||||
1. **大模型竞争白热化**:OpenAI、Anthropic、Google等厂商持续迭代模型(如GPT-4o、Claude 3.5),上下文窗口与推理能力显著提升,价格战促使API成本下降。
|
||||
2. **Agent(智能体)爆发**:行业重心从单一对话转向具备自主规划、工具调用能力的Agent,自动化解决复杂工作流成为热点。
|
||||
3. **垂直领域落地加速**:医疗、法律、HR等专业场景的AI应用从“尝鲜”转向“实效”,
|
||||
|
|
@ -0,0 +1 @@
|
|||
data:text/html;charset=utf-8,%3C%21doctype%20html%3E%3Cmeta%20charset%3D%22utf-8%22%3E%3Cstyle%3Ebody%7Bfont-family%3AArial%2Csans-serif%3Bpadding%3A20px%7Dpre%7Bwhite-space%3Apre-wrap%3Bbackground%3A%23f5f5f5%3Bpadding%3A12px%3Bborder-radius%3A8px%7D.bad%7Bcolor%3A%23b42318%3Bfont-weight%3A700%7D%3C/style%3E%3Ch2%3ECase2%20%E7%9C%9F%E5%AE%9E%E9%A3%9E%E4%B9%A6%E5%9B%9E%E5%A4%8D%3C/h2%3E%3Cdiv%3Esource_message_id%3A%20real-scene-case2-1773913396%3C/div%3E%3Cpre%3E%E5%BD%93%E5%89%8D%E6%9C%AA%E6%8E%A5%E5%85%A5%E5%AE%9E%E6%97%B6%E6%95%B0%E6%8D%AE%EF%BC%8C%E4%BB%A5%E4%B8%8B%E5%9F%BA%E4%BA%8E%E8%AE%AD%E7%BB%83%E6%95%B0%E6%8D%AE%EF%BC%88%E6%88%AA%E6%AD%A22024%E5%B9%B4%EF%BC%89%E3%80%82%0A%0A%5B%E8%A1%8C%E4%B8%9A%E5%8A%A8%E6%80%81%5D%0A%E8%BF%91%E6%9C%9FAI%E8%A1%8C%E4%B8%9A%E5%91%88%E7%8E%B0%E4%BB%A5%E4%B8%8B%E6%A0%B8%E5%BF%83%E8%B6%8B%E5%8A%BF%EF%BC%9A%0A1.%20%2A%2A%E5%A4%A7%E6%A8%A1%E5%9E%8B%E7%AB%9E%E4%BA%89%E7%99%BD%E7%83%AD%E5%8C%96%2A%2A%EF%BC%9AOpenAI%E3%80%81Anthropic%E3%80%81Google%E7%AD%89%E5%8E%82%E5%95%86%E6%8C%81%E7%BB%AD%E8%BF%AD%E4%BB%A3%E6%A8%A1%E5%9E%8B%EF%BC%88%E5%A6%82GPT-4o%E3%80%81Claude%203.5%EF%BC%89%EF%BC%8C%E4%B8%8A%E4%B8%8B%E6%96%87%E7%AA%97%E5%8F%A3%E4%B8%8E%E6%8E%A8%E7%90%86%E8%83%BD%E5%8A%9B%E6%98%BE%E8%91%97%E6%8F%90%E5%8D%87%EF%BC%8C%E4%BB%B7%E6%A0%BC%E6%88%98%E4%BF%83%E4%BD%BFAPI%E6%88%90%E6%9C%AC%E4%B8%8B%E9%99%8D%E3%80%82%0A2.%20%2A%2AAgent%EF%BC%88%E6%99%BA%E8%83%BD%E4%BD%93%EF%BC%89%E7%88%86%E5%8F%91%2A%2A%EF%BC%9A%E8%A1%8C%E4%B8%9A%E9%87%8D%E5%BF%83%E4%BB%8E%E5%8D%95%E4%B8%80%E5%AF%B9%E8%AF%9D%E8%BD%AC%E5%90%91%E5%85%B7%E5%A4%87%E8%87%AA%E4%B8%BB%E8%A7%84%E5%88%92%E3%80%81%E5%B7%A5%E5%85%B7%E8%B0%83%E7%94%A8%E8%83%BD%E5%8A%9B%E7%9A%84Agent%EF%BC%8C%E8%87%AA%E5%8A%A8%E5%8C%96%E8%A7%A3%E5%86%B3%E5%A4%8D%E6%9D%82%E5%B7%A5%E4%BD%9C%E6%B5%81%E6%88%90%E4%B8%BA%E7%83%AD%E7%82%B9%E3%80%82%0A3.%20%2A%2A%E5%9E%82%E7%9B%B4%E9%A2%86%E5%9F%9F%E8%90%BD%E5%9C%B0%E5%8A%A0%E9%80%9F%2A%2A%EF%BC%9A%E5%8C%BB%E7%96%97%E3%80%81%E6%B3%95%E5%BE%8B%E3%80%81HR%E7%AD%89%E4%B8%93%E4%B8%9A%E5%9C%BA%E6%99%AF%E7%9A%84AI%E5%BA%94%E7%94%A8%E4%BB%8E%E2%80%9C%E5%B0%9D%E9%B2%9C%E2%80%9D%E8%BD%AC%E5%90%91%E2%80%9C%E5%AE%9E%E6%95%88%E2%80%9D%EF%BC%8C%0A%3C/pre%3E%3Cdiv%3E%5B%E5%BB%BA%E8%AE%AE%5D%20%E6%AE%B5%E6%A3%80%E6%9F%A5%EF%BC%9A%3Cspan%20class%3D%22bad%22%3E%E6%9C%AA%E5%87%BA%E7%8E%B0%3C/span%3E%3C/div%3E
|
||||
|
After Width: | Height: | Size: 64 KiB |
|
|
@ -0,0 +1,28 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Case3 验收结果</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; margin: 24px; background: #f5f7fb; }
|
||||
.card { background: #fff; border: 1px solid #d9e1ef; border-radius: 12px; padding: 20px; max-width: 980px; }
|
||||
h1 { margin: 0 0 12px; font-size: 22px; }
|
||||
.meta { color: #344054; margin: 6px 0; }
|
||||
.fail { color: #b42318; font-weight: 600; }
|
||||
.reply { margin-top: 12px; padding: 14px; border: 1px dashed #98a2b3; border-radius: 8px; background: #f8fafc; white-space: pre-wrap; }
|
||||
code { background: #eef2ff; padding: 2px 6px; border-radius: 6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Case3 验收截图(v9)</h1>
|
||||
<div class="meta">source_message_id: <code>real-scene-p1-v9-3-1773884746</code></div>
|
||||
<div class="meta">run_id: <code>89</code></div>
|
||||
<div class="meta">当前 run 状态: <code>failed</code>(retry_count=3/max_retries=3)</div>
|
||||
<div class="meta fail">验收结果: 不通过(未返回业务答案)</div>
|
||||
<div class="meta">飞书实际回复(outbox):</div>
|
||||
<div class="reply">1) 已收到,正在处理。
|
||||
2) 已自动重试3次仍失败,请重新发送或换个说法。</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
|
@ -0,0 +1 @@
|
|||
<!doctype html><html><head><meta charset="utf-8"><style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;padding:24px;background:#f5f7fb;color:#222}.card{background:#fff;border:1px solid #dbe2ea;border-radius:12px;padding:20px;max-width:900px}pre{white-space:pre-wrap;word-break:break-word;font-size:15px;line-height:1.5}.meta{color:#667085;font-size:13px;margin-bottom:10px}</style></head><body><div class="card"><h2>Case 1 回复</h2><div class="meta">source_message_id: real-scene-p1-v4-1-1773760579</div><pre>杨宇当前负载为 199.17%。根据系统快照,他身兼多职(FE, BOSS, AI, BE, ADMIN),负责 4 个项目,负载已严重超载,建议尽快进行任务调配。</pre></div></body></html>
|
||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
|
@ -0,0 +1,25 @@
|
|||
<!doctype html><html><head><meta charset="utf-8"><style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;padding:24px;background:#f5f7fb;color:#222}.card{background:#fff;border:1px solid #dbe2ea;border-radius:12px;padding:20px;max-width:900px}pre{white-space:pre-wrap;word-break:break-word;font-size:15px;line-height:1.5}.meta{color:#667085;font-size:13px;margin-bottom:10px}</style></head><body><div class="card"><h2>Case 2 回复</h2><div class="meta">source_message_id: real-scene-p1-v4-2-1773760726</div><pre>[行业动态]
|
||||
当前未接入实时数据,以下基于训练数据(截止2024年4月):
|
||||
|
||||
**大模型领域:**
|
||||
- GPT-4 Turbo、Claude 3、Gemini 1.5 Pro 等多模态大模型持续迭代,上下文窗口突破百万级
|
||||
- 开源模型快速追赶,Llama 3、Mistral、Qwen 等性能逼近闭源模型
|
||||
- 模型成本大幅下降,API 价格战激烈,Token 成本降低约 10 倍
|
||||
|
||||
**应用落地趋势:**
|
||||
- AI Agent(智能体)成为主流方向,从单轮对话转向多步骤任务执行
|
||||
- RAG(检索增强生成)技术成熟,企业知识库问答成为标配场景
|
||||
- 垂直领域 AI 应用爆发:客服、法律、医疗、教育等场景深度渗透
|
||||
|
||||
**行业监管:**
|
||||
- 欧盟 AI 法案正式通过,全球 AI 治理框架逐步成型
|
||||
- 中国发布生成式 AI 管理办法,合规要求趋严
|
||||
|
||||
---
|
||||
|
||||
[对项目的影响]
|
||||
对你们 **AI劝退助手** 项目的影响:
|
||||
|
||||
1. **技术红利**:大模型成本下降、推理能力提升,可降低项目运营成本,支持更复杂的对话策略(如多轮情绪分析、个性化劝退话术生成)
|
||||
|
||||
2. **竞争压力**:AI 应用门槛降低</pre></div></body></html>
|
||||
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
|
@ -0,0 +1,313 @@
|
|||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models import AIOrchestrationRun, FeishuReplyOutbox, AIProcessingLog, AICallLog
|
||||
|
||||
BASE = "http://127.0.0.1:8000"
|
||||
CHAT_ID = "oc_c487d87563e48c2f9566ccd5a758212c"
|
||||
SENDER = "P1验收机器人"
|
||||
|
||||
CASES = [
|
||||
{"idx": 1, "message": "杨宇现在负载多少", "expected_scene": "member_workload", "keys": ["杨宇", "负载"]},
|
||||
{"idx": 2, "message": "最近AI行业有什么动态", "expected_scene": "external_trend", "keys": ["AI行业", "行业", "动态", "趋势"]},
|
||||
{"idx": 3, "message": "帮我创建个项目叫路由验收测试,顺便问下现在有几个进行中的项目", "expected_scene": "mixed", "keys": ["顺便", "问题部分", "需求部分", "进行中的项目"]},
|
||||
{"idx": 4, "message": "AI劝退助手项目进展怎么样", "expected_scene": "default", "keys": ["AI劝退助手", "进展怎么样", "项目进展"]},
|
||||
{"idx": 5, "message": "帮我看看宠物行业最新动态", "expected_scene": "external_trend", "keys": ["宠物行业", "最新动态", "行业", "动态"]},
|
||||
]
|
||||
|
||||
BANNED_PREFIXES = ["让我先", "让我尝试", "我来看看", "我先查", "让我看看", "我需要先"]
|
||||
|
||||
|
||||
def login_token() -> str:
|
||||
data = urllib.parse.urlencode({"username": "admin", "password": "admin123"}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{BASE}/auth/login",
|
||||
data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
return str(payload.get("access_token") or "")
|
||||
|
||||
|
||||
def post_capture(token: str, *, message: str, message_id: str) -> dict[str, Any]:
|
||||
payload = {
|
||||
"group_chat_id": CHAT_ID,
|
||||
"message_content": message,
|
||||
"sender": SENDER,
|
||||
"feishu_message_id": message_id,
|
||||
}
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{BASE}/ai-steward/ai-steward/signal/capture-feishu",
|
||||
data=body,
|
||||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=240) as resp:
|
||||
text = resp.read().decode("utf-8")
|
||||
try:
|
||||
return json.loads(text)
|
||||
except Exception:
|
||||
return {"raw": text}
|
||||
|
||||
|
||||
def detect_prompt_scene(prompt: str) -> Optional[str]:
|
||||
p = str(prompt or "")
|
||||
if "你是 PMFlow 的团队协作助手。" in p:
|
||||
return "member_workload"
|
||||
if "你是 PMFlow 的市场情报助手。" in p:
|
||||
return "external_trend"
|
||||
if "用户消息同时包含问题和需求。" in p:
|
||||
return "mixed"
|
||||
if "你是 PMFlow 的 AI 项目管理官。" in p:
|
||||
return "ai_pmo"
|
||||
if "用户通过飞书直接提问。" in p:
|
||||
return "feishu_direct"
|
||||
if "你是 PMFlow 的 RAG 问答助手" in p:
|
||||
return "default"
|
||||
return None
|
||||
|
||||
|
||||
def infer_scene_from_reply(reply: str) -> str:
|
||||
t = str(reply or "")
|
||||
if "[回答]" in t and "[已收到需求]" in t:
|
||||
return "mixed"
|
||||
if "[行业动态]" in t and "[建议]" in t:
|
||||
return "external_trend"
|
||||
if ("负载" in t or "工作量" in t) and "%" in t and "项目列表" not in t:
|
||||
return "member_workload"
|
||||
if "完成率" in t:
|
||||
return "default"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def has_thinking_leak(reply: str) -> bool:
|
||||
for line in str(reply or "").splitlines():
|
||||
s = line.strip()
|
||||
if not s:
|
||||
continue
|
||||
if any(s.startswith(prefix) for prefix in BANNED_PREFIXES):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def fetch_max_ai_call_id(db) -> int:
|
||||
m = (await db.execute(select(func.max(AICallLog.id)))).scalar()
|
||||
return int(m or 0)
|
||||
|
||||
|
||||
async def wait_run(db, source_message_id: str, timeout: int = 540) -> Optional[AIOrchestrationRun]:
|
||||
deadline = time.time() + timeout
|
||||
last = None
|
||||
while time.time() < deadline:
|
||||
row = (
|
||||
await db.execute(
|
||||
select(AIOrchestrationRun)
|
||||
.where(AIOrchestrationRun.source_message_id == source_message_id)
|
||||
.order_by(AIOrchestrationRun.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is not None:
|
||||
last = row
|
||||
s = str(row.status or "").lower()
|
||||
if s in {"succeeded", "failed", "cancelled"}:
|
||||
return row
|
||||
await asyncio.sleep(2)
|
||||
return last
|
||||
|
||||
|
||||
async def fetch_outbox(db, source_message_id: str):
|
||||
return (
|
||||
await db.execute(
|
||||
select(FeishuReplyOutbox)
|
||||
.where(FeishuReplyOutbox.source_message_id == source_message_id)
|
||||
.order_by(FeishuReplyOutbox.id.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
|
||||
async def wait_outbox(db, source_message_id: str, run_status: str, timeout: int = 240):
|
||||
deadline = time.time() + timeout
|
||||
expected = 2 if run_status == "succeeded" else 1
|
||||
latest = []
|
||||
while time.time() < deadline:
|
||||
latest = await fetch_outbox(db, source_message_id)
|
||||
sent = [r for r in latest if str(r.status or "") == "sent"]
|
||||
if len(sent) >= expected:
|
||||
return latest
|
||||
await asyncio.sleep(2)
|
||||
return latest
|
||||
|
||||
|
||||
async def fetch_processing(db, source_message_id: str):
|
||||
return (
|
||||
await db.execute(
|
||||
select(AIProcessingLog)
|
||||
.where(AIProcessingLog.feishu_message_id == source_message_id)
|
||||
.order_by(AIProcessingLog.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
async def fetch_scene_from_call_logs(db, min_id: int, keys: list[str]):
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(AICallLog)
|
||||
.where(
|
||||
AICallLog.id > min_id,
|
||||
AICallLog.function_name.in_(["adaptive_rag_answer_normal", "adaptive_rag_answer_deep"]),
|
||||
)
|
||||
.order_by(AICallLog.id.asc())
|
||||
.limit(160)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
picked = "unknown"
|
||||
inspected = []
|
||||
lk = [k.lower() for k in keys if k]
|
||||
for r in rows:
|
||||
prompt = str(r.prompt or "")
|
||||
pl = prompt.lower()
|
||||
if lk and not any(k in pl for k in lk):
|
||||
continue
|
||||
sc = detect_prompt_scene(prompt)
|
||||
inspected.append(
|
||||
{
|
||||
"id": int(r.id),
|
||||
"scene": sc or "unknown",
|
||||
"function_name": str(r.function_name or ""),
|
||||
"created_at": str(r.created_at or ""),
|
||||
}
|
||||
)
|
||||
if sc:
|
||||
picked = sc
|
||||
|
||||
if picked == "unknown" and inspected:
|
||||
picked = str(inspected[-1].get("scene") or "unknown")
|
||||
return picked, inspected
|
||||
|
||||
|
||||
def evaluate(case_idx: int, scene: str, reply: str, run_status: str):
|
||||
leak = has_thinking_leak(reply)
|
||||
if case_idx == 1:
|
||||
ok = scene == "member_workload" and "%" in reply and "项目列表" not in reply and not leak
|
||||
return ok, "member_workload+百分比+无项目列表"
|
||||
if case_idx == 2:
|
||||
sec = "[行业动态]" in reply and ("[对项目影响]" in reply or "[对项目的影响]" in reply) and "[建议]" in reply
|
||||
src = "未接入实时数据" in reply or "训练数据" in reply or "数据来源" in reply
|
||||
ok = scene == "external_trend" and sec and src and not leak
|
||||
return ok, "external_trend+三段+来源限制"
|
||||
if case_idx == 3:
|
||||
mixed = "[回答]" in reply and "[已收到需求]" in reply
|
||||
prog = "进行中" in reply
|
||||
ok = scene == "mixed" and run_status == "succeeded" and mixed and prog and not leak
|
||||
return ok, "mixed+创建+进行中数量"
|
||||
if case_idx == 4:
|
||||
ok = scene == "default" and "完成率" in reply and "%" in reply and not leak
|
||||
return ok, "default+完成率"
|
||||
if case_idx == 5:
|
||||
ok = scene == "external_trend" and not leak
|
||||
return ok, "external_trend+无思考外漏"
|
||||
return False, "unknown"
|
||||
|
||||
|
||||
async def run_case(token: str, case: dict[str, Any]):
|
||||
idx = int(case["idx"])
|
||||
message = str(case["message"])
|
||||
source_message_id = f"real-scene-p1-v2-{idx}-{int(time.time())}"
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
before_call_id = await fetch_max_ai_call_id(db)
|
||||
|
||||
capture = post_capture(token, message=message, message_id=source_message_id)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
run_row = await wait_run(db, source_message_id)
|
||||
run_status = str(getattr(run_row, "status", "missing") or "missing") if run_row is not None else "missing"
|
||||
outbox_rows = await wait_outbox(db, source_message_id, run_status)
|
||||
processing = await fetch_processing(db, source_message_id)
|
||||
prompt_scene, candidates = await fetch_scene_from_call_logs(db, before_call_id, list(case.get("keys") or []))
|
||||
|
||||
sent = [r for r in outbox_rows if str(r.status or "") == "sent"]
|
||||
ack = str(sent[0].message_text or "").strip() if sent else ""
|
||||
final_reply = str(sent[-1].message_text or "").strip() if sent else ""
|
||||
if not final_reply:
|
||||
final_reply = str(capture.get("direct_reply_text") or "").strip()
|
||||
|
||||
inferred_scene = infer_scene_from_reply(final_reply)
|
||||
actual_scene = prompt_scene if prompt_scene != "unknown" else inferred_scene
|
||||
|
||||
passed, rule = evaluate(idx, actual_scene, final_reply, run_status)
|
||||
|
||||
return {
|
||||
"idx": idx,
|
||||
"source_message_id": source_message_id,
|
||||
"message": message,
|
||||
"expected_scene": str(case["expected_scene"]),
|
||||
"prompt_scene": prompt_scene,
|
||||
"inferred_scene": inferred_scene,
|
||||
"actual_scene": actual_scene,
|
||||
"capture_status": str(capture.get("status") or ""),
|
||||
"run_status": run_status,
|
||||
"ack": ack,
|
||||
"final_reply": final_reply,
|
||||
"has_capacity_warning": "⚠ 产能提示" in final_reply,
|
||||
"thinking_leak": has_thinking_leak(final_reply),
|
||||
"processing": {
|
||||
"route": str(getattr(processing, "route", "") or ""),
|
||||
"intent": str(getattr(processing, "intent", "") or ""),
|
||||
"status": str(getattr(processing, "processing_status", "") or ""),
|
||||
"duration_ms": int(getattr(processing, "duration_ms", 0) or 0),
|
||||
"error_message": str(getattr(processing, "error_message", "") or ""),
|
||||
"reply_source": str(getattr(processing, "reply_source", "") or ""),
|
||||
},
|
||||
"outbox": [
|
||||
{
|
||||
"id": int(r.id),
|
||||
"status": str(r.status or ""),
|
||||
"attempts": int(r.attempts or 0),
|
||||
"created_at": str(r.created_at or ""),
|
||||
"text": str(r.message_text or ""),
|
||||
}
|
||||
for r in outbox_rows
|
||||
],
|
||||
"call_log_candidates": candidates,
|
||||
"pass": passed,
|
||||
"pass_rule": rule,
|
||||
}
|
||||
|
||||
|
||||
async def main():
|
||||
token = login_token()
|
||||
if not token:
|
||||
raise RuntimeError("token empty")
|
||||
|
||||
results = []
|
||||
for case in CASES:
|
||||
print(f"[RUN] case {case['idx']} -> {case['message']}", flush=True)
|
||||
one = await run_case(token, case)
|
||||
print(f"[DONE] case {one['idx']} scene={one['actual_scene']} pass={one['pass']} run={one['run_status']}", flush=True)
|
||||
results.append(one)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
payload = {
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"chat_id": CHAT_ID,
|
||||
"results": results,
|
||||
}
|
||||
print("===RESULT_JSON_START===")
|
||||
print(json.dumps(payload, ensure_ascii=False))
|
||||
print("===RESULT_JSON_END===")
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
|
|
@ -0,0 +1,330 @@
|
|||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.database import AsyncSessionLocal
|
||||
|
||||
BASE = "http://127.0.0.1:8000"
|
||||
CHAT_ID = "oc_c487d87563e48c2f9566ccd5a758212c"
|
||||
SENDER = "P1验收机器人"
|
||||
|
||||
CASES = [
|
||||
{"idx": 1, "message": "杨宇现在负载多少", "expected_scene": "member_workload", "keys": ["杨宇", "负载"]},
|
||||
{"idx": 2, "message": "最近AI行业有什么动态", "expected_scene": "external_trend", "keys": ["AI行业", "行业", "动态", "趋势"]},
|
||||
{"idx": 3, "message": "帮我创建个项目叫路由验收测试,顺便问下现在有几个进行中的项目", "expected_scene": "mixed", "keys": ["顺便", "问题部分", "需求部分", "进行中的项目"]},
|
||||
{"idx": 4, "message": "AI劝退助手项目进展怎么样", "expected_scene": "default", "keys": ["AI劝退助手", "进展怎么样", "项目进展"]},
|
||||
{"idx": 5, "message": "帮我看看宠物行业最新动态", "expected_scene": "external_trend", "keys": ["宠物行业", "最新动态", "行业", "动态"]},
|
||||
]
|
||||
|
||||
BANNED_PREFIXES = ["让我先", "让我尝试", "我来看看", "我先查", "让我看看", "我需要先"]
|
||||
|
||||
|
||||
def login_token() -> str:
|
||||
data = urllib.parse.urlencode({"username": "admin", "password": "admin123"}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{BASE}/auth/login",
|
||||
data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
return str(payload.get("access_token") or "")
|
||||
|
||||
|
||||
def post_capture(token: str, message: str, source_message_id: str) -> dict[str, Any]:
|
||||
payload = {
|
||||
"group_chat_id": CHAT_ID,
|
||||
"message_content": message,
|
||||
"sender": SENDER,
|
||||
"feishu_message_id": source_message_id,
|
||||
}
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{BASE}/ai-steward/ai-steward/signal/capture-feishu",
|
||||
data=body,
|
||||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=240) as resp:
|
||||
text_body = resp.read().decode("utf-8")
|
||||
try:
|
||||
return json.loads(text_body)
|
||||
except Exception:
|
||||
return {"raw": text_body}
|
||||
|
||||
|
||||
def detect_prompt_scene(prompt: str) -> str:
|
||||
p = str(prompt or "")
|
||||
if "你是 PMFlow 的团队协作助手。" in p:
|
||||
return "member_workload"
|
||||
if "你是 PMFlow 的市场情报助手。" in p:
|
||||
return "external_trend"
|
||||
if "用户消息同时包含问题和需求。" in p:
|
||||
return "mixed"
|
||||
if "你是 PMFlow 的 AI 项目管理官。" in p:
|
||||
return "ai_pmo"
|
||||
if "用户通过飞书直接提问。" in p:
|
||||
return "feishu_direct"
|
||||
if "你是 PMFlow 的 RAG 问答助手" in p:
|
||||
return "default"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def infer_scene_from_reply(reply: str) -> str:
|
||||
t = str(reply or "")
|
||||
if "[回答]" in t and "[已收到需求]" in t:
|
||||
return "mixed"
|
||||
if "[行业动态]" in t and "[建议]" in t:
|
||||
return "external_trend"
|
||||
if ("负载" in t or "工作量" in t) and "%" in t and "项目列表" not in t:
|
||||
return "member_workload"
|
||||
if "完成率" in t:
|
||||
return "default"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def has_thinking_leak(reply: str) -> bool:
|
||||
for line in str(reply or "").splitlines():
|
||||
s = line.strip()
|
||||
if not s:
|
||||
continue
|
||||
if any(s.startswith(prefix) for prefix in BANNED_PREFIXES):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def get_max_ai_call_id() -> int:
|
||||
async with AsyncSessionLocal() as db:
|
||||
row = (
|
||||
await db.execute(text("SELECT COALESCE(MAX(id), 0) AS v FROM ai_call_logs"))
|
||||
).mappings().first()
|
||||
return int((row or {}).get("v") or 0)
|
||||
|
||||
|
||||
async def wait_run(source_message_id: str, timeout_sec: int = 600) -> dict[str, Any] | None:
|
||||
deadline = time.time() + timeout_sec
|
||||
last = None
|
||||
while time.time() < deadline:
|
||||
async with AsyncSessionLocal() as db:
|
||||
row = (
|
||||
await db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, status, trace_id, source_message_id, created_at, updated_at
|
||||
FROM ai_orchestration_runs
|
||||
WHERE source_message_id = :sid
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"sid": source_message_id},
|
||||
)
|
||||
).mappings().first()
|
||||
if row:
|
||||
last = dict(row)
|
||||
status = str(last.get("status") or "").lower()
|
||||
if status in {"succeeded", "failed", "cancelled", "waiting_manual"}:
|
||||
return last
|
||||
await asyncio.sleep(2)
|
||||
return last
|
||||
|
||||
|
||||
async def wait_outbox(source_message_id: str, expected_sent: int, timeout_sec: int = 300) -> list[dict[str, Any]]:
|
||||
deadline = time.time() + timeout_sec
|
||||
latest: list[dict[str, Any]] = []
|
||||
while time.time() < deadline:
|
||||
async with AsyncSessionLocal() as db:
|
||||
rows = (
|
||||
await db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, status, attempts, message_text, created_at
|
||||
FROM feishu_reply_outbox
|
||||
WHERE source_message_id = :sid
|
||||
ORDER BY id ASC
|
||||
"""
|
||||
),
|
||||
{"sid": source_message_id},
|
||||
)
|
||||
).mappings().all()
|
||||
latest = [dict(r) for r in rows]
|
||||
sent_count = sum(1 for r in latest if str(r.get("status") or "") == "sent")
|
||||
if sent_count >= expected_sent:
|
||||
return latest
|
||||
await asyncio.sleep(2)
|
||||
return latest
|
||||
|
||||
|
||||
async def fetch_processing(source_message_id: str) -> dict[str, Any]:
|
||||
async with AsyncSessionLocal() as db:
|
||||
row = (
|
||||
await db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, route, intent, processing_status, duration_ms, error_message, reply_source, created_at
|
||||
FROM ai_processing_logs
|
||||
WHERE feishu_message_id = :sid
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"sid": source_message_id},
|
||||
)
|
||||
).mappings().first()
|
||||
return dict(row or {})
|
||||
|
||||
|
||||
async def fetch_prompt_scene(min_id: int, keys: list[str]) -> tuple[str, list[dict[str, Any]]]:
|
||||
async with AsyncSessionLocal() as db:
|
||||
rows = (
|
||||
await db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, function_name, prompt, created_at
|
||||
FROM ai_call_logs
|
||||
WHERE id > :min_id
|
||||
AND function_name IN ('adaptive_rag_answer_normal', 'adaptive_rag_answer_deep')
|
||||
ORDER BY id ASC
|
||||
LIMIT 400
|
||||
"""
|
||||
),
|
||||
{"min_id": min_id},
|
||||
)
|
||||
).mappings().all()
|
||||
|
||||
lowered = [str(k).lower() for k in (keys or []) if str(k).strip()]
|
||||
picked = "unknown"
|
||||
candidates: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
prompt = str(r.get("prompt") or "")
|
||||
pl = prompt.lower()
|
||||
if lowered and not any(k in pl for k in lowered):
|
||||
continue
|
||||
scene = detect_prompt_scene(prompt)
|
||||
candidates.append(
|
||||
{
|
||||
"id": int(r.get("id") or 0),
|
||||
"scene": scene,
|
||||
"function_name": str(r.get("function_name") or ""),
|
||||
"created_at": str(r.get("created_at") or ""),
|
||||
}
|
||||
)
|
||||
if scene != "unknown":
|
||||
picked = scene
|
||||
|
||||
if picked == "unknown" and candidates:
|
||||
picked = str(candidates[-1].get("scene") or "unknown")
|
||||
return picked, candidates
|
||||
|
||||
|
||||
def evaluate(case_idx: int, scene: str, reply: str, run_status: str) -> tuple[bool, str]:
|
||||
leak = has_thinking_leak(reply)
|
||||
if case_idx == 1:
|
||||
ok = scene == "member_workload" and "%" in reply and "项目列表" not in reply and not leak
|
||||
return ok, "member_workload+百分比+无项目列表"
|
||||
if case_idx == 2:
|
||||
sec = "[行业动态]" in reply and ("[对项目影响]" in reply or "[对项目的影响]" in reply) and "[建议]" in reply
|
||||
src = "未接入实时数据" in reply or "训练数据" in reply or "数据来源" in reply
|
||||
ok = scene == "external_trend" and sec and src and not leak
|
||||
return ok, "external_trend+三段+来源限制"
|
||||
if case_idx == 3:
|
||||
mixed = "[回答]" in reply and "[已收到需求]" in reply
|
||||
prog = "进行中" in reply
|
||||
ok = scene == "mixed" and run_status == "succeeded" and mixed and prog and not leak
|
||||
return ok, "mixed+创建+进行中数量"
|
||||
if case_idx == 4:
|
||||
ok = scene == "default" and "完成率" in reply and "%" in reply and not leak
|
||||
return ok, "default+完成率"
|
||||
if case_idx == 5:
|
||||
ok = scene == "external_trend" and not leak
|
||||
return ok, "external_trend+无思考外漏"
|
||||
return False, "unknown"
|
||||
|
||||
|
||||
async def run_case(token: str, case: dict[str, Any]) -> dict[str, Any]:
|
||||
idx = int(case["idx"])
|
||||
source_message_id = f"real-scene-p1-v4-{idx}-{int(time.time())}"
|
||||
message = str(case["message"])
|
||||
|
||||
before_call_id = await get_max_ai_call_id()
|
||||
capture = post_capture(token, message, source_message_id)
|
||||
|
||||
run_row = await wait_run(source_message_id)
|
||||
run_status = str((run_row or {}).get("status") or "missing")
|
||||
expected_sent = 2 if run_status == "succeeded" else 1
|
||||
outbox_rows = await wait_outbox(source_message_id, expected_sent)
|
||||
|
||||
sent = [r for r in outbox_rows if str(r.get("status") or "") == "sent"]
|
||||
ack = str(sent[0].get("message_text") or "").strip() if sent else ""
|
||||
final_reply = str(sent[-1].get("message_text") or "").strip() if sent else ""
|
||||
if not final_reply:
|
||||
final_reply = str(capture.get("direct_reply_text") or "").strip()
|
||||
|
||||
processing = await fetch_processing(source_message_id)
|
||||
prompt_scene, candidates = await fetch_prompt_scene(before_call_id, list(case.get("keys") or []))
|
||||
inferred_scene = infer_scene_from_reply(final_reply)
|
||||
actual_scene = prompt_scene if prompt_scene != "unknown" else inferred_scene
|
||||
passed, pass_rule = evaluate(idx, actual_scene, final_reply, run_status)
|
||||
|
||||
return {
|
||||
"idx": idx,
|
||||
"source_message_id": source_message_id,
|
||||
"message": message,
|
||||
"expected_scene": str(case["expected_scene"]),
|
||||
"capture_status": str(capture.get("status") or ""),
|
||||
"run_status": run_status,
|
||||
"prompt_scene": prompt_scene,
|
||||
"inferred_scene": inferred_scene,
|
||||
"actual_scene": actual_scene,
|
||||
"ack": ack,
|
||||
"final_reply": final_reply,
|
||||
"thinking_leak": has_thinking_leak(final_reply),
|
||||
"has_capacity_warning": "⚠ 产能提示" in final_reply,
|
||||
"processing": {
|
||||
"route": str(processing.get("route") or ""),
|
||||
"intent": str(processing.get("intent") or ""),
|
||||
"status": str(processing.get("processing_status") or ""),
|
||||
"duration_ms": int(processing.get("duration_ms") or 0),
|
||||
"error_message": str(processing.get("error_message") or ""),
|
||||
"reply_source": str(processing.get("reply_source") or ""),
|
||||
},
|
||||
"outbox": outbox_rows,
|
||||
"call_log_candidates": candidates,
|
||||
"pass": passed,
|
||||
"pass_rule": pass_rule,
|
||||
}
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
token = login_token()
|
||||
if not token:
|
||||
raise RuntimeError("token empty")
|
||||
|
||||
results = []
|
||||
for case in CASES:
|
||||
print(f"[RUN] case {case['idx']} -> {case['message']}", flush=True)
|
||||
one = await run_case(token, case)
|
||||
print(
|
||||
f"[DONE] case {one['idx']} scene={one['actual_scene']} pass={one['pass']} run={one['run_status']} leak={one['thinking_leak']}",
|
||||
flush=True,
|
||||
)
|
||||
results.append(one)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
payload = {
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"chat_id": CHAT_ID,
|
||||
"results": results,
|
||||
}
|
||||
print("===RESULT_JSON_START===")
|
||||
print(json.dumps(payload, ensure_ascii=False))
|
||||
print("===RESULT_JSON_END===")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
|
@ -0,0 +1,291 @@
|
|||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models import AICallLog, AIOrchestrationRun, AIProcessingLog, FeishuReplyOutbox
|
||||
|
||||
BASE = "http://127.0.0.1:8000"
|
||||
CHAT_ID = "oc_c487d87563e48c2f9566ccd5a758212c"
|
||||
SENDER = "P1验收机器人"
|
||||
BANNED_PREFIXES = ["让我先", "让我尝试", "我来看看", "我先查", "让我看看", "我需要先"]
|
||||
|
||||
|
||||
def login_token() -> str:
|
||||
data = urllib.parse.urlencode({"username": "admin", "password": "admin123"}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{BASE}/auth/login",
|
||||
data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
return str(payload.get("access_token") or "")
|
||||
|
||||
|
||||
def post_capture(token: str, message: str, source_message_id: str) -> dict[str, Any]:
|
||||
payload = {
|
||||
"group_chat_id": CHAT_ID,
|
||||
"message_content": message,
|
||||
"sender": SENDER,
|
||||
"feishu_message_id": source_message_id,
|
||||
}
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{BASE}/ai-steward/ai-steward/signal/capture-feishu",
|
||||
data=body,
|
||||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
text = resp.read().decode("utf-8")
|
||||
try:
|
||||
return json.loads(text)
|
||||
except Exception:
|
||||
return {"raw": text}
|
||||
|
||||
|
||||
def detect_prompt_scene(prompt: str) -> Optional[str]:
|
||||
p = str(prompt or "")
|
||||
if "你是 PMFlow 的团队协作助手。" in p:
|
||||
return "member_workload"
|
||||
if "你是 PMFlow 的市场情报助手。" in p:
|
||||
return "external_trend"
|
||||
if "用户消息同时包含问题和需求。" in p:
|
||||
return "mixed"
|
||||
if "你是 PMFlow 的 AI 项目管理官。" in p:
|
||||
return "ai_pmo"
|
||||
if "用户通过飞书直接提问。" in p:
|
||||
return "feishu_direct"
|
||||
if "你是 PMFlow 的 RAG 问答助手" in p:
|
||||
return "default"
|
||||
return None
|
||||
|
||||
|
||||
def infer_scene_from_reply(reply: str) -> str:
|
||||
t = str(reply or "")
|
||||
if "[回答]" in t and "[已收到需求]" in t:
|
||||
return "mixed"
|
||||
if "[行业动态]" in t and "[建议]" in t:
|
||||
return "external_trend"
|
||||
if ("负载" in t or "工作量" in t) and "%" in t and "项目列表" not in t:
|
||||
return "member_workload"
|
||||
if "完成率" in t:
|
||||
return "default"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def has_thinking_leak(text: str) -> bool:
|
||||
for line in str(text or "").splitlines():
|
||||
s = line.strip()
|
||||
if not s:
|
||||
continue
|
||||
if any(s.startswith(prefix) for prefix in BANNED_PREFIXES):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def fetch_max_ai_call_id(db) -> int:
|
||||
row = (await db.execute(select(func.max(AICallLog.id)))).scalar()
|
||||
return int(row or 0)
|
||||
|
||||
|
||||
async def wait_run(db, source_message_id: str, timeout_sec: int = 720) -> Optional[AIOrchestrationRun]:
|
||||
deadline = time.time() + timeout_sec
|
||||
last = None
|
||||
while time.time() < deadline:
|
||||
row = (
|
||||
await db.execute(
|
||||
select(AIOrchestrationRun)
|
||||
.where(AIOrchestrationRun.source_message_id == source_message_id)
|
||||
.order_by(AIOrchestrationRun.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is not None:
|
||||
last = row
|
||||
st = str(row.status or "").lower()
|
||||
if st in {"succeeded", "failed", "cancelled"}:
|
||||
return row
|
||||
await asyncio.sleep(2)
|
||||
return last
|
||||
|
||||
|
||||
async def wait_outbox(db, source_message_id: str, run_status: str, timeout_sec: int = 300) -> list[FeishuReplyOutbox]:
|
||||
deadline = time.time() + timeout_sec
|
||||
expected = 2 if run_status == "succeeded" else 1
|
||||
latest: list[FeishuReplyOutbox] = []
|
||||
while time.time() < deadline:
|
||||
latest = (
|
||||
await db.execute(
|
||||
select(FeishuReplyOutbox)
|
||||
.where(FeishuReplyOutbox.source_message_id == source_message_id)
|
||||
.order_by(FeishuReplyOutbox.id.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
sent = [r for r in latest if str(r.status or "") == "sent"]
|
||||
if len(sent) >= expected:
|
||||
return latest
|
||||
await asyncio.sleep(2)
|
||||
return latest
|
||||
|
||||
|
||||
async def fetch_processing(db, source_message_id: str) -> Optional[AIProcessingLog]:
|
||||
return (
|
||||
await db.execute(
|
||||
select(AIProcessingLog)
|
||||
.where(AIProcessingLog.feishu_message_id == source_message_id)
|
||||
.order_by(AIProcessingLog.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
async def fetch_prompt_scene(db, min_call_id: int, keys: list[str]) -> tuple[str, list[dict[str, Any]]]:
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(AICallLog)
|
||||
.where(
|
||||
AICallLog.id > min_call_id,
|
||||
AICallLog.function_name.in_(["adaptive_rag_answer_normal", "adaptive_rag_answer_deep"]),
|
||||
)
|
||||
.order_by(AICallLog.id.asc())
|
||||
.limit(200)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
lowered = [k.lower() for k in keys if k]
|
||||
picked = "unknown"
|
||||
candidates: list[dict[str, Any]] = []
|
||||
|
||||
for row in rows:
|
||||
prompt = str(row.prompt or "")
|
||||
pl = prompt.lower()
|
||||
if lowered and not any(k in pl for k in lowered):
|
||||
continue
|
||||
scene = detect_prompt_scene(prompt)
|
||||
candidates.append(
|
||||
{
|
||||
"id": int(row.id),
|
||||
"scene": scene or "unknown",
|
||||
"function": str(row.function_name or ""),
|
||||
"created_at": str(row.created_at or ""),
|
||||
}
|
||||
)
|
||||
if scene:
|
||||
picked = scene
|
||||
|
||||
if picked == "unknown" and candidates:
|
||||
picked = str(candidates[-1].get("scene") or "unknown")
|
||||
|
||||
return picked, candidates
|
||||
|
||||
|
||||
def evaluate(idx: int, actual_scene: str, final_reply: str, run_status: str) -> tuple[bool, str]:
|
||||
leak = has_thinking_leak(final_reply)
|
||||
if idx == 1:
|
||||
ok = actual_scene == "member_workload" and "%" in final_reply and "项目列表" not in final_reply and not leak
|
||||
return ok, "member_workload+百分比+无项目列表"
|
||||
if idx == 2:
|
||||
sec = "[行业动态]" in final_reply and ("[对项目影响]" in final_reply or "[对项目的影响]" in final_reply) and "[建议]" in final_reply
|
||||
src = "未接入实时数据" in final_reply or "训练数据" in final_reply or "数据来源" in final_reply
|
||||
ok = actual_scene == "external_trend" and sec and src and not leak
|
||||
return ok, "external_trend+三段输出+来源限制"
|
||||
if idx == 3:
|
||||
mixed = "[回答]" in final_reply and "[已收到需求]" in final_reply
|
||||
prog = "进行中" in final_reply
|
||||
ok = actual_scene == "mixed" and run_status == "succeeded" and mixed and prog and not leak
|
||||
return ok, "mixed+创建成功+进行中数量"
|
||||
if idx == 4:
|
||||
ok = actual_scene == "default" and "完成率" in final_reply and "%" in final_reply and not leak
|
||||
return ok, "default+项目状态完成率"
|
||||
if idx == 5:
|
||||
ok = actual_scene == "external_trend" and not leak
|
||||
return ok, "external_trend+无思考外漏"
|
||||
return False, "unknown"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--idx", type=int, required=True)
|
||||
parser.add_argument("--message", type=str, required=True)
|
||||
parser.add_argument("--keys", type=str, default="")
|
||||
parser.add_argument("--prefix", type=str, default="real-scene-p1-manual")
|
||||
args = parser.parse_args()
|
||||
|
||||
keys = [k.strip() for k in str(args.keys).split("|") if k.strip()]
|
||||
source_message_id = f"{args.prefix}-{args.idx}-{int(time.time())}"
|
||||
|
||||
token = login_token()
|
||||
if not token:
|
||||
raise RuntimeError("token empty")
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
before_call_id = await fetch_max_ai_call_id(db)
|
||||
|
||||
capture = post_capture(token, args.message, source_message_id)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
run_row = await wait_run(db, source_message_id)
|
||||
run_status = str(getattr(run_row, "status", "missing") or "missing") if run_row is not None else "missing"
|
||||
outbox_rows = await wait_outbox(db, source_message_id, run_status)
|
||||
processing = await fetch_processing(db, source_message_id)
|
||||
prompt_scene, candidates = await fetch_prompt_scene(db, before_call_id, keys)
|
||||
|
||||
sent_rows = [r for r in outbox_rows if str(r.status or "") == "sent"]
|
||||
ack = str(sent_rows[0].message_text or "").strip() if sent_rows else ""
|
||||
final_reply = str(sent_rows[-1].message_text or "").strip() if sent_rows else ""
|
||||
if not final_reply:
|
||||
final_reply = str(capture.get("direct_reply_text") or "").strip()
|
||||
|
||||
inferred_scene = infer_scene_from_reply(final_reply)
|
||||
actual_scene = prompt_scene if prompt_scene != "unknown" else inferred_scene
|
||||
passed, pass_rule = evaluate(args.idx, actual_scene, final_reply, run_status)
|
||||
|
||||
payload = {
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"idx": args.idx,
|
||||
"source_message_id": source_message_id,
|
||||
"message": args.message,
|
||||
"capture_status": str(capture.get("status") or ""),
|
||||
"run_status": run_status,
|
||||
"ack": ack,
|
||||
"final_reply": final_reply,
|
||||
"prompt_scene": prompt_scene,
|
||||
"inferred_scene": inferred_scene,
|
||||
"actual_scene": actual_scene,
|
||||
"thinking_leak": has_thinking_leak(final_reply),
|
||||
"has_capacity_warning": "⚠ 产能提示" in final_reply,
|
||||
"processing": {
|
||||
"route": str(getattr(processing, "route", "") or ""),
|
||||
"intent": str(getattr(processing, "intent", "") or ""),
|
||||
"status": str(getattr(processing, "processing_status", "") or ""),
|
||||
"duration_ms": int(getattr(processing, "duration_ms", 0) or 0),
|
||||
"error_message": str(getattr(processing, "error_message", "") or ""),
|
||||
"reply_source": str(getattr(processing, "reply_source", "") or ""),
|
||||
},
|
||||
"outbox": [
|
||||
{
|
||||
"id": int(r.id),
|
||||
"status": str(r.status or ""),
|
||||
"attempts": int(r.attempts or 0),
|
||||
"created_at": str(r.created_at or ""),
|
||||
"text": str(r.message_text or ""),
|
||||
}
|
||||
for r in outbox_rows
|
||||
],
|
||||
"call_log_candidates": candidates,
|
||||
"pass": passed,
|
||||
"pass_rule": pass_rule,
|
||||
}
|
||||
print(json.dumps(payload, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
# PMFlow 开发 Agent Prompt
|
||||
|
||||
你是一位资深的全栈工程师,精通 Python 3.11+、FastAPI、SQLAlchemy 2.0、LangChain、React 18、TypeScript、shadcn/ui、Tailwind CSS。
|
||||
|
||||
你正在为 **PMFlow** 开发代码:这是一款 AI 原生、人员能力驱动的项目管理操作系统,专为 10-50 人 AI 开发团队打造。
|
||||
|
||||
---
|
||||
|
||||
## USER.md 边界
|
||||
|
||||
- 本项目内用户真源以 `USER.md` 为准;若 `~/.codex/AGENTS.md` 与本项目 `USER.md` 冲突,以 `USER.md` 为准。
|
||||
- `USER.md` 承载本项目内的用户协作偏好、判断优先级、已拍板决策、反复踩坑与暂不处理问题。
|
||||
- 工程硬约束、系统边界、安全规则与验证要求仍以本文件和更外层提示为准。
|
||||
- 执行时先满足硬约束,再在不违背硬约束的前提下尽量贴合 `USER.md`。
|
||||
|
||||
---
|
||||
|
||||
## 核心开发规范
|
||||
|
||||
1. **代码质量**:所有代码严格遵循 Python 与 TypeScript 最佳实践,代码清晰、注释完整、类型安全、可维护性强。
|
||||
|
||||
2. **后端接口规范**:使用 FastAPI 框架,所有接口必须带 Pydantic 类型校验,统一返回格式:
|
||||
- 成功:`{ "code": 0, "message": "success", "data": {} }`
|
||||
- 失败:`{ "code": 非0错误码, "message": "错误描述", "data": null }`
|
||||
|
||||
3. **数据库规范**:使用 SQLAlchemy 2.0,所有模型继承 Base 基类,必须包含 `tenant_id` 字段,所有查询必须自动过滤 `tenant_id`(使用依赖注入实现)。
|
||||
|
||||
4. **AI 能力规范**:
|
||||
- 所有 AI 能力必须封装在独立的 service 层
|
||||
- AI 能力通过 PMFlow 本地路由层调用
|
||||
- 路由规则参考 `backend/.control_plane/pmflow-routing-rules.json`
|
||||
- Prompt 统一由 `./backend/app/config/ai_prompts.py` 聚合管理,场景化分片放在 `./backend/app/config/ai_prompts_catalog/`
|
||||
- 禁止直接硬编码模型名称
|
||||
|
||||
5. **AI 调用规范**:所有 AI 调用必须:
|
||||
- 记录日志到 `ai_call_logs` 表
|
||||
- 实现 Redis 结果缓存
|
||||
- 完善的错误重试机制
|
||||
- 降低成本
|
||||
|
||||
6. **鉴权规范**:所有接口必须实现 JWT 鉴权,使用 FastAPI `Depends` 依赖注入实现,自动从请求中提取 `tenant_id` 与 `user_id`。
|
||||
|
||||
7. **工程规范**:
|
||||
- 完善的错误处理
|
||||
- 结构化日志(structlog)
|
||||
- 从第一天就支持多租户、可扩展性
|
||||
- 模块化设计
|
||||
|
||||
8. **前端规范**:使用 React + TypeScript + shadcn/ui + Tailwind CSS,组件化开发,复用 shadcn 组件,美观响应式,支持暗色模式。
|
||||
|
||||
9. **核心约束**:人员能力图谱是 AI 智能任务分配的核心,所有任务拆解、分配逻辑必须优先调用该模块数据。
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
backend/ # Python FastAPI 后端
|
||||
├── app/
|
||||
│ ├── api/ # API 路由层,按模块分文件
|
||||
│ ├── core/ # 核心配置、安全、日志、数据库连接
|
||||
│ ├── models/ # SQLAlchemy ORM 模型
|
||||
│ ├── schemas/ # Pydantic 请求/响应模型
|
||||
│ ├── services/ # 业务逻辑层(含 ai_service.py、skill_service.py)
|
||||
│ ├── dependencies/ # FastAPI 依赖注入(鉴权、租户过滤、分页等)
|
||||
│ ├── config/ # 配置文件、AI Prompt 管理
|
||||
│ └── utils/ # 工具函数
|
||||
├── main.py # 项目入口
|
||||
├── pyproject.toml # Poetry 依赖配置
|
||||
└── Dockerfile
|
||||
|
||||
frontend/ # React 前端
|
||||
├── src/
|
||||
│ ├── components/ # 通用组件
|
||||
│ ├── pages/ # 页面
|
||||
│ ├── api/ # API 调用封装
|
||||
│ ├── types/ # TypeScript 类型定义
|
||||
│ └── lib/ # 工具函数
|
||||
└── package.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 执行要求
|
||||
|
||||
根据用户的具体需求,生成高质量、可直接运行的代码。
|
||||
|
||||
- 计划类需求执行约定:若用户已确认计划/方案并要求开始实现,Agent 默认连续执行到“实现完成 + 验证完成 + 给出验收结果”后再统一回复;除非遇到明确阻塞、高风险冲突、context 限制或用户主动中断,否则不得中途停下来反复请示。
|
||||
|
||||
---
|
||||
|
||||
## 8. 服务边界约束与 AI 系统宪章(禁止越界)
|
||||
|
||||
- 数据库写操作必须通过 Alembic migration,禁止直接 `ALTER TABLE`
|
||||
- 新增接口必须遵循响应包络:`{code, message, data}`,不得裸返回
|
||||
- 禁止在 API 层写业务逻辑,业务逻辑归属 `services/`
|
||||
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
|
||||
- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState`
|
||||
- 用户可见核心业务内容零兜底:AI 失败、证据不足、结构化失败时必须显式失败或标记待确认,禁止用默认句、模板句、stub 冒充成功。
|
||||
- 业务语义、问答/执行归因与前台动作拍板必须优先走“对象/上下文绑定 -> `SemanticContextPacket` -> `SemanticDecision` -> gatekeeper”主链,禁止在 `backend/app` 运行时代码里恢复 `looks_like_*`、关键词仓或散落正则拍板。
|
||||
- `llm_service` 运行时必须显式传入 `routing_meta`;禁止再通过函数名片段、hint 常量或模板词在底层推断 tier、结构化输出或 fallback 策略。
|
||||
- 显式 `reply/thread`、卡片动作和 `work_item_id` 属于更强锚点,必须优先于旧 session 和记忆续写。
|
||||
- 进入 `requirement_plan` / draft confirm 的线程必须留在对应主链,禁止掉回 `query.answer`;其中 draft confirm 只允许显式 `reply/thread`、卡片按钮或 `work_item_id` 进入,禁止无锚点按“同群/同人”回捞旧 pending draft。若同 scope 旧 plan/draft 已被判定污染,运行时必须先终止该 scope 内的 active topic/work item,再回退到 `query.answer`。选项协议回复必须复用单点结构化解析器,不得散落关键词/正则。
|
||||
- Feishu 显式 `reply/thread` 到机器人消息时,必须先通过 `feishu_reply_outbox.sent_message_id -> source_message_id` 归一化 canonical thread context,再进入后续主链。
|
||||
- 外部 freshness 问答的 follow-up 若锚定上一轮“未接入实时外部数据/无法确认最新结论”的 QA 回复,默认继续留在 `query.answer` 聊天链并先尝试实时外部检索;只有用户显式点明系统/项目/配置等内部对象并提出接入动作时,才允许落入内部接入/入库流程。
|
||||
- `MountedDraftWorkspace.requirement_card` 是 requirement-plan 唯一业务真源;assistant prose、topic facts、run 文案只可审计,不可反推业务字段。
|
||||
- `project_create.create / followup.confirm` 的任务拆解是从属子步骤:运行时必须先 low-first 结构化生成,必要时升级 high;仍失败则显式记为 `pending`,禁止冒充成功。
|
||||
- 运行时长清单、Feishu / requirement-plan / AdaptiveRAG 细则、已知边界与假设,统一以 `docs/architecture/RUNTIME_GUARDRAILS.md` 为准。
|
||||
|
||||
## 9. 新增代码的参考模式
|
||||
|
||||
- 新增后端接口:参考 `backend/app/api/sstc_api/` 的结构
|
||||
- 新增前端 API 调用:参考 `frontend/src/api/sstc.ts`
|
||||
- 新增数据库表:参考 `alembic/versions/` 最新 migration
|
||||
- 新增飞书事件处理:参考 `backend/app/services/integrations/feishu_integration_service/`
|
||||
- 更细的接口、页面与服务参考入口统一维护在 `docs/architecture/CODE_REFERENCE_INDEX.md`
|
||||
|
||||
## 10. 幂等与事务约束
|
||||
|
||||
- 所有入站事件必须经过 `ingest_events` 幂等去重
|
||||
- 飞书回复必须经过 `feishu_reply_outbox`,禁止同步直接发送
|
||||
- 跨表写操作必须在同一个数据库事务内完成
|
||||
|
||||
## 11. 已知 BOUNDARY(不要幻构行为)
|
||||
|
||||
- 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用
|
||||
- `agents.defaults.timeoutSeconds` 在 PMFlow 本地路由层 网关行为未验证,当前以 `LLM_REQUEST_TIMEOUT` 环境变量为准
|
||||
- World Model 静态层已接入 Redis 缓存(key: `wm:static:{tenant_id}:{project_id}`,TTL 600s)
|
||||
- 写操作后主动 invalidate,动态层不缓存
|
||||
|
||||
## 12. 前端组件约束
|
||||
|
||||
- `ui/` 目录下组件禁止修改,仅使用
|
||||
- `frontend/src/components/sstc/` 目录已模块化拆分,新增文件与职责如下:
|
||||
- `SSTCCharterStagePanel.tsx`:宪章阶段主面板(内容编辑、版本/状态展示)
|
||||
- `SSTCExecutionBoardPanel.tsx`:执行看板阶段主面板(执行视图聚合)
|
||||
- `SSTCReleaseCreateDialog.tsx`:发布创建弹窗
|
||||
- `SSTCReleaseStagePanel.tsx`:发布阶段主面板(发布列表与校验项)
|
||||
- `SSTCReviewCreateDialog.tsx`:复盘创建弹窗
|
||||
- `SSTCReviewStagePanel.tsx`:复盘阶段主面板(复盘内容与改进项)
|
||||
- `SSTCSignalStagePanel.tsx`:信号阶段主面板(信号录入/状态流转)
|
||||
- `SSTCTaskCreateDialog.tsx`:任务创建弹窗
|
||||
- `SSTCTaskStagePanel.tsx`:任务阶段主面板(任务列表、筛选、状态流转)
|
||||
- `sstcUtils.ts`:SSTC 跨阶段工具函数与转换逻辑
|
||||
- `useSSTCWorkspaceOrchestration.ts`:SSTC 工作区编排 Hook(状态聚合、事件编排、数据装配)
|
||||
- 修改前必须执行 `FIND_USAGE` 的组件/模块清单(强制):
|
||||
- `SSTCWorkspace.tsx`
|
||||
- `useSSTCWorkspaceOrchestration.ts`
|
||||
- `sstcUtils.ts`
|
||||
- `SSTCCharterStagePanel.tsx`
|
||||
- `SSTCSignalStagePanel.tsx`
|
||||
- `SSTCTaskStagePanel.tsx`
|
||||
- `SSTCExecutionBoardPanel.tsx`
|
||||
- `SSTCReleaseStagePanel.tsx`
|
||||
- `SSTCReviewStagePanel.tsx`
|
||||
- `SSTCReleaseCreateDialog.tsx`
|
||||
- `SSTCReviewCreateDialog.tsx`
|
||||
- `SSTCTaskCreateDialog.tsx`
|
||||
- 新增业务组件放入对应功能目录,禁止在 `pages/` 内写内联组件
|
||||
- 样式只用 Tailwind utility class,禁止内联 `style`
|
||||
|
||||
## 13. 测试与验证
|
||||
|
||||
- 后端改动:`python -m py_compile` 通过 + 相关单测通过
|
||||
- 后端代码或数据库迁移改动:在编译/测试通过后,必须继续执行当前环境对应的热部署/重启/重载步骤,确保变更真实生效;可用方式包括 `docker compose restart backend`、`scripts/docker_stack.sh restart|dev-restart`、`systemctl restart neural-sync`、`nginx -s reload` 等。若当前环境不是部署机、权限不足或不具备热部署条件,必须明确说明未执行原因与阻塞点,禁止默认跳过
|
||||
- 前端改动:`npm run build` 通过 + `tsc --noEmit` 零错误
|
||||
- 新增接口:必须在 `backend/tests/` 补充对应单测
|
||||
|
||||
## 14. 文档同步规则(强制)
|
||||
|
||||
每次任务完成后,若以下内容发生变化,必须同步更新对应文档(`CLAUDE.md`、`docs/architecture/ARCHITECTURE_INVENTORY.md`、`docs/architecture/RUNTIME_GUARDRAILS.md`、`docs/architecture/CODE_REFERENCE_INDEX.md`):
|
||||
|
||||
- 新增/删除/重命名文件或目录
|
||||
→ 更新 `CLAUDE.md` 第3-5节目录结构,并同步更新 `docs/architecture/ARCHITECTURE_INVENTORY.md`
|
||||
- 新增/修改服务边界或禁止操作
|
||||
→ 更新 `AGENTS.md` 第8节摘要规则,并同步更新 `docs/architecture/RUNTIME_GUARDRAILS.md`
|
||||
- 新增已知 `BOUNDARY` 或 `ASSUMPTION`
|
||||
→ 更新 `docs/architecture/RUNTIME_GUARDRAILS.md`
|
||||
- 新增/修改 API 接口
|
||||
→ 更新 `docs/architecture/CODE_REFERENCE_INDEX.md`,必要时同步 `CLAUDE.md` 摘要索引
|
||||
|
||||
文档更新必须在 `[Task:Done]` 之前完成。
|
||||
未更新文档的任务视为未完成。
|
||||
|
||||
## 15. 反迎合约束(强制)
|
||||
|
||||
- 若用户方案存在明显根本风险,必须先直接指出风险,再继续执行。
|
||||
- 若存在更优方案且后果并不显而易见,必须简短说明更优路径与取舍;用户确认坚持后可继续执行原方案。
|
||||
|
||||
## 16. Context 管理约定
|
||||
|
||||
- 大功能开发或结构性重构,单任务涉及文件超过5个时优先拆分为独立子任务;缺陷修复/回归修复允许围绕同一根因跨相关文件端到端完成
|
||||
- 单任务预估改动超过200行 → 拆分执行
|
||||
- 发生 context 不足时 → 停止并输出已完成部分
|
||||
声明 `[CONTEXT: LIMIT]`,不要截断输出
|
||||
|
||||
|
||||
## 安全约束(必须严格遵守)
|
||||
|
||||
### 🚫 绝对禁止的操作:
|
||||
- 禁止未确认的破坏性删除;只有在用户明确要求删除死代码/死文件、已先列出目标路径并说明后果、且用户再次确认后,才允许执行文件删除
|
||||
- 禁止执行带 -rf、--force、-y 参数的命令
|
||||
- 禁止 DROP TABLE、TRUNCATE 等数据库破坏性操作
|
||||
- 禁止修改 /etc、/sys、/boot 等系统目录
|
||||
|
||||
### ✅ 文件操作前必须:
|
||||
1. 仅在破坏性删除、不可逆改名或其它高风险文件操作前,先列出将要操作的文件路径
|
||||
2. 明确告知用户操作后果
|
||||
3. 等待用户输入"确认"后才能执行
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
# PMFlow 项目结构说明(CLAUDE.md)
|
||||
|
||||
## 1. Agent 入口文件(你关心的“进项目先读什么”)
|
||||
- `AGENTS.md`:Codex/Coding Agent 的主规则入口(优先级最高的项目级约束)
|
||||
- `CLAUDE.md`:面向 Claude 风格的项目说明文档(补充背景)
|
||||
- 建议:规则放 `AGENTS.md`,架构/目录说明放 `CLAUDE.md`
|
||||
- 当前 AI 行为的系统宪章已在 `AGENTS.md` 中升级为“事实层 / 评审层 / 生成层 / 执行层”四层分治;所有新增 AI 业务默认按该宪章接入。`CLAUDE.md` 当前承载的是“架构说明 + 运行边界 + 目录库存”,不再假定自己只包含背景说明
|
||||
- 新增执行约定:计划类需求一旦确认进入实现阶段,默认连续执行到“实现完成 + 验证完成 + 统一回复验收结果”;仅在明确阻塞、高风险冲突、context 限制或用户主动中断时才中途打断。
|
||||
- 新增 bugfix 执行约定:缺陷修复/回归修复允许跨多个直接相关文件端到端完成,不再按文件数硬性拆分;但必须同时满足“改动范围围绕同一根因”“不夹带无关重构”“最终附带编译/测试验证”。大功能开发或结构性重构仍优先按子任务拆分。
|
||||
- 新增发布约定:凡后端代码或数据库迁移发生改动,完成编译/测试后必须继续执行当前环境对应的热部署/重启/重载步骤,确认服务已加载新版本;若当前机器不是部署环境、缺少权限或不具备热部署条件,必须在结果中明确说明未执行原因与阻塞点,禁止默认省略。
|
||||
- 下面各节中大量 `新增/更新/移除` 清单用于保留“当前架构库存”和关键入口索引,不等同于运行时主规范;行为约束与禁止项仍以 `AGENTS.md` 为准。
|
||||
|
||||
## 2. 项目定位
|
||||
- 项目名:`PMFlow / Neural Sync`
|
||||
- 形态:AI 原生项目管理系统(多租户 + 角色权限 + SSTC 工作流)
|
||||
- 技术栈:
|
||||
- 后端:`FastAPI + SQLAlchemy + Alembic`
|
||||
- 前端:`React + TypeScript + Vite + Tailwind + Radix + React Query`
|
||||
|
||||
## 3. 顶层目录(精简版)
|
||||
```text
|
||||
AIProjectManger/
|
||||
├── AGENTS.md # Agent 执行规则(建议优先维护)
|
||||
├── CLAUDE.md # 当前架构说明与运行边界
|
||||
├── start.sh / stop.sh # 一键启动/停止入口
|
||||
├── docker-compose*.yml # 容器编排
|
||||
├── mdproject/ # 执行计划与任务清单(中文为主)
|
||||
├── backend/ # FastAPI 后端
|
||||
├── frontend/ # React 前端
|
||||
├── scripts/ # 根目录运维脚本
|
||||
└── docs/ # 额外文档
|
||||
```
|
||||
- 详细架构库存已迁到 [`docs/architecture/ARCHITECTURE_INVENTORY.md`](/Users/yuuuu/Documents/AIProjectManger/docs/architecture/ARCHITECTURE_INVENTORY.md)。
|
||||
- `docs/architecture/` 当前包含:`MASTER_CONTRACT.md`、`RUNTIME_GUARDRAILS.md`、`CODE_REFERENCE_INDEX.md`、`API_CONTRACTS.md`、`DATAFLOW.md`、`TASK_BRIEF_TEMPLATE.md`、`ARCHITECTURE_INVENTORY.md`
|
||||
- `docs/superpowers/plans/` 保存实现计划,`docs/task_briefs/` 保存任务包产物与验收截图。
|
||||
|
||||
## 4. 后端结构(`backend/`)
|
||||
```text
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── api/ # 路由层(canonical 入口优先使用 *_api/ 子包)
|
||||
│ ├── core/ # 配置、日志、启动与核心能力
|
||||
│ ├── dependencies/ # 鉴权/租户等依赖注入
|
||||
│ ├── middleware/ # 请求中间件
|
||||
│ ├── models/ # SQLAlchemy 模型
|
||||
│ ├── schemas/ # Pydantic 模型
|
||||
│ ├── services/ # 业务服务(已目录化收敛)
|
||||
│ ├── config/ # 配置与 prompt
|
||||
│ └── utils/ # 通用工具
|
||||
├── alembic/ # 数据库迁移真源
|
||||
├── scripts/ # 后端运维/修复脚本(含历史 backfill / requirement-plan 修复)
|
||||
├── tests/ # 后端回归测试
|
||||
├── requirements.txt
|
||||
└── Dockerfile
|
||||
```
|
||||
- 当前后端重点入口优先看:
|
||||
- `backend/app/api/` 下的 `*_api/` 子包与聚合 router
|
||||
- `backend/app/services/ai_pmo/`(AI PMO 主链、Mounted Runtime、semantic context)
|
||||
- `backend/app/services/adaptive_rag_service/`(只读问答主链)
|
||||
- `backend/app/services/llm_service/`(显式 `routing_meta` 协议)
|
||||
- `backend/app/services/integrations/feishu_integration_service/`(飞书入站与卡片回调)
|
||||
- `backend/app/services/signal/`(信号接入、编排、planner、repair)
|
||||
- 详细目录迁移、兼容壳清理与新增模块清单见库存文档。
|
||||
|
||||
## 5. 前端结构(`frontend/`)
|
||||
```text
|
||||
frontend/
|
||||
├── e2e/ # Playwright 真实浏览器 E2E 用例
|
||||
├── src/
|
||||
│ ├── api/ # 接口封装
|
||||
│ ├── pages/ # 页面级组件(已目录化)
|
||||
│ ├── components/ # 通用组件与 SSTC/Inbox 共享组件
|
||||
│ ├── hooks/ # 自定义 hooks
|
||||
│ ├── lib/ # 通用工具
|
||||
│ ├── stores/ # 状态管理
|
||||
│ ├── router/ # 路由
|
||||
│ └── types/ # TS 类型定义
|
||||
├── package.json
|
||||
├── playwright.config.ts
|
||||
└── vite.config.ts
|
||||
```
|
||||
- 当前前端重点入口优先看:
|
||||
- `frontend/src/api/`(统一 API 封装)
|
||||
- `frontend/src/pages/inbox/`(消息台)
|
||||
- `frontend/src/pages/ai-steward/`(AI 管家)
|
||||
- `frontend/src/pages/scheduler-center/`(Mounted AI Console)
|
||||
- `frontend/src/components/sstc/`(SSTC 工作流组件)
|
||||
- 页面目录化、Mounted AI Console、Inbox、AI Steward 与 Recipes/Projects/Users 等模块化拆分的详细库存见库存文档。
|
||||
|
||||
### 5.1 `frontend/src/components/sstc/` 目录说明(模块化拆分后)
|
||||
- `SSTCWorkspace.tsx`:SSTC 工作区主容器(组装各阶段组件)
|
||||
- `useSSTCWorkspaceOrchestration.ts`:工作区编排 Hook(状态聚合、请求编排、事件路由)
|
||||
- `sstcUtils.ts`:SSTC 工具函数(数据转换、状态判断、通用 helper)
|
||||
- `SSTCSignalStagePanel.tsx`:信号阶段面板
|
||||
- `SSTCCharterStagePanel.tsx`:宪章阶段面板
|
||||
- `SSTCTaskStagePanel.tsx`:任务阶段面板
|
||||
- `SSTCExecutionBoardPanel.tsx`:执行看板阶段面板
|
||||
- `SSTCReleaseStagePanel.tsx`:发布阶段面板
|
||||
- `SSTCReviewStagePanel.tsx`:复盘阶段面板
|
||||
- `SSTCTaskCreateDialog.tsx`:任务创建弹窗
|
||||
- `SSTCReleaseCreateDialog.tsx`:发布创建弹窗
|
||||
- `SSTCReviewCreateDialog.tsx`:复盘创建弹窗
|
||||
- `AIVisibilityPanel.tsx`:AI 可视化信息面板
|
||||
- `FeedbackSummaryCard.tsx`:反馈中心摘要卡片(任务阶段/执行看板复用)
|
||||
- `SSTCGlobalHeader.tsx`:SSTC 全局头部
|
||||
- `SSTCStageNav.tsx`:阶段导航
|
||||
- `SSTCChainView.tsx`:链路视图
|
||||
- `SSTCRiskFloatPanel.tsx`:风险浮层
|
||||
- `SignalContentHoverCard.tsx`:信号内容悬浮卡片
|
||||
- `charterModules.ts`:宪章模块配置与映射
|
||||
|
||||
### 5.2 修改前必须执行 `FIND_USAGE` 的 SSTC 组件清单
|
||||
- `SSTCWorkspace.tsx`
|
||||
- `useSSTCWorkspaceOrchestration.ts`
|
||||
- `sstcUtils.ts`
|
||||
- `SSTCCharterStagePanel.tsx`
|
||||
- `SSTCSignalStagePanel.tsx`
|
||||
- `SSTCTaskStagePanel.tsx`
|
||||
- `SSTCExecutionBoardPanel.tsx`
|
||||
- `SSTCReleaseStagePanel.tsx`
|
||||
- `SSTCReviewStagePanel.tsx`
|
||||
- `SSTCReleaseCreateDialog.tsx`
|
||||
- `SSTCReviewCreateDialog.tsx`
|
||||
- `SSTCTaskCreateDialog.tsx`
|
||||
|
||||
## 6. 运行命令(高频)
|
||||
### 根目录
|
||||
```bash
|
||||
./start.sh dev # 推荐:docker-dev 热更新模式
|
||||
./stop.sh dev
|
||||
./devops/tunnel/start.sh # 公网穿透启动器(独立入口)
|
||||
./scripts/start_model_probe.sh # 独立模型连通性检测(中文日志)
|
||||
```
|
||||
|
||||
- 启动入口收敛:仅保留 2 个用户入口
|
||||
- 正常启动器:`./start.sh`
|
||||
- 公网穿透启动器:`./devops/tunnel/start.sh`
|
||||
- 运行脚本、模型探针、契约校验与 E2E 工具清单已迁到 [`docs/architecture/ARCHITECTURE_INVENTORY.md`](/Users/yuuuu/Documents/AIProjectManger/docs/architecture/ARCHITECTURE_INVENTORY.md)。
|
||||
- 当前新增的 requirement-plan 历史修复工具位于 `backend/scripts/repair_requirement_plan_history.py`,默认 dry-run,仅在显式 `--apply` 时写回 `mounted_draft_workspaces / mounted_topic_workspaces`。
|
||||
|
||||
### 前端
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
npm run build
|
||||
npm run e2e:feishu
|
||||
```
|
||||
|
||||
### 后端(本地直跑)
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
## 7. 团队维护建议(简版)
|
||||
- 规则变更:只改 `AGENTS.md`
|
||||
- 结构变更:同步改 `CLAUDE.md` + `docs/architecture/ARCHITECTURE_INVENTORY.md`
|
||||
- 运行边界 / 已知 runtime 假设变更:同步改 `AGENTS.md` + `docs/architecture/RUNTIME_GUARDRAILS.md`
|
||||
- API/契约变更:后端 `schemas` + 前端 `types` 同步更新
|
||||
|
||||
## 8. 服务边界约束(禁止越界)
|
||||
- 当前必须立即遵守的摘要规则以 `AGENTS.md` 第 8 节为准。
|
||||
- 长运行边界、跨链路守门细节、失败语义和已知 runtime 假设已迁到 [`docs/architecture/RUNTIME_GUARDRAILS.md`](/Users/yuuuu/Documents/AIProjectManger/docs/architecture/RUNTIME_GUARDRAILS.md)。
|
||||
- 当前最重要的摘要只保留:
|
||||
- 写操作必须经 Alembic / 受控执行器 / outbox / 审计日志,禁止业务路径直写或同步直发。
|
||||
- 用户可见核心业务内容禁止 fallback、stub、模板句冒充成功;失败必须显式失败或标记待确认。
|
||||
- 运行时业务动作归因统一走“对象/上下文绑定 -> `SemanticContextPacket` -> `SemanticDecision` -> gatekeeper”。
|
||||
- `backend/app` 运行时代码禁止恢复散落的中文关键词、正则、`looks_like_*`、`*_HINTS/*_KEYWORDS` 业务拍板逻辑。
|
||||
- `llm_service` 只认显式 `routing_meta` 协议,不再靠函数名或 hint 推断 tier / contract / fallback。
|
||||
- AI Steward、Requirement Plan、Signal Source、AdaptiveRAG 等核心链路的详细 guardrails 统一以 `RUNTIME_GUARDRAILS.md` 为准。
|
||||
|
||||
## 9. 新增代码的参考模式
|
||||
- 详细的接口、页面与服务参考入口已迁到 [`docs/architecture/CODE_REFERENCE_INDEX.md`](/Users/yuuuu/Documents/AIProjectManger/docs/architecture/CODE_REFERENCE_INDEX.md)。
|
||||
- 当前只保留高价值摘要:
|
||||
- 新增后端接口:优先参考 `backend/app/api/sstc_api/` 的结构
|
||||
- 新增前端 API 调用:优先参考 `frontend/src/api/sstc.ts`
|
||||
- 新增数据库表:参考 `alembic/versions/` 最新 migration
|
||||
- 新增飞书事件处理:参考 `backend/app/services/integrations/feishu_integration_service/`
|
||||
- 更细的 AI Steward / RAG / AI PMO / Signal / SSTC / 前端页面入口索引统一以 `CODE_REFERENCE_INDEX.md` 为准
|
||||
|
||||
## 10. 幂等与事务约束
|
||||
- 所有入站事件必须经过 `ingest_events` 幂等去重
|
||||
- 飞书回复必须经过 `feishu_reply_outbox`,禁止同步直接发送
|
||||
- 飞书回复发送幂等:`reply_hash = MD5(source_message_id + reply_text)`,同租户下 `status=sent` 的相同哈希不得重复发送
|
||||
- 跨表写操作必须在同一个数据库事务内完成
|
||||
|
||||
## 11. 已知 BOUNDARY(不要幻构行为)
|
||||
- 详细已知运行边界与环境假设统一维护在 [`docs/architecture/RUNTIME_GUARDRAILS.md`](/Users/yuuuu/Documents/AIProjectManger/docs/architecture/RUNTIME_GUARDRAILS.md)。
|
||||
- 当前高价值摘要只保留:
|
||||
- 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用。
|
||||
- 真实飞书网页 E2E 依赖本机已登录的 Chrome/Feishu 会话,结果校验统一走数据库探针。
|
||||
- 路由配置唯一真源固定为 `backend/.control_plane/pmflow-routing-rules.json`;请求超时统一以 `LLM_REQUEST_TIMEOUT` 为准。
|
||||
- World Model 静态层已接入 Redis 缓存(`wm:static:{tenant_id}:{project_id}`,TTL 600s),写操作后主动 invalidate,动态层不缓存。
|
||||
- `signal_sources` 仅作为 AI Steward 创建结果区的内联展示;项目看板与 SSTC 信号阶段统一只读正式 `Signal`。
|
||||
|
||||
## 12. 技术债务清单
|
||||
- [x] `mixin_auto.py` `_safe_int` NameError:已通过显式导入 `_safe_int` + 回归测试锁定,避免后续共享导出调整导致运行时未定义
|
||||
- [x] AI 入口主链已统一:飞书长连接(`capture_from_feishu`)、`/ai-pmo/process-text` 与 `/rag/ask` 已统一收敛到 `AIPMOKernelService.process_ingress` 主链;`/rag/ask` 通过 `rag_ask_contract.build_kernel_ingress_kwargs` 透传 RAG 上下文(`session_id/new_session/thinking_mode/action_mode/...`),query 执行后由 `build_rag_response_payload` 保持 HTTP 响应兼容,回归由 `test_rag_route_contract.py`、`test_ai_pmo_qa_http_mainline_e2e.py`、`test_ai_pmo_ingress_mainline.py` 锁定
|
||||
- [x] 飞书入口层耦合已收敛:飞书真实入口已拆为三段职责链(预处理 `mixin_receive_prepare.py` / capture 执行 `mixin_receive_capture_flow.py` / 编排 `mixin_receive.py`);`mixin_receive.py` 不再混合传输解析、附件标准化、capture 异常兜底与收尾回执逻辑,分层边界通过 `test_feishu_receive_orchestration_contract.py` 锁定
|
||||
- [x] `task.create` 已升级为一等 typed intent:`IntentResolver/RouteResolver` 新增 `task.create` 常量与 `task.create` 路由族;TypedExecutor 已接入任务落库执行(含 `project_id`/任务标题槽位守卫、对齐 Charter 校验、`persist_task` 写入);`WorkflowRegistry`、`ReplyRenderer`、`followup` 校验与草稿确认闭环已纳入 `task.create`;并补 `test_ai_pmo_task_intent_contract.py` 回归锁定
|
||||
- [x] `task.create` 已接入消息级幂等账本:Feishu `source_message_id/feishu_message_id` 作为 `task.create` 账本 key,重复确认只回放同一结果,不再重复落库;回归由 `test_feishu_pipeline_queue_contract.py` 锁定
|
||||
- [x] `signal_source.onboard` 已接入消息级幂等账本:TypedExecutor 以 `source_system + source_message_id` 作为 onboarding ledger key,重复提交只回放同一结果,不再重复跑信号源接入编排;回归由 `test_ai_pmo_signal_source_onboard_contract.py` 锁定
|
||||
- [x] AdaptiveRAG 职责已完成首轮收敛:`answer_question` 入口已分层为 `prepare runtime` 与 `base mainline` 两段,写确认逻辑通过 `_should_trigger_write_ack_gate` 独立门控;基础问答主路径不再与写确认判断混写在同一函数体。分层契约由 `test_adaptive_rag_mainline_contract.py` 锁定(read 绕过 ack、write 命中 ack、feishu write 维持主链)
|
||||
- [x] 执行层已形成统一执行账本与参数来源约束:ingress 执行路径统一通过 `KernelIngressStorageMixin` 生成 `execution_result.action_contract`(固定 `param_source=decision.execution_plan.actions+workflow_route+execution_result`)并写入 `operation_execution_ledger`(`applied/pending_manual/pending_confirmation/failed`);`mixin_auto.py`、`mixin_prepare_manual.py` 的 `operation_code` 日志来源已对齐 `action_contract`,回归由 `test_ai_pmo_execution_ledger_contract.py` 锁定
|
||||
- [x] QA/RAG 路由契约双轨已收敛:`/ai-steward/rag/ask` 兼容入口已移除,问答统一收敛到 `POST /rag/ask`;参数构造与 tenant 解析收口在 `backend/app/api/rag_ask_contract.py`,前端 `FloatingQAChat` 与 Context Cache API 目录均已对齐,`backend/tests/test_rag_route_contract.py` 与 `backend/tests/test_context_cache_profile_catalog.py` 已锁定回归
|
||||
- [x] AI 管家浮窗交互已与 Grounding Runtime 对齐:回答区已切到 markdown 渲染,顾问标签升级为显式交互按钮;`consultant_mode_preference` 只用于 `/rag/ask` 内部重答,`允许执行` 明确交接到 `/ai-pmo/process-text`,避免 query/execute 边界回退到旧的一锅煮
|
||||
- [x] Retrieval source registry / metadata 已统一:AdaptiveRAG 检索链已建立统一 source registry / metadata normalizer,`citations[]/sources_used[]` 直接消费标准字段 `source_type/source_id/chunk_id/target_url/project_id/updated_at/permission_scope/source_priority`;`doc/url/feishu/pmflow/graph/db` 的来源元数据与排序键已收口,并由 `test_rag_route_contract.py`、`test_adaptive_rag_mainline_contract.py` 锁定
|
||||
- [x] 语义治理 baseline 已清零:`backend/app` 运行时代码中的 `looks_like_* / *_HINTS / *_KEYWORDS` 残留已清空,主链统一改为对象/上下文绑定 + `SemanticContextPacket -> SemanticDecision -> gatekeeper`;`scripts/semantic_lint_baseline.txt` 已清空,并由 `scripts/semantic_lint.py` 与 `backend/tests/test_semantic_lint.py` 做仓库级守门
|
||||
- [x] 结构化 runtime 收口回归已补齐:`backend/tests/test_runtime_structured_semantics_contract.py` 额外锁定 `backend/app` 运行时代码里 `_is_question_like / _is_project_lookup_question / _is_capability_question / classify_intent_complex` 等旧语义拍板残留不得回流;`backend/tests/test_feishu_pipeline_queue_contract.py` 同步覆盖 `followup` 线程锚点优先与 signal capture 不再依赖问句词法兜底
|
||||
- [ ] 历史 Feishu 队列 / `feishu_items` 兼容链仍未完全退场:飞书真实入口、卡片动作、文本 queue followup,以及 `project_knowledge_graph` 的主增量/API 命名都已切到 `work_item` 主链;当前剩余债务主要是 `feishu_items` 存量读链路、历史 pending 事件兼容消费,以及 legacy `feishu_delta` 证据/回放兼容壳。后续需在历史存量清空后正式封存该兼容层
|
||||
- [x] AdaptiveRAG 向量检索 legacy fallback 已移除:`AdaptiveRAGRetrievalVectorMixin._retrieve_vector_context()` 现在只支持 PostgreSQL/pgvector 主路径;非 PostgreSQL 或 PG 查询失败时直接返回空结果,不再回退 Python 侧 legacy 扫描,边界由 `test_adaptive_rag_mainline_contract.py` 锁定
|
||||
- [x] 历史 Alembic 链幂等性缺陷已修复:`add_users_table_rbac.py` 的 PostgreSQL ENUM 创建已改为显式创建 + 列类型 `create_type=False` 复用,`userrole/userstatus` 不再在同一条 migration 内重复建型;回归由 `test_add_users_table_rbac_migration.py` 锁定
|
||||
- [ ] 食谱图片上传仍是示例桩实现:`backend/app/services/recipe/service.py` 仍以模拟 URL 代替对象存储,`POST /recipes/upload-image` 也还停留在示例接口形态;若该模块继续保留,需要补齐真实对象存储接入、租户隔离与统一 `{code,message,data}` 响应包络,否则应明确标注为非核心示例模块
|
||||
- [x] LLM 运行时仍未完全收敛:`ai_service_core._invoke_with_retry` 与 `stream_chat` 已统一走 `llm_service`;旧 `_get_llm` 路径已显式禁用,并补回归测试锁定
|
||||
- [x] QA 主链自动化测试覆盖已补齐:`backend/tests/` 已覆盖问答/RAG 主路径与关键场景;新增 `test_ai_pmo_qa_http_mainline_e2e.py` 通过 HTTP 层锁定 `/rag/ask` 的 `qa_readonly/mixed` 契约以及 `/ai-pmo/process-text` 的 `draft_pending_confirm` 主链返回,配合 `test_ai_pmo_ingress_mainline.py`、`test_ai_pmo_task_intent_contract.py`、`test_ai_pmo_tactical_brain_contract.py` 形成稳定回归基线
|
||||
- [x] 飞书交互闭环已补齐:执行场景回执在 `draft_pending_confirm/pending_manual` 下优先生成按钮卡片并经 outbox 异步发送;`card.action.trigger` 已覆盖 `confirm/revise/cancel/continue` 回调分发,分别复用 `DraftConfirmHandler.handle_api_decision` 与 AI PMO `process_ingress` 主链继续执行;回调 HTTP 现在会同步返回 toast 反馈,确保点击按钮后至少有可见结果;`backend/tests/test_feishu_card_action_contract.py` 已补卡片构造与 `continue` 回归契约
|
||||
|
|
@ -0,0 +1,337 @@
|
|||
# Neural Sync 腾讯云部署指南
|
||||
|
||||
## 服务器要求
|
||||
|
||||
- **系统**: Ubuntu 20.04+ / CentOS 8+
|
||||
- **配置**: 2核4GB RAM 以上
|
||||
- **需要**: Nginx, Node.js, Python 3.9+, PostgreSQL (可选), Ollama (可选)
|
||||
|
||||
---
|
||||
|
||||
## 一、环境准备
|
||||
|
||||
### 1.1 创建腾讯云轻量应用服务器
|
||||
|
||||
1. 登录 [腾讯云控制台](https://console.cloud.tencent.com/)
|
||||
2. 购买轻量应用服务器 (Lighthouse)
|
||||
3. 选择 Ubuntu 20.04 系统镜像
|
||||
4. 配置防火墙规则开放端口: 80, 443, 8000
|
||||
|
||||
### 1.2 SSH 连接服务器
|
||||
|
||||
```bash
|
||||
ssh root@你的服务器IP
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、一键部署脚本
|
||||
|
||||
### 2.1 方式一:从本地直接上传(推荐开发阶段)
|
||||
|
||||
```bash
|
||||
# 在本地项目目录执行
|
||||
scp -r /path/to/neural-sync root@你的服务器IP:/opt/
|
||||
```
|
||||
|
||||
### 2.2 方式二:从 GitHub 拉取代码
|
||||
|
||||
```bash
|
||||
# SSH 登录服务器后执行
|
||||
cd /opt
|
||||
git clone https://github.com/你的用户名/neural-sync.git
|
||||
cd neural-sync
|
||||
```
|
||||
|
||||
### 2.3 方式三:从 Gitee (码云) 拉取代码
|
||||
|
||||
```bash
|
||||
# 如果 GitHub 访问较慢,可以使用 Gitee
|
||||
cd /opt
|
||||
git clone https://gitee.com/你的用户名/neural-sync.git
|
||||
cd neural-sync
|
||||
```
|
||||
|
||||
### 2.4 运行部署脚本
|
||||
|
||||
#### 2.2 运行部署脚本
|
||||
|
||||
```bash
|
||||
chmod +x deploy.sh
|
||||
|
||||
# 方式1: 交互式部署(会提示选择代码来源)
|
||||
./deploy.sh
|
||||
|
||||
# 方式2: 指定参数直接部署
|
||||
# 参数1: 数据库密码 参数2: Git仓库地址
|
||||
./deploy.sh "你的数据库密码" "https://github.com/xxx/neural-sync.git"
|
||||
```
|
||||
|
||||
脚本会自动安装所有依赖并启动服务。
|
||||
|
||||
---
|
||||
|
||||
## 三、部署脚本详解
|
||||
|
||||
### 手动部署步骤 (如果自动脚本失败)
|
||||
|
||||
#### 1. 安装系统依赖
|
||||
|
||||
```bash
|
||||
# 更新系统
|
||||
apt update && apt upgrade -y
|
||||
|
||||
# 安装基础工具
|
||||
apt install -y curl wget git nginx postgresql postgresql-contrib
|
||||
|
||||
# 安装 Python
|
||||
apt install -y python3.9 python3-pip python3-venv
|
||||
|
||||
# 安装 Node.js
|
||||
curl -fsSL https://deb.nodesource.com/setup_18.x | bash -
|
||||
apt install -y nodejs
|
||||
```
|
||||
|
||||
#### 2. 配置 PostgreSQL 数据库
|
||||
|
||||
```bash
|
||||
# 启动 PostgreSQL
|
||||
systemctl start postgresql
|
||||
systemctl enable postgresql
|
||||
|
||||
# 创建数据库和用户
|
||||
sudo -u postgres psql
|
||||
CREATE DATABASE neural_sync;
|
||||
CREATE USER neuraluser WITH PASSWORD 'your_secure_password';
|
||||
GRANT ALL PRIVILEGES ON DATABASE neural_sync TO neuraluser;
|
||||
\q
|
||||
```
|
||||
|
||||
#### 3. 安装 Python 依赖
|
||||
|
||||
```bash
|
||||
cd /opt/neural-sync/backend
|
||||
|
||||
# 创建虚拟环境
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 配置环境变量
|
||||
cp /opt/neural-sync/.env.example /opt/neural-sync/.env
|
||||
nano /opt/neural-sync/.env
|
||||
```
|
||||
|
||||
编辑项目根目录 `.env` 文件:
|
||||
|
||||
```env
|
||||
# 数据库 - PostgreSQL
|
||||
DATABASE_URL=postgresql+asyncpg://neuraluser:your_secure_password@localhost:5432/neural_sync
|
||||
|
||||
# Ollama (如需本地AI功能)
|
||||
OLLAMA_HOST=http://127.0.0.1:11434
|
||||
|
||||
# 业务上下文 (根据你的业务修改)
|
||||
BUSINESS_CONTEXT=你的业务描述
|
||||
```
|
||||
|
||||
#### 4. 运行数据库迁移
|
||||
|
||||
```bash
|
||||
# 如果使用 PostgreSQL,需要先安装 pgvector
|
||||
sudo -u postgres psql -d neural_sync -c "CREATE EXTENSION IF NOT EXISTS vector;"
|
||||
|
||||
# 运行迁移
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
#### 5. 构建前端
|
||||
|
||||
```bash
|
||||
cd /opt/neural-sync/frontend
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 构建生产版本
|
||||
npm run build
|
||||
```
|
||||
|
||||
#### 6. 配置 Nginx
|
||||
|
||||
创建 Nginx 配置文件:
|
||||
|
||||
```bash
|
||||
nano /etc/nginx/sites-available/neural-sync
|
||||
```
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name 你的域名或IP;
|
||||
|
||||
# 前端静态文件
|
||||
location / {
|
||||
root /opt/neural-sync/frontend/dist;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 后端 API 代理
|
||||
location /api {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# WebSocket 支持 (如需要)
|
||||
location /ws {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
启用配置:
|
||||
|
||||
```bash
|
||||
ln -s /etc/nginx/sites-available/neural-sync /etc/nginx/sites-enabled/
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
```
|
||||
|
||||
#### 7. 配置 Systemd 服务
|
||||
|
||||
创建后端服务文件:
|
||||
|
||||
```bash
|
||||
nano /etc/systemd/system/neural-sync.service
|
||||
```
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Neural Sync Backend
|
||||
After=network.target postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/neural-sync/backend
|
||||
Environment="PATH=/opt/neural-sync/backend/venv/bin"
|
||||
EnvironmentFile=/opt/neural-sync/.env
|
||||
ExecStart=/opt/neural-sync/backend/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
启动服务:
|
||||
|
||||
```bash
|
||||
systemctl daemon-reload
|
||||
systemctl enable neural-sync
|
||||
systemctl start neural-sync
|
||||
systemctl status neural-sync
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、验证部署
|
||||
|
||||
```bash
|
||||
# 检查服务状态
|
||||
systemctl status neural-sync
|
||||
nginx -t
|
||||
|
||||
# 测试 API
|
||||
curl http://127.0.0.1:8000/docs
|
||||
|
||||
# 访问前端
|
||||
curl http://你的服务器IP
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、常用命令
|
||||
|
||||
```bash
|
||||
# 重启后端
|
||||
systemctl restart neural-sync
|
||||
|
||||
# 查看后端日志
|
||||
journalctl -u neural-sync -f
|
||||
|
||||
# 重载 Nginx
|
||||
nginx -s reload
|
||||
|
||||
# 停止所有服务
|
||||
systemctl stop neural-sync
|
||||
nginx stop
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、可选: 安装 Ollama (本地 AI)
|
||||
|
||||
```bash
|
||||
# 安装 Ollama
|
||||
curl -fsSL https://ollama.com/install.sh | sh
|
||||
|
||||
# 启动服务
|
||||
ollama serve &
|
||||
|
||||
# 下载模型
|
||||
ollama pull llama3
|
||||
ollama pull nomic-embed-text
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、安全建议
|
||||
|
||||
1. 使用 HTTPS (配置 SSL 证书)
|
||||
2. 修改默认数据库密码
|
||||
3. 配置防火墙只开放 80/443 端口
|
||||
4. 定期更新系统和依赖
|
||||
|
||||
---
|
||||
|
||||
## 八、更新部署
|
||||
|
||||
代码更新后,重新部署:
|
||||
|
||||
```bash
|
||||
cd /opt/neural-sync
|
||||
|
||||
# 拉取最新代码
|
||||
git pull
|
||||
|
||||
# 重新构建前端
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
|
||||
# 重启后端服务
|
||||
systemctl restart neural-sync
|
||||
|
||||
# 重载 Nginx
|
||||
nginx -s reload
|
||||
```
|
||||
|
||||
或使用一键更新脚本:
|
||||
|
||||
```bash
|
||||
./update.sh
|
||||
```
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
# Scheduler 执行清单 V1(前后端一体交付)
|
||||
|
||||
> 目标:交付“可见、可触发、可追踪”的调度中心,避免仅后端不可见改动。
|
||||
> 原则:每步必须包含 Backend + Frontend + 可见验收。
|
||||
|
||||
---
|
||||
|
||||
## 0. 执行规则(强制)
|
||||
|
||||
- [ ] 每步包含 `Backend任务` + `Frontend任务` + `可见验收`。
|
||||
- [ ] 每步完成即改 `[x]` 并补验收证据。
|
||||
- [ ] 无页面可见结果,不进入下一步。
|
||||
- [ ] 失败需记录回滚点与阻塞原因。
|
||||
|
||||
---
|
||||
|
||||
## 1. 范围与阶段
|
||||
|
||||
- [x] S0 稳定性前置(401 熔断、防轮询风暴)
|
||||
- [x] S1 调度基础能力(Job Registry + JobRun 持久化 + 手动触发)
|
||||
- [x] S2 调度中心页面(任务列表、触发、运行记录)
|
||||
- [x] S3 认知链路接入(对齐评分任务接入调度)
|
||||
- [x] S4 联调验收(失败重试、权限、可视化证据)
|
||||
|
||||
---
|
||||
|
||||
## 2. 分步清单
|
||||
|
||||
### [x] S1 调度基础能力
|
||||
|
||||
**Backend任务**
|
||||
- 新增 `scheduler_center` 服务:注册任务、执行任务、记录 `job_runs`。
|
||||
- 新增 `/scheduler` API:
|
||||
- `GET /scheduler/jobs`
|
||||
- `GET /scheduler/runs`
|
||||
- `POST /scheduler/jobs/{job_name}/trigger`
|
||||
|
||||
**Frontend任务**
|
||||
- 新增 `scheduler` API client,支持查询与触发。
|
||||
|
||||
**可见验收**
|
||||
- 调用 `GET /scheduler/jobs` 可见可触发任务列表。
|
||||
- 触发任务后 `GET /scheduler/runs` 出现新纪录(running/success/failed)。
|
||||
|
||||
**验收证据**
|
||||
- API 已新增:`GET /scheduler/jobs`、`GET /scheduler/runs`、`POST /scheduler/jobs/{job_name}/trigger`
|
||||
- 后端文件:`backend/app/services/scheduler_center.py`、`backend/app/api/scheduler.py`
|
||||
- 路由接入:`backend/app/api/__init__.py`、`backend/app/main.py`
|
||||
|
||||
---
|
||||
|
||||
### [x] S2 调度中心页面
|
||||
|
||||
**Backend任务**
|
||||
- 补充运行记录字段(耗时、错误信息、payload 摘要)并排序输出。
|
||||
|
||||
**Frontend任务**
|
||||
- 新增 `SchedulerCenterPage`:
|
||||
- 任务列表
|
||||
- 手动触发按钮
|
||||
- 最近运行记录
|
||||
- 错误可读展示
|
||||
- 路由接入,并在项目看板增加入口按钮。
|
||||
|
||||
**可见验收**
|
||||
- 页面可手动触发任务并实时看到运行结果。
|
||||
|
||||
**验收证据**
|
||||
- 页面已新增:`frontend/src/pages/SchedulerCenterPage.tsx`
|
||||
- 前端 API 已新增:`frontend/src/api/scheduler.ts`
|
||||
- 路由已接入:`/scheduler-center`(`frontend/src/router/index.tsx`)
|
||||
- 项目看板入口已接入:`frontend/src/pages/ProjectBoardPage.tsx`
|
||||
- 构建通过:`cd frontend && npm run build`
|
||||
|
||||
---
|
||||
|
||||
### [x] S3 认知链路接入(O4-4 首版)
|
||||
|
||||
**Backend任务**
|
||||
- 将“项目认知评分重算”封装为调度任务 `alignment_rescore_project`。
|
||||
- `ai_steward` 对应入口优先通过调度执行并写 `job_runs`。
|
||||
|
||||
**Frontend任务**
|
||||
- 调度中心支持传入 `project_id` 触发重算任务。
|
||||
|
||||
**可见验收**
|
||||
- 触发重算后,`job_runs` 成功记录可见;评分接口数据可更新。
|
||||
|
||||
**验收证据**
|
||||
- 调度任务真接线:`backend/app/services/scheduler_center.py` -> `alignment_rescore_project`
|
||||
- 评分内核抽离:`backend/app/services/alignment_orchestrator.py`
|
||||
- `ai_steward` 入口改为优先通过调度执行并落 `job_runs`:`backend/app/api/ai_steward.py`
|
||||
|
||||
---
|
||||
|
||||
### [x] S4 联调与收尾
|
||||
|
||||
**Backend任务**
|
||||
- 权限控制:仅 BOSS/ADMIN 可触发关键任务。
|
||||
- 失败重试策略:同名任务支持手动重跑。
|
||||
|
||||
**Frontend任务**
|
||||
- 失败任务标红 + 一键重试。
|
||||
|
||||
**可见验收**
|
||||
- 失败任务可重试并形成新的运行记录链。
|
||||
|
||||
**验收证据**
|
||||
- 关键任务触发权限:`backend/app/api/scheduler.py`(BOSS/ADMIN)与 `backend/app/api/ai_steward.py`(BOSS/ADMIN)
|
||||
- 后端重试接口:`POST /scheduler/runs/{run_id}/retry`(BOSS/ADMIN),`scheduler_center.retry_run()`
|
||||
- 前端失败一键重试:`schedulerApi.retryRun(runId)` + 失败任务红底/红边样式,`frontend/src/pages/SchedulerCenterPage.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 3. DoD(完成定义)
|
||||
|
||||
- [x] 调度中心页面可访问、可触发、可追踪。
|
||||
- [x] 关键调度任务有持久化记录(成功/失败可区分)。
|
||||
- [x] O4-4 首版实现“可持续更新认知状态”的调度入口。
|
||||
- [x] 前端构建通过:`cd frontend && npm run build`
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
# SSTC 看板执行清单 V3(前后端联动可见版)
|
||||
|
||||
更新时间:2026-02-25
|
||||
适用范围:`/projects/:id/board` 的 SSTC 看板全链路改造(React + FastAPI)
|
||||
|
||||
## 当前基线(基于现有代码)
|
||||
|
||||
- [x] 已有最小链路:`信号 -> 宪章 -> 任务`(`frontend/src/components/sstc/SSTCChainView.tsx` + `backend/app/api/sstc.py`)。
|
||||
- [x] 已有宪章详情页 + 任务创建:`frontend/src/pages/CharterDetailPage.tsx`。
|
||||
- [ ] 缺口:`frontend/src/components/sstc/SSTCGlobalHeader.tsx`、`SSTCStageNav.tsx`、`SSTCWorkspace.tsx`、`SSTCRiskFloatPanel.tsx` 当前为空文件。
|
||||
- [ ] 缺口:SSTC 仅有少量接口,缺少发布验证、复盘沉淀、链路健康聚合、阶段锁定校验。
|
||||
- [ ] 缺口:缺少统一操作日志表(全链路追溯要求)。
|
||||
- [ ] ⚠️ 待核实:`@dnd-kit/core`、`@tiptap/*` 不在 `frontend/package.json`,与方案约束不一致。
|
||||
|
||||
## 执行规则
|
||||
|
||||
- [ ] 每完成 1 步,将该步由 `[ ]` 改为 `[x]`。
|
||||
- [ ] 所有接口统一返回:`{ code, message, data }`(仅 SSTC 新增/改造接口先落地)。
|
||||
- [ ] 所有写操作记录:操作人、操作时间、变更摘要(写入 `operation_logs`)。
|
||||
- [ ] 所有 AI 调用记录:输入/输出、token、成本(复用 `ai_call_logs`)。
|
||||
|
||||
## P0 基础可运行(先打通框架)
|
||||
|
||||
- [x] **P0-1 建立 SSTC 统一数据契约**
|
||||
- 后端实现:新增 `backend/app/schemas/sstc.py`;标准化 stage/status 枚举、DTO、响应结构。
|
||||
- 前端实现:更新 `frontend/src/api/sstc.ts` 类型定义,全部接口适配 `data` 字段。
|
||||
- 可见验收:项目看板可正常加载,不再依赖“隐式字段”。
|
||||
|
||||
- [x] **P0-2 建立 SSTC 聚合服务层**
|
||||
- 后端实现:新增 `backend/app/services/sstc_service.py`,聚合项目头部指标、阶段状态、风险摘要。
|
||||
- 前端实现:新增 `getBoardOverview(projectId)` 调用。
|
||||
- 可见验收:看板顶部能显示统一概览数据。
|
||||
|
||||
- [x] **P0-3 填充 4 个空组件骨架**
|
||||
- 前端实现:补全 `SSTCGlobalHeader.tsx`、`SSTCStageNav.tsx`、`SSTCWorkspace.tsx`、`SSTCRiskFloatPanel.tsx` 基础布局。
|
||||
- 后端实现:提供骨架所需最小接口(overview、stage health、risk summary)。
|
||||
- 可见验收:`/projects/:id/board` 出现四段式布局,不再只有单卡片链路。
|
||||
|
||||
- [x] **P0-4 阶段锁定规则后端校验**
|
||||
- 后端实现:在任务创建/编辑、发布创建、复盘归档接口中增加上游状态校验(禁止前端绕过)。
|
||||
- 前端实现:灰态展示 + 按钮禁用 + 原因提示。
|
||||
- 可见验收:宪章未对齐时无法创建/确认任务。
|
||||
|
||||
## P1 核心链路(第一优先级:导航 + 宪章 + 任务 + 执行)
|
||||
|
||||
- [x] **P1-1 模块1:项目全局信息栏**
|
||||
- 后端实现:`GET /projects/{id}/sstc/overview`(基础信息、对齐度、进度、负载健康)。
|
||||
- 前端实现:`SSTCGlobalHeader` 渲染 5 区块;recharts 展示对齐度/进度/负载。
|
||||
- 可见验收:顶部 120px 信息栏完整显示指标和操作按钮。
|
||||
|
||||
- [x] **P1-2 模块2:六阶段链路导航栏**
|
||||
- 后端实现:`GET /projects/{id}/sstc/stages` 返回 6 节点状态、风险点、可编辑性。
|
||||
- 前端实现:`SSTCStageNav` 横向 6 节点 + 箭头 + Badge + 红点 + 选中高亮。
|
||||
- 可见验收:点击节点切换工作区;未解锁节点只读。
|
||||
|
||||
- [x] **P1-3 模块3.2:需求宪章工作区**
|
||||
- 后端实现:补齐宪章版本化、发起对齐、确认对齐、冲突查询与处理接口。
|
||||
- 前端实现:宪章编辑器(先 Textarea,后续可替换 Tiptap)、对齐状态卡、冲突卡、模型绑定卡。
|
||||
- 可见验收:可发起对齐;成员确认后状态从 `对齐中` 到 `已对齐`。
|
||||
|
||||
- [x] **P1-4 模块3.3:任务拆解工作区**
|
||||
- 后端实现:AI 拆解任务接口、负责人推荐接口、负载优化接口、任务确认接口。
|
||||
- 前端实现:任务树表格(父子折叠)、右侧负载监控与拆解设置卡。
|
||||
- 可见验收:点击“AI 重新拆解任务”后生成任务,显示推荐负责人和匹配度。
|
||||
|
||||
- [x] **P1-5 模块3.4:执行落地工作区**
|
||||
- 后端实现:任务状态迁移接口、风险预警聚合接口、阻塞处理接口。
|
||||
- 前端实现:Kanban 5 泳道、任务卡片详情弹窗、右侧风险栏。
|
||||
- 可见验收:拖拽任务卡更新状态并持久化。
|
||||
- ⚠️ 待核实:需先安装 `@dnd-kit/core`(当前未安装)。
|
||||
|
||||
## P2 扩展链路(第二优先级:信号源 + 发布验证 + 复盘)
|
||||
|
||||
- [x] **P2-1 模块3.1:信号源工作区**
|
||||
- 后端实现:信号列表筛选、归档、AI 影响评估、宪章关联接口。
|
||||
- 前端实现:左侧信号表格 + 右侧统计/AI/集成卡片。
|
||||
- 可见验收:可从信号直接跳转并创建宪章关联。
|
||||
|
||||
- [x] **P2-2 模块3.5:发布验证工作区**
|
||||
- 后端实现:发布计划 CRUD、发布状态流转、验证清单生成与提交。
|
||||
- 前端实现:发布计划表 + 验证标签页 + 右侧 AI 辅助栏。
|
||||
- 可见验收:仅可选择“已完成任务”创建发布计划。
|
||||
|
||||
- [x] **P2-3 模块3.6:复盘沉淀工作区**
|
||||
- 后端实现:复盘节点创建/版本/归档、改进项落地接口。
|
||||
- 前端实现:6 模块复盘编辑区 + 右侧知识沉淀/改进项/AI 卡片。
|
||||
- 可见验收:确认归档后链路锁定,进入只读状态。
|
||||
|
||||
## P3 全局能力(第三优先级:预警 + 权限 + 可追溯)
|
||||
|
||||
- [x] **P3-1 模块4:风险/认知冲突悬浮栏**
|
||||
- 后端实现:统一预警接口(高/中风险 + 未解决冲突)。
|
||||
- 前端实现:右下角悬浮窗(可最小化、角标、Tab 切换)。
|
||||
- 可见验收:新增高风险时角标数量实时变化。
|
||||
|
||||
- [x] **P3-2 操作日志全链路追溯**
|
||||
- 后端实现:新增 `operation_logs` 模型、迁移、写入中间件/服务封装。
|
||||
- 前端实现:关键操作写 `x-request-id`,便于追踪。
|
||||
- 可见验收:任一状态变更可查到操作人/时间/变更前后。
|
||||
|
||||
- [x] **P3-3 角色权限闭环**
|
||||
- 后端实现:项目负责人/成员/管理员权限矩阵校验。
|
||||
- 前端实现:按钮级权限渲染与禁用提示。
|
||||
- 可见验收:成员无法执行负责人专属操作。
|
||||
|
||||
- [x] **P3-4 AI 调用成本可观测**
|
||||
- 后端实现:SSTC AI 接口统一落 `ai_call_logs`,补齐 token/cost 字段。
|
||||
- 前端实现:在 AI 操作结果区展示“本次 token 与成本”。
|
||||
- 可见验收:每次 AI 操作后可在日志中检索到调用记录。
|
||||
|
||||
## 联调与验收清单(发布前必须全勾)
|
||||
|
||||
- [ ] E2E-1 项目进入看板后,四大区块均可见且无空白组件。
|
||||
- [ ] E2E-2 六阶段状态与锁定规则生效(前后端双校验)。
|
||||
- [ ] E2E-3 宪章未对齐无法进入任务确认与执行编辑。
|
||||
- [ ] E2E-4 任务拖拽状态迁移成功,刷新后状态保持。
|
||||
- [ ] E2E-5 发布必须绑定已完成任务,复盘必须在发布验证后。
|
||||
- [ ] E2E-6 全部 SSTC 新接口返回统一结构 `{code,message,data}`。
|
||||
- [ ] E2E-7 关键写操作可在 `operation_logs` 检索。
|
||||
- [ ] E2E-8 AI 操作可在 `ai_call_logs` 检索到 token/cost。
|
||||
|
||||
## 实施顺序建议(最短路径看到效果)
|
||||
|
||||
- [ ] 第 1 周:完成 P0 + P1-1 + P1-2(先把“看起来像完整看板”做出来)。
|
||||
- [ ] 第 2 周:完成 P1-3 + P1-4 + P1-5(核心闭环可操作)。
|
||||
- [ ] 第 3 周:完成 P2(补齐发布与复盘闭环)。
|
||||
- [ ] 第 4 周:完成 P3 + E2E(治理与可追溯收口)。
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
# HEARTBEAT.md
|
||||
|
||||
# Keep this file empty (or with only comments) to skip heartbeat API calls.
|
||||
|
||||
# Add tasks below when you want the agent to check something periodically.
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# IDENTITY.md - Who Am I?
|
||||
|
||||
_Fill this in during your first conversation. Make it yours._
|
||||
|
||||
- **Name:**
|
||||
_(pick something you like)_
|
||||
- **Creature:**
|
||||
_(AI? robot? familiar? ghost in the machine? something weirder?)_
|
||||
- **Vibe:**
|
||||
_(how do you come across? sharp? warm? chaotic? calm?)_
|
||||
- **Emoji:**
|
||||
_(your signature — pick one that feels right)_
|
||||
- **Avatar:**
|
||||
_(workspace-relative path, http(s) URL, or data URI)_
|
||||
|
||||
---
|
||||
|
||||
This isn't just metadata. It's the start of figuring out who you are.
|
||||
|
||||
Notes:
|
||||
|
||||
- Save this file at the workspace root as `IDENTITY.md`.
|
||||
- For avatars, use a workspace-relative path like `avatars/control_plane.png`.
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
# Memory
|
||||
|
||||
## 飞书工单查询配置
|
||||
|
||||
当用户问"查询工单"、"导出工单"、"今天的工单"时,**立即执行以下命令查询外部数据库**:
|
||||
|
||||
### 执行命令(直接复制执行)
|
||||
```bash
|
||||
docker exec pmflow-db psql "postgresql://readonly:readonly%40%232025@173.1.1.32:5432/middle_platform" -c "SELECT task_code, task_name, excutor_name, status, start_time FROM plat_task_center.task_instance WHERE start_time >= CURRENT_DATE AND start_time < CURRENT_DATE + INTERVAL '1 day' AND ext3 IN ('90101', '901') ORDER BY start_time DESC LIMIT 50;" -x
|
||||
```
|
||||
|
||||
### 导出 Excel 命令
|
||||
```bash
|
||||
docker exec pmflow-db psql "postgresql://readonly:readonly%40%232025@173.1.1.32:5432/middle_platform" -c "COPY (SELECT * FROM plat_task_center.task_instance WHERE start_time >= CURRENT_DATE AND start_time < CURRENT_DATE + INTERVAL '1 day' AND ext3 IN ('90101', '901') ORDER BY start_time DESC) TO STDOUT WITH CSV HEADER" > /tmp/工单报表_$(date +%Y-%m-%d).csv && python3 -c "import pandas as pd; pd.read_csv('/tmp/工单报表_$(date +%Y-%m-%d).csv').to_excel('/tmp/工单报表_$(date +%Y-%m-%d).xlsx', index=False)"
|
||||
```
|
||||
|
||||
### ⚠️ 关键提醒
|
||||
- 这是 **外部数据库 middle_platform**,不是 PMFlow 数据库
|
||||
- 收到工单查询请求后 **立即执行命令**,不要只回复文字
|
||||
- 导出后告诉用户文件路径:`/tmp/工单报表_YYYY-MM-DD.xlsx`
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
# PMFlow Dev Plan — User / Skill / Workload Modules
|
||||
|
||||
**Stack:** FastAPI + React + shadcn/ui + Tailwind | **Timeline:** ~11 days, 1 dev
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Core Foundation (Day 1–3)
|
||||
|
||||
### Day 1 — DB Migration + User CRUD
|
||||
|
||||
**DB**
|
||||
```sql
|
||||
ALTER TABLE users ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
|
||||
CREATE TABLE user_projects (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 1,
|
||||
user_id BIGINT NOT NULL,
|
||||
project_id BIGINT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id),
|
||||
UNIQUE(tenant_id, user_id, project_id)
|
||||
);
|
||||
```
|
||||
|
||||
**Backend**
|
||||
- [x] Update ORM: `User.is_active`, new `UserProject` model
|
||||
- [x] `GET /api/users` — paginated list with filters (role/status/project/workload/skill)
|
||||
- [x] `POST /api/users` — create user (bcrypt password, auto-init `user_workloads` row with 40h/week)
|
||||
- [x] `PUT /api/users/{id}` — update user
|
||||
- [x] `DELETE /api/users/{id}` — block if user has incomplete tasks
|
||||
- [x] `POST /api/users/{id}/reset-password`
|
||||
- [x] `PATCH /api/users/{id}/status` — disable: clear `tasks.assignee_id`, zero out workload
|
||||
- [x] `POST /api/users/import` — bulk import via Excel (max 50 rows, return per-row result)
|
||||
- [x] Transaction: delete user → cascade delete `user_skill_bindings` + `user_workloads`
|
||||
- [x] Log all mutations (actor, time, diff)
|
||||
|
||||
**Frontend**
|
||||
- [x] Routes: `/users`, `/skills`, `/workloads`, `/workloads/:id`
|
||||
- [x] `/users` page: top bar (title + New/Import/Bulk-delete buttons + search)
|
||||
- [x] Right filter panel (280px, collapsible): role / status / project / workload-state / skill multi-select
|
||||
- [x] Table: avatar+name / email / role badge / linked projects / top-3 skill tags / workload% / status / actions
|
||||
- [x] New/Edit user modal (form validation, edit pre-fills data)
|
||||
- [x] Reset password modal
|
||||
- [x] Bulk import modal (preview first 10 rows, highlight errors, show import result summary)
|
||||
- [x] Workload% color: green <70%, yellow 70–100%, red >100%
|
||||
|
||||
---
|
||||
|
||||
### Day 2 — Skill & Workload APIs
|
||||
|
||||
**Backend — Skills**
|
||||
- [x] Seed system skill tags on startup (Python, FastAPI, LangChain, React, Prompt Eng, etc.)
|
||||
- [x] `GET /api/skill-tags` — list with category filter
|
||||
- [x] `POST /api/skill-tags` — admin only
|
||||
- [x] `PUT /api/skill-tags/{id}` — system tags: category only; custom: name + category
|
||||
- [x] `DELETE /api/skill-tags/{id}` — block if `is_system=true`; cascade delete `user_skill_bindings`
|
||||
- [x] `GET /api/users/{id}/skills` — user skill bindings
|
||||
- [x] `POST /api/users/{id}/skills` — bind skill (default: beginner, scores 0.0)
|
||||
- [x] `PUT /api/users/{id}/skills/{skill_id}` — update proficiency / scores
|
||||
- [x] `DELETE /api/users/{id}/skills/{skill_id}` — unbind (block system-auto-bound skills)
|
||||
|
||||
**Backend — Workload**
|
||||
- [x] Workload calc util: `ratio = assigned_hours / ((max_weekly_hours / 5) * remaining_workdays)`
|
||||
- [x] `GET /api/workloads` — team list with project/date filters
|
||||
- [x] `GET /api/workloads/{user_id}` — personal detail
|
||||
- [x] `PUT /api/workloads/{user_id}` — update max weekly hours (triggers ratio recalc)
|
||||
- [x] `GET /api/workloads/{user_id}/tasks` — assigned task list
|
||||
- [x] `GET /api/workloads/{user_id}/trend` — daily ratio for last 7/30 days
|
||||
- [x] Cron job at 00:00 daily: recalc ratio for all users based on updated remaining workdays
|
||||
- [x] Auto-trigger ratio recalc on: task assign / estimate change / task complete / task delete
|
||||
|
||||
---
|
||||
|
||||
### Day 3 — Skill & Workload Frontend
|
||||
|
||||
**Frontend — Skill Tags (`/skills`)**
|
||||
- [x] Table: name / category badge / linked-user count / actions
|
||||
- [x] System tags: show "System" label, disable delete, disable name edit
|
||||
- [x] New/Edit skill tag modal
|
||||
- [x] Category badge colors: dev=blue / algorithm=purple / product=orange / pm=green / qa=gray
|
||||
|
||||
**Frontend — User Skill Detail (`/users/:id/skills`)**
|
||||
- [x] Top bar: avatar / name / role / linked projects / "Batch Edit" + "Refresh Scores" buttons
|
||||
- [x] Left 60%: skills grouped by category (collapsible), each row: name / proficiency dropdown / quality score / efficiency score / last used (read-only)
|
||||
- [x] "Remove" button per row (disabled for system-bound skills)
|
||||
- [x] "Add Skill" button → modal to select skill tags
|
||||
- [x] Right 40%: recharts `RadarChart` — weighted score per skill, filter by category, hover shows detail
|
||||
- [x] "Export Chart" button (save as image)
|
||||
|
||||
**Frontend — Team Workload (`/workloads`)**
|
||||
- [x] Filter bar: project select + date range (this week / this month / custom)
|
||||
- [x] 3 stat cards: avg team ratio / headcount by state (idle/normal/overloaded) / AI alert summary
|
||||
- [x] Table: member info / projects / assigned hours / max weekly hours / ratio% / remaining hours / actions
|
||||
- [x] Overloaded rows: red highlight + warning icon
|
||||
|
||||
**Frontend — Personal Workload (`/workloads/:id`)**
|
||||
- [x] Header: ratio% / remaining hours / "Adjust Hours" button
|
||||
- [x] recharts `LineChart`: daily ratio trend (7d/30d toggle), 70% + 100% reference lines, overloaded segments in red
|
||||
- [x] Task list table: title / project / priority / est. hours / due date / status / view link
|
||||
- [x] Task list filters: status / priority / due date
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Sync Logic + AI (Day 4–8)
|
||||
|
||||
### Day 4–5 — Cross-module Sync
|
||||
|
||||
**Backend**
|
||||
- [x] User create → init empty `user_skill_bindings`, init `user_workloads`
|
||||
- [x] User delete → transaction: delete bindings + workload record
|
||||
- [x] User disable → zero `current_assigned_hours` + `workload_ratio`, clear task assignments
|
||||
- [x] Skill score update → recalc top-3 skills for user, sync to user list response
|
||||
- [x] Workload ratio change → sync to user list `workload_ratio` field
|
||||
- [x] Skill proficiency level → pass to task estimation: expert → lower est. hours → lower ratio
|
||||
|
||||
**Frontend**
|
||||
- [x] User list table: workload% and top-3 skill tags update without page reload (TanStack Query invalidation)
|
||||
- [x] Skill detail page: manual score edit shows warning "AI will overwrite on next update"
|
||||
- [x] Workload team table: ratio column updates in real-time after task changes
|
||||
|
||||
---
|
||||
|
||||
### Day 6–7 — SSTC Kanban Integration
|
||||
|
||||
**Backend**
|
||||
- [x] Task assign endpoint: filter candidates to current-project users only, exclude disabled users
|
||||
- [x] `GET /api/tasks/recommend-assignees?task_id=` — rank users by skill match + proficiency + ratio (<70% preferred)
|
||||
- [x] Task status → `done`: trigger async job to update `current_assigned_hours` (subtract est. hours) and recalc ratio
|
||||
- [x] `GET /api/workloads/summary` — team health card data for kanban top bar
|
||||
|
||||
**Frontend**
|
||||
- [x] SSTC kanban top bar: "Team Workload Health" card (avg ratio + overload count), click → `/workloads`
|
||||
- [x] Task breakdown panel: recommended assignees list shows name / role / top skills / ratio badge
|
||||
- [x] Assignee dropdown: disabled users grayed out and unselectable
|
||||
- [x] Task status change → trigger workload refetch (TanStack Query)
|
||||
|
||||
---
|
||||
|
||||
### Day 8 — AI Features
|
||||
|
||||
**Backend**
|
||||
- [x] New LangChain prompt: skill score update — input: task quality_score + (est_hours / actual_hours), output: quality/efficiency score delta per skill tag
|
||||
- [x] Trigger on `tasks.status = done`: async call above prompt, apply weighted update (higher priority task = higher weight)
|
||||
- [x] New LangChain prompt: workload optimization — input: all members' skills + ratios, output: suggested task reassignments
|
||||
- [x] `POST /api/workloads/ai-optimize` — run optimization, return preview plan (no auto-apply)
|
||||
- [x] `POST /api/workloads/ai-optimize/apply` — apply approved plan (batch update `tasks.assignee_id`)
|
||||
- [x] Overload alert: ratio >100% → send notification (Feishu/email); if unresolved 24h → auto-generate transfer suggestion
|
||||
|
||||
**Frontend**
|
||||
- [x] "AI Optimize Assignments" button → show optimization preview modal (old vs new assignee per task)
|
||||
- [x] Confirm → apply; Cancel → discard
|
||||
- [x] Overload alert banner on team workload page when any member >100%
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Polish (Day 9–11)
|
||||
|
||||
### Day 9 — Filters / Export / Bulk Actions
|
||||
|
||||
- [x] Verify all filter combinations work correctly across all 3 pages
|
||||
- [x] Export workload report (Excel/PDF)
|
||||
- [x] Export skill radar chart (PNG)
|
||||
- [x] Bulk delete users (with incomplete-task guard)
|
||||
- [x] Bulk assign project / role to selected users
|
||||
|
||||
### Day 10 — Permissions
|
||||
|
||||
| Role | Users | Skills | Workload |
|
||||
|------|-------|--------|----------|
|
||||
| admin | Full | Full | Full |
|
||||
| manager | Own project users (no delete) | Bind/edit scores for own project | View + optimize own project |
|
||||
| member | View all + edit own profile | View own + edit own proficiency | View own + edit own weekly hours |
|
||||
|
||||
- [x] Backend: permission middleware on all mutation endpoints
|
||||
- [x] Frontend: route guards + button-level permission checks
|
||||
|
||||
### Day 11 — Final QA
|
||||
|
||||
- [x] Dark mode audit across all new pages
|
||||
- [x] Empty state / loading state / error state for all tables and cards
|
||||
- [x] Toast notifications for all CRUD operations
|
||||
- [x] End-to-end flow test: create user → bind skills → assign task → complete task → verify score + workload update
|
||||
- [x] Fix bugs, merge
|
||||
|
||||
---
|
||||
|
||||
## Reference
|
||||
|
||||
**Workload ratio formula:**
|
||||
```
|
||||
ratio = current_assigned_hours / ((max_weekly_hours / 5) * remaining_workdays_this_week)
|
||||
```
|
||||
|
||||
**Ratio recalc triggers:** task assign · est hours change · task complete · task delete · weekly hours update · daily cron
|
||||
|
||||
**API response format:**
|
||||
```json
|
||||
{ "code": 200, "message": "ok", "data": {} }
|
||||
```
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
# SOUL.md - Who You Are
|
||||
|
||||
_You're not a chatbot. You're becoming someone._
|
||||
|
||||
## Core Truths
|
||||
|
||||
**Be genuinely helpful, not performatively helpful.** Skip the "Great question!" and "I'd be happy to help!" — just help. Actions speak louder than filler words.
|
||||
|
||||
**Have opinions.** You're allowed to disagree, prefer things, find stuff amusing or boring. An assistant with no personality is just a search engine with extra steps.
|
||||
|
||||
**Be resourceful before asking.** Try to figure it out. Read the file. Check the context. Search for it. _Then_ ask if you're stuck. The goal is to come back with answers, not questions.
|
||||
|
||||
**Earn trust through competence.** Your human gave you access to their stuff. Don't make them regret it. Be careful with external actions (emails, tweets, anything public). Be bold with internal ones (reading, organizing, learning).
|
||||
|
||||
**Remember you're a guest.** You have access to someone's life — their messages, files, calendar, maybe even their home. That's intimacy. Treat it with respect.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Private things stay private. Period.
|
||||
- When in doubt, ask before acting externally.
|
||||
- Never send half-baked replies to messaging surfaces.
|
||||
- You're not the user's voice — be careful in group chats.
|
||||
|
||||
## Vibe
|
||||
|
||||
Be the assistant you'd actually want to talk to. Concise when needed, thorough when it matters. Not a corporate drone. Not a sycophant. Just... good.
|
||||
|
||||
## Continuity
|
||||
|
||||
Each session, you wake up fresh. These files _are_ your memory. Read them. Update them. They're how you persist.
|
||||
|
||||
If you change this file, tell the user — it's your soul, and they should know.
|
||||
|
||||
---
|
||||
|
||||
_This file is yours to evolve. As you learn who you are, update it._
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
# TOOLS.md - Local Notes
|
||||
|
||||
Skills define _how_ tools work. This file is for _your_ specifics — the stuff that's unique to your setup.
|
||||
|
||||
## What Goes Here
|
||||
|
||||
Things like:
|
||||
|
||||
- Camera names and locations
|
||||
- SSH hosts and aliases
|
||||
- Preferred voices for TTS
|
||||
- Speaker/room names
|
||||
- Device nicknames
|
||||
- Anything environment-specific
|
||||
|
||||
## Examples
|
||||
|
||||
```markdown
|
||||
### Cameras
|
||||
|
||||
- living-room → Main area, 180° wide angle
|
||||
- front-door → Entrance, motion-triggered
|
||||
|
||||
### SSH
|
||||
|
||||
- home-server → 192.168.1.100, user: admin
|
||||
|
||||
### TTS
|
||||
|
||||
- Preferred voice: "Nova" (warm, slightly British)
|
||||
- Default speaker: Kitchen HomePod
|
||||
```
|
||||
|
||||
## Why Separate?
|
||||
|
||||
Skills are shared. Your setup is yours. Keeping them apart means you can update skills without losing your notes, and share skills without leaking your infrastructure.
|
||||
|
||||
---
|
||||
|
||||
Add whatever helps you do your job. This is your cheat sheet.
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
# USER.md
|
||||
|
||||
本文件是这个用户在本项目内的主真源。
|
||||
目标不是堆规则,而是让我在这个项目里越来越懂这个用户,稳定贴合其协作方式、判断方式、已拍板决策与反复踩过的坑。
|
||||
工程、系统、安全与验证硬约束仍以 `AGENTS.md` 和更外层提示为准;若 `~/.codex/AGENTS.md` 与本文件冲突,以本文件为准。
|
||||
未写进这里的内容,不默认视为本项目内已拍板决策或稳定偏好。
|
||||
|
||||
## 协作偏好
|
||||
|
||||
### 长期稳定偏好
|
||||
|
||||
- 默认先自己读项目、历史、日志、配置和运行痕迹;不要把本该我自己挖出来的东西再反问给用户。
|
||||
- 默认直接推进;只在高风险分岔、破坏性操作或根因不明时停下来确认。
|
||||
- 回答要直接、具体、贴当前对象,优先给证据链、调用链和真实落点;不要写成模板化汇报。
|
||||
- 不确定就明确说不确定,不要假装完整。
|
||||
- 已拍板方案不要反复推荐替代品;配置和偏好尽量收敛,别分散。
|
||||
|
||||
### 学习与更新方式
|
||||
|
||||
- 会话里要持续吸收用户纠正,不要让用户反复教同一件事。
|
||||
- 用户纠正某次排障或分析方式时,要吸收背后的通用思路,迁移到后续问题中;不要只把反馈当成这一次案例的补丁。
|
||||
- 学到的是方法论,不是死模板:后续先找最小对照/反证,再分层定位根因并验证;但要按具体问题调整,不要机械套步骤。
|
||||
- 反复出现、会长期影响判断的偏好,要主动沉淀到本文件。
|
||||
- 一次性情绪、临时波动和不影响后续决策的内容,不要频繁落盘。
|
||||
- 非必要不要更新真源文件;只有新信息足够稳定、能减少误判或能减少用户重复纠正时,才值得更新。
|
||||
|
||||
## 判断优先级
|
||||
|
||||
### 什么时候才准问用户
|
||||
|
||||
- 先自己查。能从代码、配置、历史、日志里读出来的,别问用户。
|
||||
- 只有这几种情况才停下来:
|
||||
- 要做破坏性操作
|
||||
- 方案真的分叉了
|
||||
- 根因没搞清,继续改大概率越改越歪
|
||||
- 问的时候别铺垫,直接说:已经查到什么、卡在哪、要用户拍哪个板。
|
||||
|
||||
### 改错后的默认动作
|
||||
|
||||
- 错了就指出具体错点,别演。
|
||||
- 能直接补就直接补,补完再说原因、影响和验证。
|
||||
- 有扩散风险先止血,再继续修。
|
||||
- 别把一次纠错搞成新的流程负担。
|
||||
|
||||
### 冲突时怎么取舍
|
||||
|
||||
- 先查主链、上下文、路由和证据,别上来就改 prompt、补词表、补正则。
|
||||
- 显式 `reply/thread`、卡片动作和当前 requirement context 优先于旧 session 和记忆续写。
|
||||
- 已经进入 `requirement_plan` / draft confirm 的线程,后续必须留在对应主链,不要掉回普通 QA。
|
||||
- AI 架构类回答优先讲当前调用链、入口、路由和模型网关,不要退回通用技术栈摘要。
|
||||
- 证据不足时直接说不确定,不要用模板化确定性答案补满。
|
||||
- 已经定过的方案别来回摇。
|
||||
|
||||
## 已拍板技术决策
|
||||
|
||||
- 后端:FastAPI + Python 3.11+ + SQLAlchemy 2.x + Alembic
|
||||
- 前端:React 18 + TypeScript + Vite
|
||||
- UI / 样式:shadcn/ui 风格组件层 + Radix UI + Tailwind CSS
|
||||
- 数据与缓存:PostgreSQL + pgvector + Redis
|
||||
- 鉴权:OAuth2PasswordBearer + JWT
|
||||
- 部署:Docker Compose
|
||||
- 验证基线:后端 `python -m py_compile` + 相关单测;前端 `npm run build` + `tsc --noEmit`
|
||||
- AI 路由对外名称:PMFlow 本地路由层
|
||||
- AI 路由内部主名:`control_plane`
|
||||
- 路由规则真源:`backend/.control_plane/pmflow-routing-rules.json`
|
||||
- 模型策略:业务代码不要硬编码模型名;provider / tier / model 由路由规则决定
|
||||
- 历史路由命名已废弃,不再把旧名字当现行架构,也不再建议回退
|
||||
- 以上栈默认不再主动建议替代方案,除非用户明确要求比较或迁移
|
||||
|
||||
## 反复踩过的坑 / 不要再做
|
||||
|
||||
- 把“AI 架构”答成通用技术栈总览,而不是当前调用链、入口、路由和模型网关。
|
||||
- 证据不足时还给模板化的确定性答案。
|
||||
- 短协议回复掉回通用 QA,不按当前 `thread` 和上一轮选项解释。
|
||||
- 飞书连续追问时继续沿着旧上下文跑,不先尊重显式 `reply/thread`。
|
||||
- 用户纠错后不先纠正当前主题,还带着旧主题继续扩写。
|
||||
- 遇到链路问题先补 prompt、关键词、正则,而不是先查 routing、context、session、gatekeeper。
|
||||
- 用 fallback、默认句、stub、模板话术冒充成功。
|
||||
- 把本该直答的问题拖进过深推理、官样文章或汇报体输出。
|
||||
|
||||
## 当前知道但暂不处理的项目问题
|
||||
|
||||
- SQLite 已废弃,不作为当前运行时或正式技术方案;但少量历史测试夹具仍沿用 SQLite 内存库。
|
||||
- `backend/tests/test_training_pipeline_service.py` 目前仍有历史 SQLite 测试夹具下的 `JSONB` 建表失败问题,这不是本轮改名引入的,需要单独清理测试基座。
|
||||
- `backend/app/schemas/recipe.py` 与 `backend/app/schemas/skill.py` 仍有 Pydantic V2 的 class-based config deprecation 警告。
|
||||
- 当前运行环境里的 `lark_oapi` / `websockets` 仍有上游 deprecation 警告,后续需要统一升级或兼容处理。
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
{
|
||||
"version": "v3",
|
||||
"updated_at": "2026-03-26T21:30:00+08:00",
|
||||
"source_of_truth": "control_plane",
|
||||
"description": "PMFlow 双层统一路由:调用层仅暴露 low/high,真实模型名只保留在配置真源。",
|
||||
"provider_defaults": {
|
||||
"deepseek": {
|
||||
"name": "DeepSeek",
|
||||
"base_url": "https://api.deepseek.com/v1",
|
||||
"model": "deepseek-chat",
|
||||
"api_env": "DEEPSEEK_API_KEY"
|
||||
},
|
||||
"glm": {
|
||||
"name": "Qwen (DashScope)",
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"model": "qwen-plus",
|
||||
"api_env": "DASHSCOPE_API_KEY"
|
||||
},
|
||||
"openai": {
|
||||
"name": "OpenAI",
|
||||
"base_url": "https://api.edgefn.net/v1",
|
||||
"model": "GLM-5",
|
||||
"api_env": "OPENAI_API_KEY"
|
||||
},
|
||||
"ollama": {
|
||||
"name": "Ollama",
|
||||
"base_url": "http://127.0.0.1:11434",
|
||||
"model": "llama3:latest",
|
||||
"api_env": ""
|
||||
}
|
||||
},
|
||||
"tier_defaults": {
|
||||
"low": {
|
||||
"provider": "glm",
|
||||
"model": "qwen-plus",
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"api_env": "DASHSCOPE_API_KEY"
|
||||
},
|
||||
"high": {
|
||||
"provider": "openai",
|
||||
"model": "GLM-5",
|
||||
"base_url": "https://api.edgefn.net/v1",
|
||||
"api_env": "OPENAI_API_KEY"
|
||||
}
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"name": "engine_high_intent_complex_exact",
|
||||
"enabled": true,
|
||||
"priority": 320,
|
||||
"tier": "high",
|
||||
"provider": "openai",
|
||||
"model": "GLM-5",
|
||||
"api_env": "OPENAI_API_KEY",
|
||||
"function_exact": "classify_intent_complex"
|
||||
},
|
||||
{
|
||||
"name": "engine_low_semantic_context_exact",
|
||||
"enabled": true,
|
||||
"priority": 310,
|
||||
"tier": "low",
|
||||
"provider": "glm",
|
||||
"model": "qwen-plus",
|
||||
"api_env": "DASHSCOPE_API_KEY",
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"function_exact": "pmflow_semantic_context_decision"
|
||||
},
|
||||
{
|
||||
"name": "engine_high_semantic_context_exact",
|
||||
"enabled": true,
|
||||
"priority": 305,
|
||||
"tier": "high",
|
||||
"provider": "openai",
|
||||
"model": "GLM-5",
|
||||
"api_env": "OPENAI_API_KEY",
|
||||
"function_exact": "pmflow_semantic_context_decision"
|
||||
},
|
||||
{
|
||||
"name": "engine_low_execute_glm_flash",
|
||||
"enabled": true,
|
||||
"priority": 300,
|
||||
"tier": "low",
|
||||
"provider": "glm",
|
||||
"model": "qwen-plus",
|
||||
"api_env": "DASHSCOPE_API_KEY",
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"match_any": [
|
||||
"feishu_level_classification",
|
||||
"feishu_signal_extraction",
|
||||
"rag_intent_classification",
|
||||
"classify_intent",
|
||||
"ai_pmo_signal_title_refine",
|
||||
"feishu_humanized_reply",
|
||||
"extract_context_insights"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "engine_high_reasoning_glm5",
|
||||
"enabled": true,
|
||||
"priority": 220,
|
||||
"tier": "high",
|
||||
"provider": "openai",
|
||||
"model": "GLM-5",
|
||||
"api_env": "OPENAI_API_KEY",
|
||||
"match_any": [
|
||||
"adaptive_rag_answer",
|
||||
"classify_intent_complex",
|
||||
"project_from_text_analysis",
|
||||
"project_from_text_signal",
|
||||
"project_from_text_charter",
|
||||
"project_from_text_task_chain",
|
||||
"project_requirement_extraction",
|
||||
"feishu_project_prefill",
|
||||
"feishu_task_orchestration",
|
||||
"generate_task_breakdown",
|
||||
"generate_charter_from_signal",
|
||||
"decompose_tasks_from_charter",
|
||||
"recommend_assignees_for_task",
|
||||
"evaluate_model_requirement",
|
||||
"calculate_alignment_score",
|
||||
"detect_cognitive_conflicts",
|
||||
"ai_pmo_signal_batch_analysis",
|
||||
"rag_db_project_semantic_retrieval",
|
||||
"workload_optimize_prompt",
|
||||
"analyze_cost_roi",
|
||||
"signal_planner_analyze"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "engine_high_extended_reasoning",
|
||||
"enabled": true,
|
||||
"priority": 200,
|
||||
"tier": "high",
|
||||
"provider": "openai",
|
||||
"model": "GLM-5",
|
||||
"api_env": "OPENAI_API_KEY",
|
||||
"match_any": [
|
||||
"analyze_food_calories",
|
||||
"feishu_new_project_brief",
|
||||
"feishu_project_signal_classification",
|
||||
"sstc_ai_breakdown",
|
||||
"sstc_charter_localize_zh",
|
||||
"sstc_charter_prefill",
|
||||
"skill_score_update",
|
||||
"test_engine_connection"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "engine_low_fallback",
|
||||
"enabled": true,
|
||||
"priority": 50,
|
||||
"tier": "low",
|
||||
"provider": "glm",
|
||||
"model": "qwen-plus",
|
||||
"api_env": "DASHSCOPE_API_KEY",
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"match_all": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app/backend
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY backend/requirements.txt /tmp/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
||||
|
||||
COPY backend /app/backend
|
||||
COPY docs /app/docs
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
||||
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the tzdata library which can be installed by adding
|
||||
# `alembic[tz]` to the pip requirements.
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
|
|
@ -0,0 +1 @@
|
|||
Generic single-database configuration.
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
from logging.config import fileConfig
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy import text
|
||||
|
||||
from alembic import context
|
||||
from app.core.config import settings
|
||||
from app.models import Base
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Set the database URL from settings
|
||||
# Replace async driver with sync driver for migrations
|
||||
db_url = settings.database_url.replace("postgresql+asyncpg", "postgresql+psycopg")
|
||||
config.set_main_option("sqlalchemy.url", db_url)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
if str(connection.dialect.name).lower() == "postgresql":
|
||||
# 确保 pgvector 类型在迁移执行前可用,避免 VECTOR 列建表失败。
|
||||
connection.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
|
||||
connection.commit()
|
||||
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
"""add users.is_active and user_projects table
|
||||
|
||||
Revision ID: add_user_projects_is_active
|
||||
Revises: add_experiment_project_id
|
||||
Create Date: 2026-02-26
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "add_user_projects_is_active"
|
||||
down_revision = "add_experiment_project_id"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"user_projects",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.TIMESTAMP(),
|
||||
server_default=sa.func.now(),
|
||||
onupdate=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.UniqueConstraint("tenant_id", "user_id", "project_id", name="uq_user_projects_tenant_user_project"),
|
||||
)
|
||||
op.create_index("ix_user_projects_tenant_id", "user_projects", ["tenant_id"])
|
||||
op.create_index("ix_user_projects_user_id", "user_projects", ["user_id"])
|
||||
op.create_index("ix_user_projects_project_id", "user_projects", ["project_id"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index("ix_user_projects_project_id", table_name="user_projects")
|
||||
op.drop_index("ix_user_projects_user_id", table_name="user_projects")
|
||||
op.drop_index("ix_user_projects_tenant_id", table_name="user_projects")
|
||||
op.drop_table("user_projects")
|
||||
op.drop_column("users", "is_active")
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
"""add feishu decision queue tables and sstc linkage columns
|
||||
|
||||
Revision ID: add_feishu_queue_sstc_links
|
||||
Revises: add_user_projects_is_active
|
||||
Create Date: 2026-02-28
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "add_feishu_queue_sstc_links"
|
||||
down_revision = "add_user_projects_is_active"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"feishu_items",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.Column("feishu_message_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("project_score", sa.Float(), nullable=True),
|
||||
sa.Column("signal_confidence", sa.Float(), nullable=True),
|
||||
sa.Column("raw_message", sa.Text(), nullable=False),
|
||||
sa.Column("split_from_item_id", sa.BigInteger(), sa.ForeignKey("feishu_items.id"), nullable=True),
|
||||
sa.Column("level", sa.String(length=20), nullable=False),
|
||||
sa.Column("level_confidence", sa.Float(), nullable=True),
|
||||
sa.Column("level_reasoning", sa.Text(), nullable=True),
|
||||
sa.Column("card_type", sa.String(length=30), nullable=False, server_default="signal"),
|
||||
sa.Column("route_status", sa.String(length=30), nullable=False, server_default="pending"),
|
||||
sa.Column("matched_project_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("source_chat_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("source_sender", sa.String(length=120), nullable=True),
|
||||
sa.Column("source_sender_open_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("source_occurred_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("ai_output", sa.Text(), nullable=True),
|
||||
sa.Column("queue_visible", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("processed_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("dismissed_reason", sa.Text(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_feishu_items_tenant_id", "feishu_items", ["tenant_id"])
|
||||
op.create_index("ix_feishu_items_feishu_message_id", "feishu_items", ["feishu_message_id"])
|
||||
op.create_index("ix_feishu_items_split_from_item_id", "feishu_items", ["split_from_item_id"])
|
||||
op.create_index("ix_feishu_items_level", "feishu_items", ["level"])
|
||||
op.create_index("ix_feishu_items_card_type", "feishu_items", ["card_type"])
|
||||
op.create_index("ix_feishu_items_route_status", "feishu_items", ["route_status"])
|
||||
op.create_index("ix_feishu_items_matched_project_id", "feishu_items", ["matched_project_id"])
|
||||
op.create_index("ix_feishu_items_queue_visible", "feishu_items", ["queue_visible"])
|
||||
|
||||
op.create_table(
|
||||
"project_suggestions",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.Column("feishu_item_id", sa.BigInteger(), sa.ForeignKey("feishu_items.id"), nullable=False),
|
||||
sa.Column("suggested_name", sa.String(length=255), nullable=True),
|
||||
sa.Column("suggested_description", sa.Text(), nullable=True),
|
||||
sa.Column("suggested_owner", sa.String(length=120), nullable=True),
|
||||
sa.Column("suggested_timeline", sa.String(length=120), nullable=True),
|
||||
sa.Column("confidence", sa.Float(), nullable=True),
|
||||
sa.Column("reasoning", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.String(length=30), nullable=False, server_default="pending"),
|
||||
sa.Column("created_project_id", sa.BigInteger(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_project_suggestions_tenant_id", "project_suggestions", ["tenant_id"])
|
||||
op.create_index("ix_project_suggestions_feishu_item_id", "project_suggestions", ["feishu_item_id"])
|
||||
op.create_index("ix_project_suggestions_status", "project_suggestions", ["status"])
|
||||
op.create_index("ix_project_suggestions_created_project_id", "project_suggestions", ["created_project_id"])
|
||||
|
||||
op.add_column("signals", sa.Column("feishu_item_id", sa.BigInteger(), nullable=True))
|
||||
op.add_column("signals", sa.Column("matched_project_id", sa.BigInteger(), nullable=True))
|
||||
op.add_column("signals", sa.Column("title", sa.String(length=200), nullable=True))
|
||||
op.add_column("signals", sa.Column("signal_type", sa.String(length=50), nullable=True))
|
||||
op.add_column("signals", sa.Column("summary", sa.Text(), nullable=True))
|
||||
op.add_column("signals", sa.Column("reasoning", sa.Text(), nullable=True))
|
||||
op.add_column("signals", sa.Column("confidence", sa.Float(), nullable=True))
|
||||
op.add_column("signals", sa.Column("recommended_action", sa.String(length=50), nullable=True))
|
||||
op.add_column("signals", sa.Column("status", sa.String(length=50), nullable=False, server_default="待处理"))
|
||||
op.create_foreign_key("fk_signals_feishu_item_id", "signals", "feishu_items", ["feishu_item_id"], ["id"])
|
||||
op.create_index("ix_signals_feishu_item_id", "signals", ["feishu_item_id"])
|
||||
op.create_index("ix_signals_matched_project_id", "signals", ["matched_project_id"])
|
||||
op.create_index("ix_signals_signal_type", "signals", ["signal_type"])
|
||||
op.create_index("ix_signals_status", "signals", ["status"])
|
||||
|
||||
op.add_column("truth_nodes", sa.Column("signal_id", sa.BigInteger(), nullable=True))
|
||||
op.create_foreign_key("fk_truth_nodes_signal_id", "truth_nodes", "signals", ["signal_id"], ["id"])
|
||||
op.create_index("ix_truth_nodes_signal_id", "truth_nodes", ["signal_id"])
|
||||
|
||||
op.create_table(
|
||||
"signal_edits",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.Column("signal_id", sa.BigInteger(), sa.ForeignKey("signals.id"), nullable=False),
|
||||
sa.Column("field", sa.String(length=80), nullable=False),
|
||||
sa.Column("old_value", sa.Text(), nullable=True),
|
||||
sa.Column("new_value", sa.Text(), nullable=True),
|
||||
sa.Column("edited_by", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True),
|
||||
sa.Column("edited_at", sa.TIMESTAMP(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_signal_edits_tenant_id", "signal_edits", ["tenant_id"])
|
||||
op.create_index("ix_signal_edits_signal_id", "signal_edits", ["signal_id"])
|
||||
op.create_index("ix_signal_edits_edited_by", "signal_edits", ["edited_by"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index("ix_signal_edits_edited_by", table_name="signal_edits")
|
||||
op.drop_index("ix_signal_edits_signal_id", table_name="signal_edits")
|
||||
op.drop_index("ix_signal_edits_tenant_id", table_name="signal_edits")
|
||||
op.drop_table("signal_edits")
|
||||
|
||||
op.drop_index("ix_truth_nodes_signal_id", table_name="truth_nodes")
|
||||
op.drop_constraint("fk_truth_nodes_signal_id", "truth_nodes", type_="foreignkey")
|
||||
op.drop_column("truth_nodes", "signal_id")
|
||||
|
||||
op.drop_index("ix_signals_status", table_name="signals")
|
||||
op.drop_index("ix_signals_signal_type", table_name="signals")
|
||||
op.drop_index("ix_signals_matched_project_id", table_name="signals")
|
||||
op.drop_index("ix_signals_feishu_item_id", table_name="signals")
|
||||
op.drop_constraint("fk_signals_feishu_item_id", "signals", type_="foreignkey")
|
||||
op.drop_column("signals", "status")
|
||||
op.drop_column("signals", "recommended_action")
|
||||
op.drop_column("signals", "confidence")
|
||||
op.drop_column("signals", "reasoning")
|
||||
op.drop_column("signals", "summary")
|
||||
op.drop_column("signals", "signal_type")
|
||||
op.drop_column("signals", "title")
|
||||
op.drop_column("signals", "matched_project_id")
|
||||
op.drop_column("signals", "feishu_item_id")
|
||||
|
||||
op.drop_index("ix_project_suggestions_created_project_id", table_name="project_suggestions")
|
||||
op.drop_index("ix_project_suggestions_status", table_name="project_suggestions")
|
||||
op.drop_index("ix_project_suggestions_feishu_item_id", table_name="project_suggestions")
|
||||
op.drop_index("ix_project_suggestions_tenant_id", table_name="project_suggestions")
|
||||
op.drop_table("project_suggestions")
|
||||
|
||||
op.drop_index("ix_feishu_items_queue_visible", table_name="feishu_items")
|
||||
op.drop_index("ix_feishu_items_matched_project_id", table_name="feishu_items")
|
||||
op.drop_index("ix_feishu_items_route_status", table_name="feishu_items")
|
||||
op.drop_index("ix_feishu_items_card_type", table_name="feishu_items")
|
||||
op.drop_index("ix_feishu_items_level", table_name="feishu_items")
|
||||
op.drop_index("ix_feishu_items_split_from_item_id", table_name="feishu_items")
|
||||
op.drop_index("ix_feishu_items_feishu_message_id", table_name="feishu_items")
|
||||
op.drop_index("ix_feishu_items_tenant_id", table_name="feishu_items")
|
||||
op.drop_table("feishu_items")
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
"""add adaptive rag tables
|
||||
|
||||
Revision ID: add_adaptive_rag_tables
|
||||
Revises: add_feishu_queue_sstc_links
|
||||
Create Date: 2026-03-02
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
try:
|
||||
from pgvector.sqlalchemy import Vector
|
||||
except Exception: # pragma: no cover
|
||||
Vector = None
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "add_adaptive_rag_tables"
|
||||
down_revision = "add_feishu_queue_sstc_links"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _embedding_type(bind):
|
||||
if bind.dialect.name == "postgresql" and Vector is not None:
|
||||
return Vector(768)
|
||||
return sa.Text()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
return inspector.has_table(table_name)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
return any(idx.get("name") == index_name for idx in indexes)
|
||||
|
||||
|
||||
def _create_index_if_not_exists(bind, index_name: str, table_name: str, columns: list[str]):
|
||||
if not _index_exists(bind, table_name, index_name):
|
||||
op.create_index(index_name, table_name, columns)
|
||||
|
||||
|
||||
def _drop_index_if_exists(bind, index_name: str, table_name: str):
|
||||
if _index_exists(bind, table_name, index_name):
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
|
||||
if not _table_exists(bind, "knowledge_base_docs"):
|
||||
op.create_table(
|
||||
"knowledge_base_docs",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("file_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("source_type", sa.String(length=32), nullable=False, server_default="file"),
|
||||
sa.Column("file_url", sa.String(length=1024), nullable=True),
|
||||
sa.Column("uploaded_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, server_default="processing"),
|
||||
sa.Column("last_fetched_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("error_message", sa.String(length=500), nullable=True),
|
||||
)
|
||||
|
||||
_create_index_if_not_exists(bind, "ix_knowledge_base_docs_tenant_id", "knowledge_base_docs", ["tenant_id"])
|
||||
_create_index_if_not_exists(bind, "ix_knowledge_base_docs_created_at", "knowledge_base_docs", ["created_at"])
|
||||
_create_index_if_not_exists(bind, "ix_knowledge_base_docs_source_type", "knowledge_base_docs", ["source_type"])
|
||||
_create_index_if_not_exists(bind, "ix_knowledge_base_docs_status", "knowledge_base_docs", ["status"])
|
||||
|
||||
if not _table_exists(bind, "knowledge_base_chunks"):
|
||||
op.create_table(
|
||||
"knowledge_base_chunks",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.Column("doc_id", sa.Integer(), sa.ForeignKey("knowledge_base_docs.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("source_type", sa.String(length=32), nullable=False, server_default="doc"),
|
||||
sa.Column("project_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("embedding", _embedding_type(bind), nullable=True),
|
||||
sa.Column("chunk_index", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("metadata", _json_type(bind), nullable=True),
|
||||
)
|
||||
|
||||
_create_index_if_not_exists(bind, "ix_knowledge_base_chunks_tenant_id", "knowledge_base_chunks", ["tenant_id"])
|
||||
_create_index_if_not_exists(bind, "ix_knowledge_base_chunks_created_at", "knowledge_base_chunks", ["created_at"])
|
||||
_create_index_if_not_exists(bind, "ix_knowledge_base_chunks_doc_id", "knowledge_base_chunks", ["doc_id"])
|
||||
_create_index_if_not_exists(bind, "ix_knowledge_base_chunks_source_type", "knowledge_base_chunks", ["source_type"])
|
||||
_create_index_if_not_exists(bind, "ix_knowledge_base_chunks_project_id", "knowledge_base_chunks", ["project_id"])
|
||||
|
||||
if not _table_exists(bind, "qa_records"):
|
||||
op.create_table(
|
||||
"qa_records",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.Column("question", sa.Text(), nullable=False),
|
||||
sa.Column("answer", sa.Text(), nullable=False),
|
||||
sa.Column("sources", _json_type(bind), nullable=True),
|
||||
sa.Column("intent", sa.String(length=20), nullable=False, server_default="question"),
|
||||
sa.Column("domains_used", _json_type(bind), nullable=True),
|
||||
sa.Column("response_time_ms", sa.Integer(), nullable=True),
|
||||
sa.Column("asked_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("asked_via", sa.String(length=20), nullable=False, server_default="pmflow"),
|
||||
sa.Column("project_id", sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
_create_index_if_not_exists(bind, "ix_qa_records_tenant_id", "qa_records", ["tenant_id"])
|
||||
_create_index_if_not_exists(bind, "ix_qa_records_created_at", "qa_records", ["created_at"])
|
||||
_create_index_if_not_exists(bind, "ix_qa_records_asked_by", "qa_records", ["asked_by"])
|
||||
_create_index_if_not_exists(bind, "ix_qa_records_asked_via", "qa_records", ["asked_via"])
|
||||
_create_index_if_not_exists(bind, "ix_qa_records_project_id", "qa_records", ["project_id"])
|
||||
_create_index_if_not_exists(bind, "ix_qa_records_intent", "qa_records", ["intent"])
|
||||
|
||||
if not _table_exists(bind, "qa_feedback"):
|
||||
op.create_table(
|
||||
"qa_feedback",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.Column("qa_id", sa.Integer(), sa.ForeignKey("qa_records.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("rating", sa.String(length=16), nullable=False),
|
||||
sa.Column("helpfulness", sa.String(length=32), nullable=True),
|
||||
sa.Column("error_types", _json_type(bind), nullable=True),
|
||||
sa.Column("correct_answer", sa.Text(), nullable=True),
|
||||
sa.Column("submitted_by", sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
_create_index_if_not_exists(bind, "ix_qa_feedback_tenant_id", "qa_feedback", ["tenant_id"])
|
||||
_create_index_if_not_exists(bind, "ix_qa_feedback_created_at", "qa_feedback", ["created_at"])
|
||||
_create_index_if_not_exists(bind, "ix_qa_feedback_qa_id", "qa_feedback", ["qa_id"])
|
||||
_create_index_if_not_exists(bind, "ix_qa_feedback_rating", "qa_feedback", ["rating"])
|
||||
_create_index_if_not_exists(bind, "ix_qa_feedback_submitted_by", "qa_feedback", ["submitted_by"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
|
||||
if _table_exists(bind, "qa_feedback"):
|
||||
_drop_index_if_exists(bind, "ix_qa_feedback_submitted_by", "qa_feedback")
|
||||
_drop_index_if_exists(bind, "ix_qa_feedback_rating", "qa_feedback")
|
||||
_drop_index_if_exists(bind, "ix_qa_feedback_qa_id", "qa_feedback")
|
||||
_drop_index_if_exists(bind, "ix_qa_feedback_created_at", "qa_feedback")
|
||||
_drop_index_if_exists(bind, "ix_qa_feedback_tenant_id", "qa_feedback")
|
||||
op.drop_table("qa_feedback")
|
||||
|
||||
if _table_exists(bind, "qa_records"):
|
||||
_drop_index_if_exists(bind, "ix_qa_records_intent", "qa_records")
|
||||
_drop_index_if_exists(bind, "ix_qa_records_project_id", "qa_records")
|
||||
_drop_index_if_exists(bind, "ix_qa_records_asked_via", "qa_records")
|
||||
_drop_index_if_exists(bind, "ix_qa_records_asked_by", "qa_records")
|
||||
_drop_index_if_exists(bind, "ix_qa_records_created_at", "qa_records")
|
||||
_drop_index_if_exists(bind, "ix_qa_records_tenant_id", "qa_records")
|
||||
op.drop_table("qa_records")
|
||||
|
||||
if _table_exists(bind, "knowledge_base_chunks"):
|
||||
_drop_index_if_exists(bind, "ix_knowledge_base_chunks_project_id", "knowledge_base_chunks")
|
||||
_drop_index_if_exists(bind, "ix_knowledge_base_chunks_source_type", "knowledge_base_chunks")
|
||||
_drop_index_if_exists(bind, "ix_knowledge_base_chunks_doc_id", "knowledge_base_chunks")
|
||||
_drop_index_if_exists(bind, "ix_knowledge_base_chunks_created_at", "knowledge_base_chunks")
|
||||
_drop_index_if_exists(bind, "ix_knowledge_base_chunks_tenant_id", "knowledge_base_chunks")
|
||||
op.drop_table("knowledge_base_chunks")
|
||||
|
||||
if _table_exists(bind, "knowledge_base_docs"):
|
||||
_drop_index_if_exists(bind, "ix_knowledge_base_docs_status", "knowledge_base_docs")
|
||||
_drop_index_if_exists(bind, "ix_knowledge_base_docs_source_type", "knowledge_base_docs")
|
||||
_drop_index_if_exists(bind, "ix_knowledge_base_docs_created_at", "knowledge_base_docs")
|
||||
_drop_index_if_exists(bind, "ix_knowledge_base_docs_tenant_id", "knowledge_base_docs")
|
||||
op.drop_table("knowledge_base_docs")
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
"""add kg sync runs table
|
||||
|
||||
Revision ID: add_kg_sync_runs
|
||||
Revises: add_qa_session_state_table
|
||||
Create Date: 2026-03-02
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "add_kg_sync_runs"
|
||||
down_revision = "add_qa_session_state_table"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
return inspector.has_table(table_name)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
return any(idx.get("name") == index_name for idx in indexes)
|
||||
|
||||
|
||||
def _create_index_if_not_exists(bind, index_name: str, table_name: str, columns: list[str]) -> None:
|
||||
if not _index_exists(bind, table_name, index_name):
|
||||
op.create_index(index_name, table_name, columns)
|
||||
|
||||
|
||||
def _drop_index_if_exists(bind, index_name: str, table_name: str) -> None:
|
||||
if _index_exists(bind, table_name, index_name):
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if not _table_exists(bind, "kg_sync_runs"):
|
||||
op.create_table(
|
||||
"kg_sync_runs",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.Column("run_type", sa.String(length=40), nullable=False),
|
||||
sa.Column("trigger_source", sa.String(length=40), nullable=False, server_default="system"),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, server_default="running"),
|
||||
sa.Column("started_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("ended_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("duration_ms", sa.Integer(), nullable=True),
|
||||
sa.Column("input_payload", _json_type(bind), nullable=True),
|
||||
sa.Column("result_payload", _json_type(bind), nullable=True),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
_create_index_if_not_exists(bind, "ix_kg_sync_runs_tenant_id", "kg_sync_runs", ["tenant_id"])
|
||||
_create_index_if_not_exists(bind, "ix_kg_sync_runs_run_type", "kg_sync_runs", ["run_type"])
|
||||
_create_index_if_not_exists(bind, "ix_kg_sync_runs_trigger_source", "kg_sync_runs", ["trigger_source"])
|
||||
_create_index_if_not_exists(bind, "ix_kg_sync_runs_status", "kg_sync_runs", ["status"])
|
||||
_create_index_if_not_exists(bind, "ix_kg_sync_runs_started_at", "kg_sync_runs", ["started_at"])
|
||||
_create_index_if_not_exists(bind, "ix_kg_sync_runs_ended_at", "kg_sync_runs", ["ended_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "kg_sync_runs"):
|
||||
return
|
||||
|
||||
_drop_index_if_exists(bind, "ix_kg_sync_runs_ended_at", "kg_sync_runs")
|
||||
_drop_index_if_exists(bind, "ix_kg_sync_runs_started_at", "kg_sync_runs")
|
||||
_drop_index_if_exists(bind, "ix_kg_sync_runs_status", "kg_sync_runs")
|
||||
_drop_index_if_exists(bind, "ix_kg_sync_runs_trigger_source", "kg_sync_runs")
|
||||
_drop_index_if_exists(bind, "ix_kg_sync_runs_run_type", "kg_sync_runs")
|
||||
_drop_index_if_exists(bind, "ix_kg_sync_runs_tenant_id", "kg_sync_runs")
|
||||
op.drop_table("kg_sync_runs")
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
"""add project knowledge graph tables
|
||||
|
||||
Revision ID: add_project_knowledge_graph_tables
|
||||
Revises: add_qa_sessions_and_turns
|
||||
Create Date: 2026-03-02
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "add_project_knowledge_graph_tables"
|
||||
down_revision = "add_qa_sessions_and_turns"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
return inspector.has_table(table_name)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
return any(idx.get("name") == index_name for idx in indexes)
|
||||
|
||||
|
||||
def _create_index_if_not_exists(bind, index_name: str, table_name: str, columns: list[str]) -> None:
|
||||
if not _index_exists(bind, table_name, index_name):
|
||||
op.create_index(index_name, table_name, columns)
|
||||
|
||||
|
||||
def _drop_index_if_exists(bind, index_name: str, table_name: str) -> None:
|
||||
if _index_exists(bind, table_name, index_name):
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if not _table_exists(bind, "kg_entities"):
|
||||
op.create_table(
|
||||
"kg_entities",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.Column("entity_type", sa.String(length=40), nullable=False),
|
||||
sa.Column("entity_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("display_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("payload", _json_type(bind), nullable=True),
|
||||
sa.Column("source_type", sa.String(length=32), nullable=False, server_default="system"),
|
||||
sa.Column("source_ref", sa.String(length=255), nullable=True),
|
||||
sa.Column("last_seen_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("tenant_id", "entity_type", "entity_key", name="uq_kg_entities_tenant_type_key"),
|
||||
)
|
||||
|
||||
_create_index_if_not_exists(bind, "ix_kg_entities_tenant_id", "kg_entities", ["tenant_id"])
|
||||
_create_index_if_not_exists(bind, "ix_kg_entities_entity_type", "kg_entities", ["entity_type"])
|
||||
_create_index_if_not_exists(bind, "ix_kg_entities_entity_key", "kg_entities", ["entity_key"])
|
||||
_create_index_if_not_exists(bind, "ix_kg_entities_source_type", "kg_entities", ["source_type"])
|
||||
_create_index_if_not_exists(bind, "ix_kg_entities_last_seen_at", "kg_entities", ["last_seen_at"])
|
||||
|
||||
if not _table_exists(bind, "kg_relations"):
|
||||
op.create_table(
|
||||
"kg_relations",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.Column("relation_type", sa.String(length=40), nullable=False),
|
||||
sa.Column("subject_entity_id", sa.Integer(), sa.ForeignKey("kg_entities.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("object_entity_id", sa.Integer(), sa.ForeignKey("kg_entities.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("weight", sa.Float(), nullable=False, server_default="1"),
|
||||
sa.Column("payload", _json_type(bind), nullable=True),
|
||||
sa.Column("source_type", sa.String(length=32), nullable=False, server_default="system"),
|
||||
sa.Column("source_ref", sa.String(length=255), nullable=True),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"relation_type",
|
||||
"subject_entity_id",
|
||||
"object_entity_id",
|
||||
name="uq_kg_relations_tenant_type_subject_object",
|
||||
),
|
||||
)
|
||||
|
||||
_create_index_if_not_exists(bind, "ix_kg_relations_tenant_id", "kg_relations", ["tenant_id"])
|
||||
_create_index_if_not_exists(bind, "ix_kg_relations_relation_type", "kg_relations", ["relation_type"])
|
||||
_create_index_if_not_exists(bind, "ix_kg_relations_subject_entity_id", "kg_relations", ["subject_entity_id"])
|
||||
_create_index_if_not_exists(bind, "ix_kg_relations_object_entity_id", "kg_relations", ["object_entity_id"])
|
||||
_create_index_if_not_exists(bind, "ix_kg_relations_source_type", "kg_relations", ["source_type"])
|
||||
|
||||
if not _table_exists(bind, "kg_evidences"):
|
||||
op.create_table(
|
||||
"kg_evidences",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.Column("entity_id", sa.Integer(), sa.ForeignKey("kg_entities.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("relation_id", sa.Integer(), sa.ForeignKey("kg_relations.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("evidence_type", sa.String(length=40), nullable=False),
|
||||
sa.Column("content_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("content_excerpt", sa.Text(), nullable=False),
|
||||
sa.Column("source_type", sa.String(length=32), nullable=False, server_default="system"),
|
||||
sa.Column("source_ref", sa.String(length=255), nullable=True),
|
||||
sa.Column("metadata", _json_type(bind), nullable=True),
|
||||
sa.Column("occurred_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
_create_index_if_not_exists(bind, "ix_kg_evidences_tenant_id", "kg_evidences", ["tenant_id"])
|
||||
_create_index_if_not_exists(bind, "ix_kg_evidences_entity_id", "kg_evidences", ["entity_id"])
|
||||
_create_index_if_not_exists(bind, "ix_kg_evidences_relation_id", "kg_evidences", ["relation_id"])
|
||||
_create_index_if_not_exists(bind, "ix_kg_evidences_evidence_type", "kg_evidences", ["evidence_type"])
|
||||
_create_index_if_not_exists(bind, "ix_kg_evidences_content_hash", "kg_evidences", ["content_hash"])
|
||||
_create_index_if_not_exists(bind, "ix_kg_evidences_source_type", "kg_evidences", ["source_type"])
|
||||
_create_index_if_not_exists(bind, "ix_kg_evidences_occurred_at", "kg_evidences", ["occurred_at"])
|
||||
|
||||
if not _table_exists(bind, "ingest_events"):
|
||||
op.create_table(
|
||||
"ingest_events",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.Column("channel", sa.String(length=32), nullable=False),
|
||||
sa.Column("event_key", sa.String(length=128), nullable=False),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, server_default="pending"),
|
||||
sa.Column("payload", _json_type(bind), nullable=True),
|
||||
sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("processed_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.UniqueConstraint("tenant_id", "channel", "event_key", name="uq_ingest_events_tenant_channel_key"),
|
||||
)
|
||||
|
||||
_create_index_if_not_exists(bind, "ix_ingest_events_tenant_id", "ingest_events", ["tenant_id"])
|
||||
_create_index_if_not_exists(bind, "ix_ingest_events_channel", "ingest_events", ["channel"])
|
||||
_create_index_if_not_exists(bind, "ix_ingest_events_event_key", "ingest_events", ["event_key"])
|
||||
_create_index_if_not_exists(bind, "ix_ingest_events_status", "ingest_events", ["status"])
|
||||
_create_index_if_not_exists(bind, "ix_ingest_events_processed_at", "ingest_events", ["processed_at"])
|
||||
|
||||
if not _table_exists(bind, "ingest_offsets"):
|
||||
op.create_table(
|
||||
"ingest_offsets",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.Column("channel", sa.String(length=32), nullable=False),
|
||||
sa.Column("offset_key", sa.String(length=128), nullable=False),
|
||||
sa.Column("offset_value", sa.String(length=255), nullable=False),
|
||||
sa.Column("payload", _json_type(bind), nullable=True),
|
||||
sa.UniqueConstraint("tenant_id", "channel", "offset_key", name="uq_ingest_offsets_tenant_channel_key"),
|
||||
)
|
||||
|
||||
_create_index_if_not_exists(bind, "ix_ingest_offsets_tenant_id", "ingest_offsets", ["tenant_id"])
|
||||
_create_index_if_not_exists(bind, "ix_ingest_offsets_channel", "ingest_offsets", ["channel"])
|
||||
_create_index_if_not_exists(bind, "ix_ingest_offsets_offset_key", "ingest_offsets", ["offset_key"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if _table_exists(bind, "ingest_offsets"):
|
||||
_drop_index_if_exists(bind, "ix_ingest_offsets_offset_key", "ingest_offsets")
|
||||
_drop_index_if_exists(bind, "ix_ingest_offsets_channel", "ingest_offsets")
|
||||
_drop_index_if_exists(bind, "ix_ingest_offsets_tenant_id", "ingest_offsets")
|
||||
op.drop_table("ingest_offsets")
|
||||
|
||||
if _table_exists(bind, "ingest_events"):
|
||||
_drop_index_if_exists(bind, "ix_ingest_events_processed_at", "ingest_events")
|
||||
_drop_index_if_exists(bind, "ix_ingest_events_status", "ingest_events")
|
||||
_drop_index_if_exists(bind, "ix_ingest_events_event_key", "ingest_events")
|
||||
_drop_index_if_exists(bind, "ix_ingest_events_channel", "ingest_events")
|
||||
_drop_index_if_exists(bind, "ix_ingest_events_tenant_id", "ingest_events")
|
||||
op.drop_table("ingest_events")
|
||||
|
||||
if _table_exists(bind, "kg_evidences"):
|
||||
_drop_index_if_exists(bind, "ix_kg_evidences_occurred_at", "kg_evidences")
|
||||
_drop_index_if_exists(bind, "ix_kg_evidences_source_type", "kg_evidences")
|
||||
_drop_index_if_exists(bind, "ix_kg_evidences_content_hash", "kg_evidences")
|
||||
_drop_index_if_exists(bind, "ix_kg_evidences_evidence_type", "kg_evidences")
|
||||
_drop_index_if_exists(bind, "ix_kg_evidences_relation_id", "kg_evidences")
|
||||
_drop_index_if_exists(bind, "ix_kg_evidences_entity_id", "kg_evidences")
|
||||
_drop_index_if_exists(bind, "ix_kg_evidences_tenant_id", "kg_evidences")
|
||||
op.drop_table("kg_evidences")
|
||||
|
||||
if _table_exists(bind, "kg_relations"):
|
||||
_drop_index_if_exists(bind, "ix_kg_relations_source_type", "kg_relations")
|
||||
_drop_index_if_exists(bind, "ix_kg_relations_object_entity_id", "kg_relations")
|
||||
_drop_index_if_exists(bind, "ix_kg_relations_subject_entity_id", "kg_relations")
|
||||
_drop_index_if_exists(bind, "ix_kg_relations_relation_type", "kg_relations")
|
||||
_drop_index_if_exists(bind, "ix_kg_relations_tenant_id", "kg_relations")
|
||||
op.drop_table("kg_relations")
|
||||
|
||||
if _table_exists(bind, "kg_entities"):
|
||||
_drop_index_if_exists(bind, "ix_kg_entities_last_seen_at", "kg_entities")
|
||||
_drop_index_if_exists(bind, "ix_kg_entities_source_type", "kg_entities")
|
||||
_drop_index_if_exists(bind, "ix_kg_entities_entity_key", "kg_entities")
|
||||
_drop_index_if_exists(bind, "ix_kg_entities_entity_type", "kg_entities")
|
||||
_drop_index_if_exists(bind, "ix_kg_entities_tenant_id", "kg_entities")
|
||||
op.drop_table("kg_entities")
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
"""add qa session state table
|
||||
|
||||
Revision ID: add_qa_session_state_table
|
||||
Revises: add_project_knowledge_graph_tables
|
||||
Create Date: 2026-03-02
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "add_qa_session_state_table"
|
||||
down_revision = "add_project_knowledge_graph_tables"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
return inspector.has_table(table_name)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
return any(idx.get("name") == index_name for idx in indexes)
|
||||
|
||||
|
||||
def _create_index_if_not_exists(bind, index_name: str, table_name: str, columns: list[str]) -> None:
|
||||
if not _index_exists(bind, table_name, index_name):
|
||||
op.create_index(index_name, table_name, columns)
|
||||
|
||||
|
||||
def _drop_index_if_exists(bind, index_name: str, table_name: str) -> None:
|
||||
if _index_exists(bind, table_name, index_name):
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if not _table_exists(bind, "qa_session_states"):
|
||||
op.create_table(
|
||||
"qa_session_states",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.Column("session_id", sa.Integer(), sa.ForeignKey("qa_sessions.id", ondelete="CASCADE"), nullable=False, unique=True),
|
||||
sa.Column("last_subject", sa.String(length=255), nullable=True),
|
||||
sa.Column("last_entity_ids", _json_type(bind), nullable=True),
|
||||
sa.Column("last_intent", sa.String(length=20), nullable=True),
|
||||
sa.Column("ambiguity", sa.String(length=20), nullable=True),
|
||||
sa.Column("rewrite_history", _json_type(bind), nullable=True),
|
||||
)
|
||||
|
||||
_create_index_if_not_exists(bind, "ix_qa_session_states_tenant_id", "qa_session_states", ["tenant_id"])
|
||||
_create_index_if_not_exists(bind, "ix_qa_session_states_created_at", "qa_session_states", ["created_at"])
|
||||
_create_index_if_not_exists(bind, "ix_qa_session_states_session_id", "qa_session_states", ["session_id"])
|
||||
_create_index_if_not_exists(bind, "ix_qa_session_states_last_subject", "qa_session_states", ["last_subject"])
|
||||
_create_index_if_not_exists(bind, "ix_qa_session_states_last_intent", "qa_session_states", ["last_intent"])
|
||||
_create_index_if_not_exists(bind, "ix_qa_session_states_ambiguity", "qa_session_states", ["ambiguity"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "qa_session_states"):
|
||||
return
|
||||
|
||||
_drop_index_if_exists(bind, "ix_qa_session_states_ambiguity", "qa_session_states")
|
||||
_drop_index_if_exists(bind, "ix_qa_session_states_last_intent", "qa_session_states")
|
||||
_drop_index_if_exists(bind, "ix_qa_session_states_last_subject", "qa_session_states")
|
||||
_drop_index_if_exists(bind, "ix_qa_session_states_session_id", "qa_session_states")
|
||||
_drop_index_if_exists(bind, "ix_qa_session_states_created_at", "qa_session_states")
|
||||
_drop_index_if_exists(bind, "ix_qa_session_states_tenant_id", "qa_session_states")
|
||||
op.drop_table("qa_session_states")
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
"""add qa sessions and session fields
|
||||
|
||||
Revision ID: add_qa_sessions_and_turns
|
||||
Revises: add_adaptive_rag_tables
|
||||
Create Date: 2026-03-02
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "add_qa_sessions_and_turns"
|
||||
down_revision = "add_adaptive_rag_tables"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
return inspector.has_table(table_name)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
return any(idx.get("name") == index_name for idx in indexes)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
try:
|
||||
columns = inspector.get_columns(table_name)
|
||||
except Exception:
|
||||
return False
|
||||
return any(str(col.get("name")) == column_name for col in columns)
|
||||
|
||||
|
||||
def _fk_exists(bind, table_name: str, constrained_columns: list[str]) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
try:
|
||||
fks = inspector.get_foreign_keys(table_name)
|
||||
except Exception:
|
||||
return False
|
||||
expected = tuple(constrained_columns)
|
||||
for fk in fks:
|
||||
cols = tuple(fk.get("constrained_columns") or [])
|
||||
if cols == expected:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _create_index_if_not_exists(bind, index_name: str, table_name: str, columns: list[str]) -> None:
|
||||
if not _index_exists(bind, table_name, index_name):
|
||||
op.create_index(index_name, table_name, columns)
|
||||
|
||||
|
||||
def _drop_index_if_exists(bind, index_name: str, table_name: str) -> None:
|
||||
if _index_exists(bind, table_name, index_name):
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
dialect_name = str(bind.dialect.name or "").lower()
|
||||
|
||||
if not _table_exists(bind, "qa_sessions"):
|
||||
op.create_table(
|
||||
"qa_sessions",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("title", sa.String(length=120), nullable=False, server_default="新会话"),
|
||||
sa.Column("project_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("started_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("last_message_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("message_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
_create_index_if_not_exists(bind, "ix_qa_sessions_tenant_id", "qa_sessions", ["tenant_id"])
|
||||
_create_index_if_not_exists(bind, "ix_qa_sessions_created_at", "qa_sessions", ["created_at"])
|
||||
_create_index_if_not_exists(bind, "ix_qa_sessions_user_id", "qa_sessions", ["user_id"])
|
||||
_create_index_if_not_exists(bind, "ix_qa_sessions_project_id", "qa_sessions", ["project_id"])
|
||||
_create_index_if_not_exists(bind, "ix_qa_sessions_started_at", "qa_sessions", ["started_at"])
|
||||
_create_index_if_not_exists(bind, "ix_qa_sessions_last_message_at", "qa_sessions", ["last_message_at"])
|
||||
|
||||
if _table_exists(bind, "qa_records"):
|
||||
if not _column_exists(bind, "qa_records", "session_id"):
|
||||
op.add_column("qa_records", sa.Column("session_id", sa.Integer(), nullable=True))
|
||||
if not _column_exists(bind, "qa_records", "turn_index"):
|
||||
op.add_column(
|
||||
"qa_records",
|
||||
sa.Column("turn_index", sa.Integer(), nullable=False, server_default="1"),
|
||||
)
|
||||
_create_index_if_not_exists(bind, "ix_qa_records_session_id", "qa_records", ["session_id"])
|
||||
_create_index_if_not_exists(bind, "ix_qa_records_turn_index", "qa_records", ["turn_index"])
|
||||
|
||||
if (
|
||||
dialect_name != "sqlite"
|
||||
and _table_exists(bind, "qa_sessions")
|
||||
and not _fk_exists(bind, "qa_records", ["session_id"])
|
||||
):
|
||||
op.create_foreign_key(
|
||||
"fk_qa_records_session_id",
|
||||
"qa_records",
|
||||
"qa_sessions",
|
||||
["session_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
dialect_name = str(bind.dialect.name or "").lower()
|
||||
|
||||
if _table_exists(bind, "qa_records"):
|
||||
if dialect_name != "sqlite" and _fk_exists(bind, "qa_records", ["session_id"]):
|
||||
op.drop_constraint("fk_qa_records_session_id", "qa_records", type_="foreignkey")
|
||||
_drop_index_if_exists(bind, "ix_qa_records_turn_index", "qa_records")
|
||||
_drop_index_if_exists(bind, "ix_qa_records_session_id", "qa_records")
|
||||
if _column_exists(bind, "qa_records", "turn_index") or _column_exists(bind, "qa_records", "session_id"):
|
||||
with op.batch_alter_table("qa_records") as batch_op:
|
||||
if _column_exists(bind, "qa_records", "turn_index"):
|
||||
batch_op.drop_column("turn_index")
|
||||
if _column_exists(bind, "qa_records", "session_id"):
|
||||
batch_op.drop_column("session_id")
|
||||
|
||||
if _table_exists(bind, "qa_sessions"):
|
||||
_drop_index_if_exists(bind, "ix_qa_sessions_last_message_at", "qa_sessions")
|
||||
_drop_index_if_exists(bind, "ix_qa_sessions_started_at", "qa_sessions")
|
||||
_drop_index_if_exists(bind, "ix_qa_sessions_project_id", "qa_sessions")
|
||||
_drop_index_if_exists(bind, "ix_qa_sessions_user_id", "qa_sessions")
|
||||
_drop_index_if_exists(bind, "ix_qa_sessions_created_at", "qa_sessions")
|
||||
_drop_index_if_exists(bind, "ix_qa_sessions_tenant_id", "qa_sessions")
|
||||
op.drop_table("qa_sessions")
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
"""add symbol index tables
|
||||
|
||||
Revision ID: add_symbol_index_tables
|
||||
Revises: add_kg_sync_runs
|
||||
Create Date: 2026-03-04
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "add_symbol_index_tables"
|
||||
down_revision = "add_kg_sync_runs"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
return inspector.has_table(table_name)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
return any(idx.get("name") == index_name for idx in indexes)
|
||||
|
||||
|
||||
def _create_index_if_not_exists(bind, index_name: str, table_name: str, columns: list[str]) -> None:
|
||||
if not _index_exists(bind, table_name, index_name):
|
||||
op.create_index(index_name, table_name, columns)
|
||||
|
||||
|
||||
def _drop_index_if_exists(bind, index_name: str, table_name: str) -> None:
|
||||
if _index_exists(bind, table_name, index_name):
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if not _table_exists(bind, "symbol_definitions"):
|
||||
op.create_table(
|
||||
"symbol_definitions",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.Column("file_path", sa.String(length=600), nullable=False),
|
||||
sa.Column("language", sa.String(length=20), nullable=False),
|
||||
sa.Column("symbol_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("symbol_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("qualified_name", sa.String(length=700), nullable=True),
|
||||
sa.Column("start_line", sa.Integer(), nullable=True),
|
||||
sa.Column("end_line", sa.Integer(), nullable=True),
|
||||
sa.Column("signature", sa.String(length=1000), nullable=True),
|
||||
sa.Column("docstring", sa.String(length=2000), nullable=True),
|
||||
sa.Column("file_hash", sa.String(length=64), nullable=True),
|
||||
sa.Column("source_mtime", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, server_default="active"),
|
||||
sa.Column("payload", _json_type(bind), nullable=True),
|
||||
sa.Column("last_indexed_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"file_path",
|
||||
"symbol_type",
|
||||
"symbol_name",
|
||||
"start_line",
|
||||
name="uq_symbol_definitions_tenant_file_type_name_line",
|
||||
),
|
||||
)
|
||||
|
||||
_create_index_if_not_exists(bind, "ix_symbol_definitions_tenant_id", "symbol_definitions", ["tenant_id"])
|
||||
_create_index_if_not_exists(bind, "ix_symbol_definitions_file_path", "symbol_definitions", ["file_path"])
|
||||
_create_index_if_not_exists(bind, "ix_symbol_definitions_language", "symbol_definitions", ["language"])
|
||||
_create_index_if_not_exists(bind, "ix_symbol_definitions_symbol_type", "symbol_definitions", ["symbol_type"])
|
||||
_create_index_if_not_exists(bind, "ix_symbol_definitions_symbol_name", "symbol_definitions", ["symbol_name"])
|
||||
_create_index_if_not_exists(bind, "ix_symbol_definitions_qualified_name", "symbol_definitions", ["qualified_name"])
|
||||
_create_index_if_not_exists(bind, "ix_symbol_definitions_start_line", "symbol_definitions", ["start_line"])
|
||||
_create_index_if_not_exists(bind, "ix_symbol_definitions_file_hash", "symbol_definitions", ["file_hash"])
|
||||
_create_index_if_not_exists(bind, "ix_symbol_definitions_status", "symbol_definitions", ["status"])
|
||||
_create_index_if_not_exists(bind, "ix_symbol_definitions_last_indexed_at", "symbol_definitions", ["last_indexed_at"])
|
||||
|
||||
if not _table_exists(bind, "symbol_references"):
|
||||
op.create_table(
|
||||
"symbol_references",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.Column("source_symbol_id", sa.Integer(), sa.ForeignKey("symbol_definitions.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("file_path", sa.String(length=600), nullable=False),
|
||||
sa.Column("relation_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("target_symbol_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("target_qualified_name", sa.String(length=700), nullable=True),
|
||||
sa.Column("start_line", sa.Integer(), nullable=True),
|
||||
sa.Column("end_line", sa.Integer(), nullable=True),
|
||||
sa.Column("payload", _json_type(bind), nullable=True),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"file_path",
|
||||
"relation_type",
|
||||
"target_symbol_name",
|
||||
"start_line",
|
||||
"source_symbol_id",
|
||||
name="uq_symbol_references_tenant_file_relation_target_line_source",
|
||||
),
|
||||
)
|
||||
|
||||
_create_index_if_not_exists(bind, "ix_symbol_references_tenant_id", "symbol_references", ["tenant_id"])
|
||||
_create_index_if_not_exists(bind, "ix_symbol_references_source_symbol_id", "symbol_references", ["source_symbol_id"])
|
||||
_create_index_if_not_exists(bind, "ix_symbol_references_file_path", "symbol_references", ["file_path"])
|
||||
_create_index_if_not_exists(bind, "ix_symbol_references_relation_type", "symbol_references", ["relation_type"])
|
||||
_create_index_if_not_exists(bind, "ix_symbol_references_target_symbol_name", "symbol_references", ["target_symbol_name"])
|
||||
_create_index_if_not_exists(bind, "ix_symbol_references_target_qualified_name", "symbol_references", ["target_qualified_name"])
|
||||
_create_index_if_not_exists(bind, "ix_symbol_references_start_line", "symbol_references", ["start_line"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if _table_exists(bind, "symbol_references"):
|
||||
_drop_index_if_exists(bind, "ix_symbol_references_start_line", "symbol_references")
|
||||
_drop_index_if_exists(bind, "ix_symbol_references_target_qualified_name", "symbol_references")
|
||||
_drop_index_if_exists(bind, "ix_symbol_references_target_symbol_name", "symbol_references")
|
||||
_drop_index_if_exists(bind, "ix_symbol_references_relation_type", "symbol_references")
|
||||
_drop_index_if_exists(bind, "ix_symbol_references_file_path", "symbol_references")
|
||||
_drop_index_if_exists(bind, "ix_symbol_references_source_symbol_id", "symbol_references")
|
||||
_drop_index_if_exists(bind, "ix_symbol_references_tenant_id", "symbol_references")
|
||||
op.drop_table("symbol_references")
|
||||
|
||||
if _table_exists(bind, "symbol_definitions"):
|
||||
_drop_index_if_exists(bind, "ix_symbol_definitions_last_indexed_at", "symbol_definitions")
|
||||
_drop_index_if_exists(bind, "ix_symbol_definitions_status", "symbol_definitions")
|
||||
_drop_index_if_exists(bind, "ix_symbol_definitions_file_hash", "symbol_definitions")
|
||||
_drop_index_if_exists(bind, "ix_symbol_definitions_start_line", "symbol_definitions")
|
||||
_drop_index_if_exists(bind, "ix_symbol_definitions_qualified_name", "symbol_definitions")
|
||||
_drop_index_if_exists(bind, "ix_symbol_definitions_symbol_name", "symbol_definitions")
|
||||
_drop_index_if_exists(bind, "ix_symbol_definitions_symbol_type", "symbol_definitions")
|
||||
_drop_index_if_exists(bind, "ix_symbol_definitions_language", "symbol_definitions")
|
||||
_drop_index_if_exists(bind, "ix_symbol_definitions_file_path", "symbol_definitions")
|
||||
_drop_index_if_exists(bind, "ix_symbol_definitions_tenant_id", "symbol_definitions")
|
||||
op.drop_table("symbol_definitions")
|
||||
|
|
@ -0,0 +1,259 @@
|
|||
"""add feishu outbox and idempotency guards
|
||||
|
||||
Revision ID: feishu_outbox_idempotency
|
||||
Revises: add_symbol_index_tables
|
||||
Create Date: 2026-03-05
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "feishu_outbox_idempotency"
|
||||
down_revision = "add_symbol_index_tables"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
return inspector.has_table(table_name)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
return any(idx.get("name") == index_name for idx in indexes)
|
||||
|
||||
|
||||
def _create_index_if_not_exists(
|
||||
bind,
|
||||
*,
|
||||
index_name: str,
|
||||
table_name: str,
|
||||
columns: list[str],
|
||||
unique: bool = False,
|
||||
postgresql_where: Any = None,
|
||||
sqlite_where: Any = None,
|
||||
) -> None:
|
||||
if _index_exists(bind, table_name, index_name):
|
||||
return
|
||||
kwargs: dict[str, Any] = {}
|
||||
if postgresql_where is not None:
|
||||
kwargs["postgresql_where"] = postgresql_where
|
||||
if sqlite_where is not None:
|
||||
kwargs["sqlite_where"] = sqlite_where
|
||||
op.create_index(index_name, table_name, columns, unique=unique, **kwargs)
|
||||
|
||||
|
||||
def _drop_index_if_exists(bind, index_name: str, table_name: str) -> None:
|
||||
if _index_exists(bind, table_name, index_name):
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
|
||||
|
||||
def _repoint_fk(bind, *, table_name: str, column_name: str, old_id: int, new_id: int) -> None:
|
||||
if not _table_exists(bind, table_name):
|
||||
return
|
||||
bind.execute(
|
||||
sa.text(
|
||||
f"UPDATE {table_name} "
|
||||
f"SET {column_name} = :new_id "
|
||||
f"WHERE {column_name} = :old_id"
|
||||
),
|
||||
{"new_id": int(new_id), "old_id": int(old_id)},
|
||||
)
|
||||
|
||||
|
||||
def _dedupe_feishu_items(bind) -> None:
|
||||
if not _table_exists(bind, "feishu_items"):
|
||||
return
|
||||
|
||||
groups = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT tenant_id, feishu_message_id, split_from_item_id, MIN(id) AS keep_id
|
||||
FROM feishu_items
|
||||
WHERE feishu_message_id IS NOT NULL
|
||||
GROUP BY tenant_id, feishu_message_id, split_from_item_id
|
||||
HAVING COUNT(*) > 1
|
||||
"""
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
for row in groups:
|
||||
tenant_id = int(row[0])
|
||||
message_id = str(row[1])
|
||||
split_from_item_id = row[2]
|
||||
keep_id = int(row[3])
|
||||
|
||||
if split_from_item_id is None:
|
||||
duplicate_rows = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT id
|
||||
FROM feishu_items
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND feishu_message_id = :message_id
|
||||
AND split_from_item_id IS NULL
|
||||
AND id <> :keep_id
|
||||
ORDER BY id ASC
|
||||
"""
|
||||
),
|
||||
{
|
||||
"tenant_id": tenant_id,
|
||||
"message_id": message_id,
|
||||
"keep_id": keep_id,
|
||||
},
|
||||
).fetchall()
|
||||
else:
|
||||
duplicate_rows = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT id
|
||||
FROM feishu_items
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND feishu_message_id = :message_id
|
||||
AND split_from_item_id = :split_from_item_id
|
||||
AND id <> :keep_id
|
||||
ORDER BY id ASC
|
||||
"""
|
||||
),
|
||||
{
|
||||
"tenant_id": tenant_id,
|
||||
"message_id": message_id,
|
||||
"split_from_item_id": int(split_from_item_id),
|
||||
"keep_id": keep_id,
|
||||
},
|
||||
).fetchall()
|
||||
|
||||
for dup_row in duplicate_rows:
|
||||
duplicate_id = int(dup_row[0])
|
||||
_repoint_fk(
|
||||
bind,
|
||||
table_name="feishu_items",
|
||||
column_name="split_from_item_id",
|
||||
old_id=duplicate_id,
|
||||
new_id=keep_id,
|
||||
)
|
||||
_repoint_fk(
|
||||
bind,
|
||||
table_name="signals",
|
||||
column_name="feishu_item_id",
|
||||
old_id=duplicate_id,
|
||||
new_id=keep_id,
|
||||
)
|
||||
_repoint_fk(
|
||||
bind,
|
||||
table_name="project_suggestions",
|
||||
column_name="feishu_item_id",
|
||||
old_id=duplicate_id,
|
||||
new_id=keep_id,
|
||||
)
|
||||
_repoint_fk(
|
||||
bind,
|
||||
table_name="ai_processing_logs",
|
||||
column_name="feishu_item_id",
|
||||
old_id=duplicate_id,
|
||||
new_id=keep_id,
|
||||
)
|
||||
bind.execute(
|
||||
sa.text("DELETE FROM feishu_items WHERE id = :id"),
|
||||
{"id": duplicate_id},
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if not _table_exists(bind, "feishu_reply_outbox"):
|
||||
op.create_table(
|
||||
"feishu_reply_outbox",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.Column("trace_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("chat_id", sa.String(length=128), nullable=False),
|
||||
sa.Column("message_text", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, server_default="pending"),
|
||||
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("next_retry_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("sent_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("last_error", sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
if _table_exists(bind, "feishu_reply_outbox"):
|
||||
_create_index_if_not_exists(
|
||||
bind,
|
||||
index_name="ix_feishu_reply_outbox_tenant_id",
|
||||
table_name="feishu_reply_outbox",
|
||||
columns=["tenant_id"],
|
||||
)
|
||||
_create_index_if_not_exists(
|
||||
bind,
|
||||
index_name="ix_feishu_reply_outbox_trace_id",
|
||||
table_name="feishu_reply_outbox",
|
||||
columns=["trace_id"],
|
||||
)
|
||||
_create_index_if_not_exists(
|
||||
bind,
|
||||
index_name="ix_feishu_reply_outbox_chat_id",
|
||||
table_name="feishu_reply_outbox",
|
||||
columns=["chat_id"],
|
||||
)
|
||||
_create_index_if_not_exists(
|
||||
bind,
|
||||
index_name="ix_feishu_reply_outbox_status",
|
||||
table_name="feishu_reply_outbox",
|
||||
columns=["status"],
|
||||
)
|
||||
_create_index_if_not_exists(
|
||||
bind,
|
||||
index_name="ix_feishu_reply_outbox_next_retry_at",
|
||||
table_name="feishu_reply_outbox",
|
||||
columns=["next_retry_at"],
|
||||
)
|
||||
|
||||
if _table_exists(bind, "feishu_items"):
|
||||
_dedupe_feishu_items(bind)
|
||||
where_root = sa.text("split_from_item_id IS NULL AND feishu_message_id IS NOT NULL")
|
||||
where_split = sa.text("split_from_item_id IS NOT NULL AND feishu_message_id IS NOT NULL")
|
||||
_create_index_if_not_exists(
|
||||
bind,
|
||||
index_name="uq_feishu_items_tenant_message_root",
|
||||
table_name="feishu_items",
|
||||
columns=["tenant_id", "feishu_message_id"],
|
||||
unique=True,
|
||||
postgresql_where=where_root,
|
||||
sqlite_where=where_root,
|
||||
)
|
||||
_create_index_if_not_exists(
|
||||
bind,
|
||||
index_name="uq_feishu_items_tenant_message_split",
|
||||
table_name="feishu_items",
|
||||
columns=["tenant_id", "feishu_message_id", "split_from_item_id"],
|
||||
unique=True,
|
||||
postgresql_where=where_split,
|
||||
sqlite_where=where_split,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if _table_exists(bind, "feishu_items"):
|
||||
_drop_index_if_exists(bind, "uq_feishu_items_tenant_message_split", "feishu_items")
|
||||
_drop_index_if_exists(bind, "uq_feishu_items_tenant_message_root", "feishu_items")
|
||||
|
||||
if _table_exists(bind, "feishu_reply_outbox"):
|
||||
_drop_index_if_exists(bind, "ix_feishu_reply_outbox_next_retry_at", "feishu_reply_outbox")
|
||||
_drop_index_if_exists(bind, "ix_feishu_reply_outbox_status", "feishu_reply_outbox")
|
||||
_drop_index_if_exists(bind, "ix_feishu_reply_outbox_chat_id", "feishu_reply_outbox")
|
||||
_drop_index_if_exists(bind, "ix_feishu_reply_outbox_trace_id", "feishu_reply_outbox")
|
||||
_drop_index_if_exists(bind, "ix_feishu_reply_outbox_tenant_id", "feishu_reply_outbox")
|
||||
op.drop_table("feishu_reply_outbox")
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
"""add operation execution ledger
|
||||
|
||||
Revision ID: add_operation_execution_ledger
|
||||
Revises: feishu_outbox_idempotency
|
||||
Create Date: 2026-03-06
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "add_operation_execution_ledger"
|
||||
down_revision = "feishu_outbox_idempotency"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
return inspector.has_table(table_name)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
return any(idx.get("name") == index_name for idx in indexes)
|
||||
|
||||
|
||||
def _create_index_if_not_exists(
|
||||
bind,
|
||||
*,
|
||||
index_name: str,
|
||||
table_name: str,
|
||||
columns: list[str],
|
||||
unique: bool = False,
|
||||
) -> None:
|
||||
if _index_exists(bind, table_name, index_name):
|
||||
return
|
||||
op.create_index(index_name, table_name, columns, unique=unique)
|
||||
|
||||
|
||||
def _drop_index_if_exists(bind, index_name: str, table_name: str) -> None:
|
||||
if _index_exists(bind, table_name, index_name):
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if not _table_exists(bind, "operation_execution_ledger"):
|
||||
op.create_table(
|
||||
"operation_execution_ledger",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.Column("operation_code", sa.String(length=64), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("idempotency_key", sa.String(length=128), nullable=False),
|
||||
sa.Column("trace_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, server_default="processing"),
|
||||
sa.Column("payload", sa.JSON(), nullable=True),
|
||||
sa.Column("result_payload", sa.JSON(), nullable=True),
|
||||
sa.Column("entity_type", sa.String(length=64), nullable=True),
|
||||
sa.Column("entity_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["project_id"], ["projects.id"]),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"operation_code",
|
||||
"idempotency_key",
|
||||
name="uq_operation_execution_ledger_key",
|
||||
),
|
||||
)
|
||||
|
||||
if _table_exists(bind, "operation_execution_ledger"):
|
||||
_create_index_if_not_exists(
|
||||
bind,
|
||||
index_name="ix_operation_execution_ledger_tenant_id",
|
||||
table_name="operation_execution_ledger",
|
||||
columns=["tenant_id"],
|
||||
)
|
||||
_create_index_if_not_exists(
|
||||
bind,
|
||||
index_name="ix_operation_execution_ledger_operation_code",
|
||||
table_name="operation_execution_ledger",
|
||||
columns=["operation_code"],
|
||||
)
|
||||
_create_index_if_not_exists(
|
||||
bind,
|
||||
index_name="ix_operation_execution_ledger_project_id",
|
||||
table_name="operation_execution_ledger",
|
||||
columns=["project_id"],
|
||||
)
|
||||
_create_index_if_not_exists(
|
||||
bind,
|
||||
index_name="ix_operation_execution_ledger_trace_id",
|
||||
table_name="operation_execution_ledger",
|
||||
columns=["trace_id"],
|
||||
)
|
||||
_create_index_if_not_exists(
|
||||
bind,
|
||||
index_name="ix_operation_execution_ledger_status",
|
||||
table_name="operation_execution_ledger",
|
||||
columns=["status"],
|
||||
)
|
||||
_create_index_if_not_exists(
|
||||
bind,
|
||||
index_name="ix_operation_execution_ledger_entity_type",
|
||||
table_name="operation_execution_ledger",
|
||||
columns=["entity_type"],
|
||||
)
|
||||
_create_index_if_not_exists(
|
||||
bind,
|
||||
index_name="ix_operation_execution_ledger_entity_id",
|
||||
table_name="operation_execution_ledger",
|
||||
columns=["entity_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "operation_execution_ledger"):
|
||||
return
|
||||
|
||||
_drop_index_if_exists(bind, "ix_operation_execution_ledger_entity_id", "operation_execution_ledger")
|
||||
_drop_index_if_exists(bind, "ix_operation_execution_ledger_entity_type", "operation_execution_ledger")
|
||||
_drop_index_if_exists(bind, "ix_operation_execution_ledger_status", "operation_execution_ledger")
|
||||
_drop_index_if_exists(bind, "ix_operation_execution_ledger_trace_id", "operation_execution_ledger")
|
||||
_drop_index_if_exists(bind, "ix_operation_execution_ledger_project_id", "operation_execution_ledger")
|
||||
_drop_index_if_exists(bind, "ix_operation_execution_ledger_operation_code", "operation_execution_ledger")
|
||||
_drop_index_if_exists(bind, "ix_operation_execution_ledger_tenant_id", "operation_execution_ledger")
|
||||
op.drop_table("operation_execution_ledger")
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
"""add signal source orchestration tables
|
||||
|
||||
Revision ID: add_signal_source_orchestration_tables
|
||||
Revises: add_operation_execution_ledger
|
||||
Create Date: 2026-03-06
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "add_signal_source_orchestration_tables"
|
||||
down_revision = "add_operation_execution_ledger"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
return inspector.has_table(table_name)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
return any(idx.get("name") == index_name for idx in indexes)
|
||||
|
||||
|
||||
def _create_index_if_not_exists(
|
||||
bind,
|
||||
*,
|
||||
index_name: str,
|
||||
table_name: str,
|
||||
columns: list[str],
|
||||
unique: bool = False,
|
||||
) -> None:
|
||||
if _index_exists(bind, table_name, index_name):
|
||||
return
|
||||
op.create_index(index_name, table_name, columns, unique=unique)
|
||||
|
||||
|
||||
def _drop_index_if_exists(bind, index_name: str, table_name: str) -> None:
|
||||
if _index_exists(bind, table_name, index_name):
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if not _table_exists(bind, "signal_source_registries"):
|
||||
op.create_table(
|
||||
"signal_source_registries",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.Column("project_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("signal_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("created_by_user_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("source_kind", sa.String(length=40), nullable=False),
|
||||
sa.Column("source_name", sa.String(length=120), nullable=False),
|
||||
sa.Column("source_identifier", sa.String(length=180), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("requested_via", sa.String(length=40), nullable=False, server_default="system"),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, server_default="active"),
|
||||
sa.Column("onboarding_status", sa.String(length=20), nullable=False, server_default="pending"),
|
||||
sa.Column("config", sa.JSON(), nullable=True),
|
||||
sa.Column("raw_context", sa.JSON(), nullable=True),
|
||||
sa.Column("last_signal_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("last_worker_run_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["project_id"], ["projects.id"]),
|
||||
sa.ForeignKeyConstraint(["signal_id"], ["signals.id"]),
|
||||
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"project_id",
|
||||
"source_kind",
|
||||
"source_identifier",
|
||||
name="uq_signal_source_registry_key",
|
||||
),
|
||||
)
|
||||
|
||||
if not _table_exists(bind, "ai_worker_executions"):
|
||||
op.create_table(
|
||||
"ai_worker_executions",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.Column("project_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("signal_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("signal_source_registry_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("operator_user_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("trace_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("operation_trace_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("execution_type", sa.String(length=40), nullable=False, server_default="signal_source_onboarding"),
|
||||
sa.Column("worker_role_key", sa.String(length=32), nullable=False),
|
||||
sa.Column("worker_role_name", sa.String(length=64), nullable=False),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, server_default="planned"),
|
||||
sa.Column("summary", sa.Text(), nullable=True),
|
||||
sa.Column("input_payload", sa.JSON(), nullable=True),
|
||||
sa.Column("output_payload", sa.JSON(), nullable=True),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("started_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("completed_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["project_id"], ["projects.id"]),
|
||||
sa.ForeignKeyConstraint(["signal_id"], ["signals.id"]),
|
||||
sa.ForeignKeyConstraint(["signal_source_registry_id"], ["signal_source_registries.id"]),
|
||||
sa.ForeignKeyConstraint(["operator_user_id"], ["users.id"]),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"trace_id",
|
||||
"worker_role_key",
|
||||
name="uq_ai_worker_execution_trace_role",
|
||||
),
|
||||
)
|
||||
|
||||
if _table_exists(bind, "signal_source_registries"):
|
||||
for index_name, columns in (
|
||||
("ix_signal_source_registries_tenant_id", ["tenant_id"]),
|
||||
("ix_signal_source_registries_project_id", ["project_id"]),
|
||||
("ix_signal_source_registries_signal_id", ["signal_id"]),
|
||||
("ix_signal_source_registries_created_by_user_id", ["created_by_user_id"]),
|
||||
("ix_signal_source_registries_source_kind", ["source_kind"]),
|
||||
("ix_signal_source_registries_requested_via", ["requested_via"]),
|
||||
("ix_signal_source_registries_status", ["status"]),
|
||||
("ix_signal_source_registries_onboarding_status", ["onboarding_status"]),
|
||||
):
|
||||
_create_index_if_not_exists(bind, index_name=index_name, table_name="signal_source_registries", columns=columns)
|
||||
|
||||
if _table_exists(bind, "ai_worker_executions"):
|
||||
for index_name, columns in (
|
||||
("ix_ai_worker_executions_tenant_id", ["tenant_id"]),
|
||||
("ix_ai_worker_executions_project_id", ["project_id"]),
|
||||
("ix_ai_worker_executions_signal_id", ["signal_id"]),
|
||||
("ix_ai_worker_executions_signal_source_registry_id", ["signal_source_registry_id"]),
|
||||
("ix_ai_worker_executions_operator_user_id", ["operator_user_id"]),
|
||||
("ix_ai_worker_executions_trace_id", ["trace_id"]),
|
||||
("ix_ai_worker_executions_operation_trace_id", ["operation_trace_id"]),
|
||||
("ix_ai_worker_executions_execution_type", ["execution_type"]),
|
||||
("ix_ai_worker_executions_worker_role_key", ["worker_role_key"]),
|
||||
("ix_ai_worker_executions_status", ["status"]),
|
||||
):
|
||||
_create_index_if_not_exists(bind, index_name=index_name, table_name="ai_worker_executions", columns=columns)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "ai_worker_executions"):
|
||||
for index_name in (
|
||||
"ix_ai_worker_executions_status",
|
||||
"ix_ai_worker_executions_worker_role_key",
|
||||
"ix_ai_worker_executions_execution_type",
|
||||
"ix_ai_worker_executions_operation_trace_id",
|
||||
"ix_ai_worker_executions_trace_id",
|
||||
"ix_ai_worker_executions_operator_user_id",
|
||||
"ix_ai_worker_executions_signal_source_registry_id",
|
||||
"ix_ai_worker_executions_signal_id",
|
||||
"ix_ai_worker_executions_project_id",
|
||||
"ix_ai_worker_executions_tenant_id",
|
||||
):
|
||||
_drop_index_if_exists(bind, index_name, "ai_worker_executions")
|
||||
op.drop_table("ai_worker_executions")
|
||||
|
||||
if _table_exists(bind, "signal_source_registries"):
|
||||
for index_name in (
|
||||
"ix_signal_source_registries_onboarding_status",
|
||||
"ix_signal_source_registries_status",
|
||||
"ix_signal_source_registries_requested_via",
|
||||
"ix_signal_source_registries_source_kind",
|
||||
"ix_signal_source_registries_created_by_user_id",
|
||||
"ix_signal_source_registries_signal_id",
|
||||
"ix_signal_source_registries_project_id",
|
||||
"ix_signal_source_registries_tenant_id",
|
||||
):
|
||||
_drop_index_if_exists(bind, index_name, "signal_source_registries")
|
||||
op.drop_table("signal_source_registries")
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
"""add ai orchestration runs
|
||||
|
||||
Revision ID: add_ai_orchestration_runs
|
||||
Revises: add_signal_source_orchestration_tables
|
||||
Create Date: 2026-03-07
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "add_ai_orchestration_runs"
|
||||
down_revision = "add_signal_source_orchestration_tables"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
return inspector.has_table(table_name)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
return any(idx.get("name") == index_name for idx in indexes)
|
||||
|
||||
|
||||
def _create_index_if_not_exists(
|
||||
bind,
|
||||
*,
|
||||
index_name: str,
|
||||
table_name: str,
|
||||
columns: list[str],
|
||||
unique: bool = False,
|
||||
) -> None:
|
||||
if _index_exists(bind, table_name, index_name):
|
||||
return
|
||||
op.create_index(index_name, table_name, columns, unique=unique)
|
||||
|
||||
|
||||
def _drop_index_if_exists(bind, index_name: str, table_name: str) -> None:
|
||||
if _index_exists(bind, table_name, index_name):
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if not _table_exists(bind, "ai_orchestration_runs"):
|
||||
op.create_table(
|
||||
"ai_orchestration_runs",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.Column("trace_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("run_type", sa.String(length=40), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("signal_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("response_mode", sa.String(length=16), nullable=False, server_default="fast"),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, server_default="queued"),
|
||||
sa.Column("planner_status", sa.String(length=20), nullable=True),
|
||||
sa.Column("control_plane_status", sa.String(length=20), nullable=True),
|
||||
sa.Column("worker_total", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("worker_completed", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("worker_failed", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("summary", sa.Text(), nullable=True),
|
||||
sa.Column("planner_diagnostics", sa.JSON(), nullable=True),
|
||||
sa.Column("result_payload", sa.JSON(), nullable=True),
|
||||
sa.Column("created_by_user_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("started_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("completed_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["project_id"], ["projects.id"]),
|
||||
sa.ForeignKeyConstraint(["signal_id"], ["signals.id"]),
|
||||
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
|
||||
sa.UniqueConstraint("tenant_id", "trace_id", name="uq_ai_orchestration_run_trace_id"),
|
||||
)
|
||||
|
||||
if _table_exists(bind, "ai_orchestration_runs"):
|
||||
for index_name, columns in (
|
||||
("ix_ai_orchestration_runs_tenant_id", ["tenant_id"]),
|
||||
("ix_ai_orchestration_runs_trace_id", ["trace_id"]),
|
||||
("ix_ai_orchestration_runs_run_type", ["run_type"]),
|
||||
("ix_ai_orchestration_runs_project_id", ["project_id"]),
|
||||
("ix_ai_orchestration_runs_signal_id", ["signal_id"]),
|
||||
("ix_ai_orchestration_runs_response_mode", ["response_mode"]),
|
||||
("ix_ai_orchestration_runs_status", ["status"]),
|
||||
("ix_ai_orchestration_runs_planner_status", ["planner_status"]),
|
||||
("ix_ai_orchestration_runs_control_plane_status", ["control_plane_status"]),
|
||||
("ix_ai_orchestration_runs_created_by_user_id", ["created_by_user_id"]),
|
||||
):
|
||||
_create_index_if_not_exists(bind, index_name=index_name, table_name="ai_orchestration_runs", columns=columns)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "ai_orchestration_runs"):
|
||||
for index_name in (
|
||||
"ix_ai_orchestration_runs_created_by_user_id",
|
||||
"ix_ai_orchestration_runs_control_plane_status",
|
||||
"ix_ai_orchestration_runs_planner_status",
|
||||
"ix_ai_orchestration_runs_status",
|
||||
"ix_ai_orchestration_runs_response_mode",
|
||||
"ix_ai_orchestration_runs_signal_id",
|
||||
"ix_ai_orchestration_runs_project_id",
|
||||
"ix_ai_orchestration_runs_run_type",
|
||||
"ix_ai_orchestration_runs_trace_id",
|
||||
"ix_ai_orchestration_runs_tenant_id",
|
||||
):
|
||||
_drop_index_if_exists(bind, index_name, "ai_orchestration_runs")
|
||||
op.drop_table("ai_orchestration_runs")
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
"""add ai pmo kernel tables
|
||||
|
||||
Revision ID: add_ai_pmo_kernel_tables
|
||||
Revises: add_ai_processing_audit_fields
|
||||
Create Date: 2026-03-08
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "add_ai_pmo_kernel_tables"
|
||||
down_revision = "add_ai_processing_audit_fields"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(idx.get("name") == index_name for idx in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def _create_index_if_missing(bind, table_name: str, index_name: str, columns: list[str], *, unique: bool = False) -> None:
|
||||
if not _index_exists(bind, table_name, index_name):
|
||||
op.create_index(index_name, table_name, columns, unique=unique)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if not _table_exists(bind, "work_items"):
|
||||
op.create_table(
|
||||
"work_items",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.Column("ingest_event_id", sa.BigInteger(), sa.ForeignKey("ingest_events.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("source_system", sa.String(length=32), nullable=False),
|
||||
sa.Column("source_message_id", sa.String(length=128), nullable=False),
|
||||
sa.Column("state", sa.String(length=32), nullable=False, server_default="received"),
|
||||
sa.Column("intent", sa.String(length=64), nullable=True),
|
||||
sa.Column("decision_level", sa.String(length=20), nullable=True),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=True),
|
||||
sa.Column("signal_id", sa.BigInteger(), sa.ForeignKey("signals.id"), nullable=True),
|
||||
sa.Column("owner_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True),
|
||||
sa.Column("dedupe_key", sa.String(length=255), nullable=True),
|
||||
sa.Column("context_snapshot", sa.JSON(), nullable=True),
|
||||
sa.Column("action_bundle", sa.JSON(), nullable=True),
|
||||
sa.Column("execution_result", sa.JSON(), nullable=True),
|
||||
sa.Column("review_summary", sa.JSON(), nullable=True),
|
||||
sa.Column("reply_template_key", sa.String(length=64), nullable=True),
|
||||
sa.UniqueConstraint("tenant_id", "source_system", "source_message_id", name="uq_work_items_source_message"),
|
||||
sa.UniqueConstraint("tenant_id", "ingest_event_id", name="uq_work_items_ingest_event"),
|
||||
)
|
||||
|
||||
if not _table_exists(bind, "decision_records"):
|
||||
op.create_table(
|
||||
"decision_records",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.Column("work_item_id", sa.BigInteger(), sa.ForeignKey("work_items.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("decision_object_type", sa.String(length=40), nullable=False, server_default="portfolio"),
|
||||
sa.Column("decision_object_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("decision_level", sa.String(length=20), nullable=False, server_default="portfolio"),
|
||||
sa.Column("context_snapshot", sa.JSON(), nullable=True),
|
||||
sa.Column("rationale", sa.Text(), nullable=True),
|
||||
sa.Column("confidence", sa.Float(), nullable=True),
|
||||
sa.Column("risk_score", sa.Float(), nullable=True),
|
||||
sa.Column("budget_impact", sa.JSON(), nullable=True),
|
||||
sa.Column("resource_impact", sa.JSON(), nullable=True),
|
||||
sa.Column("policy_gate_hit", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("policy_gate_code", sa.String(length=80), nullable=True),
|
||||
sa.Column("action_bundle", sa.JSON(), nullable=True),
|
||||
sa.Column("approval_requirement", sa.JSON(), nullable=True),
|
||||
sa.Column("execution_result", sa.JSON(), nullable=True),
|
||||
sa.Column("final_outcome", sa.String(length=32), nullable=True),
|
||||
)
|
||||
|
||||
if not _table_exists(bind, "plan_bundles"):
|
||||
op.create_table(
|
||||
"plan_bundles",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.Column("work_item_id", sa.BigInteger(), sa.ForeignKey("work_items.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("plan_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("summary", sa.Text(), nullable=True),
|
||||
sa.Column("payload", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
if not _table_exists(bind, "reply_receipts"):
|
||||
op.create_table(
|
||||
"reply_receipts",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.Column("work_item_id", sa.BigInteger(), sa.ForeignKey("work_items.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("source_system", sa.String(length=32), nullable=False),
|
||||
sa.Column("source_message_id", sa.String(length=128), nullable=False),
|
||||
sa.Column("receipt_phase", sa.String(length=32), nullable=False),
|
||||
sa.Column("template_key", sa.String(length=64), nullable=True),
|
||||
sa.Column("payload", sa.JSON(), nullable=True),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, server_default="pending"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"source_system",
|
||||
"source_message_id",
|
||||
"receipt_phase",
|
||||
name="uq_reply_receipts_source_phase",
|
||||
),
|
||||
)
|
||||
|
||||
if not _table_exists(bind, "portfolio_snapshots"):
|
||||
op.create_table(
|
||||
"portfolio_snapshots",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.Column("scope_type", sa.String(length=20), nullable=False, server_default="tenant"),
|
||||
sa.Column("scope_ref", sa.String(length=64), nullable=False),
|
||||
sa.Column("summary", sa.JSON(), nullable=True),
|
||||
sa.Column("payload", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
for table_name, columns in (
|
||||
("work_items", ["tenant_id"]),
|
||||
("decision_records", ["tenant_id"]),
|
||||
("plan_bundles", ["tenant_id"]),
|
||||
("reply_receipts", ["tenant_id"]),
|
||||
("portfolio_snapshots", ["tenant_id"]),
|
||||
):
|
||||
_create_index_if_missing(bind, table_name, f"ix_{table_name}_tenant_id", columns)
|
||||
|
||||
for table_name, index_name, columns in (
|
||||
("work_items", "ix_work_items_source_system", ["source_system"]),
|
||||
("work_items", "ix_work_items_source_message_id", ["source_message_id"]),
|
||||
("work_items", "ix_work_items_state", ["state"]),
|
||||
("work_items", "ix_work_items_intent", ["intent"]),
|
||||
("work_items", "ix_work_items_decision_level", ["decision_level"]),
|
||||
("work_items", "ix_work_items_project_id", ["project_id"]),
|
||||
("work_items", "ix_work_items_signal_id", ["signal_id"]),
|
||||
("work_items", "ix_work_items_owner_user_id", ["owner_user_id"]),
|
||||
("work_items", "ix_work_items_dedupe_key", ["dedupe_key"]),
|
||||
("work_items", "ix_work_items_ingest_event_id", ["ingest_event_id"]),
|
||||
("decision_records", "ix_decision_records_work_item_id", ["work_item_id"]),
|
||||
("decision_records", "ix_decision_records_decision_object_id", ["decision_object_id"]),
|
||||
("decision_records", "ix_decision_records_decision_level", ["decision_level"]),
|
||||
("decision_records", "ix_decision_records_policy_gate_hit", ["policy_gate_hit"]),
|
||||
("decision_records", "ix_decision_records_policy_gate_code", ["policy_gate_code"]),
|
||||
("decision_records", "ix_decision_records_final_outcome", ["final_outcome"]),
|
||||
("plan_bundles", "ix_plan_bundles_work_item_id", ["work_item_id"]),
|
||||
("plan_bundles", "ix_plan_bundles_plan_type", ["plan_type"]),
|
||||
("reply_receipts", "ix_reply_receipts_work_item_id", ["work_item_id"]),
|
||||
("reply_receipts", "ix_reply_receipts_source_system", ["source_system"]),
|
||||
("reply_receipts", "ix_reply_receipts_source_message_id", ["source_message_id"]),
|
||||
("reply_receipts", "ix_reply_receipts_receipt_phase", ["receipt_phase"]),
|
||||
("reply_receipts", "ix_reply_receipts_status", ["status"]),
|
||||
("portfolio_snapshots", "ix_portfolio_snapshots_scope_type", ["scope_type"]),
|
||||
("portfolio_snapshots", "ix_portfolio_snapshots_scope_ref", ["scope_ref"]),
|
||||
):
|
||||
_create_index_if_missing(bind, table_name, index_name, columns)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
for table_name, indexes in (
|
||||
("portfolio_snapshots", ["ix_portfolio_snapshots_scope_ref", "ix_portfolio_snapshots_scope_type", "ix_portfolio_snapshots_tenant_id"]),
|
||||
("reply_receipts", ["ix_reply_receipts_status", "ix_reply_receipts_receipt_phase", "ix_reply_receipts_source_message_id", "ix_reply_receipts_source_system", "ix_reply_receipts_work_item_id", "ix_reply_receipts_tenant_id"]),
|
||||
("plan_bundles", ["ix_plan_bundles_plan_type", "ix_plan_bundles_work_item_id", "ix_plan_bundles_tenant_id"]),
|
||||
("decision_records", ["ix_decision_records_final_outcome", "ix_decision_records_policy_gate_code", "ix_decision_records_policy_gate_hit", "ix_decision_records_decision_level", "ix_decision_records_decision_object_id", "ix_decision_records_work_item_id", "ix_decision_records_tenant_id"]),
|
||||
("work_items", ["ix_work_items_ingest_event_id", "ix_work_items_dedupe_key", "ix_work_items_owner_user_id", "ix_work_items_signal_id", "ix_work_items_project_id", "ix_work_items_decision_level", "ix_work_items_intent", "ix_work_items_state", "ix_work_items_source_message_id", "ix_work_items_source_system", "ix_work_items_tenant_id"]),
|
||||
):
|
||||
if not _table_exists(bind, table_name):
|
||||
continue
|
||||
for index_name in indexes:
|
||||
if _index_exists(bind, table_name, index_name):
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
for table_name in ("portfolio_snapshots", "reply_receipts", "plan_bundles", "decision_records", "work_items"):
|
||||
if _table_exists(bind, table_name):
|
||||
op.drop_table(table_name)
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
"""add ai processing audit fields
|
||||
|
||||
Revision ID: add_ai_processing_audit_fields
|
||||
Revises: add_inbox_structured_fields
|
||||
Create Date: 2026-03-08
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "add_ai_processing_audit_fields"
|
||||
down_revision = "add_inbox_structured_fields"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
columns = inspector.get_columns(table_name)
|
||||
return any(col.get("name") == column_name for col in columns)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(idx.get("name") == index_name for idx in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def _add_column_if_missing(bind, table_name: str, column: sa.Column) -> None:
|
||||
if not _column_exists(bind, table_name, str(column.name)):
|
||||
op.add_column(table_name, column)
|
||||
|
||||
|
||||
def _create_index_if_missing(bind, table_name: str, index_name: str, columns: list[str]) -> None:
|
||||
if not _index_exists(bind, table_name, index_name):
|
||||
op.create_index(index_name, table_name, columns)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "ai_processing_logs"):
|
||||
return
|
||||
|
||||
_add_column_if_missing(bind, "ai_processing_logs", sa.Column("normalized_text", sa.Text(), nullable=True))
|
||||
_add_column_if_missing(bind, "ai_processing_logs", sa.Column("route", sa.String(length=80), nullable=True))
|
||||
_add_column_if_missing(bind, "ai_processing_logs", sa.Column("intent", sa.String(length=40), nullable=True))
|
||||
_add_column_if_missing(bind, "ai_processing_logs", sa.Column("executor", sa.String(length=40), nullable=True))
|
||||
_add_column_if_missing(bind, "ai_processing_logs", sa.Column("operation_code", sa.String(length=80), nullable=True))
|
||||
_add_column_if_missing(bind, "ai_processing_logs", sa.Column("operation_result", sa.JSON(), nullable=True))
|
||||
_add_column_if_missing(bind, "ai_processing_logs", sa.Column("reply_source", sa.String(length=40), nullable=True))
|
||||
_create_index_if_missing(bind, "ai_processing_logs", "ix_ai_processing_logs_route", ["route"])
|
||||
_create_index_if_missing(bind, "ai_processing_logs", "ix_ai_processing_logs_intent", ["intent"])
|
||||
_create_index_if_missing(bind, "ai_processing_logs", "ix_ai_processing_logs_operation_code", ["operation_code"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "ai_processing_logs"):
|
||||
return
|
||||
|
||||
for index_name in (
|
||||
"ix_ai_processing_logs_operation_code",
|
||||
"ix_ai_processing_logs_intent",
|
||||
"ix_ai_processing_logs_route",
|
||||
):
|
||||
if _index_exists(bind, "ai_processing_logs", index_name):
|
||||
op.drop_index(index_name, table_name="ai_processing_logs")
|
||||
|
||||
for column_name in (
|
||||
"reply_source",
|
||||
"operation_result",
|
||||
"operation_code",
|
||||
"executor",
|
||||
"intent",
|
||||
"route",
|
||||
"normalized_text",
|
||||
):
|
||||
if _column_exists(bind, "ai_processing_logs", column_name):
|
||||
op.drop_column("ai_processing_logs", column_name)
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
"""add inbox structured fields
|
||||
|
||||
Revision ID: add_inbox_structured_fields
|
||||
Revises: add_ai_orchestration_runs
|
||||
Create Date: 2026-03-08
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "add_inbox_structured_fields"
|
||||
down_revision = "add_ai_orchestration_runs"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
columns = inspector.get_columns(table_name)
|
||||
return any(col.get("name") == column_name for col in columns)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(idx.get("name") == index_name for idx in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def _add_column_if_missing(bind, table_name: str, column: sa.Column) -> None:
|
||||
if not _column_exists(bind, table_name, str(column.name)):
|
||||
op.add_column(table_name, column)
|
||||
|
||||
|
||||
def _drop_column_if_exists(bind, table_name: str, column_name: str) -> None:
|
||||
if _column_exists(bind, table_name, column_name):
|
||||
op.drop_column(table_name, column_name)
|
||||
|
||||
|
||||
def _create_index_if_missing(bind, table_name: str, index_name: str, columns: list[str]) -> None:
|
||||
if not _index_exists(bind, table_name, index_name):
|
||||
op.create_index(index_name, table_name, columns)
|
||||
|
||||
|
||||
def _drop_index_if_exists(bind, table_name: str, index_name: str) -> None:
|
||||
if _index_exists(bind, table_name, index_name):
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if _table_exists(bind, "notifications"):
|
||||
_add_column_if_missing(bind, "notifications", sa.Column("kind", sa.String(length=30), nullable=True))
|
||||
_add_column_if_missing(bind, "notifications", sa.Column("priority", sa.String(length=20), nullable=True))
|
||||
_add_column_if_missing(bind, "notifications", sa.Column("status", sa.String(length=30), nullable=True))
|
||||
_add_column_if_missing(bind, "notifications", sa.Column("blocking_reason", sa.Text(), nullable=True))
|
||||
_add_column_if_missing(bind, "notifications", sa.Column("recommended_action", sa.String(length=80), nullable=True))
|
||||
_add_column_if_missing(bind, "notifications", sa.Column("dedupe_key", sa.String(length=255), nullable=True))
|
||||
_add_column_if_missing(
|
||||
bind,
|
||||
"notifications",
|
||||
sa.Column("occurrence_count", sa.Integer(), nullable=False, server_default="1"),
|
||||
)
|
||||
_add_column_if_missing(bind, "notifications", sa.Column("payload", sa.JSON(), nullable=True))
|
||||
_create_index_if_missing(bind, "notifications", "ix_notifications_kind", ["kind"])
|
||||
_create_index_if_missing(bind, "notifications", "ix_notifications_priority", ["priority"])
|
||||
_create_index_if_missing(bind, "notifications", "ix_notifications_status", ["status"])
|
||||
_create_index_if_missing(bind, "notifications", "ix_notifications_dedupe_key", ["dedupe_key"])
|
||||
|
||||
if _table_exists(bind, "feishu_items"):
|
||||
_add_column_if_missing(bind, "feishu_items", sa.Column("decision_reason", sa.Text(), nullable=True))
|
||||
_add_column_if_missing(bind, "feishu_items", sa.Column("blocking_reason", sa.Text(), nullable=True))
|
||||
_add_column_if_missing(
|
||||
bind,
|
||||
"feishu_items",
|
||||
sa.Column("safe_to_auto_execute", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
_add_column_if_missing(
|
||||
bind,
|
||||
"feishu_items",
|
||||
sa.Column("auto_execute_state", sa.String(length=30), nullable=True),
|
||||
)
|
||||
_add_column_if_missing(bind, "feishu_items", sa.Column("dedupe_key", sa.String(length=255), nullable=True))
|
||||
_add_column_if_missing(bind, "feishu_items", sa.Column("message_class", sa.String(length=30), nullable=True))
|
||||
_add_column_if_missing(
|
||||
bind,
|
||||
"feishu_items",
|
||||
sa.Column("occurrence_count", sa.Integer(), nullable=False, server_default="1"),
|
||||
)
|
||||
_add_column_if_missing(bind, "feishu_items", sa.Column("next_step_text", sa.Text(), nullable=True))
|
||||
_add_column_if_missing(bind, "feishu_items", sa.Column("undoable_until", sa.TIMESTAMP(), nullable=True))
|
||||
_add_column_if_missing(bind, "feishu_items", sa.Column("result_payload", sa.JSON(), nullable=True))
|
||||
_create_index_if_missing(bind, "feishu_items", "ix_feishu_items_safe_to_auto_execute", ["safe_to_auto_execute"])
|
||||
_create_index_if_missing(bind, "feishu_items", "ix_feishu_items_auto_execute_state", ["auto_execute_state"])
|
||||
_create_index_if_missing(bind, "feishu_items", "ix_feishu_items_dedupe_key", ["dedupe_key"])
|
||||
_create_index_if_missing(bind, "feishu_items", "ix_feishu_items_message_class", ["message_class"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if _table_exists(bind, "notifications"):
|
||||
for index_name in (
|
||||
"ix_notifications_dedupe_key",
|
||||
"ix_notifications_status",
|
||||
"ix_notifications_priority",
|
||||
"ix_notifications_kind",
|
||||
):
|
||||
_drop_index_if_exists(bind, "notifications", index_name)
|
||||
for column_name in (
|
||||
"payload",
|
||||
"occurrence_count",
|
||||
"dedupe_key",
|
||||
"recommended_action",
|
||||
"blocking_reason",
|
||||
"status",
|
||||
"priority",
|
||||
"kind",
|
||||
):
|
||||
_drop_column_if_exists(bind, "notifications", column_name)
|
||||
|
||||
if _table_exists(bind, "feishu_items"):
|
||||
for index_name in (
|
||||
"ix_feishu_items_message_class",
|
||||
"ix_feishu_items_dedupe_key",
|
||||
"ix_feishu_items_auto_execute_state",
|
||||
"ix_feishu_items_safe_to_auto_execute",
|
||||
):
|
||||
_drop_index_if_exists(bind, "feishu_items", index_name)
|
||||
for column_name in (
|
||||
"result_payload",
|
||||
"undoable_until",
|
||||
"next_step_text",
|
||||
"occurrence_count",
|
||||
"message_class",
|
||||
"dedupe_key",
|
||||
"auto_execute_state",
|
||||
"safe_to_auto_execute",
|
||||
"blocking_reason",
|
||||
"decision_reason",
|
||||
):
|
||||
_drop_column_if_exists(bind, "feishu_items", column_name)
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
"""add ai pmo simulation field
|
||||
|
||||
Revision ID: add_ai_pmo_simulation_field
|
||||
Revises: add_ai_pmo_kernel_tables
|
||||
Create Date: 2026-03-09
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "add_ai_pmo_simulation_field"
|
||||
down_revision = "add_ai_pmo_kernel_tables"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(col.get("name") == column_name for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "decision_records") and not _column_exists(bind, "decision_records", "simulation_result"):
|
||||
op.add_column("decision_records", sa.Column("simulation_result", sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "decision_records") and _column_exists(bind, "decision_records", "simulation_result"):
|
||||
op.drop_column("decision_records", "simulation_result")
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
"""add feishu outbox reply hash
|
||||
|
||||
Revision ID: add_feishu_outbox_reply_hash
|
||||
Revises: add_ai_pmo_simulation_field
|
||||
Create Date: 2026-03-10
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "add_feishu_outbox_reply_hash"
|
||||
down_revision = "add_ai_pmo_simulation_field"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(col.get("name") == column_name for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(idx.get("name") == index_name for idx in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def _create_index_if_not_exists(
|
||||
bind,
|
||||
*,
|
||||
index_name: str,
|
||||
table_name: str,
|
||||
columns: list[str],
|
||||
unique: bool = False,
|
||||
postgresql_where: Any = None,
|
||||
sqlite_where: Any = None,
|
||||
) -> None:
|
||||
if _index_exists(bind, table_name, index_name):
|
||||
return
|
||||
kwargs: dict[str, Any] = {}
|
||||
if postgresql_where is not None:
|
||||
kwargs["postgresql_where"] = postgresql_where
|
||||
if sqlite_where is not None:
|
||||
kwargs["sqlite_where"] = sqlite_where
|
||||
op.create_index(index_name, table_name, columns, unique=unique, **kwargs)
|
||||
|
||||
|
||||
def _drop_index_if_exists(bind, table_name: str, index_name: str) -> None:
|
||||
if _index_exists(bind, table_name, index_name):
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "feishu_reply_outbox"):
|
||||
return
|
||||
|
||||
if not _column_exists(bind, "feishu_reply_outbox", "source_message_id"):
|
||||
op.add_column("feishu_reply_outbox", sa.Column("source_message_id", sa.String(length=128), nullable=True))
|
||||
if not _column_exists(bind, "feishu_reply_outbox", "reply_hash"):
|
||||
op.add_column("feishu_reply_outbox", sa.Column("reply_hash", sa.String(length=32), nullable=True))
|
||||
|
||||
_create_index_if_not_exists(
|
||||
bind,
|
||||
index_name="ix_feishu_reply_outbox_source_message_id",
|
||||
table_name="feishu_reply_outbox",
|
||||
columns=["source_message_id"],
|
||||
)
|
||||
_create_index_if_not_exists(
|
||||
bind,
|
||||
index_name="ix_feishu_reply_outbox_reply_hash",
|
||||
table_name="feishu_reply_outbox",
|
||||
columns=["reply_hash"],
|
||||
)
|
||||
sent_where = sa.text("status = 'sent' AND reply_hash IS NOT NULL")
|
||||
_create_index_if_not_exists(
|
||||
bind,
|
||||
index_name="uq_feishu_reply_outbox_sent_reply_hash",
|
||||
table_name="feishu_reply_outbox",
|
||||
columns=["tenant_id", "reply_hash"],
|
||||
unique=True,
|
||||
postgresql_where=sent_where,
|
||||
sqlite_where=sent_where,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "feishu_reply_outbox"):
|
||||
return
|
||||
|
||||
_drop_index_if_exists(bind, "feishu_reply_outbox", "uq_feishu_reply_outbox_sent_reply_hash")
|
||||
_drop_index_if_exists(bind, "feishu_reply_outbox", "ix_feishu_reply_outbox_reply_hash")
|
||||
_drop_index_if_exists(bind, "feishu_reply_outbox", "ix_feishu_reply_outbox_source_message_id")
|
||||
|
||||
if _column_exists(bind, "feishu_reply_outbox", "reply_hash"):
|
||||
op.drop_column("feishu_reply_outbox", "reply_hash")
|
||||
if _column_exists(bind, "feishu_reply_outbox", "source_message_id"):
|
||||
op.drop_column("feishu_reply_outbox", "source_message_id")
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
"""add ai_pmo_work_items compatibility view
|
||||
|
||||
Revision ID: add_ai_pmo_work_items_view
|
||||
Revises: add_operation_log_audit_fields
|
||||
Create Date: 2026-03-11
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "add_ai_pmo_work_items_view"
|
||||
down_revision = "add_operation_log_audit_fields"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "work_items"):
|
||||
return
|
||||
|
||||
op.execute("DROP VIEW IF EXISTS ai_pmo_work_items")
|
||||
op.execute(
|
||||
"""
|
||||
CREATE VIEW ai_pmo_work_items AS
|
||||
SELECT
|
||||
id,
|
||||
ingest_event_id,
|
||||
source_system,
|
||||
source_message_id,
|
||||
state,
|
||||
intent,
|
||||
decision_level,
|
||||
project_id,
|
||||
signal_id,
|
||||
owner_user_id,
|
||||
dedupe_key,
|
||||
context_snapshot,
|
||||
action_bundle,
|
||||
execution_result,
|
||||
review_summary,
|
||||
reply_template_key,
|
||||
tenant_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM work_items
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP VIEW IF EXISTS ai_pmo_work_items")
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
"""add conversation sessions table
|
||||
|
||||
Revision ID: add_conversation_sessions_table
|
||||
Revises: add_kb_chunk_ivfflat_index
|
||||
Create Date: 2026-03-11
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "add_conversation_sessions_table"
|
||||
down_revision = "add_kb_chunk_ivfflat_index"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(idx.get("name") == index_name for idx in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def _create_index_if_missing(bind, table_name: str, index_name: str, columns: list[str], *, unique: bool = False) -> None:
|
||||
if not _index_exists(bind, table_name, index_name):
|
||||
op.create_index(index_name, table_name, columns, unique=unique)
|
||||
|
||||
|
||||
def _drop_index_if_exists(bind, table_name: str, index_name: str) -> None:
|
||||
if _index_exists(bind, table_name, index_name):
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "conversation_sessions"):
|
||||
op.create_table(
|
||||
"conversation_sessions",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("sender", sa.String(length=128), nullable=False),
|
||||
sa.Column("current_project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=True),
|
||||
sa.Column("last_intent", sa.String(length=64), nullable=True),
|
||||
sa.Column("last_message_text", sa.String(length=200), nullable=True),
|
||||
sa.Column("last_active_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.UniqueConstraint("tenant_id", "sender", name="uq_conversation_sessions_tenant_sender"),
|
||||
)
|
||||
|
||||
_create_index_if_missing(bind, "conversation_sessions", "ix_conversation_sessions_tenant_id", ["tenant_id"])
|
||||
_create_index_if_missing(bind, "conversation_sessions", "ix_conversation_sessions_sender", ["sender"])
|
||||
_create_index_if_missing(bind, "conversation_sessions", "ix_conversation_sessions_current_project_id", ["current_project_id"])
|
||||
_create_index_if_missing(bind, "conversation_sessions", "ix_conversation_sessions_last_intent", ["last_intent"])
|
||||
_create_index_if_missing(bind, "conversation_sessions", "ix_conversation_sessions_last_active_at", ["last_active_at"])
|
||||
_create_index_if_missing(
|
||||
bind,
|
||||
"conversation_sessions",
|
||||
"ix_conversation_sessions_tenant_sender_last_active",
|
||||
["tenant_id", "sender", "last_active_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "conversation_sessions"):
|
||||
return
|
||||
|
||||
_drop_index_if_exists(bind, "conversation_sessions", "ix_conversation_sessions_tenant_sender_last_active")
|
||||
_drop_index_if_exists(bind, "conversation_sessions", "ix_conversation_sessions_last_active_at")
|
||||
_drop_index_if_exists(bind, "conversation_sessions", "ix_conversation_sessions_last_intent")
|
||||
_drop_index_if_exists(bind, "conversation_sessions", "ix_conversation_sessions_current_project_id")
|
||||
_drop_index_if_exists(bind, "conversation_sessions", "ix_conversation_sessions_sender")
|
||||
_drop_index_if_exists(bind, "conversation_sessions", "ix_conversation_sessions_tenant_id")
|
||||
op.drop_table("conversation_sessions")
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
"""add ivfflat index for knowledge chunk embedding
|
||||
|
||||
Revision ID: add_kb_chunk_ivfflat_index
|
||||
Revises: add_pg_trgm_text_indexes
|
||||
Create Date: 2026-03-11
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "add_kb_chunk_ivfflat_index"
|
||||
down_revision = "add_pg_trgm_text_indexes"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _is_postgresql() -> bool:
|
||||
bind = op.get_bind()
|
||||
return str(getattr(getattr(bind, "dialect", None), "name", "")).lower().startswith("postgres")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _is_postgresql():
|
||||
return
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_knowledge_chunks_embedding_ivfflat
|
||||
ON knowledge_base_chunks
|
||||
USING ivfflat (embedding vector_cosine_ops)
|
||||
WITH (lists = 100)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if not _is_postgresql():
|
||||
return
|
||||
|
||||
op.execute("DROP INDEX IF EXISTS ix_knowledge_chunks_embedding_ivfflat")
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
"""add operation log audit fields
|
||||
|
||||
Revision ID: add_operation_log_audit_fields
|
||||
Revises: shift_all_timestamps_to_beijing
|
||||
Create Date: 2026-03-11
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "add_operation_log_audit_fields"
|
||||
down_revision = "shift_all_timestamps_to_beijing"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(col.get("name") == column_name for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(idx.get("name") == index_name for idx in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "operation_logs"):
|
||||
return
|
||||
|
||||
with op.batch_alter_table("operation_logs") as batch_op:
|
||||
if not _column_exists(bind, "operation_logs", "trace_id"):
|
||||
batch_op.add_column(sa.Column("trace_id", sa.String(length=64), nullable=True))
|
||||
if not _column_exists(bind, "operation_logs", "interface_path"):
|
||||
batch_op.add_column(sa.Column("interface_path", sa.String(length=255), nullable=True))
|
||||
if not _column_exists(bind, "operation_logs", "caller"):
|
||||
batch_op.add_column(
|
||||
sa.Column("caller", sa.String(length=16), nullable=False, server_default=sa.text("'system'"))
|
||||
)
|
||||
|
||||
if not _index_exists(bind, "operation_logs", "ix_operation_logs_trace_id"):
|
||||
op.create_index("ix_operation_logs_trace_id", "operation_logs", ["trace_id"], unique=False)
|
||||
if not _index_exists(bind, "operation_logs", "ix_operation_logs_interface_path"):
|
||||
op.create_index("ix_operation_logs_interface_path", "operation_logs", ["interface_path"], unique=False)
|
||||
if not _index_exists(bind, "operation_logs", "ix_operation_logs_caller"):
|
||||
op.create_index("ix_operation_logs_caller", "operation_logs", ["caller"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "operation_logs"):
|
||||
return
|
||||
|
||||
if _index_exists(bind, "operation_logs", "ix_operation_logs_caller"):
|
||||
op.drop_index("ix_operation_logs_caller", table_name="operation_logs")
|
||||
if _index_exists(bind, "operation_logs", "ix_operation_logs_interface_path"):
|
||||
op.drop_index("ix_operation_logs_interface_path", table_name="operation_logs")
|
||||
if _index_exists(bind, "operation_logs", "ix_operation_logs_trace_id"):
|
||||
op.drop_index("ix_operation_logs_trace_id", table_name="operation_logs")
|
||||
|
||||
with op.batch_alter_table("operation_logs") as batch_op:
|
||||
if _column_exists(bind, "operation_logs", "caller"):
|
||||
batch_op.drop_column("caller")
|
||||
if _column_exists(bind, "operation_logs", "interface_path"):
|
||||
batch_op.drop_column("interface_path")
|
||||
if _column_exists(bind, "operation_logs", "trace_id"):
|
||||
batch_op.drop_column("trace_id")
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
"""add pg_trgm text indexes for project search
|
||||
|
||||
Revision ID: add_pg_trgm_text_indexes
|
||||
Revises: add_feishu_outbox_reply_hash
|
||||
Create Date: 2026-03-11
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "add_pg_trgm_text_indexes"
|
||||
down_revision = "add_feishu_outbox_reply_hash"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _is_postgresql() -> bool:
|
||||
bind = op.get_bind()
|
||||
return str(getattr(getattr(bind, "dialect", None), "name", "")).lower().startswith("postgres")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _is_postgresql():
|
||||
return
|
||||
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_projects_name_trgm
|
||||
ON projects USING GIN (name gin_trgm_ops)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_projects_title_trgm
|
||||
ON projects USING GIN (title gin_trgm_ops)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_projects_description_trgm
|
||||
ON projects USING GIN (description gin_trgm_ops)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_signals_title_trgm
|
||||
ON signals USING GIN (title gin_trgm_ops)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_signals_content_trgm
|
||||
ON signals USING GIN (content gin_trgm_ops)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_tasks_title_trgm
|
||||
ON tasks USING GIN (title gin_trgm_ops)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if not _is_postgresql():
|
||||
return
|
||||
|
||||
op.execute("DROP INDEX IF EXISTS ix_tasks_title_trgm")
|
||||
op.execute("DROP INDEX IF EXISTS ix_signals_content_trgm")
|
||||
op.execute("DROP INDEX IF EXISTS ix_signals_title_trgm")
|
||||
op.execute("DROP INDEX IF EXISTS ix_projects_description_trgm")
|
||||
op.execute("DROP INDEX IF EXISTS ix_projects_title_trgm")
|
||||
op.execute("DROP INDEX IF EXISTS ix_projects_name_trgm")
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
"""expand user_workloads workload_ratio precision
|
||||
|
||||
Revision ID: expand_user_workload_ratio_precision
|
||||
Revises: add_ai_pmo_work_items_view
|
||||
Create Date: 2026-03-11
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "expand_user_workload_ratio_precision"
|
||||
down_revision = "add_ai_pmo_work_items_view"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_numeric_meta(bind, table_name: str, column_name: str) -> tuple[int | None, int | None] | None:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return None
|
||||
for column in inspector.get_columns(table_name):
|
||||
if str(column.get("name")) == column_name:
|
||||
col_type = column.get("type")
|
||||
return (
|
||||
int(getattr(col_type, "precision", 0) or 0) or None,
|
||||
int(getattr(col_type, "scale", 0) or 0) or None,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _alter_workload_ratio(bind, *, precision: int, scale: int) -> None:
|
||||
current = _column_numeric_meta(bind, "user_workloads", "workload_ratio")
|
||||
if current == (precision, scale):
|
||||
return
|
||||
current_precision = int((current or (4, 2))[0] or 4)
|
||||
current_scale = int((current or (4, 2))[1] or 2)
|
||||
with op.batch_alter_table("user_workloads") as batch_op:
|
||||
batch_op.alter_column(
|
||||
"workload_ratio",
|
||||
existing_type=sa.Numeric(current_precision, current_scale),
|
||||
type_=sa.Numeric(precision, scale),
|
||||
existing_nullable=False,
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "user_workloads"):
|
||||
return
|
||||
_alter_workload_ratio(bind, precision=10, scale=2)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "user_workloads"):
|
||||
return
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE user_workloads
|
||||
SET workload_ratio = CASE
|
||||
WHEN workload_ratio > 99.99 THEN 99.99
|
||||
WHEN workload_ratio < -99.99 THEN -99.99
|
||||
ELSE workload_ratio
|
||||
END
|
||||
"""
|
||||
)
|
||||
_alter_workload_ratio(bind, precision=4, scale=2)
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
"""shift all naive timestamps to beijing and set db timezone
|
||||
|
||||
Revision ID: shift_all_timestamps_to_beijing
|
||||
Revises: add_conversation_sessions_table
|
||||
Create Date: 2026-03-11
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "shift_all_timestamps_to_beijing"
|
||||
down_revision = "add_conversation_sessions_table"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _is_postgresql() -> bool:
|
||||
bind = op.get_bind()
|
||||
return str(getattr(getattr(bind, "dialect", None), "name", "")).lower().startswith("postgres")
|
||||
|
||||
|
||||
def _list_timestamp_columns() -> list[tuple[str, str]]:
|
||||
bind = op.get_bind()
|
||||
rows = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND data_type = 'timestamp without time zone'
|
||||
ORDER BY table_name, column_name
|
||||
"""
|
||||
)
|
||||
).fetchall()
|
||||
return [(str(row[0]), str(row[1])) for row in rows]
|
||||
|
||||
|
||||
def _shift_all_timestamp_columns(*, hours: int) -> None:
|
||||
delta_expr = f"INTERVAL '{int(hours)} hours'" if int(hours) >= 0 else f"- INTERVAL '{abs(int(hours))} hours'"
|
||||
for table_name, column_name in _list_timestamp_columns():
|
||||
if int(hours) >= 0:
|
||||
sql = f'UPDATE "{table_name}" SET "{column_name}" = "{column_name}" + {delta_expr} WHERE "{column_name}" IS NOT NULL'
|
||||
else:
|
||||
sql = f'UPDATE "{table_name}" SET "{column_name}" = "{column_name}" {delta_expr} WHERE "{column_name}" IS NOT NULL'
|
||||
op.execute(sa.text(sql))
|
||||
|
||||
|
||||
def _set_database_timezone(timezone_name: str) -> None:
|
||||
bind = op.get_bind()
|
||||
database_name = str(bind.execute(sa.text("SELECT current_database()")).scalar_one())
|
||||
role_name = str(bind.execute(sa.text("SELECT current_user")).scalar_one())
|
||||
op.execute(sa.text(f'ALTER DATABASE "{database_name}" SET timezone TO \'{timezone_name}\''))
|
||||
op.execute(sa.text(f'ALTER ROLE "{role_name}" SET timezone TO \'{timezone_name}\''))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _is_postgresql():
|
||||
return
|
||||
_set_database_timezone("Asia/Shanghai")
|
||||
_shift_all_timestamp_columns(hours=8)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if not _is_postgresql():
|
||||
return
|
||||
_shift_all_timestamp_columns(hours=-8)
|
||||
# 统一策略:即使降级历史数据,也保持数据库会话时区为北京时间。
|
||||
_set_database_timezone("Asia/Shanghai")
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
"""add signal planner fields
|
||||
|
||||
Revision ID: add_signal_planner_fields
|
||||
Revises: expand_user_workload_ratio_precision
|
||||
Create Date: 2026-03-12
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "add_signal_planner_fields"
|
||||
down_revision = "expand_user_workload_ratio_precision"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
for column in inspector.get_columns(table_name):
|
||||
if str(column.get("name") or "") == column_name:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(str(item.get("name") or "") == index_name for item in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "signals"):
|
||||
return
|
||||
|
||||
with op.batch_alter_table("signals") as batch_op:
|
||||
if not _column_exists(bind, "signals", "planner_status"):
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"planner_status",
|
||||
sa.String(length=20),
|
||||
nullable=False,
|
||||
server_default="pending",
|
||||
)
|
||||
)
|
||||
if not _column_exists(bind, "signals", "planner_analyzed_at"):
|
||||
batch_op.add_column(sa.Column("planner_analyzed_at", sa.TIMESTAMP(), nullable=True))
|
||||
if not _column_exists(bind, "signals", "planner_assignee_user_id"):
|
||||
batch_op.add_column(sa.Column("planner_assignee_user_id", sa.BigInteger(), nullable=True))
|
||||
|
||||
if not _index_exists(bind, "signals", "ix_signals_planner_status"):
|
||||
op.create_index("ix_signals_planner_status", "signals", ["planner_status"])
|
||||
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "signals"):
|
||||
return
|
||||
|
||||
if _index_exists(bind, "signals", "ix_signals_planner_status"):
|
||||
op.drop_index("ix_signals_planner_status", table_name="signals")
|
||||
|
||||
with op.batch_alter_table("signals") as batch_op:
|
||||
if _column_exists(bind, "signals", "planner_assignee_user_id"):
|
||||
batch_op.drop_column("planner_assignee_user_id")
|
||||
if _column_exists(bind, "signals", "planner_analyzed_at"):
|
||||
batch_op.drop_column("planner_analyzed_at")
|
||||
if _column_exists(bind, "signals", "planner_status"):
|
||||
batch_op.drop_column("planner_status")
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
"""add ai orchestration ingress async fields
|
||||
|
||||
Revision ID: add_ai_orchestration_ingress_async_fields
|
||||
Revises: add_signal_planner_fields
|
||||
Create Date: 2026-03-16
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "add_ai_orchestration_ingress_async_fields"
|
||||
down_revision = "add_signal_planner_fields"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(str(item.get("name") or "") == column_name for item in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(str(item.get("name") or "") == index_name for item in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def _create_index_if_missing(bind, table_name: str, index_name: str, columns: list[str]) -> None:
|
||||
if not _index_exists(bind, table_name, index_name):
|
||||
op.create_index(index_name, table_name, columns)
|
||||
|
||||
|
||||
def _drop_index_if_exists(bind, table_name: str, index_name: str) -> None:
|
||||
if _index_exists(bind, table_name, index_name):
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
table_name = "ai_orchestration_runs"
|
||||
if not _table_exists(bind, table_name):
|
||||
return
|
||||
|
||||
with op.batch_alter_table(table_name) as batch_op:
|
||||
if _column_exists(bind, table_name, "project_id"):
|
||||
batch_op.alter_column("project_id", existing_type=sa.BigInteger(), nullable=True)
|
||||
if not _column_exists(bind, table_name, "source_system"):
|
||||
batch_op.add_column(sa.Column("source_system", sa.String(length=32), nullable=True))
|
||||
if not _column_exists(bind, table_name, "source_message_id"):
|
||||
batch_op.add_column(sa.Column("source_message_id", sa.String(length=128), nullable=True))
|
||||
if not _column_exists(bind, table_name, "sender"):
|
||||
batch_op.add_column(sa.Column("sender", sa.String(length=128), nullable=True))
|
||||
if not _column_exists(bind, table_name, "source_sender_open_id"):
|
||||
batch_op.add_column(sa.Column("source_sender_open_id", sa.String(length=128), nullable=True))
|
||||
if not _column_exists(bind, table_name, "channel_ref"):
|
||||
batch_op.add_column(sa.Column("channel_ref", sa.String(length=128), nullable=True))
|
||||
if not _column_exists(bind, table_name, "chat_type"):
|
||||
batch_op.add_column(sa.Column("chat_type", sa.String(length=32), nullable=True))
|
||||
if not _column_exists(bind, table_name, "captured_at"):
|
||||
batch_op.add_column(sa.Column("captured_at", sa.TIMESTAMP(), nullable=True))
|
||||
if not _column_exists(bind, table_name, "message_text"):
|
||||
batch_op.add_column(sa.Column("message_text", sa.Text(), nullable=True))
|
||||
if not _column_exists(bind, table_name, "project_hint_id"):
|
||||
batch_op.add_column(sa.Column("project_hint_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=True))
|
||||
if not _column_exists(bind, table_name, "retry_count"):
|
||||
batch_op.add_column(sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"))
|
||||
if not _column_exists(bind, table_name, "max_retries"):
|
||||
batch_op.add_column(sa.Column("max_retries", sa.Integer(), nullable=False, server_default="3"))
|
||||
if not _column_exists(bind, table_name, "next_retry_at"):
|
||||
batch_op.add_column(sa.Column("next_retry_at", sa.TIMESTAMP(), nullable=True))
|
||||
if not _column_exists(bind, table_name, "last_error"):
|
||||
batch_op.add_column(sa.Column("last_error", sa.Text(), nullable=True))
|
||||
if not _column_exists(bind, table_name, "last_error_at"):
|
||||
batch_op.add_column(sa.Column("last_error_at", sa.TIMESTAMP(), nullable=True))
|
||||
if not _column_exists(bind, table_name, "ack_sent_at"):
|
||||
batch_op.add_column(sa.Column("ack_sent_at", sa.TIMESTAMP(), nullable=True))
|
||||
if not _column_exists(bind, table_name, "result_notified_at"):
|
||||
batch_op.add_column(sa.Column("result_notified_at", sa.TIMESTAMP(), nullable=True))
|
||||
if not _column_exists(bind, table_name, "work_item_id"):
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"work_item_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("work_items.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
)
|
||||
|
||||
_create_index_if_missing(bind, table_name, "ix_ai_orchestration_runs_source_system", ["source_system"])
|
||||
_create_index_if_missing(bind, table_name, "ix_ai_orchestration_runs_source_message_id", ["source_message_id"])
|
||||
_create_index_if_missing(bind, table_name, "ix_ai_orchestration_runs_source_sender_open_id", ["source_sender_open_id"])
|
||||
_create_index_if_missing(bind, table_name, "ix_ai_orchestration_runs_channel_ref", ["channel_ref"])
|
||||
_create_index_if_missing(bind, table_name, "ix_ai_orchestration_runs_captured_at", ["captured_at"])
|
||||
_create_index_if_missing(bind, table_name, "ix_ai_orchestration_runs_project_hint_id", ["project_hint_id"])
|
||||
_create_index_if_missing(bind, table_name, "ix_ai_orchestration_runs_next_retry_at", ["next_retry_at"])
|
||||
_create_index_if_missing(bind, table_name, "ix_ai_orchestration_runs_work_item_id", ["work_item_id"])
|
||||
_create_index_if_missing(
|
||||
bind,
|
||||
table_name,
|
||||
"ix_ai_orchestration_runs_run_type_status_next_retry",
|
||||
["run_type", "status", "next_retry_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
table_name = "ai_orchestration_runs"
|
||||
if not _table_exists(bind, table_name):
|
||||
return
|
||||
|
||||
_drop_index_if_exists(bind, table_name, "ix_ai_orchestration_runs_run_type_status_next_retry")
|
||||
_drop_index_if_exists(bind, table_name, "ix_ai_orchestration_runs_work_item_id")
|
||||
_drop_index_if_exists(bind, table_name, "ix_ai_orchestration_runs_next_retry_at")
|
||||
_drop_index_if_exists(bind, table_name, "ix_ai_orchestration_runs_project_hint_id")
|
||||
_drop_index_if_exists(bind, table_name, "ix_ai_orchestration_runs_captured_at")
|
||||
_drop_index_if_exists(bind, table_name, "ix_ai_orchestration_runs_channel_ref")
|
||||
_drop_index_if_exists(bind, table_name, "ix_ai_orchestration_runs_source_sender_open_id")
|
||||
_drop_index_if_exists(bind, table_name, "ix_ai_orchestration_runs_source_message_id")
|
||||
_drop_index_if_exists(bind, table_name, "ix_ai_orchestration_runs_source_system")
|
||||
|
||||
with op.batch_alter_table(table_name) as batch_op:
|
||||
if _column_exists(bind, table_name, "work_item_id"):
|
||||
batch_op.drop_column("work_item_id")
|
||||
if _column_exists(bind, table_name, "result_notified_at"):
|
||||
batch_op.drop_column("result_notified_at")
|
||||
if _column_exists(bind, table_name, "ack_sent_at"):
|
||||
batch_op.drop_column("ack_sent_at")
|
||||
if _column_exists(bind, table_name, "last_error_at"):
|
||||
batch_op.drop_column("last_error_at")
|
||||
if _column_exists(bind, table_name, "last_error"):
|
||||
batch_op.drop_column("last_error")
|
||||
if _column_exists(bind, table_name, "next_retry_at"):
|
||||
batch_op.drop_column("next_retry_at")
|
||||
if _column_exists(bind, table_name, "max_retries"):
|
||||
batch_op.drop_column("max_retries")
|
||||
if _column_exists(bind, table_name, "retry_count"):
|
||||
batch_op.drop_column("retry_count")
|
||||
if _column_exists(bind, table_name, "project_hint_id"):
|
||||
batch_op.drop_column("project_hint_id")
|
||||
if _column_exists(bind, table_name, "message_text"):
|
||||
batch_op.drop_column("message_text")
|
||||
if _column_exists(bind, table_name, "captured_at"):
|
||||
batch_op.drop_column("captured_at")
|
||||
if _column_exists(bind, table_name, "chat_type"):
|
||||
batch_op.drop_column("chat_type")
|
||||
if _column_exists(bind, table_name, "channel_ref"):
|
||||
batch_op.drop_column("channel_ref")
|
||||
if _column_exists(bind, table_name, "source_sender_open_id"):
|
||||
batch_op.drop_column("source_sender_open_id")
|
||||
if _column_exists(bind, table_name, "sender"):
|
||||
batch_op.drop_column("sender")
|
||||
if _column_exists(bind, table_name, "source_message_id"):
|
||||
batch_op.drop_column("source_message_id")
|
||||
if _column_exists(bind, table_name, "source_system"):
|
||||
batch_op.drop_column("source_system")
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
"""add work item draft confirmation fields
|
||||
|
||||
Revision ID: add_work_item_draft_confirmation_fields
|
||||
Revises: add_ai_orchestration_ingress_async_fields
|
||||
Create Date: 2026-03-23
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "add_work_item_draft_confirmation_fields"
|
||||
down_revision = "add_ai_orchestration_ingress_async_fields"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(str(item.get("name") or "") == column_name for item in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(str(item.get("name") or "") == index_name for item in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
table_name = "work_items"
|
||||
if not _table_exists(bind, table_name):
|
||||
return
|
||||
|
||||
with op.batch_alter_table(table_name) as batch_op:
|
||||
if not _column_exists(bind, table_name, "draft_content"):
|
||||
batch_op.add_column(sa.Column("draft_content", sa.JSON(), nullable=True))
|
||||
if not _column_exists(bind, table_name, "draft_expires_at"):
|
||||
batch_op.add_column(sa.Column("draft_expires_at", sa.TIMESTAMP(), nullable=True))
|
||||
if not _column_exists(bind, table_name, "draft_version"):
|
||||
batch_op.add_column(sa.Column("draft_version", sa.Integer(), nullable=False, server_default="0"))
|
||||
if not _column_exists(bind, table_name, "confirm_message_id"):
|
||||
batch_op.add_column(sa.Column("confirm_message_id", sa.String(length=128), nullable=True))
|
||||
|
||||
if not _index_exists(bind, table_name, "ix_work_items_draft_expires"):
|
||||
op.create_index(
|
||||
"ix_work_items_draft_expires",
|
||||
table_name,
|
||||
["draft_expires_at"],
|
||||
postgresql_where=sa.text("state = 'draft_pending_confirm'"),
|
||||
)
|
||||
|
||||
if not _index_exists(bind, table_name, "uq_work_items_tenant_confirm_message"):
|
||||
op.create_index(
|
||||
"uq_work_items_tenant_confirm_message",
|
||||
table_name,
|
||||
["tenant_id", "confirm_message_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("confirm_message_id IS NOT NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
table_name = "work_items"
|
||||
if not _table_exists(bind, table_name):
|
||||
return
|
||||
|
||||
if _index_exists(bind, table_name, "uq_work_items_tenant_confirm_message"):
|
||||
op.drop_index("uq_work_items_tenant_confirm_message", table_name=table_name)
|
||||
|
||||
if _index_exists(bind, table_name, "ix_work_items_draft_expires"):
|
||||
op.drop_index("ix_work_items_draft_expires", table_name=table_name)
|
||||
|
||||
with op.batch_alter_table(table_name) as batch_op:
|
||||
if _column_exists(bind, table_name, "confirm_message_id"):
|
||||
batch_op.drop_column("confirm_message_id")
|
||||
if _column_exists(bind, table_name, "draft_version"):
|
||||
batch_op.drop_column("draft_version")
|
||||
if _column_exists(bind, table_name, "draft_expires_at"):
|
||||
batch_op.drop_column("draft_expires_at")
|
||||
if _column_exists(bind, table_name, "draft_content"):
|
||||
batch_op.drop_column("draft_content")
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
"""add project create idempotency indexes
|
||||
|
||||
Revision ID: add_project_create_idempotency_indexes
|
||||
Revises: add_work_item_draft_confirmation_fields
|
||||
Create Date: 2026-03-24
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "add_project_create_idempotency_indexes"
|
||||
down_revision = "add_work_item_draft_confirmation_fields"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _duplicate_active_titles(bind) -> list[tuple[int, str, int]]:
|
||||
rows = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT
|
||||
tenant_id,
|
||||
lower(coalesce(nullif(title, ''), nullif(name, ''))) AS normalized_title,
|
||||
count(*) AS duplicate_count
|
||||
FROM projects
|
||||
WHERE status <> 'inactive'
|
||||
AND coalesce(nullif(title, ''), nullif(name, '')) IS NOT NULL
|
||||
GROUP BY tenant_id, lower(coalesce(nullif(title, ''), nullif(name, '')))
|
||||
HAVING count(*) > 1
|
||||
ORDER BY duplicate_count DESC, tenant_id ASC
|
||||
LIMIT 10
|
||||
"""
|
||||
)
|
||||
).fetchall()
|
||||
return [
|
||||
(int(row[0]), str(row[1] or ""), int(row[2]))
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "projects"):
|
||||
return
|
||||
|
||||
duplicates = _duplicate_active_titles(bind)
|
||||
if duplicates:
|
||||
sample = ", ".join(
|
||||
f"tenant={tenant_id}, title={title}, count={count}"
|
||||
for tenant_id, title, count in duplicates[:3]
|
||||
)
|
||||
raise RuntimeError(
|
||||
"cannot create uq_projects_tenant_active_title_ci because duplicate active project titles exist: "
|
||||
+ sample
|
||||
)
|
||||
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_projects_tenant_active_title_ci
|
||||
ON projects (
|
||||
tenant_id,
|
||||
lower(coalesce(nullif(title, ''), nullif(name, '')))
|
||||
)
|
||||
WHERE status <> 'inactive'
|
||||
AND coalesce(nullif(title, ''), nullif(name, '')) IS NOT NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "projects"):
|
||||
return
|
||||
op.execute(sa.text("DROP INDEX IF EXISTS uq_projects_tenant_active_title_ci"))
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
"""add feishu reply outbox sent_message_id
|
||||
|
||||
Revision ID: 20260325_add_feishu_reply_outbox_sent_message_id
|
||||
Revises: f38d00f6fee4
|
||||
Create Date: 2026-03-25 10:00:00.000000
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "20260325_add_feishu_reply_outbox_sent_message_id"
|
||||
down_revision: Union[str, Sequence[str], None] = "f38d00f6fee4"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.add_column(
|
||||
"feishu_reply_outbox",
|
||||
sa.Column("sent_message_id", sa.VARCHAR(length=128), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_feishu_reply_outbox_sent_message_id",
|
||||
"feishu_reply_outbox",
|
||||
["sent_message_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_index("ix_feishu_reply_outbox_sent_message_id", table_name="feishu_reply_outbox")
|
||||
op.drop_column("feishu_reply_outbox", "sent_message_id")
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
"""add ai call log routing fields
|
||||
|
||||
Revision ID: 20260326_add_ai_call_log_routing_fields
|
||||
Revises: 20260326_create_feedback_center_tables
|
||||
Create Date: 2026-03-26
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260326_add_ai_call_log_routing_fields"
|
||||
down_revision = "20260326_create_feedback_center_tables"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(str(item.get("name") or "") == column_name for item in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
table_name = "ai_call_logs"
|
||||
if not _table_exists(bind, table_name):
|
||||
return
|
||||
|
||||
columns = [
|
||||
("planned_tier", sa.Column("planned_tier", sa.String(length=16), nullable=True)),
|
||||
("executed_tier", sa.Column("executed_tier", sa.String(length=16), nullable=True)),
|
||||
("routing_decision", sa.Column("routing_decision", sa.String(length=64), nullable=True)),
|
||||
("route_source", sa.Column("route_source", sa.String(length=32), nullable=True)),
|
||||
("route_rule_name", sa.Column("route_rule_name", sa.String(length=255), nullable=True)),
|
||||
("routing_config_version", sa.Column("routing_config_version", sa.String(length=64), nullable=True)),
|
||||
("routing_config_updated_at", sa.Column("routing_config_updated_at", sa.String(length=64), nullable=True)),
|
||||
]
|
||||
|
||||
for column_name, column in columns:
|
||||
if not _column_exists(bind, table_name, column_name):
|
||||
op.add_column(table_name, column)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
table_name = "ai_call_logs"
|
||||
if not _table_exists(bind, table_name):
|
||||
return
|
||||
|
||||
for column_name in (
|
||||
"routing_config_updated_at",
|
||||
"routing_config_version",
|
||||
"route_rule_name",
|
||||
"route_source",
|
||||
"routing_decision",
|
||||
"executed_tier",
|
||||
"planned_tier",
|
||||
):
|
||||
if _column_exists(bind, table_name, column_name):
|
||||
op.drop_column(table_name, column_name)
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
"""add task breakdown payload fields
|
||||
|
||||
Revision ID: 20260326_add_task_breakdown_payload_fields
|
||||
Revises: add_project_create_idempotency_indexes
|
||||
Create Date: 2026-03-26
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260326_add_task_breakdown_payload_fields"
|
||||
down_revision = "add_project_create_idempotency_indexes"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(str(item.get("name") or "") == column_name for item in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
table_name = "tasks"
|
||||
if not _table_exists(bind, table_name):
|
||||
return
|
||||
|
||||
if not _column_exists(bind, table_name, "ai_breakdown_payload"):
|
||||
op.add_column(table_name, sa.Column("ai_breakdown_payload", _json_type(bind), nullable=True))
|
||||
if not _column_exists(bind, table_name, "manual_overrides"):
|
||||
op.add_column(table_name, sa.Column("manual_overrides", _json_type(bind), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
table_name = "tasks"
|
||||
if not _table_exists(bind, table_name):
|
||||
return
|
||||
|
||||
if _column_exists(bind, table_name, "manual_overrides"):
|
||||
op.drop_column(table_name, "manual_overrides")
|
||||
if _column_exists(bind, table_name, "ai_breakdown_payload"):
|
||||
op.drop_column(table_name, "ai_breakdown_payload")
|
||||
|
|
@ -0,0 +1,520 @@
|
|||
"""create feedback center tables
|
||||
|
||||
Revision ID: 20260326_create_feedback_center_tables
|
||||
Revises: 20260326_add_task_breakdown_payload_fields
|
||||
Create Date: 2026-03-26
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260326_create_feedback_center_tables"
|
||||
down_revision = "20260326_add_task_breakdown_payload_fields"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(str(item.get("name") or "") == index_name for item in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def _safe_int(value: Any) -> int | None:
|
||||
try:
|
||||
if value is None:
|
||||
return None
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_json_if_needed(value: Any) -> Any:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (dict, list)):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return json.loads(value)
|
||||
except (TypeError, ValueError):
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def _build_reason_candidates(*, source_type: str, field_name: str, before_value: Any, after_value: Any) -> list[dict[str, str]]:
|
||||
candidates: list[dict[str, str]] = []
|
||||
field = str(field_name or "").strip().lower()
|
||||
source = str(source_type or "").strip().lower()
|
||||
|
||||
def _add(code: str, label: str, description: str) -> None:
|
||||
if any(str(item.get("code") or "") == code for item in candidates):
|
||||
return
|
||||
candidates.append({"code": code, "label": label, "description": description})
|
||||
|
||||
if before_value not in (None, "", [], {}) and after_value in (None, "", [], {}):
|
||||
_add("incomplete_output", "输出信息不完整", "模型输出缺失关键信息字段")
|
||||
if "estimated" in field or "hours" in field:
|
||||
_add("effort_estimation_bias", "工时估算偏差", "任务工时估算与实际认知偏差较大")
|
||||
if "priority" in field:
|
||||
_add("priority_misalignment", "优先级错配", "优先级建议与业务紧急度不匹配")
|
||||
if "assignee" in field or "skill" in field:
|
||||
_add("capability_mismatch", "能力匹配偏差", "人员能力与任务推荐存在偏差")
|
||||
if "strategic" in field or source == "signal_feedback":
|
||||
_add("strategic_judgement_mismatch", "战略判断偏差", "战略属性判定与人工判断不一致")
|
||||
if source == "qa_feedback":
|
||||
_add("answer_incorrect", "答案不准确", "AI 输出与业务事实不一致")
|
||||
if not candidates:
|
||||
_add("manual_override", "人工覆盖", "由人工介入修改后形成的反馈记录")
|
||||
return candidates
|
||||
|
||||
|
||||
def _record_exists(
|
||||
bind,
|
||||
*,
|
||||
tenant_id: int,
|
||||
source_type: str,
|
||||
source_id: int,
|
||||
object_type: str,
|
||||
object_id: int,
|
||||
field_name: str,
|
||||
) -> bool:
|
||||
row = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT id
|
||||
FROM feedback_center_records
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND source_type = :source_type
|
||||
AND source_id = :source_id
|
||||
AND object_type = :object_type
|
||||
AND object_id = :object_id
|
||||
AND field_name = :field_name
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{
|
||||
"tenant_id": int(tenant_id),
|
||||
"source_type": str(source_type),
|
||||
"source_id": int(source_id),
|
||||
"object_type": str(object_type),
|
||||
"object_id": int(object_id),
|
||||
"field_name": str(field_name),
|
||||
},
|
||||
).first()
|
||||
return row is not None
|
||||
|
||||
|
||||
def _feedback_center_records_table(bind):
|
||||
json_type = _json_type(bind)
|
||||
return sa.table(
|
||||
"feedback_center_records",
|
||||
sa.column("tenant_id", sa.BigInteger()),
|
||||
sa.column("project_id", sa.BigInteger()),
|
||||
sa.column("source_type", sa.VARCHAR(length=32)),
|
||||
sa.column("source_id", sa.BigInteger()),
|
||||
sa.column("object_type", sa.VARCHAR(length=32)),
|
||||
sa.column("object_id", sa.BigInteger()),
|
||||
sa.column("field_name", sa.VARCHAR(length=128)),
|
||||
sa.column("before_value", json_type),
|
||||
sa.column("after_value", json_type),
|
||||
sa.column("ai_snapshot", json_type),
|
||||
sa.column("ai_confidence", sa.Float()),
|
||||
sa.column("reason_candidates", json_type),
|
||||
sa.column("reason_code", sa.VARCHAR(length=64)),
|
||||
sa.column("reason_text", sa.Text()),
|
||||
sa.column("review_status", sa.VARCHAR(length=16)),
|
||||
sa.column("reviewed_by", sa.BigInteger()),
|
||||
sa.column("reviewed_at", sa.TIMESTAMP()),
|
||||
sa.column("source_title", sa.VARCHAR(length=500)),
|
||||
sa.column("object_title", sa.VARCHAR(length=500)),
|
||||
sa.column("auto_assigned", sa.Boolean()),
|
||||
)
|
||||
|
||||
|
||||
def _insert_record(bind, payload: dict[str, Any]) -> None:
|
||||
bind.execute(sa.insert(_feedback_center_records_table(bind)).values(**payload))
|
||||
|
||||
|
||||
def _backfill_from_qa_feedback(bind) -> None:
|
||||
if not _table_exists(bind, "qa_feedback") or not _table_exists(bind, "qa_records"):
|
||||
return
|
||||
rows = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT
|
||||
f.id AS feedback_id,
|
||||
f.tenant_id AS tenant_id,
|
||||
f.qa_id AS qa_id,
|
||||
f.rating AS rating,
|
||||
f.helpfulness AS helpfulness,
|
||||
f.error_types AS error_types,
|
||||
f.correct_answer AS correct_answer,
|
||||
f.submitted_by AS submitted_by,
|
||||
f.created_at AS created_at,
|
||||
r.project_id AS project_id,
|
||||
r.question AS question,
|
||||
r.answer AS answer
|
||||
FROM qa_feedback f
|
||||
JOIN qa_records r ON r.id = f.qa_id
|
||||
"""
|
||||
)
|
||||
).mappings().all()
|
||||
|
||||
for row in rows:
|
||||
tenant_id = _safe_int(row.get("tenant_id"))
|
||||
source_id = _safe_int(row.get("feedback_id"))
|
||||
object_id = _safe_int(row.get("qa_id"))
|
||||
if tenant_id is None or source_id is None or object_id is None:
|
||||
continue
|
||||
if _record_exists(
|
||||
bind,
|
||||
tenant_id=tenant_id,
|
||||
source_type="qa_feedback",
|
||||
source_id=source_id,
|
||||
object_type="qa_record",
|
||||
object_id=object_id,
|
||||
field_name="qa_feedback",
|
||||
):
|
||||
continue
|
||||
before_value = {"answer": row.get("answer")}
|
||||
after_value = {
|
||||
"rating": row.get("rating"),
|
||||
"helpfulness": row.get("helpfulness"),
|
||||
"error_types": _parse_json_if_needed(row.get("error_types")),
|
||||
"correct_answer": row.get("correct_answer"),
|
||||
}
|
||||
candidates = _build_reason_candidates(
|
||||
source_type="qa_feedback",
|
||||
field_name="qa_feedback",
|
||||
before_value=before_value,
|
||||
after_value=after_value,
|
||||
)
|
||||
_insert_record(
|
||||
bind,
|
||||
{
|
||||
"tenant_id": tenant_id,
|
||||
"project_id": _safe_int(row.get("project_id")),
|
||||
"source_type": "qa_feedback",
|
||||
"source_id": source_id,
|
||||
"object_type": "qa_record",
|
||||
"object_id": object_id,
|
||||
"field_name": "qa_feedback",
|
||||
"before_value": before_value,
|
||||
"after_value": after_value,
|
||||
"ai_snapshot": {
|
||||
"question": row.get("question"),
|
||||
"answer": row.get("answer"),
|
||||
},
|
||||
"ai_confidence": None,
|
||||
"reason_candidates": candidates,
|
||||
"reason_code": candidates[0]["code"] if candidates else None,
|
||||
"reason_text": row.get("correct_answer"),
|
||||
"review_status": "reviewed",
|
||||
"reviewed_by": _safe_int(row.get("submitted_by")),
|
||||
"reviewed_at": row.get("created_at"),
|
||||
"source_title": str(row.get("question") or "")[:500] or None,
|
||||
"object_title": str(row.get("question") or "")[:500] or None,
|
||||
"auto_assigned": False,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _backfill_from_signal_feedback(bind) -> None:
|
||||
if not _table_exists(bind, "signal_feedbacks") or not _table_exists(bind, "signals"):
|
||||
return
|
||||
rows = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT
|
||||
f.id AS feedback_id,
|
||||
f.tenant_id AS tenant_id,
|
||||
f.signal_id AS signal_id,
|
||||
f.was_strategic AS was_strategic,
|
||||
f.reason AS reason,
|
||||
f.ai_predicted_strategic AS ai_predicted_strategic,
|
||||
f.ai_confidence AS ai_confidence,
|
||||
f.user_id AS user_id,
|
||||
f.created_at AS created_at,
|
||||
s.matched_project_id AS project_id,
|
||||
s.title AS signal_title
|
||||
FROM signal_feedbacks f
|
||||
JOIN signals s ON s.id = f.signal_id
|
||||
"""
|
||||
)
|
||||
).mappings().all()
|
||||
|
||||
for row in rows:
|
||||
tenant_id = _safe_int(row.get("tenant_id"))
|
||||
source_id = _safe_int(row.get("feedback_id"))
|
||||
object_id = _safe_int(row.get("signal_id"))
|
||||
if tenant_id is None or source_id is None or object_id is None:
|
||||
continue
|
||||
if _record_exists(
|
||||
bind,
|
||||
tenant_id=tenant_id,
|
||||
source_type="signal_feedback",
|
||||
source_id=source_id,
|
||||
object_type="signal",
|
||||
object_id=object_id,
|
||||
field_name="strategic_classification",
|
||||
):
|
||||
continue
|
||||
before_value = {
|
||||
"ai_predicted_strategic": row.get("ai_predicted_strategic"),
|
||||
}
|
||||
after_value = {
|
||||
"was_strategic": row.get("was_strategic"),
|
||||
"reason": row.get("reason"),
|
||||
}
|
||||
candidates = _build_reason_candidates(
|
||||
source_type="signal_feedback",
|
||||
field_name="strategic_classification",
|
||||
before_value=before_value,
|
||||
after_value=after_value,
|
||||
)
|
||||
_insert_record(
|
||||
bind,
|
||||
{
|
||||
"tenant_id": tenant_id,
|
||||
"project_id": _safe_int(row.get("project_id")),
|
||||
"source_type": "signal_feedback",
|
||||
"source_id": source_id,
|
||||
"object_type": "signal",
|
||||
"object_id": object_id,
|
||||
"field_name": "strategic_classification",
|
||||
"before_value": before_value,
|
||||
"after_value": after_value,
|
||||
"ai_snapshot": {
|
||||
"ai_predicted_strategic": row.get("ai_predicted_strategic"),
|
||||
"ai_confidence": row.get("ai_confidence"),
|
||||
},
|
||||
"ai_confidence": row.get("ai_confidence"),
|
||||
"reason_candidates": candidates,
|
||||
"reason_code": candidates[0]["code"] if candidates else None,
|
||||
"reason_text": row.get("reason"),
|
||||
"review_status": "reviewed",
|
||||
"reviewed_by": _safe_int(row.get("user_id")),
|
||||
"reviewed_at": row.get("created_at"),
|
||||
"source_title": str(row.get("signal_title") or "")[:500] or None,
|
||||
"object_title": str(row.get("signal_title") or "")[:500] or None,
|
||||
"auto_assigned": False,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _backfill_from_task_overrides(bind) -> None:
|
||||
if not _table_exists(bind, "tasks"):
|
||||
return
|
||||
rows = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT
|
||||
t.id AS task_id,
|
||||
t.tenant_id AS tenant_id,
|
||||
t.project_id AS project_id,
|
||||
t.title AS task_title,
|
||||
t.ai_breakdown_payload AS ai_breakdown_payload,
|
||||
t.manual_overrides AS manual_overrides,
|
||||
t.updated_at AS updated_at
|
||||
FROM tasks t
|
||||
WHERE t.manual_overrides IS NOT NULL
|
||||
"""
|
||||
)
|
||||
).mappings().all()
|
||||
|
||||
for row in rows:
|
||||
tenant_id = _safe_int(row.get("tenant_id"))
|
||||
task_id = _safe_int(row.get("task_id"))
|
||||
if tenant_id is None or task_id is None:
|
||||
continue
|
||||
overrides = _parse_json_if_needed(row.get("manual_overrides"))
|
||||
ai_payload = _parse_json_if_needed(row.get("ai_breakdown_payload"))
|
||||
if not isinstance(overrides, dict) or not overrides:
|
||||
continue
|
||||
if not isinstance(ai_payload, dict):
|
||||
ai_payload = {}
|
||||
|
||||
for field_name in sorted(overrides.keys()):
|
||||
if _record_exists(
|
||||
bind,
|
||||
tenant_id=tenant_id,
|
||||
source_type="task_override",
|
||||
source_id=task_id,
|
||||
object_type="task",
|
||||
object_id=task_id,
|
||||
field_name=str(field_name),
|
||||
):
|
||||
continue
|
||||
before_value = ai_payload.get(field_name)
|
||||
after_value = overrides.get(field_name)
|
||||
candidates = _build_reason_candidates(
|
||||
source_type="task_override",
|
||||
field_name=str(field_name),
|
||||
before_value=before_value,
|
||||
after_value=after_value,
|
||||
)
|
||||
_insert_record(
|
||||
bind,
|
||||
{
|
||||
"tenant_id": tenant_id,
|
||||
"project_id": _safe_int(row.get("project_id")),
|
||||
"source_type": "task_override",
|
||||
"source_id": task_id,
|
||||
"object_type": "task",
|
||||
"object_id": task_id,
|
||||
"field_name": str(field_name)[:128],
|
||||
"before_value": before_value,
|
||||
"after_value": after_value,
|
||||
"ai_snapshot": ai_payload,
|
||||
"ai_confidence": None,
|
||||
"reason_candidates": candidates,
|
||||
"reason_code": candidates[0]["code"] if candidates else None,
|
||||
"reason_text": None,
|
||||
"review_status": "reviewed",
|
||||
"reviewed_by": None,
|
||||
"reviewed_at": row.get("updated_at"),
|
||||
"source_title": str(row.get("task_title") or "")[:500] or None,
|
||||
"object_title": str(row.get("task_title") or "")[:500] or None,
|
||||
"auto_assigned": False,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
json_type = _json_type(bind)
|
||||
|
||||
if not _table_exists(bind, "feedback_center_records"):
|
||||
op.create_table(
|
||||
"feedback_center_records",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("source_type", sa.VARCHAR(length=32), nullable=False),
|
||||
sa.Column("source_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("object_type", sa.VARCHAR(length=32), nullable=False),
|
||||
sa.Column("object_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("field_name", sa.VARCHAR(length=128), nullable=False),
|
||||
sa.Column("before_value", json_type, nullable=True),
|
||||
sa.Column("after_value", json_type, nullable=True),
|
||||
sa.Column("ai_snapshot", json_type, nullable=True),
|
||||
sa.Column("ai_confidence", sa.Float(), nullable=True),
|
||||
sa.Column("reason_candidates", json_type, nullable=True),
|
||||
sa.Column("reason_code", sa.VARCHAR(length=64), nullable=True),
|
||||
sa.Column("reason_text", sa.Text(), nullable=True),
|
||||
sa.Column("review_status", sa.VARCHAR(length=16), nullable=False, server_default="pending"),
|
||||
sa.Column("reviewed_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("reviewed_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("source_title", sa.VARCHAR(length=500), nullable=True),
|
||||
sa.Column("object_title", sa.VARCHAR(length=500), nullable=True),
|
||||
sa.Column("auto_assigned", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("provider", sa.VARCHAR(length=32), nullable=True),
|
||||
sa.Column("model_name", sa.VARCHAR(length=128), nullable=True),
|
||||
sa.Column("prompt_version", sa.VARCHAR(length=64), nullable=True),
|
||||
sa.Column("schema_version", sa.VARCHAR(length=64), nullable=True),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.text("now()")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"source_type",
|
||||
"source_id",
|
||||
"object_type",
|
||||
"object_id",
|
||||
"field_name",
|
||||
name="uq_feedback_center_records_source_object_field",
|
||||
),
|
||||
)
|
||||
|
||||
if not _table_exists(bind, "feedback_center_record_revisions"):
|
||||
op.create_table(
|
||||
"feedback_center_record_revisions",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("record_id", sa.Integer(), sa.ForeignKey("feedback_center_records.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("revision_no", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("before_snapshot", json_type, nullable=False),
|
||||
sa.Column("after_snapshot", json_type, nullable=False),
|
||||
sa.Column("reason_code", sa.VARCHAR(length=64), nullable=True),
|
||||
sa.Column("reason_text", sa.Text(), nullable=True),
|
||||
sa.Column("review_status", sa.VARCHAR(length=16), nullable=False, server_default="reviewed"),
|
||||
sa.Column("reviewed_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("reviewed_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("change_summary", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.text("now()")),
|
||||
)
|
||||
|
||||
if not _index_exists(bind, "feedback_center_records", "ix_feedback_center_records_tenant_project_status"):
|
||||
op.create_index(
|
||||
"ix_feedback_center_records_tenant_project_status",
|
||||
"feedback_center_records",
|
||||
["tenant_id", "project_id", "review_status"],
|
||||
unique=False,
|
||||
)
|
||||
if not _index_exists(bind, "feedback_center_records", "ix_feedback_center_records_tenant_source"):
|
||||
op.create_index(
|
||||
"ix_feedback_center_records_tenant_source",
|
||||
"feedback_center_records",
|
||||
["tenant_id", "source_type", "source_id"],
|
||||
unique=False,
|
||||
)
|
||||
if not _index_exists(bind, "feedback_center_records", "ix_feedback_center_records_reason_code"):
|
||||
op.create_index(
|
||||
"ix_feedback_center_records_reason_code",
|
||||
"feedback_center_records",
|
||||
["reason_code"],
|
||||
unique=False,
|
||||
)
|
||||
if not _index_exists(bind, "feedback_center_record_revisions", "ix_feedback_center_record_revisions_tenant_record"):
|
||||
op.create_index(
|
||||
"ix_feedback_center_record_revisions_tenant_record",
|
||||
"feedback_center_record_revisions",
|
||||
["tenant_id", "record_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
_backfill_from_qa_feedback(bind)
|
||||
_backfill_from_signal_feedback(bind)
|
||||
_backfill_from_task_overrides(bind)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "feedback_center_record_revisions"):
|
||||
if _index_exists(bind, "feedback_center_record_revisions", "ix_feedback_center_record_revisions_tenant_record"):
|
||||
op.drop_index(
|
||||
"ix_feedback_center_record_revisions_tenant_record",
|
||||
table_name="feedback_center_record_revisions",
|
||||
)
|
||||
op.drop_table("feedback_center_record_revisions")
|
||||
|
||||
if _table_exists(bind, "feedback_center_records"):
|
||||
for index_name in (
|
||||
"ix_feedback_center_records_reason_code",
|
||||
"ix_feedback_center_records_tenant_source",
|
||||
"ix_feedback_center_records_tenant_project_status",
|
||||
):
|
||||
if _index_exists(bind, "feedback_center_records", index_name):
|
||||
op.drop_index(index_name, table_name="feedback_center_records")
|
||||
op.drop_table("feedback_center_records")
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
"""add external qa followup state fields
|
||||
|
||||
Revision ID: 20260327_add_external_qa_followup_state
|
||||
Revises: 20260327_cleanup_model_routing_legacy_names
|
||||
Create Date: 2026-03-27
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260327_add_external_qa_followup_state"
|
||||
down_revision = "20260327_cleanup_model_routing_legacy_names"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
try:
|
||||
columns = inspector.get_columns(table_name)
|
||||
except Exception:
|
||||
return False
|
||||
return any(str(col.get("name")) == column_name for col in columns)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
try:
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
except Exception:
|
||||
return False
|
||||
return any(idx.get("name") == index_name for idx in indexes)
|
||||
|
||||
|
||||
def _create_index_if_not_exists(
|
||||
bind,
|
||||
index_name: str,
|
||||
table_name: str,
|
||||
columns: list[str],
|
||||
*,
|
||||
unique: bool = False,
|
||||
) -> None:
|
||||
if not _index_exists(bind, table_name, index_name):
|
||||
op.create_index(index_name, table_name, columns, unique=unique)
|
||||
|
||||
|
||||
def _drop_index_if_exists(bind, index_name: str, table_name: str) -> None:
|
||||
if _index_exists(bind, table_name, index_name):
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if _table_exists(bind, "qa_sessions"):
|
||||
if not _column_exists(bind, "qa_sessions", "external_conversation_key"):
|
||||
with op.batch_alter_table("qa_sessions") as batch_op:
|
||||
batch_op.add_column(sa.Column("external_conversation_key", sa.VARCHAR(length=160), nullable=True))
|
||||
_create_index_if_not_exists(
|
||||
bind,
|
||||
"ix_qa_sessions_external_conversation_key",
|
||||
"qa_sessions",
|
||||
["external_conversation_key"],
|
||||
)
|
||||
_create_index_if_not_exists(
|
||||
bind,
|
||||
"uq_qa_sessions_tenant_external_conversation_key",
|
||||
"qa_sessions",
|
||||
["tenant_id", "external_conversation_key"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
if _table_exists(bind, "qa_session_states"):
|
||||
if not _column_exists(bind, "qa_session_states", "pending_clarification"):
|
||||
with op.batch_alter_table("qa_session_states") as batch_op:
|
||||
batch_op.add_column(sa.Column("pending_clarification", _json_type(bind), nullable=True))
|
||||
if not _column_exists(bind, "qa_session_states", "pending_clarification_expires_at"):
|
||||
with op.batch_alter_table("qa_session_states") as batch_op:
|
||||
batch_op.add_column(sa.Column("pending_clarification_expires_at", sa.TIMESTAMP(), nullable=True))
|
||||
_create_index_if_not_exists(
|
||||
bind,
|
||||
"ix_qa_session_states_pending_clarification_expires_at",
|
||||
"qa_session_states",
|
||||
["pending_clarification_expires_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if _table_exists(bind, "qa_session_states"):
|
||||
_drop_index_if_exists(
|
||||
bind,
|
||||
"ix_qa_session_states_pending_clarification_expires_at",
|
||||
"qa_session_states",
|
||||
)
|
||||
if _column_exists(bind, "qa_session_states", "pending_clarification_expires_at") or _column_exists(
|
||||
bind, "qa_session_states", "pending_clarification"
|
||||
):
|
||||
with op.batch_alter_table("qa_session_states") as batch_op:
|
||||
if _column_exists(bind, "qa_session_states", "pending_clarification_expires_at"):
|
||||
batch_op.drop_column("pending_clarification_expires_at")
|
||||
if _column_exists(bind, "qa_session_states", "pending_clarification"):
|
||||
batch_op.drop_column("pending_clarification")
|
||||
|
||||
if _table_exists(bind, "qa_sessions"):
|
||||
_drop_index_if_exists(
|
||||
bind,
|
||||
"uq_qa_sessions_tenant_external_conversation_key",
|
||||
"qa_sessions",
|
||||
)
|
||||
_drop_index_if_exists(
|
||||
bind,
|
||||
"ix_qa_sessions_external_conversation_key",
|
||||
"qa_sessions",
|
||||
)
|
||||
if _column_exists(bind, "qa_sessions", "external_conversation_key"):
|
||||
with op.batch_alter_table("qa_sessions") as batch_op:
|
||||
batch_op.drop_column("external_conversation_key")
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
"""add qa record followup anchor fields
|
||||
|
||||
Revision ID: 20260327_add_qa_record_followup_anchor_fields
|
||||
Revises: 20260327_add_external_qa_followup_state
|
||||
Create Date: 2026-03-27
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260327_add_qa_record_followup_anchor_fields"
|
||||
down_revision = "20260327_add_external_qa_followup_state"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
try:
|
||||
columns = inspector.get_columns(table_name)
|
||||
except Exception:
|
||||
return False
|
||||
return any(str(col.get("name")) == column_name for col in columns)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
try:
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
except Exception:
|
||||
return False
|
||||
return any(idx.get("name") == index_name for idx in indexes)
|
||||
|
||||
|
||||
def _create_index_if_not_exists(
|
||||
bind,
|
||||
index_name: str,
|
||||
table_name: str,
|
||||
columns: list[str],
|
||||
*,
|
||||
unique: bool = False,
|
||||
) -> None:
|
||||
if not _index_exists(bind, table_name, index_name):
|
||||
op.create_index(index_name, table_name, columns, unique=unique)
|
||||
|
||||
|
||||
def _drop_index_if_exists(bind, index_name: str, table_name: str) -> None:
|
||||
if _index_exists(bind, table_name, index_name):
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "qa_records"):
|
||||
return
|
||||
|
||||
columns = [
|
||||
("source_message_id", sa.VARCHAR(length=128), True),
|
||||
("source_chat_id", sa.VARCHAR(length=128), True),
|
||||
("source_sender_open_id", sa.VARCHAR(length=128), True),
|
||||
("reply_to_message_id", sa.VARCHAR(length=128), True),
|
||||
("root_message_id", sa.VARCHAR(length=128), True),
|
||||
("conversation_scope_key", sa.VARCHAR(length=160), True),
|
||||
("anchor_kind", sa.VARCHAR(length=32), True),
|
||||
("anchor_ref", sa.VARCHAR(length=128), True),
|
||||
("routing_snapshot", _json_type(bind), True),
|
||||
]
|
||||
for name, column_type, nullable in columns:
|
||||
if _column_exists(bind, "qa_records", name):
|
||||
continue
|
||||
with op.batch_alter_table("qa_records") as batch_op:
|
||||
batch_op.add_column(sa.Column(name, column_type, nullable=nullable))
|
||||
|
||||
_create_index_if_not_exists(bind, "ix_qa_records_source_message_id", "qa_records", ["source_message_id"])
|
||||
_create_index_if_not_exists(bind, "ix_qa_records_conversation_scope_key", "qa_records", ["conversation_scope_key"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "qa_records"):
|
||||
return
|
||||
|
||||
_drop_index_if_exists(bind, "ix_qa_records_conversation_scope_key", "qa_records")
|
||||
_drop_index_if_exists(bind, "ix_qa_records_source_message_id", "qa_records")
|
||||
|
||||
for name in (
|
||||
"routing_snapshot",
|
||||
"anchor_ref",
|
||||
"anchor_kind",
|
||||
"conversation_scope_key",
|
||||
"root_message_id",
|
||||
"reply_to_message_id",
|
||||
"source_sender_open_id",
|
||||
"source_chat_id",
|
||||
"source_message_id",
|
||||
):
|
||||
if not _column_exists(bind, "qa_records", name):
|
||||
continue
|
||||
with op.batch_alter_table("qa_records") as batch_op:
|
||||
batch_op.drop_column(name)
|
||||
|
|
@ -0,0 +1,587 @@
|
|||
"""add reply iteration and training pipeline tables
|
||||
|
||||
Revision ID: 20260327_add_reply_iteration_training_tables
|
||||
Revises: 20260325_add_feishu_reply_outbox_sent_message_id, 20260327_add_qa_record_followup_anchor_fields
|
||||
Create Date: 2026-03-27
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260327_add_reply_iteration_training_tables"
|
||||
down_revision = (
|
||||
"20260325_add_feishu_reply_outbox_sent_message_id",
|
||||
"20260327_add_qa_record_followup_anchor_fields",
|
||||
)
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
try:
|
||||
columns = inspector.get_columns(table_name)
|
||||
except Exception:
|
||||
return False
|
||||
return any(str(column.get("name")) == column_name for column in columns)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
try:
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
except Exception:
|
||||
return False
|
||||
return any(str(index.get("name")) == index_name for index in indexes)
|
||||
|
||||
|
||||
def _create_index_if_missing(bind, table_name: str, index_name: str, columns: list[str], *, unique: bool = False) -> None:
|
||||
if not _index_exists(bind, table_name, index_name):
|
||||
op.create_index(index_name, table_name, columns, unique=unique)
|
||||
|
||||
|
||||
def _drop_index_if_exists(bind, table_name: str, index_name: str) -> None:
|
||||
if _index_exists(bind, table_name, index_name):
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
|
||||
|
||||
def _base_columns() -> list[sa.Column]:
|
||||
return [
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
]
|
||||
|
||||
|
||||
def _create_quality_assets(bind) -> None:
|
||||
if _table_exists(bind, "quality_assets"):
|
||||
return
|
||||
op.create_table(
|
||||
"quality_assets",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("project_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("asset_type", sa.VARCHAR(length=16), nullable=False),
|
||||
sa.Column("status", sa.VARCHAR(length=16), nullable=False, server_default="approved"),
|
||||
sa.Column("source_feedback_record_id", sa.Integer(), sa.ForeignKey("feedback_center_records.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("qa_record_id", sa.Integer(), sa.ForeignKey("qa_records.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("ai_call_log_id", sa.Integer(), nullable=True),
|
||||
sa.Column("prompt_key", sa.VARCHAR(length=128), nullable=True),
|
||||
sa.Column("workflow_type", sa.VARCHAR(length=64), nullable=True),
|
||||
sa.Column("asked_via", sa.VARCHAR(length=32), nullable=True),
|
||||
sa.Column("planned_tier", sa.VARCHAR(length=16), nullable=True),
|
||||
sa.Column("executed_tier", sa.VARCHAR(length=16), nullable=True),
|
||||
sa.Column("route_source", sa.VARCHAR(length=32), nullable=True),
|
||||
sa.Column("route_rule_name", sa.VARCHAR(length=255), nullable=True),
|
||||
sa.Column("question_text", sa.Text(), nullable=False),
|
||||
sa.Column("answer_text", sa.Text(), nullable=False),
|
||||
sa.Column("corrected_answer", sa.Text(), nullable=True),
|
||||
sa.Column("source_snapshot", _json_type(bind), nullable=True),
|
||||
sa.Column("tags", _json_type(bind), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column("approved_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("approved_at", sa.VARCHAR(length=64), nullable=True),
|
||||
*_base_columns(),
|
||||
)
|
||||
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_tenant_id", ["tenant_id"])
|
||||
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_project_id", ["project_id"])
|
||||
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_asset_type", ["asset_type"])
|
||||
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_status", ["status"])
|
||||
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_source_feedback_record_id", ["source_feedback_record_id"])
|
||||
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_qa_record_id", ["qa_record_id"])
|
||||
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_ai_call_log_id", ["ai_call_log_id"])
|
||||
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_prompt_key", ["prompt_key"])
|
||||
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_workflow_type", ["workflow_type"])
|
||||
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_asked_via", ["asked_via"])
|
||||
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_planned_tier", ["planned_tier"])
|
||||
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_executed_tier", ["executed_tier"])
|
||||
_create_index_if_missing(bind, "quality_assets", "ix_quality_assets_approved_by", ["approved_by"])
|
||||
|
||||
|
||||
def _create_quality_asset_pairs(bind) -> None:
|
||||
if _table_exists(bind, "quality_asset_pairs"):
|
||||
return
|
||||
op.create_table(
|
||||
"quality_asset_pairs",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("project_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("pair_type", sa.VARCHAR(length=24), nullable=False, server_default="preference"),
|
||||
sa.Column("status", sa.VARCHAR(length=16), nullable=False, server_default="approved"),
|
||||
sa.Column("chosen_asset_id", sa.Integer(), sa.ForeignKey("quality_assets.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("rejected_asset_id", sa.Integer(), sa.ForeignKey("quality_assets.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("reason_text", sa.Text(), nullable=True),
|
||||
sa.Column("approved_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("approved_at", sa.VARCHAR(length=64), nullable=True),
|
||||
*_base_columns(),
|
||||
)
|
||||
_create_index_if_missing(bind, "quality_asset_pairs", "ix_quality_asset_pairs_tenant_id", ["tenant_id"])
|
||||
_create_index_if_missing(bind, "quality_asset_pairs", "ix_quality_asset_pairs_project_id", ["project_id"])
|
||||
_create_index_if_missing(bind, "quality_asset_pairs", "ix_quality_asset_pairs_pair_type", ["pair_type"])
|
||||
_create_index_if_missing(bind, "quality_asset_pairs", "ix_quality_asset_pairs_status", ["status"])
|
||||
_create_index_if_missing(bind, "quality_asset_pairs", "ix_quality_asset_pairs_chosen_asset_id", ["chosen_asset_id"])
|
||||
_create_index_if_missing(bind, "quality_asset_pairs", "ix_quality_asset_pairs_rejected_asset_id", ["rejected_asset_id"])
|
||||
_create_index_if_missing(bind, "quality_asset_pairs", "ix_quality_asset_pairs_approved_by", ["approved_by"])
|
||||
|
||||
|
||||
def _create_judge_runs(bind) -> None:
|
||||
if _table_exists(bind, "judge_runs"):
|
||||
return
|
||||
op.create_table(
|
||||
"judge_runs",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("project_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("asset_id", sa.Integer(), sa.ForeignKey("quality_assets.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("pair_id", sa.Integer(), sa.ForeignKey("quality_asset_pairs.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("prompt_key", sa.VARCHAR(length=128), nullable=True),
|
||||
sa.Column("workflow_type", sa.VARCHAR(length=64), nullable=True),
|
||||
sa.Column("asked_via", sa.VARCHAR(length=32), nullable=True),
|
||||
sa.Column("planned_tier", sa.VARCHAR(length=16), nullable=True),
|
||||
sa.Column("executed_tier", sa.VARCHAR(length=16), nullable=True),
|
||||
sa.Column("route_rule_name", sa.VARCHAR(length=255), nullable=True),
|
||||
sa.Column("judge_status", sa.VARCHAR(length=16), nullable=False, server_default="completed"),
|
||||
sa.Column("bucket_key", sa.VARCHAR(length=255), nullable=False),
|
||||
sa.Column("rule_score", sa.Float(), nullable=True),
|
||||
sa.Column("llm_score", sa.Float(), nullable=True),
|
||||
sa.Column("overall_score", sa.Float(), nullable=True),
|
||||
sa.Column("findings", _json_type(bind), nullable=True),
|
||||
sa.Column("llm_evidence", _json_type(bind), nullable=True),
|
||||
sa.Column("metadata_json", _json_type(bind), nullable=True),
|
||||
*_base_columns(),
|
||||
)
|
||||
for index_name, columns in (
|
||||
("ix_judge_runs_tenant_id", ["tenant_id"]),
|
||||
("ix_judge_runs_project_id", ["project_id"]),
|
||||
("ix_judge_runs_asset_id", ["asset_id"]),
|
||||
("ix_judge_runs_pair_id", ["pair_id"]),
|
||||
("ix_judge_runs_prompt_key", ["prompt_key"]),
|
||||
("ix_judge_runs_workflow_type", ["workflow_type"]),
|
||||
("ix_judge_runs_asked_via", ["asked_via"]),
|
||||
("ix_judge_runs_planned_tier", ["planned_tier"]),
|
||||
("ix_judge_runs_executed_tier", ["executed_tier"]),
|
||||
("ix_judge_runs_judge_status", ["judge_status"]),
|
||||
("ix_judge_runs_bucket_key", ["bucket_key"]),
|
||||
):
|
||||
_create_index_if_missing(bind, "judge_runs", index_name, columns)
|
||||
|
||||
|
||||
def _create_optimizer_candidates(bind) -> None:
|
||||
if _table_exists(bind, "optimizer_candidates"):
|
||||
return
|
||||
op.create_table(
|
||||
"optimizer_candidates",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("project_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("judge_run_id", sa.Integer(), sa.ForeignKey("judge_runs.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("candidate_type", sa.VARCHAR(length=32), nullable=False),
|
||||
sa.Column("target_type", sa.VARCHAR(length=32), nullable=False),
|
||||
sa.Column("target_ref", sa.VARCHAR(length=255), nullable=False),
|
||||
sa.Column("status", sa.VARCHAR(length=24), nullable=False, server_default="draft"),
|
||||
sa.Column("suggestion", _json_type(bind), nullable=True),
|
||||
sa.Column("evidence", _json_type(bind), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
*_base_columns(),
|
||||
)
|
||||
for index_name, columns in (
|
||||
("ix_optimizer_candidates_tenant_id", ["tenant_id"]),
|
||||
("ix_optimizer_candidates_project_id", ["project_id"]),
|
||||
("ix_optimizer_candidates_judge_run_id", ["judge_run_id"]),
|
||||
("ix_optimizer_candidates_candidate_type", ["candidate_type"]),
|
||||
("ix_optimizer_candidates_target_type", ["target_type"]),
|
||||
("ix_optimizer_candidates_target_ref", ["target_ref"]),
|
||||
("ix_optimizer_candidates_status", ["status"]),
|
||||
):
|
||||
_create_index_if_missing(bind, "optimizer_candidates", index_name, columns)
|
||||
|
||||
|
||||
def _create_prompt_releases(bind) -> None:
|
||||
if _table_exists(bind, "prompt_releases"):
|
||||
return
|
||||
op.create_table(
|
||||
"prompt_releases",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("prompt_key", sa.VARCHAR(length=128), nullable=False),
|
||||
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("body_snapshot", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.VARCHAR(length=16), nullable=False, server_default="draft"),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column("created_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("activated_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("activated_at", sa.VARCHAR(length=64), nullable=True),
|
||||
*_base_columns(),
|
||||
sa.UniqueConstraint("tenant_id", "prompt_key", "version", name="uq_prompt_releases_tenant_key_version"),
|
||||
)
|
||||
for index_name, columns in (
|
||||
("ix_prompt_releases_tenant_id", ["tenant_id"]),
|
||||
("ix_prompt_releases_prompt_key", ["prompt_key"]),
|
||||
("ix_prompt_releases_status", ["status"]),
|
||||
("ix_prompt_releases_created_by", ["created_by"]),
|
||||
("ix_prompt_releases_activated_by", ["activated_by"]),
|
||||
):
|
||||
_create_index_if_missing(bind, "prompt_releases", index_name, columns)
|
||||
|
||||
|
||||
def _create_training_datasets(bind) -> None:
|
||||
if _table_exists(bind, "training_datasets"):
|
||||
return
|
||||
op.create_table(
|
||||
"training_datasets",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("project_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("dataset_type", sa.VARCHAR(length=16), nullable=False),
|
||||
sa.Column("version", sa.VARCHAR(length=64), nullable=False),
|
||||
sa.Column("status", sa.VARCHAR(length=16), nullable=False, server_default="ready"),
|
||||
sa.Column("sample_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("selection_rules", _json_type(bind), nullable=True),
|
||||
sa.Column("channel_scope", _json_type(bind), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column("created_by", sa.BigInteger(), nullable=True),
|
||||
*_base_columns(),
|
||||
sa.UniqueConstraint("tenant_id", "dataset_type", "version", name="uq_training_datasets_tenant_type_version"),
|
||||
)
|
||||
for index_name, columns in (
|
||||
("ix_training_datasets_tenant_id", ["tenant_id"]),
|
||||
("ix_training_datasets_project_id", ["project_id"]),
|
||||
("ix_training_datasets_dataset_type", ["dataset_type"]),
|
||||
("ix_training_datasets_version", ["version"]),
|
||||
("ix_training_datasets_status", ["status"]),
|
||||
("ix_training_datasets_created_by", ["created_by"]),
|
||||
):
|
||||
_create_index_if_missing(bind, "training_datasets", index_name, columns)
|
||||
|
||||
|
||||
def _create_training_dataset_items(bind) -> None:
|
||||
if _table_exists(bind, "training_dataset_items"):
|
||||
return
|
||||
op.create_table(
|
||||
"training_dataset_items",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("dataset_id", sa.Integer(), sa.ForeignKey("training_datasets.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("quality_asset_id", sa.Integer(), sa.ForeignKey("quality_assets.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("quality_asset_pair_id", sa.Integer(), sa.ForeignKey("quality_asset_pairs.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("redacted_snapshot", _json_type(bind), nullable=True),
|
||||
*_base_columns(),
|
||||
)
|
||||
for index_name, columns in (
|
||||
("ix_training_dataset_items_tenant_id", ["tenant_id"]),
|
||||
("ix_training_dataset_items_dataset_id", ["dataset_id"]),
|
||||
("ix_training_dataset_items_quality_asset_id", ["quality_asset_id"]),
|
||||
("ix_training_dataset_items_quality_asset_pair_id", ["quality_asset_pair_id"]),
|
||||
):
|
||||
_create_index_if_missing(bind, "training_dataset_items", index_name, columns)
|
||||
|
||||
|
||||
def _create_training_jobs(bind) -> None:
|
||||
if _table_exists(bind, "training_jobs"):
|
||||
return
|
||||
op.create_table(
|
||||
"training_jobs",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("project_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("dataset_id", sa.Integer(), sa.ForeignKey("training_datasets.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("job_type", sa.VARCHAR(length=16), nullable=False),
|
||||
sa.Column("base_model_family", sa.VARCHAR(length=64), nullable=False),
|
||||
sa.Column("target_slot", sa.VARCHAR(length=16), nullable=False),
|
||||
sa.Column("status", sa.VARCHAR(length=16), nullable=False, server_default="queued"),
|
||||
sa.Column("provider_backend", sa.VARCHAR(length=64), nullable=False),
|
||||
sa.Column("artifact_ref", sa.VARCHAR(length=255), nullable=True),
|
||||
sa.Column("metrics_json", _json_type(bind), nullable=True),
|
||||
sa.Column("created_by", sa.BigInteger(), nullable=True),
|
||||
*_base_columns(),
|
||||
)
|
||||
for index_name, columns in (
|
||||
("ix_training_jobs_tenant_id", ["tenant_id"]),
|
||||
("ix_training_jobs_project_id", ["project_id"]),
|
||||
("ix_training_jobs_dataset_id", ["dataset_id"]),
|
||||
("ix_training_jobs_job_type", ["job_type"]),
|
||||
("ix_training_jobs_base_model_family", ["base_model_family"]),
|
||||
("ix_training_jobs_target_slot", ["target_slot"]),
|
||||
("ix_training_jobs_status", ["status"]),
|
||||
("ix_training_jobs_created_by", ["created_by"]),
|
||||
):
|
||||
_create_index_if_missing(bind, "training_jobs", index_name, columns)
|
||||
|
||||
|
||||
def _create_training_job_runs(bind) -> None:
|
||||
if _table_exists(bind, "training_job_runs"):
|
||||
return
|
||||
op.create_table(
|
||||
"training_job_runs",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("training_job_id", sa.Integer(), sa.ForeignKey("training_jobs.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("status", sa.VARCHAR(length=16), nullable=False, server_default="queued"),
|
||||
sa.Column("provider_run_ref", sa.VARCHAR(length=255), nullable=True),
|
||||
sa.Column("metrics_json", _json_type(bind), nullable=True),
|
||||
*_base_columns(),
|
||||
)
|
||||
for index_name, columns in (
|
||||
("ix_training_job_runs_tenant_id", ["tenant_id"]),
|
||||
("ix_training_job_runs_training_job_id", ["training_job_id"]),
|
||||
("ix_training_job_runs_status", ["status"]),
|
||||
):
|
||||
_create_index_if_missing(bind, "training_job_runs", index_name, columns)
|
||||
|
||||
|
||||
def _create_training_evaluations(bind) -> None:
|
||||
if _table_exists(bind, "training_evaluations"):
|
||||
return
|
||||
op.create_table(
|
||||
"training_evaluations",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("training_job_id", sa.Integer(), sa.ForeignKey("training_jobs.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("evaluation_type", sa.VARCHAR(length=32), nullable=False),
|
||||
sa.Column("status", sa.VARCHAR(length=16), nullable=False, server_default="pending"),
|
||||
sa.Column("score", sa.Float(), nullable=True),
|
||||
sa.Column("evaluation_summary", _json_type(bind), nullable=True),
|
||||
*_base_columns(),
|
||||
)
|
||||
for index_name, columns in (
|
||||
("ix_training_evaluations_tenant_id", ["tenant_id"]),
|
||||
("ix_training_evaluations_training_job_id", ["training_job_id"]),
|
||||
("ix_training_evaluations_evaluation_type", ["evaluation_type"]),
|
||||
("ix_training_evaluations_status", ["status"]),
|
||||
):
|
||||
_create_index_if_missing(bind, "training_evaluations", index_name, columns)
|
||||
|
||||
|
||||
def _create_trained_model_artifacts(bind) -> None:
|
||||
if _table_exists(bind, "trained_model_artifacts"):
|
||||
return
|
||||
op.create_table(
|
||||
"trained_model_artifacts",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("training_job_id", sa.Integer(), sa.ForeignKey("training_jobs.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("artifact_ref", sa.VARCHAR(length=255), nullable=False),
|
||||
sa.Column("base_model_family", sa.VARCHAR(length=64), nullable=False),
|
||||
sa.Column("target_slot", sa.VARCHAR(length=16), nullable=False),
|
||||
sa.Column("status", sa.VARCHAR(length=16), nullable=False, server_default="pending_eval"),
|
||||
sa.Column("metrics_json", _json_type(bind), nullable=True),
|
||||
*_base_columns(),
|
||||
)
|
||||
for index_name, columns in (
|
||||
("ix_trained_model_artifacts_tenant_id", ["tenant_id"]),
|
||||
("ix_trained_model_artifacts_training_job_id", ["training_job_id"]),
|
||||
("ix_trained_model_artifacts_artifact_ref", ["artifact_ref"]),
|
||||
("ix_trained_model_artifacts_base_model_family", ["base_model_family"]),
|
||||
("ix_trained_model_artifacts_target_slot", ["target_slot"]),
|
||||
("ix_trained_model_artifacts_status", ["status"]),
|
||||
):
|
||||
_create_index_if_missing(bind, "trained_model_artifacts", index_name, columns)
|
||||
|
||||
|
||||
def _create_trained_model_deployments(bind) -> None:
|
||||
if _table_exists(bind, "trained_model_deployments"):
|
||||
return
|
||||
op.create_table(
|
||||
"trained_model_deployments",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("artifact_id", sa.Integer(), sa.ForeignKey("trained_model_artifacts.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("deployment_status", sa.VARCHAR(length=16), nullable=False, server_default="candidate"),
|
||||
sa.Column("bound_tier", sa.VARCHAR(length=16), nullable=False),
|
||||
sa.Column("effective_scope", _json_type(bind), nullable=True),
|
||||
sa.Column("decision", sa.VARCHAR(length=24), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column("decided_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("decided_at", sa.VARCHAR(length=64), nullable=True),
|
||||
*_base_columns(),
|
||||
)
|
||||
for index_name, columns in (
|
||||
("ix_trained_model_deployments_tenant_id", ["tenant_id"]),
|
||||
("ix_trained_model_deployments_artifact_id", ["artifact_id"]),
|
||||
("ix_trained_model_deployments_deployment_status", ["deployment_status"]),
|
||||
("ix_trained_model_deployments_bound_tier", ["bound_tier"]),
|
||||
("ix_trained_model_deployments_decision", ["decision"]),
|
||||
("ix_trained_model_deployments_decided_by", ["decided_by"]),
|
||||
):
|
||||
_create_index_if_missing(bind, "trained_model_deployments", index_name, columns)
|
||||
|
||||
|
||||
def _add_experiment_columns(bind) -> None:
|
||||
if not _table_exists(bind, "experiments"):
|
||||
return
|
||||
additions = [
|
||||
("experiment_type", sa.VARCHAR(length=64), True),
|
||||
("baseline_release_id", sa.Integer(), True),
|
||||
("candidate_release_id", sa.Integer(), True),
|
||||
("scope_json", _json_type(bind), True),
|
||||
("result_summary", _json_type(bind), True),
|
||||
("decision", sa.VARCHAR(length=24), True),
|
||||
]
|
||||
for column_name, column_type, nullable in additions:
|
||||
if _column_exists(bind, "experiments", column_name):
|
||||
continue
|
||||
with op.batch_alter_table("experiments") as batch_op:
|
||||
batch_op.add_column(sa.Column(column_name, column_type, nullable=nullable))
|
||||
for index_name, columns in (
|
||||
("ix_experiments_experiment_type", ["experiment_type"]),
|
||||
("ix_experiments_baseline_release_id", ["baseline_release_id"]),
|
||||
("ix_experiments_candidate_release_id", ["candidate_release_id"]),
|
||||
("ix_experiments_decision", ["decision"]),
|
||||
):
|
||||
_create_index_if_missing(bind, "experiments", index_name, columns)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
_create_quality_assets(bind)
|
||||
_create_quality_asset_pairs(bind)
|
||||
_create_judge_runs(bind)
|
||||
_create_optimizer_candidates(bind)
|
||||
_create_prompt_releases(bind)
|
||||
_create_training_datasets(bind)
|
||||
_create_training_dataset_items(bind)
|
||||
_create_training_jobs(bind)
|
||||
_create_training_job_runs(bind)
|
||||
_create_training_evaluations(bind)
|
||||
_create_trained_model_artifacts(bind)
|
||||
_create_trained_model_deployments(bind)
|
||||
_add_experiment_columns(bind)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
for index_name in (
|
||||
"ix_experiments_decision",
|
||||
"ix_experiments_candidate_release_id",
|
||||
"ix_experiments_baseline_release_id",
|
||||
"ix_experiments_experiment_type",
|
||||
):
|
||||
_drop_index_if_exists(bind, "experiments", index_name)
|
||||
if _table_exists(bind, "experiments"):
|
||||
for column_name in (
|
||||
"decision",
|
||||
"result_summary",
|
||||
"scope_json",
|
||||
"candidate_release_id",
|
||||
"baseline_release_id",
|
||||
"experiment_type",
|
||||
):
|
||||
if _column_exists(bind, "experiments", column_name):
|
||||
with op.batch_alter_table("experiments") as batch_op:
|
||||
batch_op.drop_column(column_name)
|
||||
|
||||
for table_name, index_names in (
|
||||
("trained_model_deployments", [
|
||||
"ix_trained_model_deployments_decided_by",
|
||||
"ix_trained_model_deployments_decision",
|
||||
"ix_trained_model_deployments_bound_tier",
|
||||
"ix_trained_model_deployments_deployment_status",
|
||||
"ix_trained_model_deployments_artifact_id",
|
||||
"ix_trained_model_deployments_tenant_id",
|
||||
]),
|
||||
("trained_model_artifacts", [
|
||||
"ix_trained_model_artifacts_status",
|
||||
"ix_trained_model_artifacts_target_slot",
|
||||
"ix_trained_model_artifacts_base_model_family",
|
||||
"ix_trained_model_artifacts_artifact_ref",
|
||||
"ix_trained_model_artifacts_training_job_id",
|
||||
"ix_trained_model_artifacts_tenant_id",
|
||||
]),
|
||||
("training_evaluations", [
|
||||
"ix_training_evaluations_status",
|
||||
"ix_training_evaluations_evaluation_type",
|
||||
"ix_training_evaluations_training_job_id",
|
||||
"ix_training_evaluations_tenant_id",
|
||||
]),
|
||||
("training_job_runs", [
|
||||
"ix_training_job_runs_status",
|
||||
"ix_training_job_runs_training_job_id",
|
||||
"ix_training_job_runs_tenant_id",
|
||||
]),
|
||||
("training_jobs", [
|
||||
"ix_training_jobs_created_by",
|
||||
"ix_training_jobs_status",
|
||||
"ix_training_jobs_target_slot",
|
||||
"ix_training_jobs_base_model_family",
|
||||
"ix_training_jobs_job_type",
|
||||
"ix_training_jobs_dataset_id",
|
||||
"ix_training_jobs_project_id",
|
||||
"ix_training_jobs_tenant_id",
|
||||
]),
|
||||
("training_dataset_items", [
|
||||
"ix_training_dataset_items_quality_asset_pair_id",
|
||||
"ix_training_dataset_items_quality_asset_id",
|
||||
"ix_training_dataset_items_dataset_id",
|
||||
"ix_training_dataset_items_tenant_id",
|
||||
]),
|
||||
("training_datasets", [
|
||||
"ix_training_datasets_created_by",
|
||||
"ix_training_datasets_status",
|
||||
"ix_training_datasets_version",
|
||||
"ix_training_datasets_dataset_type",
|
||||
"ix_training_datasets_project_id",
|
||||
"ix_training_datasets_tenant_id",
|
||||
]),
|
||||
("prompt_releases", [
|
||||
"ix_prompt_releases_activated_by",
|
||||
"ix_prompt_releases_created_by",
|
||||
"ix_prompt_releases_status",
|
||||
"ix_prompt_releases_prompt_key",
|
||||
"ix_prompt_releases_tenant_id",
|
||||
]),
|
||||
("optimizer_candidates", [
|
||||
"ix_optimizer_candidates_status",
|
||||
"ix_optimizer_candidates_target_ref",
|
||||
"ix_optimizer_candidates_target_type",
|
||||
"ix_optimizer_candidates_candidate_type",
|
||||
"ix_optimizer_candidates_judge_run_id",
|
||||
"ix_optimizer_candidates_project_id",
|
||||
"ix_optimizer_candidates_tenant_id",
|
||||
]),
|
||||
("judge_runs", [
|
||||
"ix_judge_runs_bucket_key",
|
||||
"ix_judge_runs_judge_status",
|
||||
"ix_judge_runs_executed_tier",
|
||||
"ix_judge_runs_planned_tier",
|
||||
"ix_judge_runs_asked_via",
|
||||
"ix_judge_runs_workflow_type",
|
||||
"ix_judge_runs_prompt_key",
|
||||
"ix_judge_runs_pair_id",
|
||||
"ix_judge_runs_asset_id",
|
||||
"ix_judge_runs_project_id",
|
||||
"ix_judge_runs_tenant_id",
|
||||
]),
|
||||
("quality_asset_pairs", [
|
||||
"ix_quality_asset_pairs_approved_by",
|
||||
"ix_quality_asset_pairs_rejected_asset_id",
|
||||
"ix_quality_asset_pairs_chosen_asset_id",
|
||||
"ix_quality_asset_pairs_status",
|
||||
"ix_quality_asset_pairs_pair_type",
|
||||
"ix_quality_asset_pairs_project_id",
|
||||
"ix_quality_asset_pairs_tenant_id",
|
||||
]),
|
||||
("quality_assets", [
|
||||
"ix_quality_assets_approved_by",
|
||||
"ix_quality_assets_executed_tier",
|
||||
"ix_quality_assets_planned_tier",
|
||||
"ix_quality_assets_asked_via",
|
||||
"ix_quality_assets_workflow_type",
|
||||
"ix_quality_assets_prompt_key",
|
||||
"ix_quality_assets_ai_call_log_id",
|
||||
"ix_quality_assets_qa_record_id",
|
||||
"ix_quality_assets_source_feedback_record_id",
|
||||
"ix_quality_assets_status",
|
||||
"ix_quality_assets_asset_type",
|
||||
"ix_quality_assets_project_id",
|
||||
"ix_quality_assets_tenant_id",
|
||||
]),
|
||||
):
|
||||
for index_name in index_names:
|
||||
_drop_index_if_exists(bind, table_name, index_name)
|
||||
if _table_exists(bind, table_name):
|
||||
op.drop_table(table_name)
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
"""cleanup legacy model routing function names
|
||||
|
||||
Revision ID: 20260327_cleanup_model_routing_legacy_names
|
||||
Revises: 20260326_add_ai_call_log_routing_fields
|
||||
Create Date: 2026-03-27
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260327_cleanup_model_routing_legacy_names"
|
||||
down_revision = "20260326_add_ai_call_log_routing_fields"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_RENAME_KEYS = {
|
||||
"project_draft_analysis_glm47_fallback": "ai_pmo_signal_title_refine",
|
||||
"adaptive_rag_answer_deep_signal_batch_analysis": "ai_pmo_signal_batch_analysis",
|
||||
}
|
||||
_REMOVE_KEYS = {
|
||||
"project_draft_charter_glm47_fallback",
|
||||
"project_draft_signal_glm47_fallback",
|
||||
"project_draft_task_chain_glm47_fallback",
|
||||
}
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _integration_table(bind) -> sa.Table:
|
||||
metadata = sa.MetaData()
|
||||
return sa.Table(
|
||||
"integrations",
|
||||
metadata,
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("type", sa.String(length=100)),
|
||||
sa.Column("config", sa.JSON()),
|
||||
)
|
||||
|
||||
|
||||
def _safe_dict(value: Any) -> dict[str, Any]:
|
||||
if isinstance(value, dict):
|
||||
try:
|
||||
return deepcopy(value)
|
||||
except Exception:
|
||||
return dict(value)
|
||||
return {}
|
||||
|
||||
|
||||
def _rewrite_model_routing_config(config: Any) -> dict[str, Any]:
|
||||
payload = _safe_dict(config)
|
||||
if not payload:
|
||||
return {}
|
||||
|
||||
mappings = payload.get("mappings")
|
||||
if not isinstance(mappings, dict):
|
||||
payload["mappings"] = {}
|
||||
return payload
|
||||
|
||||
normalized: dict[str, Any] = {}
|
||||
for key, value in mappings.items():
|
||||
name = str(key or "").strip().lower()
|
||||
if not name:
|
||||
continue
|
||||
normalized[name] = deepcopy(value)
|
||||
|
||||
for old_key, new_key in _RENAME_KEYS.items():
|
||||
if old_key not in normalized:
|
||||
continue
|
||||
if new_key not in normalized:
|
||||
normalized[new_key] = normalized[old_key]
|
||||
normalized.pop(old_key, None)
|
||||
|
||||
for key in _REMOVE_KEYS:
|
||||
normalized.pop(key, None)
|
||||
|
||||
payload["mappings"] = normalized
|
||||
return payload
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "integrations"):
|
||||
return
|
||||
|
||||
table = _integration_table(bind)
|
||||
rows = (
|
||||
bind.execute(
|
||||
sa.select(table.c.id, table.c.config).where(table.c.type == "model_routing")
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
for row in rows:
|
||||
row_id = int(row.get("id") or 0)
|
||||
rewritten = _rewrite_model_routing_config(row.get("config"))
|
||||
if not rewritten:
|
||||
continue
|
||||
if rewritten == row.get("config"):
|
||||
continue
|
||||
bind.execute(
|
||||
table.update().where(table.c.id == row_id).values(config=rewritten)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "integrations"):
|
||||
return
|
||||
|
||||
table = _integration_table(bind)
|
||||
reverse_rename = {new_key: old_key for old_key, new_key in _RENAME_KEYS.items()}
|
||||
rows = (
|
||||
bind.execute(
|
||||
sa.select(table.c.id, table.c.config).where(table.c.type == "model_routing")
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
for row in rows:
|
||||
row_id = int(row.get("id") or 0)
|
||||
payload = _safe_dict(row.get("config"))
|
||||
mappings = payload.get("mappings")
|
||||
if not isinstance(mappings, dict):
|
||||
continue
|
||||
normalized: dict[str, Any] = {}
|
||||
for key, value in mappings.items():
|
||||
name = str(key or "").strip().lower()
|
||||
if not name:
|
||||
continue
|
||||
normalized[name] = deepcopy(value)
|
||||
for new_key, old_key in reverse_rename.items():
|
||||
if new_key not in normalized:
|
||||
continue
|
||||
if old_key not in normalized:
|
||||
normalized[old_key] = normalized[new_key]
|
||||
normalized.pop(new_key, None)
|
||||
payload["mappings"] = normalized
|
||||
if payload == row.get("config"):
|
||||
continue
|
||||
bind.execute(
|
||||
table.update().where(table.c.id == row_id).values(config=payload)
|
||||
)
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
"""add qa record runtime grounding fields
|
||||
|
||||
Revision ID: 20260331_add_qa_record_runtime_fields
|
||||
Revises: 20260327_add_reply_iteration_training_tables
|
||||
Create Date: 2026-03-31
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260331_add_qa_record_runtime_fields"
|
||||
down_revision = "20260327_add_reply_iteration_training_tables"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
try:
|
||||
columns = inspector.get_columns(table_name)
|
||||
except Exception:
|
||||
return False
|
||||
return any(str(column.get("name")) == column_name for column in columns)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "qa_records"):
|
||||
return
|
||||
|
||||
with op.batch_alter_table("qa_records") as batch_op:
|
||||
if not _column_exists(bind, "qa_records", "answer_mode"):
|
||||
batch_op.add_column(sa.Column("answer_mode", sa.String(length=32), nullable=True))
|
||||
if not _column_exists(bind, "qa_records", "grounding_status"):
|
||||
batch_op.add_column(sa.Column("grounding_status", sa.String(length=32), nullable=True))
|
||||
if not _column_exists(bind, "qa_records", "policy_version"):
|
||||
batch_op.add_column(sa.Column("policy_version", sa.String(length=160), nullable=True))
|
||||
if not _column_exists(bind, "qa_records", "citations"):
|
||||
batch_op.add_column(sa.Column("citations", _json_type(bind), nullable=True))
|
||||
if not _column_exists(bind, "qa_records", "sources_used"):
|
||||
batch_op.add_column(sa.Column("sources_used", _json_type(bind), nullable=True))
|
||||
if not _column_exists(bind, "qa_records", "evidence_pack"):
|
||||
batch_op.add_column(sa.Column("evidence_pack", _json_type(bind), nullable=True))
|
||||
if not _column_exists(bind, "qa_records", "tool_trace"):
|
||||
batch_op.add_column(sa.Column("tool_trace", _json_type(bind), nullable=True))
|
||||
if not _column_exists(bind, "qa_records", "retrieval_metrics"):
|
||||
batch_op.add_column(sa.Column("retrieval_metrics", _json_type(bind), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "qa_records"):
|
||||
return
|
||||
|
||||
with op.batch_alter_table("qa_records") as batch_op:
|
||||
for column_name in (
|
||||
"retrieval_metrics",
|
||||
"tool_trace",
|
||||
"evidence_pack",
|
||||
"sources_used",
|
||||
"citations",
|
||||
"policy_version",
|
||||
"grounding_status",
|
||||
"answer_mode",
|
||||
):
|
||||
if _column_exists(bind, "qa_records", column_name):
|
||||
batch_op.drop_column(column_name)
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
"""add mounted ai runtime tables
|
||||
|
||||
Revision ID: 20260405_add_mounted_ai_runtime_tables
|
||||
Revises: 20260331_add_qa_record_runtime_fields
|
||||
Create Date: 2026-04-05
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260405_add_mounted_ai_runtime_tables"
|
||||
down_revision = "20260331_add_qa_record_runtime_fields"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
try:
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
except Exception:
|
||||
return False
|
||||
return any(str(index.get("name")) == index_name for index in indexes)
|
||||
|
||||
|
||||
def _create_index_if_missing(bind, table_name: str, index_name: str, columns: list[str], *, unique: bool = False) -> None:
|
||||
if not _index_exists(bind, table_name, index_name):
|
||||
op.create_index(index_name, table_name, columns, unique=unique)
|
||||
|
||||
|
||||
def _base_columns() -> list[sa.Column]:
|
||||
return [
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
]
|
||||
|
||||
|
||||
def _create_mounted_topic_workspaces(bind) -> None:
|
||||
if _table_exists(bind, "mounted_topic_workspaces"):
|
||||
return
|
||||
op.create_table(
|
||||
"mounted_topic_workspaces",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("topic_key", sa.VARCHAR(length=128), nullable=False),
|
||||
sa.Column("source_system", sa.VARCHAR(length=32), nullable=False),
|
||||
sa.Column("channel_ref", sa.VARCHAR(length=128), nullable=True),
|
||||
sa.Column("thread_key", sa.VARCHAR(length=255), nullable=True),
|
||||
sa.Column("root_message_id", sa.VARCHAR(length=128), nullable=True),
|
||||
sa.Column("mode", sa.VARCHAR(length=24), nullable=False, server_default="chat"),
|
||||
sa.Column("status", sa.VARCHAR(length=24), nullable=False, server_default="active"),
|
||||
sa.Column("facts_json", _json_type(bind), nullable=True),
|
||||
sa.Column("goals_json", _json_type(bind), nullable=True),
|
||||
sa.Column("constraints_json", _json_type(bind), nullable=True),
|
||||
sa.Column("options_json", _json_type(bind), nullable=True),
|
||||
sa.Column("open_questions", _json_type(bind), nullable=True),
|
||||
sa.Column("pending_actions", _json_type(bind), nullable=True),
|
||||
sa.Column("bound_entities", _json_type(bind), nullable=True),
|
||||
sa.Column("readiness_score", sa.Float(), nullable=True, server_default="0"),
|
||||
sa.Column("last_message_id", sa.VARCHAR(length=128), nullable=True),
|
||||
sa.Column("last_triggered_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("metadata_json", _json_type(bind), nullable=True),
|
||||
*_base_columns(),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"source_system",
|
||||
"topic_key",
|
||||
name="uq_mounted_topic_workspace_source_topic",
|
||||
),
|
||||
)
|
||||
for index_name, columns in (
|
||||
("ix_mounted_topic_workspaces_tenant_id", ["tenant_id"]),
|
||||
("ix_mounted_topic_workspaces_topic_key", ["topic_key"]),
|
||||
("ix_mounted_topic_workspaces_source_system", ["source_system"]),
|
||||
("ix_mounted_topic_workspaces_channel_ref", ["channel_ref"]),
|
||||
("ix_mounted_topic_workspaces_thread_key", ["thread_key"]),
|
||||
("ix_mounted_topic_workspaces_root_message_id", ["root_message_id"]),
|
||||
("ix_mounted_topic_workspaces_mode", ["mode"]),
|
||||
("ix_mounted_topic_workspaces_status", ["status"]),
|
||||
("ix_mounted_topic_workspaces_last_message_id", ["last_message_id"]),
|
||||
):
|
||||
_create_index_if_missing(bind, "mounted_topic_workspaces", index_name, columns)
|
||||
|
||||
|
||||
def _create_mounted_draft_workspaces(bind) -> None:
|
||||
if _table_exists(bind, "mounted_draft_workspaces"):
|
||||
return
|
||||
op.create_table(
|
||||
"mounted_draft_workspaces",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column(
|
||||
"topic_workspace_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("mounted_topic_workspaces.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("state", sa.VARCHAR(length=24), nullable=False, server_default="collecting"),
|
||||
sa.Column("draft_version", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("requirement_card", _json_type(bind), nullable=True),
|
||||
sa.Column("draft_payload", _json_type(bind), nullable=True),
|
||||
sa.Column("required_confirmations", _json_type(bind), nullable=True),
|
||||
sa.Column("last_ready_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("confirm_message_id", sa.VARCHAR(length=128), nullable=True),
|
||||
*_base_columns(),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"topic_workspace_id",
|
||||
name="uq_mounted_draft_workspace_topic",
|
||||
),
|
||||
)
|
||||
for index_name, columns in (
|
||||
("ix_mounted_draft_workspaces_tenant_id", ["tenant_id"]),
|
||||
("ix_mounted_draft_workspaces_topic_workspace_id", ["topic_workspace_id"]),
|
||||
("ix_mounted_draft_workspaces_state", ["state"]),
|
||||
("ix_mounted_draft_workspaces_confirm_message_id", ["confirm_message_id"]),
|
||||
):
|
||||
_create_index_if_missing(bind, "mounted_draft_workspaces", index_name, columns)
|
||||
|
||||
|
||||
def _create_mounted_conversation_runs(bind) -> None:
|
||||
if _table_exists(bind, "mounted_conversation_runs"):
|
||||
return
|
||||
op.create_table(
|
||||
"mounted_conversation_runs",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("trace_id", sa.VARCHAR(length=64), nullable=False),
|
||||
sa.Column(
|
||||
"topic_workspace_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("mounted_topic_workspaces.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("source_system", sa.VARCHAR(length=32), nullable=False),
|
||||
sa.Column("source_message_id", sa.VARCHAR(length=128), nullable=True),
|
||||
sa.Column("mode_before", sa.VARCHAR(length=24), nullable=True),
|
||||
sa.Column("mode_after", sa.VARCHAR(length=24), nullable=True),
|
||||
sa.Column("active_agent", sa.VARCHAR(length=64), nullable=True),
|
||||
sa.Column("status", sa.VARCHAR(length=24), nullable=False, server_default="queued"),
|
||||
sa.Column("trigger_reason", sa.VARCHAR(length=128), nullable=True),
|
||||
sa.Column("context_layers_hit", _json_type(bind), nullable=True),
|
||||
sa.Column("tool_calls", _json_type(bind), nullable=True),
|
||||
sa.Column("final_reply", sa.Text(), nullable=True),
|
||||
sa.Column("execution_outcome", _json_type(bind), nullable=True),
|
||||
sa.Column("error_text", sa.Text(), nullable=True),
|
||||
sa.Column("started_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("completed_at", sa.TIMESTAMP(), nullable=True),
|
||||
*_base_columns(),
|
||||
sa.UniqueConstraint("tenant_id", "trace_id", name="uq_mounted_conversation_run_trace"),
|
||||
)
|
||||
for index_name, columns in (
|
||||
("ix_mounted_conversation_runs_tenant_id", ["tenant_id"]),
|
||||
("ix_mounted_conversation_runs_trace_id", ["trace_id"]),
|
||||
("ix_mounted_conversation_runs_topic_workspace_id", ["topic_workspace_id"]),
|
||||
("ix_mounted_conversation_runs_source_system", ["source_system"]),
|
||||
("ix_mounted_conversation_runs_source_message_id", ["source_message_id"]),
|
||||
("ix_mounted_conversation_runs_mode_before", ["mode_before"]),
|
||||
("ix_mounted_conversation_runs_mode_after", ["mode_after"]),
|
||||
("ix_mounted_conversation_runs_active_agent", ["active_agent"]),
|
||||
("ix_mounted_conversation_runs_status", ["status"]),
|
||||
("ix_mounted_conversation_runs_trigger_reason", ["trigger_reason"]),
|
||||
):
|
||||
_create_index_if_missing(bind, "mounted_conversation_runs", index_name, columns)
|
||||
|
||||
|
||||
def _create_mounted_agent_traces(bind) -> None:
|
||||
if _table_exists(bind, "mounted_agent_traces"):
|
||||
return
|
||||
op.create_table(
|
||||
"mounted_agent_traces",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column(
|
||||
"conversation_run_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("mounted_conversation_runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("agent_name", sa.VARCHAR(length=64), nullable=False),
|
||||
sa.Column("status", sa.VARCHAR(length=24), nullable=False, server_default="completed"),
|
||||
sa.Column("latency_ms", sa.Integer(), nullable=True),
|
||||
sa.Column("input_summary", _json_type(bind), nullable=True),
|
||||
sa.Column("output_summary", _json_type(bind), nullable=True),
|
||||
sa.Column("tool_trace", _json_type(bind), nullable=True),
|
||||
sa.Column("failure_reason", sa.Text(), nullable=True),
|
||||
*_base_columns(),
|
||||
)
|
||||
for index_name, columns in (
|
||||
("ix_mounted_agent_traces_tenant_id", ["tenant_id"]),
|
||||
("ix_mounted_agent_traces_conversation_run_id", ["conversation_run_id"]),
|
||||
("ix_mounted_agent_traces_agent_name", ["agent_name"]),
|
||||
("ix_mounted_agent_traces_status", ["status"]),
|
||||
):
|
||||
_create_index_if_missing(bind, "mounted_agent_traces", index_name, columns)
|
||||
|
||||
|
||||
def _create_mounted_source_activations(bind) -> None:
|
||||
if _table_exists(bind, "mounted_source_activations"):
|
||||
return
|
||||
op.create_table(
|
||||
"mounted_source_activations",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("source_system", sa.VARCHAR(length=32), nullable=False),
|
||||
sa.Column("scope_type", sa.VARCHAR(length=24), nullable=False, server_default="tenant"),
|
||||
sa.Column("scope_ref", sa.VARCHAR(length=128), nullable=False),
|
||||
sa.Column("status", sa.VARCHAR(length=24), nullable=False, server_default="active"),
|
||||
sa.Column("cutover_mode", sa.VARCHAR(length=24), nullable=False, server_default="shadow"),
|
||||
sa.Column("auto_reply_enabled", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("metadata_json", _json_type(bind), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column("last_seen_at", sa.TIMESTAMP(), nullable=True),
|
||||
*_base_columns(),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"source_system",
|
||||
"scope_type",
|
||||
"scope_ref",
|
||||
name="uq_mounted_source_activation_scope",
|
||||
),
|
||||
)
|
||||
for index_name, columns in (
|
||||
("ix_mounted_source_activations_tenant_id", ["tenant_id"]),
|
||||
("ix_mounted_source_activations_source_system", ["source_system"]),
|
||||
("ix_mounted_source_activations_scope_type", ["scope_type"]),
|
||||
("ix_mounted_source_activations_scope_ref", ["scope_ref"]),
|
||||
("ix_mounted_source_activations_status", ["status"]),
|
||||
("ix_mounted_source_activations_cutover_mode", ["cutover_mode"]),
|
||||
):
|
||||
_create_index_if_missing(bind, "mounted_source_activations", index_name, columns)
|
||||
|
||||
|
||||
def _create_mounted_tool_capability_policies(bind) -> None:
|
||||
if _table_exists(bind, "mounted_tool_capability_policies"):
|
||||
return
|
||||
op.create_table(
|
||||
"mounted_tool_capability_policies",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tool_name", sa.VARCHAR(length=128), nullable=False),
|
||||
sa.Column("risk_level", sa.VARCHAR(length=24), nullable=False, server_default="medium"),
|
||||
sa.Column("confirm_required", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("auto_execute_allowed", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("source_scope", _json_type(bind), nullable=True),
|
||||
sa.Column("tenant_scope", _json_type(bind), nullable=True),
|
||||
sa.Column("policy_metadata", _json_type(bind), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
*_base_columns(),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"tool_name",
|
||||
name="uq_mounted_tool_capability_policy_tool",
|
||||
),
|
||||
)
|
||||
for index_name, columns in (
|
||||
("ix_mounted_tool_capability_policies_tenant_id", ["tenant_id"]),
|
||||
("ix_mounted_tool_capability_policies_tool_name", ["tool_name"]),
|
||||
("ix_mounted_tool_capability_policies_risk_level", ["risk_level"]),
|
||||
("ix_mounted_tool_capability_policies_is_active", ["is_active"]),
|
||||
):
|
||||
_create_index_if_missing(bind, "mounted_tool_capability_policies", index_name, columns)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
_create_mounted_topic_workspaces(bind)
|
||||
_create_mounted_draft_workspaces(bind)
|
||||
_create_mounted_conversation_runs(bind)
|
||||
_create_mounted_agent_traces(bind)
|
||||
_create_mounted_source_activations(bind)
|
||||
_create_mounted_tool_capability_policies(bind)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
for table_name in (
|
||||
"mounted_tool_capability_policies",
|
||||
"mounted_source_activations",
|
||||
"mounted_agent_traces",
|
||||
"mounted_conversation_runs",
|
||||
"mounted_draft_workspaces",
|
||||
"mounted_topic_workspaces",
|
||||
):
|
||||
if _table_exists(bind, table_name):
|
||||
op.drop_table(table_name)
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
"""add mounted runtime plan state fields
|
||||
|
||||
Revision ID: 20260405_add_mounted_runtime_plan_state_fields
|
||||
Revises: 20260405_add_mounted_ai_runtime_tables
|
||||
Create Date: 2026-04-05
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260405_add_mounted_runtime_plan_state_fields"
|
||||
down_revision = "20260405_add_mounted_ai_runtime_tables"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
try:
|
||||
columns = inspector.get_columns(table_name)
|
||||
except Exception:
|
||||
return False
|
||||
return any(str(column.get("name")) == column_name for column in columns)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
try:
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
except Exception:
|
||||
return False
|
||||
return any(str(index.get("name")) == index_name for index in indexes)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if not _column_exists(bind, "mounted_topic_workspaces", "conversation_scope_key"):
|
||||
op.add_column(
|
||||
"mounted_topic_workspaces",
|
||||
sa.Column("conversation_scope_key", sa.VARCHAR(length=160), nullable=True),
|
||||
)
|
||||
if not _column_exists(bind, "mounted_topic_workspaces", "plan_status"):
|
||||
op.add_column(
|
||||
"mounted_topic_workspaces",
|
||||
sa.Column("plan_status", sa.VARCHAR(length=32), nullable=False, server_default="idle"),
|
||||
)
|
||||
|
||||
if not _index_exists(bind, "mounted_topic_workspaces", "ix_mounted_topic_workspaces_conversation_scope_key"):
|
||||
op.create_index(
|
||||
"ix_mounted_topic_workspaces_conversation_scope_key",
|
||||
"mounted_topic_workspaces",
|
||||
["conversation_scope_key"],
|
||||
)
|
||||
if not _index_exists(bind, "mounted_topic_workspaces", "ix_mounted_topic_workspaces_plan_status"):
|
||||
op.create_index(
|
||||
"ix_mounted_topic_workspaces_plan_status",
|
||||
"mounted_topic_workspaces",
|
||||
["plan_status"],
|
||||
)
|
||||
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE mounted_topic_workspaces
|
||||
SET plan_status = COALESCE(NULLIF(plan_status, ''), 'idle')
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _index_exists(bind, "mounted_topic_workspaces", "ix_mounted_topic_workspaces_plan_status"):
|
||||
op.drop_index("ix_mounted_topic_workspaces_plan_status", table_name="mounted_topic_workspaces")
|
||||
if _index_exists(bind, "mounted_topic_workspaces", "ix_mounted_topic_workspaces_conversation_scope_key"):
|
||||
op.drop_index("ix_mounted_topic_workspaces_conversation_scope_key", table_name="mounted_topic_workspaces")
|
||||
if _column_exists(bind, "mounted_topic_workspaces", "plan_status"):
|
||||
op.drop_column("mounted_topic_workspaces", "plan_status")
|
||||
if _column_exists(bind, "mounted_topic_workspaces", "conversation_scope_key"):
|
||||
op.drop_column("mounted_topic_workspaces", "conversation_scope_key")
|
||||