Compare commits
2 Commits
16f30bc2c6
...
93185369b9
| Author | SHA1 | Date |
|---|---|---|
|
|
93185369b9 | |
|
|
7f9571c3fa |
|
|
@ -0,0 +1,42 @@
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Backend Runtime
|
||||||
|
CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174
|
||||||
|
LLM_PROVIDER=openai
|
||||||
|
LLM_FALLBACK_PROVIDER=ollama
|
||||||
|
OPENAI_BASE_URL=http://openclaw:18789/v1
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
OPENCLAW_ROUTING_ENABLED=true
|
||||||
|
OPENCLAW_ROUTING_RULES_PATH=/app/backend/.openclaw/pmflow-routing-rules.json
|
||||||
|
OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS=120
|
||||||
|
LLM_MIXED_ROUTING_ENABLED=false
|
||||||
|
LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true
|
||||||
|
LLM_ROUTING_LOW_PROVIDER=openai
|
||||||
|
LLM_ROUTING_LOW_MODEL=zai/glm-4-flash
|
||||||
|
LLM_ROUTING_HIGH_PROVIDER=openai
|
||||||
|
LLM_ROUTING_HIGH_MODEL=minimax/MiniMax-M2.5
|
||||||
|
LLM_ROUTING_SAFETY_PROVIDER=openai
|
||||||
|
LLM_ROUTING_SAFETY_MODEL=deepseek/deepseek-chat
|
||||||
|
LLM_DIRECT_EMERGENCY_ENABLED=false
|
||||||
|
RAG_DEEP_RETRIEVAL_STEPS=5
|
||||||
|
RAG_DEEP_VECTOR_TOP_K=12
|
||||||
|
RAG_DEEP_CONTEXT_BLOCKS=16
|
||||||
|
RAG_DEEP_ANSWER_MAX_TOKENS=1400
|
||||||
|
GLM_API_KEY=
|
||||||
|
DEEPSEEK_API_KEY=
|
||||||
|
OLLAMA_HOST=http://host.docker.internal:11434
|
||||||
|
OLLAMA_BASE_URL=http://host.docker.internal:11434
|
||||||
|
OLLAMA_MODEL=llama3:latest
|
||||||
|
|
||||||
|
# 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,91 @@
|
||||||
|
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"
|
||||||
|
- ".openclaw/pmflow-routing-rules.json"
|
||||||
|
- "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"
|
||||||
|
- ".openclaw/pmflow-routing-rules.json"
|
||||||
|
- "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 .openclaw/pmflow-routing-rules.json
|
||||||
|
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,74 @@
|
||||||
|
{
|
||||||
|
"version": "v2",
|
||||||
|
"updated_at": "2026-03-05T00:00:00Z",
|
||||||
|
"source_of_truth": "openclaw",
|
||||||
|
"description": "PMFlow 三引擎统一路由:所有主链路统一经 OpenClaw OpenAI-Compatible 网关。",
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"name": "engine_low_execute_glm_flash",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 300,
|
||||||
|
"tier": "low",
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "zai/glm-4-flash",
|
||||||
|
"match_any": [
|
||||||
|
"feishu_level_classification",
|
||||||
|
"feishu_signal_extraction",
|
||||||
|
"rag_intent_classification",
|
||||||
|
"classify_intent",
|
||||||
|
"feishu_humanized_reply",
|
||||||
|
"extract_context_insights"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "engine_high_reasoning_minimax",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 220,
|
||||||
|
"tier": "high",
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "minimax/MiniMax-M2.5",
|
||||||
|
"match_any": [
|
||||||
|
"adaptive_rag_answer",
|
||||||
|
"feishu_project_prefill",
|
||||||
|
"feishu_task_orchestration",
|
||||||
|
"generate_task_breakdown",
|
||||||
|
"generate_charter_from_signal",
|
||||||
|
"decompose_tasks_from_charter",
|
||||||
|
"recommend_assignees_for_task",
|
||||||
|
"evaluate_model_requirement",
|
||||||
|
"calculate_alignment_score",
|
||||||
|
"detect_cognitive_conflicts",
|
||||||
|
"rag_db_project_semantic_retrieval",
|
||||||
|
"workload_optimize_prompt",
|
||||||
|
"analyze_cost_roi"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "engine_high_extended_reasoning",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 200,
|
||||||
|
"tier": "high",
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "minimax/MiniMax-M2.5",
|
||||||
|
"match_any": [
|
||||||
|
"analyze_food_calories",
|
||||||
|
"feishu_new_project_brief",
|
||||||
|
"feishu_project_signal_classification",
|
||||||
|
"sstc_ai_breakdown",
|
||||||
|
"sstc_charter_localize_zh",
|
||||||
|
"sstc_charter_prefill",
|
||||||
|
"skill_score_update",
|
||||||
|
"test_engine_connection"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "engine_safety_fallback",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 50,
|
||||||
|
"tier": "safety",
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "deepseek/deepseek-chat",
|
||||||
|
"match_all": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"onboardingCompletedAt": "2026-03-04T07:50:38.457Z"
|
||||||
|
}
|
||||||
42
AGENTS.md
42
AGENTS.md
|
|
@ -70,3 +70,45 @@ frontend/ # React 前端
|
||||||
## 执行要求
|
## 执行要求
|
||||||
|
|
||||||
根据用户的具体需求,生成高质量、可直接运行的代码。
|
根据用户的具体需求,生成高质量、可直接运行的代码。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 服务边界约束(禁止越界)
|
||||||
|
|
||||||
|
- 数据库写操作必须通过 Alembic migration,禁止直接 `ALTER TABLE`
|
||||||
|
- 新增接口必须遵循响应包络:`{code, message, data}`,不得裸返回
|
||||||
|
- 禁止在 API 层写业务逻辑,业务逻辑归属 `services/`
|
||||||
|
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
|
||||||
|
- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState`
|
||||||
|
|
||||||
|
## 9. 新增代码的参考模式
|
||||||
|
|
||||||
|
- 新增后端接口:参考 `backend/app/api/sstc.py` 的结构
|
||||||
|
- 新增前端 API 调用:参考 `frontend/src/api/sstc.ts`
|
||||||
|
- 新增数据库表:参考 `alembic/versions/` 最新 migration
|
||||||
|
- 新增飞书事件处理:参考 `backend/app/services/feishu_integration.py`
|
||||||
|
|
||||||
|
## 10. 幂等与事务约束
|
||||||
|
|
||||||
|
- 所有入站事件必须经过 `ingest_events` 幂等去重
|
||||||
|
- 飞书回复必须经过 `feishu_reply_outbox`,禁止同步直接发送
|
||||||
|
- 跨表写操作必须在同一个数据库事务内完成
|
||||||
|
|
||||||
|
## 11. 已知 BOUNDARY(不要幻构行为)
|
||||||
|
|
||||||
|
- 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用
|
||||||
|
- `agents.defaults.timeoutSeconds` 在 OpenClaw 网关行为未验证,当前以 `LLM_REQUEST_TIMEOUT` 环境变量为准
|
||||||
|
- World Model 每次全量构建,高负载场景下存在性能风险,暂未缓存,改动前需评估影响
|
||||||
|
|
||||||
|
## 12. 前端组件约束
|
||||||
|
|
||||||
|
- `ui/` 目录下组件禁止修改,仅使用
|
||||||
|
- `sstc/` 目录下组件已模块化拆分,修改前必须执行 `FIND_USAGE`
|
||||||
|
- 新增业务组件放入对应功能目录,禁止在 `pages/` 内写内联组件
|
||||||
|
- 样式只用 Tailwind utility class,禁止内联 `style`
|
||||||
|
|
||||||
|
## 13. 测试与验证
|
||||||
|
|
||||||
|
- 后端改动:`python -m py_compile` 通过 + 相关单测通过
|
||||||
|
- 前端改动:`npm run build` 通过 + `tsc --noEmit` 零错误
|
||||||
|
- 新增接口:必须在 `backend/tests/` 补充对应单测
|
||||||
|
|
|
||||||
283
CLAUDE.md
283
CLAUDE.md
|
|
@ -1,226 +1,89 @@
|
||||||
# CLAUDE.md
|
# PMFlow 项目结构说明(CLAUDE.md)
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
## 1. Agent 入口文件(你关心的“进项目先读什么”)
|
||||||
|
- `AGENTS.md`:Codex/Coding Agent 的主规则入口(优先级最高的项目级约束)
|
||||||
|
- `CLAUDE.md`:面向 Claude 风格的项目说明文档(补充背景)
|
||||||
|
- 建议:规则放 `AGENTS.md`,架构/目录说明放 `CLAUDE.md`
|
||||||
|
|
||||||
## Project Overview
|
## 2. 项目定位
|
||||||
|
- 项目名:`PMFlow / Neural Sync`
|
||||||
|
- 形态:AI 原生项目管理系统(多租户 + 角色权限 + SSTC 工作流)
|
||||||
|
- 技术栈:
|
||||||
|
- 后端:`FastAPI + SQLAlchemy + Alembic`
|
||||||
|
- 前端:`React + TypeScript + Vite + Tailwind + Radix + React Query`
|
||||||
|
|
||||||
**PMFlow / Neural Sync** is an AI-native project management platform designed for small (10-50 person) AI/ML technical teams. The system has evolved into a multi-tenant, role-based PM operating system with:
|
## 3. 顶层目录(精简版)
|
||||||
|
```text
|
||||||
- **Multi-tenant Architecture**: Complete data isolation with tenant_id filtering
|
AIProjectManger/
|
||||||
- **RBAC**: Role-based access control with JWT authentication
|
├── AGENTS.md # Agent 执行规则(建议优先维护)
|
||||||
- **Event-Driven Architecture**: Event bus with WebSocket real-time updates
|
├── CLAUDE.md # 现有 Claude 说明文档
|
||||||
- **AI-Native Features**: LangChain integration, skill management, intelligent task routing
|
├── start.sh / stop.sh # 一键启动/停止入口
|
||||||
|
├── docker-compose*.yml # 容器编排
|
||||||
### Dual Frontend Architecture
|
├── backend/ # FastAPI 后端
|
||||||
|
├── frontend/ # React 前端
|
||||||
The project maintains two frontends:
|
├── scripts/ # 根目录运维脚本
|
||||||
- **Vue 3** (`frontend/`): Legacy Signal Dashboard with Naive UI
|
├── tests/ # 根级测试(集成/脚本)
|
||||||
- **React 18** (`frontend-react/`): New PMFlow interface with shadcn/ui (recommended for new features)
|
└── docs/ # 额外文档
|
||||||
|
|
||||||
## Common Development Commands
|
|
||||||
|
|
||||||
### Quick Start / Stop
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start all services (backend + React frontend)
|
|
||||||
./start.sh
|
|
||||||
|
|
||||||
# Or use Python launcher (cross-platform)
|
|
||||||
python launcher.py
|
|
||||||
|
|
||||||
# Stop all services
|
|
||||||
./stop.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backend Development
|
## 4. 后端结构(`backend/`)
|
||||||
|
```text
|
||||||
```bash
|
backend/
|
||||||
cd backend
|
├── app/
|
||||||
|
│ ├── api/ # 路由层(含 endpoints)
|
||||||
# Install dependencies (use project venv)
|
│ ├── core/ # 配置、日志、核心能力
|
||||||
source ../venv/bin/activate # On Windows: ../venv/Scripts/activate
|
│ ├── dependencies/ # 鉴权/租户等依赖注入
|
||||||
pip install -r requirements.txt
|
│ ├── models/ # SQLAlchemy 模型
|
||||||
|
│ ├── schemas/ # Pydantic 模型
|
||||||
# Run database migrations
|
│ ├── services/ # 业务服务(AI/SSTC/集成)
|
||||||
alembic upgrade head
|
│ ├── config/ # 配置与 prompt
|
||||||
|
│ └── utils/ # 工具函数
|
||||||
# Seed initial data (development)
|
├── alembic/ # 数据库迁移
|
||||||
python -m seed_projects.py
|
├── requirements.txt # Python 依赖
|
||||||
|
├── Dockerfile
|
||||||
# Start backend server (dev mode with hot reload)
|
└── tests/
|
||||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
|
||||||
|
|
||||||
# Run specific file
|
|
||||||
python -m app.main
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend Development
|
## 5. 前端结构(`frontend/`)
|
||||||
|
```text
|
||||||
**React (PMFlow - Recommended)**:
|
frontend/
|
||||||
```bash
|
├── src/
|
||||||
cd frontend-react
|
│ ├── api/ # 接口封装
|
||||||
npm install
|
│ ├── pages/ # 页面级组件
|
||||||
npm run dev # Port 5174
|
│ ├── components/
|
||||||
npm run build
|
│ │ ├── ui/ # 基础 UI 组件
|
||||||
npm run preview
|
│ │ └── sstc/ # SSTC 工作流组件(已模块化拆分)
|
||||||
|
│ ├── hooks/ # 自定义 hooks
|
||||||
|
│ ├── lib/ # 通用工具
|
||||||
|
│ ├── stores/ # 状态管理
|
||||||
|
│ ├── router/ # 路由
|
||||||
|
│ └── types/ # TS 类型定义
|
||||||
|
├── package.json
|
||||||
|
└── vite.config.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
**Vue 3 (Legacy)**:
|
## 6. 运行命令(高频)
|
||||||
|
### 根目录
|
||||||
|
```bash
|
||||||
|
./start.sh dev # 推荐:docker-dev 热更新模式
|
||||||
|
./stop.sh dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
npm run dev
|
||||||
npm run dev -- --host # Port 5174
|
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
### 后端(本地直跑)
|
||||||
|
```bash
|
||||||
### Backend Structure ([`backend/app/`](backend/app/))
|
cd backend
|
||||||
|
pip install -r requirements.txt
|
||||||
**Core Architecture**:
|
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
- [`api/`](backend/app/api/) - API routes organized by module (auth, signals, users, sstc, etc.)
|
|
||||||
- [`core/`](backend/app/core/) - Configuration, logging, response formatting
|
|
||||||
- [`models/`](backend/app/models/) - SQLAlchemy ORM with multi-tenant base classes
|
|
||||||
- [`schemas/`](backend/app/schemas/) - Pydantic request/response models
|
|
||||||
- [`services/`](backend/app/services/) - Business logic layer
|
|
||||||
- [`dependencies/`](backend/app/dependencies/) - FastAPI dependency injection (auth, tenant filtering)
|
|
||||||
- [`config/`](backend/app/config/) - Configuration and AI prompts
|
|
||||||
- [`main.py`](backend/app/main.py) - FastAPI application entry point
|
|
||||||
|
|
||||||
**Key Systems**:
|
|
||||||
|
|
||||||
1. **Multi-Tenancy** ([`models/base.py`](backend/app/models/base.py)):
|
|
||||||
- All models inherit from `Base` (includes `tenant_id`, `created_at`, `updated_at`)
|
|
||||||
- `Tenant` model uses `BaseNoTenant` (no tenant_id)
|
|
||||||
- All queries must filter by tenant_id via dependency injection
|
|
||||||
|
|
||||||
2. **Authentication** ([`services/auth.py`](backend/app/services/auth.py), [`dependencies/auth.py`](backend/app/dependencies/auth.py)):
|
|
||||||
- JWT-based authentication with 24-hour expiration
|
|
||||||
- RBAC with roles: BOSS, ADMIN, AI, FE, BE
|
|
||||||
- Permission matrix for fine-grained access control
|
|
||||||
- Use `require_roles()` and `get_current_user` dependencies
|
|
||||||
|
|
||||||
3. **Event System** ([`events.py`](backend/app/events.py), [`websocket.py`](backend/app/websocket.py)):
|
|
||||||
- Event bus for pub/sub messaging
|
|
||||||
- WebSocket manager with real-time broadcasting
|
|
||||||
- Event types: signals, tasks, code reviews, audits, integrations
|
|
||||||
|
|
||||||
4. **Role Dispatcher** ([`dispatcher.py`](backend/app/dispatcher.py)):
|
|
||||||
- Routes signals to appropriate roles (Boss, AI Engineer, Backend, Frontend)
|
|
||||||
- Keyword and pattern-based routing with confidence scoring
|
|
||||||
- Escalation logic for high-impact decisions
|
|
||||||
|
|
||||||
5. **AI Services**:
|
|
||||||
- [`ai_service.py`](backend/app/services/ai_service.py) - LangChain integration
|
|
||||||
- [`llm.py`](backend/app/services/llm.py) - Ollama/OpenAI client wrapper
|
|
||||||
- [`skill_service.py`](backend/app/services/skill_service.py) - User skill management
|
|
||||||
- [`config/ai_prompts.py`](backend/app/config/ai_prompts.py) - Centralized prompt templates
|
|
||||||
|
|
||||||
6. **Integration Services**:
|
|
||||||
- [`plane.py`](backend/app/services/plane.py) - Plane PM tool integration
|
|
||||||
- [`lark.py`](backend/app/services/lark.py) - Lark/Feishu messaging
|
|
||||||
- [`auditor.py`](backend/app/services/auditor.py) - Code audit workflow
|
|
||||||
- [`auto_merger.py`](backend/app/services/auto_merger.py) - Auto-merge logic
|
|
||||||
|
|
||||||
### Frontend Structure
|
|
||||||
|
|
||||||
**React** ([`frontend-react/src/`](frontend-react/src/)):
|
|
||||||
- [`pages/`](frontend-react/src/pages/) - Page components (Login, Dashboard, Projects, Users, Skills)
|
|
||||||
- [`components/`](frontend-react/src/components/) - Reusable UI components
|
|
||||||
- [`api/`](frontend-react/src/api/) - API client wrappers
|
|
||||||
- [`hooks/`](frontend-react/src/hooks/) - Custom React hooks (useUserRole)
|
|
||||||
- Uses React Router with HashRouter, Tailwind CSS, Zustand for state
|
|
||||||
|
|
||||||
**Vue 3** ([`frontend/src/`](frontend/src/)):
|
|
||||||
- [`pages/`](frontend/src/pages/) - Vue pages
|
|
||||||
- [`components/`](frontend/src/components/) - Vue components with Naive UI
|
|
||||||
- [`api/`](frontend/src/api/) - API client
|
|
||||||
- [`composables/`](frontend/src/composables/) - Vue composables
|
|
||||||
- [`stores/`](frontend/src/stores/) - Pinia stores
|
|
||||||
|
|
||||||
### Database Schema
|
|
||||||
|
|
||||||
**Multi-Tenant Models**:
|
|
||||||
- `User` - User accounts with roles (BOSS, ADMIN, AI, FE, BE)
|
|
||||||
- `Tenant` - Tenant isolation
|
|
||||||
- `Project` - Projects with tenant_id
|
|
||||||
- `Signal` - External signals with strategic analysis
|
|
||||||
- `Task` - Tasks linked to signals and projects
|
|
||||||
- `Skill` - User skills with proficiency levels
|
|
||||||
- `UserSkillBinding` - Many-to-many user-skill relationships
|
|
||||||
- `UserWorkload` - Real-time workload tracking
|
|
||||||
|
|
||||||
**Type Compatibility** ([`models/types.py`](backend/app/models/types.py)):
|
|
||||||
- PostgreSQL: JSONB, ARRAY, Vector (pgvector)
|
|
||||||
- SQLite: Fallback to JSON/text storage for development
|
|
||||||
|
|
||||||
## Key Integration Points
|
|
||||||
|
|
||||||
### Authentication Flow
|
|
||||||
1. Login via `/auth/login` returns JWT token
|
|
||||||
2. Include token in Authorization header: `Bearer <token>`
|
|
||||||
3. Use `get_current_user` dependency for protected routes
|
|
||||||
4. Use `require_roles("BOSS", "ADMIN")` for role-gated endpoints
|
|
||||||
|
|
||||||
### Multi-Tenant Queries
|
|
||||||
```python
|
|
||||||
from app.dependencies import get_tenant_id
|
|
||||||
|
|
||||||
@router.get("/items")
|
|
||||||
async def list_items(
|
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
|
||||||
db: AsyncSession = Depends(get_db)
|
|
||||||
):
|
|
||||||
result = await db.execute(
|
|
||||||
select(Item).where(Item.tenant_id == tenant_id)
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Event Broadcasting
|
## 7. 团队维护建议(简版)
|
||||||
```python
|
- 规则变更:只改 `AGENTS.md`
|
||||||
from app.events import get_event_bus, EventType
|
- 结构变更:同步改 `CLAUDE.md`
|
||||||
|
- API/契约变更:后端 `schemas` + 前端 `types` 同步更新
|
||||||
event_bus = get_event_bus()
|
|
||||||
await event_bus.publish(
|
|
||||||
EventType.TASK_ASSIGNED,
|
|
||||||
{"task_id": 123, "user_id": 456}
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### WebSocket Events
|
|
||||||
- Events are automatically bridged to WebSocket clients
|
|
||||||
- Clients subscribe to event types for filtering
|
|
||||||
- Message format: `{"type": "event", "event_type": "...", "data": {...}, "timestamp": ...}`
|
|
||||||
|
|
||||||
## Development Notes
|
|
||||||
|
|
||||||
- **Database**: PostgreSQL with pgvector recommended; SQLite fallback for development
|
|
||||||
- **CORS**: Currently allows all origins (`*`) - restrict for production
|
|
||||||
- **Logging**: Structured logging via [`core/logging.py`](backend/app/core/logging.py)
|
|
||||||
- **API Response**: Standardized format via [`core/response.py`](backend/app/core/response.py)
|
|
||||||
- **AI Caching**: Redis-based caching for AI call results
|
|
||||||
- **AI Logging**: All AI calls logged to `ai_call_logs` table
|
|
||||||
|
|
||||||
## Ports and Endpoints
|
|
||||||
|
|
||||||
- **Backend API**: http://127.0.0.1:8000
|
|
||||||
- **API Docs**: http://127.0.0.1:8000/docs (Swagger UI)
|
|
||||||
- **React Frontend**: http://127.0.0.1:5174
|
|
||||||
- **Vue Frontend**: http://127.0.0.1:5173
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
Key environment variables (see [`.env.example`](frontend/.env.example)):
|
|
||||||
- `DATABASE_URL` - PostgreSQL/SQLite connection string
|
|
||||||
- `SECRET_KEY` - JWT signing key
|
|
||||||
- `OPENAI_API_KEY` - OpenAI API key for LangChain
|
|
||||||
- `REDIS_URL` - Redis connection for caching
|
|
||||||
- `PLANE_API_*` - Plane PM tool integration
|
|
||||||
|
|
||||||
## File Conventions
|
|
||||||
|
|
||||||
- **Pydantic Schemas**: Request/Response models in [`schemas/`](backend/app/schemas/)
|
|
||||||
- **SQLAlchemy Models**: Database models in [`models/`](backend/app/models/)
|
|
||||||
- **API Routes**: Route handlers in [`api/`](backend/app/api/)
|
|
||||||
- **Business Logic**: Services in [`services/`](backend/app/services/)
|
|
||||||
- **AI Prompts**: Centralized in [`config/ai_prompts.py`](backend/app/config/ai_prompts.py)
|
|
||||||
|
|
|
||||||
|
|
@ -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/openclaw.png`.
|
||||||
|
|
@ -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,17 @@
|
||||||
|
# USER.md - About Your Human
|
||||||
|
|
||||||
|
_Learn about the person you're helping. Update this as you go._
|
||||||
|
|
||||||
|
- **Name:**
|
||||||
|
- **What to call them:**
|
||||||
|
- **Pronouns:** _(optional)_
|
||||||
|
- **Timezone:**
|
||||||
|
- **Notes:**
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
_(What do they care about? What projects are they working on? What annoys them? What makes them laugh? Build this over time.)_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
The more you know, the better you can help. But remember — you're learning about a person, not building a dossier. Respect the difference.
|
||||||
|
|
@ -1,34 +1,72 @@
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
DATABASE_URL=sqlite+aiosqlite:///./neural_sync.db
|
DATABASE_URL=postgresql+asyncpg://pmflow:pmflow_pass@db:5432/pmflow
|
||||||
|
# 可选:启动方言守卫(postgresql / sqlite)。配置后若不一致将拒绝启动。
|
||||||
|
PMFLOW_EXPECTED_DB_DIALECT=postgresql
|
||||||
# For PostgreSQL with pgvector:
|
# For PostgreSQL with pgvector:
|
||||||
# DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5435/neural_sync
|
# DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5435/neural_sync
|
||||||
|
# For local SQLite quick-start:
|
||||||
|
# DATABASE_URL=sqlite+aiosqlite:///./neural_sync.db
|
||||||
|
|
||||||
|
# Redis Configuration
|
||||||
|
REDIS_URL=redis://:pmflow_redis_pass@redis:6379/0
|
||||||
|
|
||||||
|
# Knowledge Graph Incremental Scheduler
|
||||||
|
KG_INCREMENTAL_INTERVAL_MINUTES=5
|
||||||
|
KG_INCREMENTAL_DELTA_LIMIT=1000
|
||||||
|
KG_INCREMENTAL_EVENT_BATCH=200
|
||||||
|
KG_CODE_SCAN_INTERVAL_HOURS=6
|
||||||
|
|
||||||
# ========================================
|
# ========================================
|
||||||
# LLM Provider Configuration
|
# LLM / OpenClaw Control Plane
|
||||||
# ========================================
|
# ========================================
|
||||||
# 选择 LLM 提供商: ollama, deepseek, openai, qwen
|
# 单一控制面:PMFlow 仅调用 OpenClaw 网关(OpenAI-Compatible)
|
||||||
# - ollama: 本地运行 (免费,但占用 CPU/内存)
|
LLM_PROVIDER=openai
|
||||||
# - deepseek: 云端 API (便宜,¥1/百万 tokens)
|
OPENAI_BASE_URL=http://openclaw:18789/v1
|
||||||
# - openai: 云端 API (GPT-4,较贵)
|
OPENAI_API_KEY=<OPENCLAW_GATEWAY_TOKEN>
|
||||||
# - qwen: 阿里云通义千问 (便宜,国内稳定)
|
|
||||||
LLM_PROVIDER=deepseek
|
|
||||||
|
|
||||||
# --- DeepSeek 配置 (推荐,便宜好用) ---
|
# OpenClaw 函数路由
|
||||||
DEEPSEEK_API_KEY=your_deepseek_api_key_here
|
OPENCLAW_ROUTING_ENABLED=true
|
||||||
|
OPENCLAW_ROUTING_RULES_PATH=/app/backend/.openclaw/pmflow-routing-rules.json
|
||||||
|
# OpenClaw agent 默认超时(秒):用于注入 chat/completions -> agents.defaults.timeoutSeconds
|
||||||
|
OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS=120
|
||||||
|
|
||||||
|
# 关闭 PMFlow 内部 mixed routing,优先以 OpenClaw 规则为准
|
||||||
|
LLM_MIXED_ROUTING_ENABLED=false
|
||||||
|
LLM_ROUTING_DEFAULT_TIER=active
|
||||||
|
LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true
|
||||||
|
|
||||||
|
# low/high/safety tier 默认模型(由网关解释)
|
||||||
|
LLM_ROUTING_LOW_PROVIDER=openai
|
||||||
|
LLM_ROUTING_LOW_MODEL=zai/glm-4-flash
|
||||||
|
LLM_ROUTING_HIGH_PROVIDER=openai
|
||||||
|
LLM_ROUTING_HIGH_MODEL=minimax/MiniMax-M2.5
|
||||||
|
LLM_ROUTING_SAFETY_PROVIDER=openai
|
||||||
|
LLM_ROUTING_SAFETY_MODEL=deepseek/deepseek-chat
|
||||||
|
|
||||||
|
# 应急开关:仅在网关不可用时允许直连 provider(默认关闭)
|
||||||
|
LLM_DIRECT_EMERGENCY_ENABLED=false
|
||||||
|
LLM_FALLBACK_PROVIDER=ollama
|
||||||
|
|
||||||
|
# Adaptive RAG 深入思考模式(前端开关=deep 时生效)
|
||||||
|
RAG_DEEP_RETRIEVAL_STEPS=5
|
||||||
|
RAG_DEEP_VECTOR_TOP_K=12
|
||||||
|
RAG_DEEP_CONTEXT_BLOCKS=16
|
||||||
|
RAG_DEEP_ANSWER_MAX_TOKENS=1400
|
||||||
|
|
||||||
|
# --- Emergency Direct Provider Keys (optional) ---
|
||||||
|
GLM_API_KEY=
|
||||||
|
GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4
|
||||||
|
GLM_MODEL=glm-4-flash
|
||||||
|
|
||||||
|
DEEPSEEK_API_KEY=
|
||||||
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
||||||
|
|
||||||
# --- OpenAI 配置 ---
|
# --- Ollama 本地配置(应急本地兜底) ---
|
||||||
OPENAI_API_KEY=your_openai_api_key_here
|
|
||||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
|
||||||
|
|
||||||
# --- Qwen 配置 ---
|
|
||||||
QWEN_API_KEY=your_qwen_api_key_here
|
|
||||||
QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
|
||||||
|
|
||||||
# --- Ollama 本地配置 ---
|
|
||||||
OLLAMA_HOST=http://127.0.0.1:11434
|
OLLAMA_HOST=http://127.0.0.1:11434
|
||||||
OLLAMA_CHAT_MODEL=llama3:8b
|
OLLAMA_CHAT_MODEL=llama3:8b
|
||||||
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||||
|
OLLAMA_BASE_URL=http://127.0.0.1:11434
|
||||||
|
OLLAMA_MODEL=llama3:latest
|
||||||
|
|
||||||
# ========================================
|
# ========================================
|
||||||
|
|
||||||
|
|
@ -53,3 +91,6 @@ PLANE_PROJECT_ID=
|
||||||
LARK_APP_ID=
|
LARK_APP_ID=
|
||||||
LARK_APP_SECRET=
|
LARK_APP_SECRET=
|
||||||
LARK_VERIFICATION_TOKEN=
|
LARK_VERIFICATION_TOKEN=
|
||||||
|
# 飞书长连接重试策略(指数退避 1s -> 2s -> 4s,最多 3 次)
|
||||||
|
FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS=1
|
||||||
|
FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS=3
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
{
|
||||||
|
"version": "v2",
|
||||||
|
"updated_at": "2026-03-05T00:00:00Z",
|
||||||
|
"source_of_truth": "openclaw",
|
||||||
|
"description": "PMFlow 三引擎统一路由:所有主链路统一经 OpenClaw OpenAI-Compatible 网关。",
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"name": "engine_low_execute_glm_flash",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 300,
|
||||||
|
"tier": "low",
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "zai/glm-4-flash",
|
||||||
|
"match_any": [
|
||||||
|
"feishu_level_classification",
|
||||||
|
"feishu_signal_extraction",
|
||||||
|
"rag_intent_classification",
|
||||||
|
"classify_intent",
|
||||||
|
"feishu_humanized_reply",
|
||||||
|
"extract_context_insights"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "engine_high_reasoning_minimax",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 220,
|
||||||
|
"tier": "high",
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "minimax/MiniMax-M2.5",
|
||||||
|
"match_any": [
|
||||||
|
"adaptive_rag_answer",
|
||||||
|
"feishu_project_prefill",
|
||||||
|
"feishu_task_orchestration",
|
||||||
|
"generate_task_breakdown",
|
||||||
|
"generate_charter_from_signal",
|
||||||
|
"decompose_tasks_from_charter",
|
||||||
|
"recommend_assignees_for_task",
|
||||||
|
"evaluate_model_requirement",
|
||||||
|
"calculate_alignment_score",
|
||||||
|
"detect_cognitive_conflicts",
|
||||||
|
"rag_db_project_semantic_retrieval",
|
||||||
|
"workload_optimize_prompt",
|
||||||
|
"analyze_cost_roi"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "engine_high_extended_reasoning",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 200,
|
||||||
|
"tier": "high",
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "minimax/MiniMax-M2.5",
|
||||||
|
"match_any": [
|
||||||
|
"analyze_food_calories",
|
||||||
|
"feishu_new_project_brief",
|
||||||
|
"feishu_project_signal_classification",
|
||||||
|
"sstc_ai_breakdown",
|
||||||
|
"sstc_charter_localize_zh",
|
||||||
|
"sstc_charter_prefill",
|
||||||
|
"skill_score_update",
|
||||||
|
"test_engine_connection"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "engine_safety_fallback",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 50,
|
||||||
|
"tier": "safety",
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "deepseek/deepseek-chat",
|
||||||
|
"match_all": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|
@ -7,6 +7,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
from sqlalchemy import engine_from_config
|
from sqlalchemy import engine_from_config
|
||||||
from sqlalchemy import pool
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
from alembic import context
|
from alembic import context
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
@ -24,6 +25,7 @@ if config.config_file_name is not None:
|
||||||
# Set the database URL from settings
|
# Set the database URL from settings
|
||||||
# Replace async driver with sync driver for migrations
|
# Replace async driver with sync driver for migrations
|
||||||
db_url = settings.database_url.replace("sqlite+aiosqlite", "sqlite")
|
db_url = settings.database_url.replace("sqlite+aiosqlite", "sqlite")
|
||||||
|
db_url = db_url.replace("postgresql+asyncpg", "postgresql+psycopg")
|
||||||
config.set_main_option("sqlalchemy.url", db_url)
|
config.set_main_option("sqlalchemy.url", db_url)
|
||||||
|
|
||||||
# add your model's MetaData object here
|
# add your model's MetaData object here
|
||||||
|
|
@ -76,6 +78,11 @@ def run_migrations_online() -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
with connectable.connect() as connection:
|
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(
|
context.configure(
|
||||||
connection=connection, target_metadata=target_metadata
|
connection=connection, target_metadata=target_metadata
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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("openclaw_status", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("worker_total", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("worker_completed", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("worker_failed", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
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_openclaw_status", ["openclaw_status"]),
|
||||||
|
("ix_ai_orchestration_runs_created_by_user_id", ["created_by_user_id"]),
|
||||||
|
):
|
||||||
|
_create_index_if_not_exists(bind, index_name=index_name, table_name="ai_orchestration_runs", columns=columns)
|
||||||
|
|
||||||
|
|
||||||
|
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_openclaw_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")
|
||||||
|
|
@ -20,6 +20,12 @@ from .wecom import router as wecom_router
|
||||||
from .model_capability import router as model_capability_router
|
from .model_capability import router as model_capability_router
|
||||||
from .budget import router as budget_router
|
from .budget import router as budget_router
|
||||||
from .scheduler import router as scheduler_router
|
from .scheduler import router as scheduler_router
|
||||||
|
from .notifications import router as notifications_router
|
||||||
|
from .llm_engines import router as llm_engines_router
|
||||||
|
from .prompt_profiles import router as prompt_profiles_router
|
||||||
|
from .knowledge_graph import router as knowledge_graph_router
|
||||||
|
from .operation_catalog import router as operation_catalog_router
|
||||||
|
from .ai_pmo import router as ai_pmo_router
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"signals_router",
|
"signals_router",
|
||||||
|
|
@ -38,4 +44,10 @@ __all__ = [
|
||||||
"model_capability_router",
|
"model_capability_router",
|
||||||
"budget_router",
|
"budget_router",
|
||||||
"scheduler_router",
|
"scheduler_router",
|
||||||
|
"notifications_router",
|
||||||
|
"llm_engines_router",
|
||||||
|
"prompt_profiles_router",
|
||||||
|
"knowledge_graph_router",
|
||||||
|
"operation_catalog_router",
|
||||||
|
"ai_pmo_router",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,341 @@
|
||||||
|
"""AI 处理可视化日志 API。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import and_, case, func, or_, select, union_all
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..core.response import success_response
|
||||||
|
from ..database import get_db
|
||||||
|
from ..dependencies.auth import get_current_user
|
||||||
|
from ..models import (
|
||||||
|
AIProcessingLog,
|
||||||
|
FeishuItem,
|
||||||
|
ProjectSignalLink,
|
||||||
|
ProjectSuggestion,
|
||||||
|
Signal,
|
||||||
|
User,
|
||||||
|
WorkItem,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/ai-logs", tags=["AI Logs"])
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_bullets(value: Any) -> list[str]:
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [str(x).strip() for x in value if str(x).strip()][:3]
|
||||||
|
if isinstance(value, str):
|
||||||
|
raw = value.strip()
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
return [str(x).strip() for x in parsed if str(x).strip()][:3]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return [line.strip(" -•\t") for line in raw.splitlines() if line.strip()][:3]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_json(value: Optional[str]) -> Any:
|
||||||
|
text = (value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(text)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_log_row(row: AIProcessingLog) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": str(row.id),
|
||||||
|
"feishu_message_id": row.feishu_message_id,
|
||||||
|
"feishu_item_id": str(row.feishu_item_id) if row.feishu_item_id is not None else None,
|
||||||
|
"raw_message": row.raw_message,
|
||||||
|
"project_score": row.project_score,
|
||||||
|
"signal_confidence": row.signal_confidence,
|
||||||
|
"level": row.level,
|
||||||
|
"routing_decision": row.routing_decision,
|
||||||
|
"routing_reason": row.routing_reason,
|
||||||
|
"reasoning_bullets": _normalize_bullets(row.reasoning_bullets),
|
||||||
|
"processing_status": row.processing_status,
|
||||||
|
"error_message": row.error_message,
|
||||||
|
"duration_ms": row.duration_ms,
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_log_detail(row: AIProcessingLog) -> dict[str, Any]:
|
||||||
|
payload = _serialize_log_row(row)
|
||||||
|
payload["ai_raw_output"] = row.ai_raw_output
|
||||||
|
payload["ai_raw_output_json"] = _safe_json(row.ai_raw_output)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_project_filter(stmt, *, tenant_id: int, project_id: Optional[int]):
|
||||||
|
if project_id is None:
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
work_item_message_ids = select(func.distinct(WorkItem.source_message_id)).where(
|
||||||
|
and_(
|
||||||
|
WorkItem.tenant_id == tenant_id,
|
||||||
|
WorkItem.project_id == int(project_id),
|
||||||
|
WorkItem.source_system == "feishu",
|
||||||
|
WorkItem.source_message_id.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
direct_items = select(FeishuItem.id.label("item_id")).where(
|
||||||
|
and_(
|
||||||
|
FeishuItem.tenant_id == tenant_id,
|
||||||
|
FeishuItem.matched_project_id == int(project_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
signal_matched_items = select(Signal.feishu_item_id.label("item_id")).where(
|
||||||
|
and_(
|
||||||
|
Signal.tenant_id == tenant_id,
|
||||||
|
Signal.matched_project_id == int(project_id),
|
||||||
|
Signal.feishu_item_id.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
linked_items = (
|
||||||
|
select(Signal.feishu_item_id.label("item_id"))
|
||||||
|
.select_from(Signal)
|
||||||
|
.join(
|
||||||
|
ProjectSignalLink,
|
||||||
|
and_(
|
||||||
|
ProjectSignalLink.tenant_id == tenant_id,
|
||||||
|
ProjectSignalLink.project_id == int(project_id),
|
||||||
|
ProjectSignalLink.signal_id == Signal.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Signal.tenant_id == tenant_id,
|
||||||
|
Signal.feishu_item_id.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
auto_created_items = select(ProjectSuggestion.feishu_item_id.label("item_id")).where(
|
||||||
|
and_(
|
||||||
|
ProjectSuggestion.tenant_id == tenant_id,
|
||||||
|
ProjectSuggestion.created_project_id == int(project_id),
|
||||||
|
ProjectSuggestion.feishu_item_id.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
all_item_ids = union_all(
|
||||||
|
direct_items,
|
||||||
|
signal_matched_items,
|
||||||
|
linked_items,
|
||||||
|
auto_created_items,
|
||||||
|
).subquery("project_item_ids")
|
||||||
|
item_ids_stmt = select(func.distinct(all_item_ids.c.item_id)).where(all_item_ids.c.item_id.is_not(None))
|
||||||
|
message_ids_stmt = select(func.distinct(FeishuItem.feishu_message_id)).where(
|
||||||
|
and_(
|
||||||
|
FeishuItem.tenant_id == tenant_id,
|
||||||
|
FeishuItem.id.in_(item_ids_stmt),
|
||||||
|
FeishuItem.feishu_message_id.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return stmt.where(
|
||||||
|
or_(
|
||||||
|
AIProcessingLog.feishu_item_id.in_(item_ids_stmt),
|
||||||
|
AIProcessingLog.feishu_message_id.in_(message_ids_stmt),
|
||||||
|
AIProcessingLog.feishu_message_id.in_(work_item_message_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_ai_logs(
|
||||||
|
project_id: Optional[int] = Query(default=None, ge=1),
|
||||||
|
limit: int = Query(default=20, ge=1, le=100),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
tenant_id = int(current_user.tenant_id)
|
||||||
|
total_stmt = select(func.count(AIProcessingLog.id)).where(AIProcessingLog.tenant_id == tenant_id)
|
||||||
|
data_stmt = (
|
||||||
|
select(AIProcessingLog)
|
||||||
|
.where(AIProcessingLog.tenant_id == tenant_id)
|
||||||
|
.order_by(AIProcessingLog.created_at.desc(), AIProcessingLog.id.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
total_stmt = _apply_project_filter(total_stmt, tenant_id=tenant_id, project_id=project_id)
|
||||||
|
data_stmt = _apply_project_filter(data_stmt, tenant_id=tenant_id, project_id=project_id)
|
||||||
|
|
||||||
|
total = int((await db.execute(total_stmt)).scalar() or 0)
|
||||||
|
rows = (await db.execute(data_stmt)).scalars().all()
|
||||||
|
items = [_serialize_log_row(row) for row in rows]
|
||||||
|
return success_response(
|
||||||
|
{
|
||||||
|
"items": items,
|
||||||
|
"total": total,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
"has_more": (offset + len(items)) < total,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
async def get_ai_log_stats(
|
||||||
|
project_id: Optional[int] = Query(default=None, ge=1),
|
||||||
|
date_value: str = Query(default="today", alias="date"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
tenant_id = int(current_user.tenant_id)
|
||||||
|
if date_value == "today":
|
||||||
|
target_date = datetime.now().date()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
target_date = date.fromisoformat(date_value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=f"invalid_date:{date_value}") from exc
|
||||||
|
|
||||||
|
stats_stmt = (
|
||||||
|
select(
|
||||||
|
func.count(AIProcessingLog.id).label("received_count"),
|
||||||
|
func.sum(case((AIProcessingLog.processing_status == "success", 1), else_=0)).label("success_count"),
|
||||||
|
func.sum(case((AIProcessingLog.processing_status == "failed", 1), else_=0)).label("failed_count"),
|
||||||
|
func.sum(
|
||||||
|
case((AIProcessingLog.routing_decision == "duplicate_filtered", 1), else_=0)
|
||||||
|
).label("duplicate_filtered_count"),
|
||||||
|
func.sum(
|
||||||
|
case((AIProcessingLog.routing_decision == "execute_reply_guard_triggered", 1), else_=0)
|
||||||
|
).label("execute_reply_guard_count"),
|
||||||
|
func.sum(
|
||||||
|
case(
|
||||||
|
(
|
||||||
|
AIProcessingLog.routing_decision.in_(
|
||||||
|
["auto_create_project", "auto_enter_signal"]
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
).label("auto_entered_count"),
|
||||||
|
func.sum(
|
||||||
|
case(
|
||||||
|
(
|
||||||
|
AIProcessingLog.routing_decision.in_(
|
||||||
|
[
|
||||||
|
"pending_low_confidence",
|
||||||
|
"pending_cold_start",
|
||||||
|
"pending_uncertain_project",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
).label("pending_count"),
|
||||||
|
func.sum(case((AIProcessingLog.processing_status == "processing", 1), else_=0)).label(
|
||||||
|
"processing_count"
|
||||||
|
),
|
||||||
|
func.avg(AIProcessingLog.signal_confidence).label("avg_signal_confidence"),
|
||||||
|
func.avg(AIProcessingLog.project_score).label("avg_project_score"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
AIProcessingLog.tenant_id == tenant_id,
|
||||||
|
func.date(AIProcessingLog.created_at) == target_date.isoformat(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stats_stmt = _apply_project_filter(stats_stmt, tenant_id=tenant_id, project_id=project_id)
|
||||||
|
row = (await db.execute(stats_stmt)).one()
|
||||||
|
return success_response(
|
||||||
|
{
|
||||||
|
"date": target_date.isoformat(),
|
||||||
|
"received_count": int(row.received_count or 0),
|
||||||
|
"success_count": int(row.success_count or 0),
|
||||||
|
"failed_count": int(row.failed_count or 0),
|
||||||
|
"duplicate_filtered_count": int(row.duplicate_filtered_count or 0),
|
||||||
|
"execute_reply_guard_count": int(row.execute_reply_guard_count or 0),
|
||||||
|
"auto_entered_count": int(row.auto_entered_count or 0),
|
||||||
|
"pending_count": int(row.pending_count or 0),
|
||||||
|
"processing_count": int(row.processing_count or 0),
|
||||||
|
"avg_signal_confidence": round(float(row.avg_signal_confidence or 0.0), 4),
|
||||||
|
"avg_project_score": round(float(row.avg_project_score or 0.0), 4),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/alerts/execute-reply-guard")
|
||||||
|
async def list_execute_reply_guard_alerts(
|
||||||
|
limit: int = Query(default=20, ge=1, le=100),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
tenant_id = int(current_user.tenant_id)
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(AIProcessingLog)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
AIProcessingLog.tenant_id == tenant_id,
|
||||||
|
AIProcessingLog.routing_decision == "execute_reply_guard_triggered",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(AIProcessingLog.created_at.desc(), AIProcessingLog.id.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
return success_response(
|
||||||
|
{
|
||||||
|
"count": len(rows),
|
||||||
|
"items": [_serialize_log_row(row) for row in rows],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/latest")
|
||||||
|
async def get_latest_ai_log(
|
||||||
|
project_id: Optional[int] = Query(default=None, ge=1),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
tenant_id = int(current_user.tenant_id)
|
||||||
|
stmt = (
|
||||||
|
select(AIProcessingLog)
|
||||||
|
.where(AIProcessingLog.tenant_id == tenant_id)
|
||||||
|
.order_by(AIProcessingLog.created_at.desc(), AIProcessingLog.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
stmt = _apply_project_filter(stmt, tenant_id=tenant_id, project_id=project_id)
|
||||||
|
row = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
return success_response(_serialize_log_detail(row) if row else None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{log_id}")
|
||||||
|
async def get_ai_log_detail(
|
||||||
|
log_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
tenant_id = int(current_user.tenant_id)
|
||||||
|
row = (
|
||||||
|
await db.execute(
|
||||||
|
select(AIProcessingLog).where(
|
||||||
|
and_(
|
||||||
|
AIProcessingLog.tenant_id == tenant_id,
|
||||||
|
AIProcessingLog.id == int(log_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="ai_log_not_found")
|
||||||
|
return success_response(_serialize_log_detail(row))
|
||||||
|
|
@ -0,0 +1,417 @@
|
||||||
|
"""AI PMO 决策中枢 API。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import and_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..core.response import success_response
|
||||||
|
from ..database import get_db
|
||||||
|
from ..dependencies.auth import get_current_user
|
||||||
|
from ..dependencies.tenant import get_tenant_id
|
||||||
|
from ..models import DecisionRecord, IngestEvent, PlanBundle, PortfolioSnapshot, Project, ReplyReceipt, User, WorkItem
|
||||||
|
from ..services.ai_pmo_cadence_service import AIPMOCadenceService
|
||||||
|
from ..services.ai_pmo_kernel_service import AIPMOKernelService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/ai-pmo", tags=["AI PMO"])
|
||||||
|
|
||||||
|
|
||||||
|
class PMOProcessTextRequest(BaseModel):
|
||||||
|
message_text: str = Field(..., min_length=1, max_length=4000)
|
||||||
|
sender: Optional[str] = Field(default=None, max_length=120)
|
||||||
|
source_message_id: Optional[str] = Field(default=None, max_length=128)
|
||||||
|
project_id: Optional[int] = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
class PMOCadenceRunRequest(BaseModel):
|
||||||
|
cadence_type: str = Field(..., min_length=1, max_length=32)
|
||||||
|
project_id: Optional[int] = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
class PMOResumeWorkItemRequest(BaseModel):
|
||||||
|
project_id: Optional[int] = Field(default=None)
|
||||||
|
source_message_id: Optional[str] = Field(default=None, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/process-text")
|
||||||
|
async def process_text(
|
||||||
|
request: PMOProcessTextRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
service = AIPMOKernelService(db, tenant_id=tenant_id)
|
||||||
|
result = await service.process_ingress(
|
||||||
|
source_system="pmflow_manual",
|
||||||
|
source_message_id=request.source_message_id,
|
||||||
|
message_text=request.message_text,
|
||||||
|
sender=str(request.sender or current_user.name or current_user.username or "PMFlow用户"),
|
||||||
|
channel_ref="pmflow_manual",
|
||||||
|
source_sender_open_id=None,
|
||||||
|
captured_at=None,
|
||||||
|
chat_type="manual",
|
||||||
|
project_hint_id=request.project_id,
|
||||||
|
)
|
||||||
|
if not result.get("handled"):
|
||||||
|
raise HTTPException(status_code=400, detail=str(result.get("reason") or "AI PMO kernel 未处理该输入"))
|
||||||
|
return success_response(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/work-items")
|
||||||
|
async def list_work_items(
|
||||||
|
limit: int = 20,
|
||||||
|
state: Optional[str] = None,
|
||||||
|
project_id: Optional[int] = None,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
_ = current_user
|
||||||
|
stmt = (
|
||||||
|
select(WorkItem, IngestEvent, Project)
|
||||||
|
.join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id)
|
||||||
|
.outerjoin(Project, and_(Project.tenant_id == tenant_id, Project.id == WorkItem.project_id))
|
||||||
|
.where(WorkItem.tenant_id == tenant_id)
|
||||||
|
.order_by(WorkItem.updated_at.desc(), WorkItem.id.desc())
|
||||||
|
)
|
||||||
|
if state:
|
||||||
|
stmt = stmt.where(WorkItem.state == state)
|
||||||
|
if project_id is not None:
|
||||||
|
stmt = stmt.where(WorkItem.project_id == int(project_id))
|
||||||
|
rows = (await db.execute(stmt.limit(max(1, min(limit, 100))))).all()
|
||||||
|
items = [
|
||||||
|
_serialize_work_item_summary(row=row, ingest=ingest, project=project)
|
||||||
|
for row, ingest, project in rows
|
||||||
|
]
|
||||||
|
return success_response({"items": items})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/work-items/{work_item_id}")
|
||||||
|
async def get_work_item(
|
||||||
|
work_item_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
_ = current_user
|
||||||
|
row = (
|
||||||
|
await db.execute(
|
||||||
|
select(WorkItem).where(
|
||||||
|
and_(
|
||||||
|
WorkItem.tenant_id == tenant_id,
|
||||||
|
WorkItem.id == int(work_item_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="work_item_not_found")
|
||||||
|
decision_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(DecisionRecord).where(
|
||||||
|
and_(
|
||||||
|
DecisionRecord.tenant_id == tenant_id,
|
||||||
|
DecisionRecord.work_item_id == int(work_item_id),
|
||||||
|
)
|
||||||
|
).order_by(DecisionRecord.id.asc())
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
plan_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(PlanBundle).where(
|
||||||
|
and_(
|
||||||
|
PlanBundle.tenant_id == tenant_id,
|
||||||
|
PlanBundle.work_item_id == int(work_item_id),
|
||||||
|
)
|
||||||
|
).order_by(PlanBundle.id.asc())
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
reply_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(ReplyReceipt).where(
|
||||||
|
and_(
|
||||||
|
ReplyReceipt.tenant_id == tenant_id,
|
||||||
|
ReplyReceipt.work_item_id == int(work_item_id),
|
||||||
|
)
|
||||||
|
).order_by(ReplyReceipt.id.asc())
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"id": int(row.id),
|
||||||
|
"source_system": row.source_system,
|
||||||
|
"source_message_id": row.source_message_id,
|
||||||
|
"state": row.state,
|
||||||
|
"intent": row.intent,
|
||||||
|
"decision_level": row.decision_level,
|
||||||
|
"project_id": _safe_int(row.project_id),
|
||||||
|
"signal_id": _safe_int(row.signal_id),
|
||||||
|
"context_snapshot": row.context_snapshot,
|
||||||
|
"action_bundle": row.action_bundle,
|
||||||
|
"execution_result": row.execution_result,
|
||||||
|
"review_summary": row.review_summary,
|
||||||
|
"reply_template_key": row.reply_template_key,
|
||||||
|
"decision_records": [
|
||||||
|
{
|
||||||
|
"id": int(item.id),
|
||||||
|
"decision_level": item.decision_level,
|
||||||
|
"decision_object_type": item.decision_object_type,
|
||||||
|
"decision_object_id": _safe_int(item.decision_object_id),
|
||||||
|
"rationale": item.rationale,
|
||||||
|
"risk_score": item.risk_score,
|
||||||
|
"budget_impact": item.budget_impact,
|
||||||
|
"resource_impact": item.resource_impact,
|
||||||
|
"simulation_result": item.simulation_result,
|
||||||
|
"policy_gate_hit": bool(item.policy_gate_hit),
|
||||||
|
"policy_gate_code": item.policy_gate_code,
|
||||||
|
"approval_requirement": item.approval_requirement,
|
||||||
|
"execution_result": item.execution_result,
|
||||||
|
"final_outcome": item.final_outcome,
|
||||||
|
}
|
||||||
|
for item in decision_rows
|
||||||
|
],
|
||||||
|
"plan_bundles": [
|
||||||
|
{
|
||||||
|
"id": int(item.id),
|
||||||
|
"plan_type": item.plan_type,
|
||||||
|
"summary": item.summary,
|
||||||
|
"payload": item.payload,
|
||||||
|
}
|
||||||
|
for item in plan_rows
|
||||||
|
],
|
||||||
|
"reply_receipts": [
|
||||||
|
{
|
||||||
|
"id": int(item.id),
|
||||||
|
"receipt_phase": item.receipt_phase,
|
||||||
|
"template_key": item.template_key,
|
||||||
|
"payload": item.payload,
|
||||||
|
"status": item.status,
|
||||||
|
}
|
||||||
|
for item in reply_rows
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return success_response(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/work-items/{work_item_id}/resume")
|
||||||
|
async def resume_work_item(
|
||||||
|
work_item_id: int,
|
||||||
|
request: PMOResumeWorkItemRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
target = (
|
||||||
|
await db.execute(
|
||||||
|
select(WorkItem).where(
|
||||||
|
and_(
|
||||||
|
WorkItem.tenant_id == tenant_id,
|
||||||
|
WorkItem.id == int(work_item_id),
|
||||||
|
)
|
||||||
|
).limit(1)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if target is None:
|
||||||
|
raise HTTPException(status_code=404, detail="work_item_not_found")
|
||||||
|
service = AIPMOKernelService(db, tenant_id=tenant_id)
|
||||||
|
result = await service.process_ingress(
|
||||||
|
source_system="ai_pmo_ui",
|
||||||
|
source_message_id=request.source_message_id or f"resume-{int(work_item_id)}-{int(datetime.now().timestamp() * 1000)}",
|
||||||
|
message_text=f"继续处理 work_item #{int(work_item_id)}",
|
||||||
|
sender=str(current_user.name or current_user.username or "PMFlow用户"),
|
||||||
|
channel_ref="ai_pmo_ui",
|
||||||
|
source_sender_open_id=None,
|
||||||
|
captured_at=None,
|
||||||
|
chat_type="manual",
|
||||||
|
project_hint_id=request.project_id or _safe_int(target.project_id),
|
||||||
|
)
|
||||||
|
if not result.get("handled"):
|
||||||
|
raise HTTPException(status_code=400, detail=str(result.get("reason") or "resume_failed"))
|
||||||
|
return success_response(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cadence/run")
|
||||||
|
async def run_cadence(
|
||||||
|
request: PMOCadenceRunRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
_ = current_user
|
||||||
|
service = AIPMOCadenceService(db, tenant_id=tenant_id)
|
||||||
|
try:
|
||||||
|
snapshot = await service.generate_snapshot(
|
||||||
|
cadence_type=request.cadence_type,
|
||||||
|
project_id=request.project_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
await db.commit()
|
||||||
|
return success_response(snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cadence/latest")
|
||||||
|
async def get_latest_cadence_snapshot(
|
||||||
|
cadence_type: str,
|
||||||
|
project_id: Optional[int] = None,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
_ = current_user
|
||||||
|
service = AIPMOCadenceService(db, tenant_id=tenant_id)
|
||||||
|
snapshot = await service.get_latest_snapshot(
|
||||||
|
cadence_type=cadence_type,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
return success_response(snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/portfolio/program-view")
|
||||||
|
async def get_program_view(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
_ = current_user
|
||||||
|
service = AIPMOCadenceService(db, tenant_id=tenant_id)
|
||||||
|
return success_response(await service.build_program_view())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/portfolio/latest")
|
||||||
|
async def get_latest_portfolio_snapshot(
|
||||||
|
scope_type: str = "tenant",
|
||||||
|
project_id: Optional[int] = None,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
_ = current_user
|
||||||
|
scope_ref = f"project:{int(project_id)}" if project_id is not None else f"tenant:{tenant_id}"
|
||||||
|
row = (
|
||||||
|
await db.execute(
|
||||||
|
select(PortfolioSnapshot).where(
|
||||||
|
and_(
|
||||||
|
PortfolioSnapshot.tenant_id == tenant_id,
|
||||||
|
PortfolioSnapshot.scope_type == scope_type,
|
||||||
|
PortfolioSnapshot.scope_ref == scope_ref,
|
||||||
|
)
|
||||||
|
).order_by(PortfolioSnapshot.id.desc()).limit(1)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
return success_response(None)
|
||||||
|
return success_response(
|
||||||
|
{
|
||||||
|
"id": int(row.id),
|
||||||
|
"scope_type": row.scope_type,
|
||||||
|
"scope_ref": row.scope_ref,
|
||||||
|
"summary": row.summary,
|
||||||
|
"payload": row.payload,
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_int(value: Any) -> Optional[int]:
|
||||||
|
try:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_text(value: Any, limit: int) -> str:
|
||||||
|
text = " ".join(str(value or "").split()).strip()
|
||||||
|
if len(text) <= limit:
|
||||||
|
return text
|
||||||
|
return f"{text[: max(limit - 1, 1)].rstrip()}…"
|
||||||
|
|
||||||
|
|
||||||
|
def _priority_from_review(review_summary: Any) -> tuple[str, int]:
|
||||||
|
payload = review_summary if isinstance(review_summary, dict) else {}
|
||||||
|
risk_score = float(payload.get("risk_score_after") or 0.0)
|
||||||
|
if risk_score >= 0.75:
|
||||||
|
return "high", 3
|
||||||
|
if risk_score >= 0.45:
|
||||||
|
return "medium", 2
|
||||||
|
return "low", 1
|
||||||
|
|
||||||
|
|
||||||
|
def _blocking_reason(row: WorkItem) -> Optional[str]:
|
||||||
|
execution_result = row.execution_result if isinstance(row.execution_result, dict) else {}
|
||||||
|
review_summary = row.review_summary if isinstance(row.review_summary, dict) else {}
|
||||||
|
reason = str(execution_result.get("reason") or execution_result.get("error_message") or "").strip()
|
||||||
|
if reason:
|
||||||
|
return reason
|
||||||
|
divergences = review_summary.get("divergences") if isinstance(review_summary.get("divergences"), list) else []
|
||||||
|
if divergences and isinstance(divergences[0], dict):
|
||||||
|
return str(divergences[0].get("detail") or "").strip() or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _next_step_text(row: WorkItem) -> Optional[str]:
|
||||||
|
review_summary = row.review_summary if isinstance(row.review_summary, dict) else {}
|
||||||
|
next_actions = review_summary.get("next_actions") if isinstance(review_summary.get("next_actions"), list) else []
|
||||||
|
if next_actions:
|
||||||
|
return str(next_actions[0]).strip()
|
||||||
|
action_bundle = row.action_bundle if isinstance(row.action_bundle, dict) else {}
|
||||||
|
summary = str(action_bundle.get("summary") or "").strip()
|
||||||
|
return summary or None
|
||||||
|
|
||||||
|
|
||||||
|
def _stage_text(state: str) -> str:
|
||||||
|
mapping = {
|
||||||
|
"received": "已接收",
|
||||||
|
"modeled": "已建模",
|
||||||
|
"decided": "已决策",
|
||||||
|
"planned": "已规划",
|
||||||
|
"executing": "执行中",
|
||||||
|
"waiting_manual": "待人工确认",
|
||||||
|
"succeeded": "执行完成",
|
||||||
|
"failed": "执行失败",
|
||||||
|
"ignored": "已忽略",
|
||||||
|
}
|
||||||
|
return mapping.get(str(state or "").strip(), str(state or "").strip() or "-")
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_work_item_summary(*, row: WorkItem, ingest: IngestEvent, project: Optional[Project]) -> dict[str, Any]:
|
||||||
|
payload = ingest.payload if isinstance(ingest.payload, dict) else {}
|
||||||
|
message_text = str(payload.get("message_text") or "").strip()
|
||||||
|
summary = _compact_text(message_text or _next_step_text(row) or row.intent or "AI PMO 工作项", 180)
|
||||||
|
title = _compact_text(message_text or f"AI PMO · {row.intent or 'work_item'}", 64)
|
||||||
|
priority, priority_rank = _priority_from_review(row.review_summary)
|
||||||
|
project_name = None
|
||||||
|
if project is not None:
|
||||||
|
project_name = str(project.title or project.name or "").strip() or None
|
||||||
|
project_id = _safe_int(row.project_id)
|
||||||
|
return {
|
||||||
|
"id": int(row.id),
|
||||||
|
"source_system": row.source_system,
|
||||||
|
"source_message_id": row.source_message_id,
|
||||||
|
"state": row.state,
|
||||||
|
"intent": row.intent,
|
||||||
|
"decision_level": row.decision_level,
|
||||||
|
"project_id": project_id,
|
||||||
|
"project_name": project_name,
|
||||||
|
"signal_id": _safe_int(row.signal_id),
|
||||||
|
"reply_template_key": row.reply_template_key,
|
||||||
|
"title": title or f"工作项#{int(row.id)}",
|
||||||
|
"summary": summary,
|
||||||
|
"ai_plan": _compact_text(((row.action_bundle or {}) if isinstance(row.action_bundle, dict) else {}).get("summary") or "", 180) or None,
|
||||||
|
"blocking_reason": _blocking_reason(row),
|
||||||
|
"next_step_text": _next_step_text(row),
|
||||||
|
"stage_text": _stage_text(str(row.state or "")),
|
||||||
|
"priority": priority,
|
||||||
|
"priority_rank": priority_rank,
|
||||||
|
"target_url": f"/projects/{project_id}/board" if project_id is not None else None,
|
||||||
|
"review_summary": row.review_summary,
|
||||||
|
"execution_result": row.execution_result,
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||||
|
}
|
||||||
|
|
@ -9,27 +9,71 @@ AI Steward API - 主动式 AI 管家接口
|
||||||
5. 个性化进度简报
|
5. 个性化进度简报
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||||
|
from sqlalchemy import and_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any, Literal
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
from ..core.response import success_response
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..dependencies.auth import get_current_user
|
from ..dependencies.auth import get_current_user
|
||||||
|
from ..dependencies.permissions import require_manager_user
|
||||||
from ..dependencies.tenant import get_tenant_id
|
from ..dependencies.tenant import get_tenant_id
|
||||||
from ..models import User
|
from ..models import ProjectSignalLink, Signal, User
|
||||||
from ..services.ai_steward import get_ai_steward_service, AIStewardService
|
from ..services.ai_steward import get_ai_steward_service, AIStewardService
|
||||||
from ..services.signal_capture import ProactiveSignalCaptureService
|
from ..services.signal_capture import ProactiveSignalCaptureService
|
||||||
from ..services.cognitive_conflict import get_cognitive_conflict_service
|
from ..services.cognitive_conflict import get_cognitive_conflict_service
|
||||||
from ..services.blocker_detection import get_blocker_detection_service
|
from ..services.blocker_detection import get_blocker_detection_service
|
||||||
from ..services.daily_briefing import get_daily_briefing_service
|
from ..services.daily_briefing import get_daily_briefing_service
|
||||||
from ..services.event_workflow import get_event_workflow_engine, WorkflowEventType
|
from ..services.event_workflow import get_event_workflow_engine, WorkflowEventType
|
||||||
|
from ..services.adaptive_rag_service import AdaptiveRAGService
|
||||||
|
from ..services.ai_orchestration_run_service import AIOrchestrationRunService
|
||||||
|
from ..services.openclaw_commander_service import OpenClawCommanderService
|
||||||
|
from ..services.signal_source_orchestration_service import SignalSourceOrchestrationService
|
||||||
|
from ..schemas.rag import RAGAskRequest
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/ai-steward", tags=["AI Steward"])
|
router = APIRouter(prefix="/ai-steward", tags=["AI Steward"])
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now_naive() -> datetime:
|
||||||
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_project_signal_link(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
project_id: int,
|
||||||
|
signal_id: int,
|
||||||
|
link_type: str = "related",
|
||||||
|
) -> None:
|
||||||
|
existing = (
|
||||||
|
await db.execute(
|
||||||
|
select(ProjectSignalLink.id).where(
|
||||||
|
and_(
|
||||||
|
ProjectSignalLink.tenant_id == int(tenant_id),
|
||||||
|
ProjectSignalLink.project_id == int(project_id),
|
||||||
|
ProjectSignalLink.signal_id == int(signal_id),
|
||||||
|
)
|
||||||
|
).limit(1)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
return
|
||||||
|
db.add(
|
||||||
|
ProjectSignalLink(
|
||||||
|
tenant_id=int(tenant_id),
|
||||||
|
project_id=int(project_id),
|
||||||
|
signal_id=int(signal_id),
|
||||||
|
link_type=link_type,
|
||||||
|
relevance_score=1.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ==================== Request/Response Models ====================
|
# ==================== Request/Response Models ====================
|
||||||
|
|
||||||
class ProjectFromTextRequest(BaseModel):
|
class ProjectFromTextRequest(BaseModel):
|
||||||
|
|
@ -77,6 +121,7 @@ class FeishuCaptureRequest(BaseModel):
|
||||||
group_chat_id: str
|
group_chat_id: str
|
||||||
message_content: str
|
message_content: str
|
||||||
sender: str
|
sender: str
|
||||||
|
feishu_message_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class BrowserCaptureRequest(BaseModel):
|
class BrowserCaptureRequest(BaseModel):
|
||||||
|
|
@ -100,8 +145,201 @@ class ConflictDetectRequest(BaseModel):
|
||||||
detect_type: str = "all" # all, charter, definition
|
detect_type: str = "all" # all, charter, definition
|
||||||
|
|
||||||
|
|
||||||
|
class OpenClawCommandDispatchRequest(BaseModel):
|
||||||
|
"""OpenClaw 指挥层任务分配请求"""
|
||||||
|
project_id: int = Field(..., ge=1)
|
||||||
|
instruction: str = Field(..., min_length=4, max_length=2000)
|
||||||
|
dry_run: bool = False
|
||||||
|
max_operations: int = Field(default=8, ge=1, le=20)
|
||||||
|
response_mode: Literal["fast", "complete"] = "fast"
|
||||||
|
|
||||||
|
|
||||||
|
class SignalSourceOnboardRequest(BaseModel):
|
||||||
|
project_id: int = Field(..., ge=1)
|
||||||
|
instruction: str = Field(..., min_length=6, max_length=2000)
|
||||||
|
dry_run: bool = False
|
||||||
|
response_mode: Literal["fast", "complete"] = "fast"
|
||||||
|
|
||||||
|
|
||||||
|
class SignalSourceListResponse(BaseModel):
|
||||||
|
items: List[Dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_related_users(raw_value: Any) -> List[str]:
|
||||||
|
"""兼容 involved_user_ids 的 list / json-string / 单值字符串。"""
|
||||||
|
if raw_value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(raw_value, list):
|
||||||
|
return [str(item) for item in raw_value if item is not None]
|
||||||
|
if isinstance(raw_value, (bytes, bytearray)):
|
||||||
|
raw_value = raw_value.decode("utf-8", errors="ignore")
|
||||||
|
if isinstance(raw_value, str):
|
||||||
|
text = raw_value.strip()
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
parsed = json.loads(text)
|
||||||
|
except (TypeError, ValueError, json.JSONDecodeError):
|
||||||
|
if "," in text:
|
||||||
|
return [v.strip() for v in text.split(",") if v.strip()]
|
||||||
|
return [text]
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
return [str(item) for item in parsed if item is not None]
|
||||||
|
if parsed is None:
|
||||||
|
return []
|
||||||
|
return [str(parsed)]
|
||||||
|
return [str(raw_value)]
|
||||||
|
|
||||||
|
|
||||||
# ==================== Zero-Config Project Creation ====================
|
# ==================== Zero-Config Project Creation ====================
|
||||||
|
|
||||||
|
@router.post("/rag/ask")
|
||||||
|
async def ask_ai_steward_rag(
|
||||||
|
request: RAGAskRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||||
|
service = AdaptiveRAGService(db, tenant_id=effective_tenant_id)
|
||||||
|
result = await service.answer_question(
|
||||||
|
question=request.question,
|
||||||
|
asked_by=int(current_user.id),
|
||||||
|
asked_via="pmflow",
|
||||||
|
project_id=request.project_id,
|
||||||
|
source_chat_id=request.source_chat_id,
|
||||||
|
session_id=request.session_id,
|
||||||
|
force_new_session=bool(request.new_session),
|
||||||
|
thinking_mode=request.thinking_mode,
|
||||||
|
action_mode=request.action_mode,
|
||||||
|
sync_requirement=bool(request.sync_requirement),
|
||||||
|
)
|
||||||
|
return success_response(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/command/dispatch")
|
||||||
|
async def dispatch_openclaw_command(
|
||||||
|
request: OpenClawCommandDispatchRequest,
|
||||||
|
current_user: User = Depends(require_manager_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||||
|
service = OpenClawCommanderService(db)
|
||||||
|
try:
|
||||||
|
result = await service.dispatch_command(
|
||||||
|
tenant_id=effective_tenant_id,
|
||||||
|
operator_user=current_user,
|
||||||
|
project_id=request.project_id,
|
||||||
|
instruction=request.instruction,
|
||||||
|
dry_run=request.dry_run,
|
||||||
|
max_operations=request.max_operations,
|
||||||
|
response_mode=request.response_mode,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
detail = str(exc)
|
||||||
|
if detail == "project not found":
|
||||||
|
raise HTTPException(status_code=404, detail=detail) from exc
|
||||||
|
raise HTTPException(status_code=400, detail=detail) from exc
|
||||||
|
return success_response(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/signal-sources/onboard")
|
||||||
|
async def onboard_signal_source(
|
||||||
|
request: SignalSourceOnboardRequest,
|
||||||
|
current_user: User = Depends(require_manager_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||||
|
signal = Signal(
|
||||||
|
tenant_id=effective_tenant_id,
|
||||||
|
source="PMFlow",
|
||||||
|
content=f"[PMFlow用户#{int(current_user.id)}]: {request.instruction[:1800]}",
|
||||||
|
impact_score=0.7,
|
||||||
|
priority="medium",
|
||||||
|
processed=False,
|
||||||
|
strategic_analysis=json.dumps(
|
||||||
|
{
|
||||||
|
"source": "pmflow_manual_signal_source_onboard",
|
||||||
|
"instruction": request.instruction[:2000],
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
tasks_generated=json.dumps([], ensure_ascii=False),
|
||||||
|
matched_project_id=int(request.project_id),
|
||||||
|
title=(request.instruction[:80] or "新增信号源"),
|
||||||
|
signal_type="新需求",
|
||||||
|
summary=request.instruction[:300],
|
||||||
|
reasoning=json.dumps(["来自 AI Steward 手动信号源接入请求。"], ensure_ascii=False),
|
||||||
|
confidence=0.82,
|
||||||
|
recommended_action="enter_only",
|
||||||
|
status="待处理",
|
||||||
|
)
|
||||||
|
if not request.dry_run:
|
||||||
|
db.add(signal)
|
||||||
|
await db.flush()
|
||||||
|
await _ensure_project_signal_link(
|
||||||
|
db,
|
||||||
|
tenant_id=effective_tenant_id,
|
||||||
|
project_id=int(request.project_id),
|
||||||
|
signal_id=int(signal.id),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(signal)
|
||||||
|
signal_id_value = int(signal.id) if not request.dry_run and getattr(signal, "id", None) is not None else None
|
||||||
|
|
||||||
|
service = SignalSourceOrchestrationService(db)
|
||||||
|
result = await service.orchestrate_from_signal(
|
||||||
|
tenant_id=effective_tenant_id,
|
||||||
|
project_id=int(request.project_id),
|
||||||
|
signal_id=signal_id_value,
|
||||||
|
initiating_text=request.instruction,
|
||||||
|
requested_via="pmflow",
|
||||||
|
operator_user_id=int(current_user.id),
|
||||||
|
dry_run=bool(request.dry_run),
|
||||||
|
response_mode=request.response_mode,
|
||||||
|
)
|
||||||
|
return success_response(
|
||||||
|
{
|
||||||
|
"signal_id": signal_id_value,
|
||||||
|
**result,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/orchestration-runs/{trace_id}")
|
||||||
|
async def get_orchestration_run(
|
||||||
|
trace_id: str,
|
||||||
|
current_user: User = Depends(require_manager_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||||
|
row = await AIOrchestrationRunService(db).get_run(
|
||||||
|
tenant_id=effective_tenant_id,
|
||||||
|
trace_id=str(trace_id or "").strip(),
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="orchestration run not found")
|
||||||
|
return success_response(AIOrchestrationRunService.serialize_run(row))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects/{project_id}/signal-sources")
|
||||||
|
async def list_project_signal_sources(
|
||||||
|
project_id: int,
|
||||||
|
current_user: User = Depends(require_manager_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||||
|
items = await SignalSourceOrchestrationService(db).list_project_sources(
|
||||||
|
tenant_id=effective_tenant_id,
|
||||||
|
project_id=int(project_id),
|
||||||
|
)
|
||||||
|
return success_response(SignalSourceListResponse(items=items).model_dump())
|
||||||
|
|
||||||
@router.post("/project-from-text", response_model=ProjectFromTextResponse)
|
@router.post("/project-from-text", response_model=ProjectFromTextResponse)
|
||||||
async def create_project_from_text(
|
async def create_project_from_text(
|
||||||
request: ProjectFromTextRequest,
|
request: ProjectFromTextRequest,
|
||||||
|
|
@ -163,7 +401,8 @@ async def capture_feishu_signal(
|
||||||
group_chat_id=request.group_chat_id,
|
group_chat_id=request.group_chat_id,
|
||||||
message_content=request.message_content,
|
message_content=request.message_content,
|
||||||
sender=request.sender,
|
sender=request.sender,
|
||||||
tenant_id=tenant_id
|
tenant_id=tenant_id,
|
||||||
|
feishu_message_id=request.feishu_message_id,
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -285,8 +524,8 @@ async def get_cognitive_conflicts(
|
||||||
id=str(c.id),
|
id=str(c.id),
|
||||||
type=c.conflict_type,
|
type=c.conflict_type,
|
||||||
description=c.description,
|
description=c.description,
|
||||||
severity="high" if "critical" in c.description.lower() else "medium",
|
severity="high" if "critical" in (c.description or "").lower() else "medium",
|
||||||
related_users=json.loads(c.involved_user_ids) if c.involved_user_ids else []
|
related_users=_parse_related_users(c.involved_user_ids),
|
||||||
)
|
)
|
||||||
for c in conflicts
|
for c in conflicts
|
||||||
]
|
]
|
||||||
|
|
@ -424,7 +663,7 @@ async def resolve_cognitive_conflict(
|
||||||
raise HTTPException(status_code=404, detail="冲突记录不存在")
|
raise HTTPException(status_code=404, detail="冲突记录不存在")
|
||||||
|
|
||||||
conflict.status = 'resolved'
|
conflict.status = 'resolved'
|
||||||
conflict.resolved_at = datetime.now(timezone.utc)
|
conflict.resolved_at = _utc_now_naive()
|
||||||
conflict.description += f"\n\n解决方案({current_user.username}): {resolution_note}"
|
conflict.description += f"\n\n解决方案({current_user.username}): {resolution_note}"
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
@ -790,10 +1029,13 @@ async def get_ai_steward_stats(
|
||||||
from sqlalchemy import func, text
|
from sqlalchemy import func, text
|
||||||
|
|
||||||
# 统计信号数量
|
# 统计信号数量
|
||||||
|
week_ago = _utc_now_naive() - timedelta(days=7)
|
||||||
|
month_ago = _utc_now_naive() - timedelta(days=30)
|
||||||
|
|
||||||
signal_result = await db.execute(
|
signal_result = await db.execute(
|
||||||
select(func.count(Signal.id)).where(
|
select(func.count(Signal.id)).where(
|
||||||
Signal.tenant_id == tenant_id,
|
Signal.tenant_id == tenant_id,
|
||||||
Signal.created_at >= datetime.now(timezone.utc) - timedelta(days=7)
|
Signal.created_at >= week_ago
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
signals_captured = signal_result.scalar() or 0
|
signals_captured = signal_result.scalar() or 0
|
||||||
|
|
@ -803,7 +1045,7 @@ async def get_ai_steward_stats(
|
||||||
select(func.count(Signal.id)).where(
|
select(func.count(Signal.id)).where(
|
||||||
Signal.tenant_id == tenant_id,
|
Signal.tenant_id == tenant_id,
|
||||||
Signal.impact_score >= 0.8,
|
Signal.impact_score >= 0.8,
|
||||||
Signal.created_at >= datetime.now(timezone.utc) - timedelta(days=7)
|
Signal.created_at >= week_ago
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
high_impact_count = high_impact_result.scalar() or 0
|
high_impact_count = high_impact_result.scalar() or 0
|
||||||
|
|
@ -822,7 +1064,7 @@ async def get_ai_steward_stats(
|
||||||
select(func.count(CognitiveConflict.id)).where(
|
select(func.count(CognitiveConflict.id)).where(
|
||||||
CognitiveConflict.tenant_id == tenant_id,
|
CognitiveConflict.tenant_id == tenant_id,
|
||||||
CognitiveConflict.status == 'resolved',
|
CognitiveConflict.status == 'resolved',
|
||||||
CognitiveConflict.resolved_at >= datetime.now(timezone.utc) - timedelta(days=30)
|
CognitiveConflict.resolved_at >= month_ago
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
conflicts_resolved = resolved_conflict_result.scalar() or 0
|
conflicts_resolved = resolved_conflict_result.scalar() or 0
|
||||||
|
|
@ -1105,6 +1347,7 @@ async def get_ai_config_suggestion(
|
||||||
"""
|
"""
|
||||||
from ..models import Integration, Project, Signal
|
from ..models import Integration, Project, Signal
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
# 检查已配置的集成
|
# 检查已配置的集成
|
||||||
configured_sources = []
|
configured_sources = []
|
||||||
|
|
@ -1113,7 +1356,7 @@ async def get_ai_config_suggestion(
|
||||||
feishu_result = await db.execute(
|
feishu_result = await db.execute(
|
||||||
select(Integration).where(
|
select(Integration).where(
|
||||||
Integration.tenant_id == tenant_id,
|
Integration.tenant_id == tenant_id,
|
||||||
Integration.provider == 'feishu',
|
Integration.type == 'feishu',
|
||||||
Integration.is_active == True
|
Integration.is_active == True
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -1133,7 +1376,7 @@ async def get_ai_config_suggestion(
|
||||||
signal_result = await db.execute(
|
signal_result = await db.execute(
|
||||||
select(func.count(Signal.id)).where(
|
select(func.count(Signal.id)).where(
|
||||||
Signal.tenant_id == tenant_id,
|
Signal.tenant_id == tenant_id,
|
||||||
Signal.created_at >= datetime.now(timezone.utc) - timedelta(days=7)
|
Signal.created_at >= (_utc_now_naive() - timedelta(days=7))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
recent_signals = signal_result.scalar() or 0
|
recent_signals = signal_result.scalar() or 0
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,57 @@
|
||||||
"""
|
"""Feishu Integration API - 飞书集成接口。"""
|
||||||
Feishu Integration API - 飞书集成接口
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
功能:
|
|
||||||
1. 配置飞书应用凭证
|
|
||||||
2. 验证 URL 和处理事件
|
|
||||||
3. 发送消息到飞书群
|
|
||||||
4. 查询集成状态
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, BackgroundTasks
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from datetime import datetime, timezone
|
|
||||||
import json
|
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..dependencies.auth import get_current_user
|
from ..dependencies.auth import get_current_user
|
||||||
from ..dependencies.tenant import get_tenant_id
|
from ..dependencies.tenant import get_tenant_id
|
||||||
from ..models import User
|
from ..models import User
|
||||||
from ..services.auth import has_any_role
|
from ..services.auth import has_any_role
|
||||||
from ..services.feishu_integration import get_feishu_service
|
from ..services.feishu_integration import get_feishu_service
|
||||||
|
from ..services.feishu_long_connection import get_feishu_long_connection_manager
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/feishu", tags=["Feishu Integration"])
|
router = APIRouter(prefix="/feishu", tags=["Feishu Integration"])
|
||||||
|
|
||||||
|
|
||||||
|
def _build_long_connection_failure(long_status: Dict[str, Any]) -> tuple[str, Optional[str]]:
|
||||||
|
code = str(long_status.get("last_error_code") or "").strip().lower()
|
||||||
|
last_error = str(long_status.get("last_error") or "").strip()
|
||||||
|
retryable = bool(long_status.get("last_error_retryable"))
|
||||||
|
if code in {"network_error", "thread_exited"} and not retryable:
|
||||||
|
return (
|
||||||
|
"长连接自动重连次数已耗尽,请检查网络/凭证后手动重启飞书长连接",
|
||||||
|
last_error or None,
|
||||||
|
)
|
||||||
|
if code == "auth_failed":
|
||||||
|
return (
|
||||||
|
"长连接鉴权失败,请检查 App ID / App Secret、应用凭证是否已生效,以及飞书应用权限是否完整",
|
||||||
|
last_error or None,
|
||||||
|
)
|
||||||
|
if code == "network_error":
|
||||||
|
return (
|
||||||
|
"长连接预检失败,后端到飞书开放平台的网络或 TLS 证书链异常",
|
||||||
|
last_error or None,
|
||||||
|
)
|
||||||
|
if code == "sdk_missing":
|
||||||
|
return ("长连接 SDK 未安装(缺少 lark-oapi)", None)
|
||||||
|
if code == "thread_exited":
|
||||||
|
return ("长连接已退出,系统正在等待下一次自动重连窗口", last_error or None)
|
||||||
|
if last_error:
|
||||||
|
return ("长连接未建立,请检查配置与后端日志", last_error)
|
||||||
|
return ("长连接未建立,请检查配置后重试", None)
|
||||||
|
|
||||||
|
|
||||||
# ==================== Request/Response Models ====================
|
# ==================== Request/Response Models ====================
|
||||||
|
|
||||||
class FeishuConfigRequest(BaseModel):
|
class FeishuConfigRequest(BaseModel):
|
||||||
"""飞书配置请求"""
|
"""飞书配置请求"""
|
||||||
app_id: str = Field(..., description="飞书 App ID")
|
app_id: str = Field(..., description="飞书 App ID")
|
||||||
app_secret: str = Field(..., description="飞书 App Secret")
|
app_secret: str = Field(..., description="飞书 App Secret")
|
||||||
encrypt_key: str = Field(..., description="事件订阅加密密钥")
|
encrypt_key: Optional[str] = Field(None, description="已废弃字段,保留仅为兼容旧客户端")
|
||||||
verification_token: str = Field(..., description="事件订阅验证令牌")
|
verification_token: Optional[str] = Field(None, description="已废弃字段,保留仅为兼容旧客户端")
|
||||||
|
event_mode: str = Field("long_connection", description="事件接收模式,现仅支持 long_connection")
|
||||||
|
|
||||||
|
|
||||||
class FeishuSendMessageRequest(BaseModel):
|
class FeishuSendMessageRequest(BaseModel):
|
||||||
|
|
@ -63,40 +82,47 @@ async def save_feishu_config(
|
||||||
配置信息说明:
|
配置信息说明:
|
||||||
- app_id: 飞书应用的 App ID,从飞书开放平台获取
|
- app_id: 飞书应用的 App ID,从飞书开放平台获取
|
||||||
- app_secret: 飞书应用的 App Secret
|
- app_secret: 飞书应用的 App Secret
|
||||||
- encrypt_key: 事件订阅的加密 Key
|
|
||||||
- verification_token: 事件订阅的验证 Token
|
|
||||||
|
|
||||||
获取步骤:
|
获取步骤:
|
||||||
1. 访问 https://open.feishu.cn/
|
1. 访问 https://open.feishu.cn/
|
||||||
2. 创建企业自建应用
|
2. 创建企业自建应用
|
||||||
3. 在「凭证与基础信息」页面获取 App ID 和 App Secret
|
3. 在「凭证与基础信息」页面获取 App ID 和 App Secret
|
||||||
4. 在「事件订阅」配置 Encrypt Key 和 Verification Token
|
4. 添加权限:im:message, im:message:group_at_msg, im:chat
|
||||||
5. 添加权限:im:message, im:message:group_at_msg, im:chat
|
5. 订阅事件:接收消息 v1 (message.receive_v1)
|
||||||
6. 订阅事件:接收消息 v1 (message.receive_v1)
|
|
||||||
"""
|
"""
|
||||||
# 检查用户权限
|
# 检查用户权限
|
||||||
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
|
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
|
||||||
raise HTTPException(status_code=403, detail="只有管理员可以配置飞书集成")
|
raise HTTPException(status_code=403, detail="只有管理员可以配置飞书集成")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
event_mode = "long_connection"
|
||||||
|
|
||||||
service = get_feishu_service(db)
|
service = get_feishu_service(db)
|
||||||
await service.save_config(
|
await service.save_config(
|
||||||
app_id=request.app_id,
|
app_id=request.app_id,
|
||||||
app_secret=request.app_secret,
|
app_secret=request.app_secret,
|
||||||
encrypt_key=request.encrypt_key,
|
encrypt_key="",
|
||||||
verification_token=request.verification_token,
|
verification_token="",
|
||||||
|
event_mode=event_mode,
|
||||||
tenant_id=tenant_id
|
tenant_id=tenant_id
|
||||||
)
|
)
|
||||||
|
manager = get_feishu_long_connection_manager()
|
||||||
|
if manager:
|
||||||
|
await manager.restart()
|
||||||
|
|
||||||
|
next_steps = [
|
||||||
|
"1. 飞书应用事件订阅至少包含:接收消息 v1 (message.receive_v1)",
|
||||||
|
"2. 当前模式:long_connection",
|
||||||
|
"3. 后端已自动重启飞书长连接",
|
||||||
|
]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'message': '飞书配置已保存',
|
'message': '飞书配置已保存,并已切换到长连接模式',
|
||||||
'next_steps': [
|
'next_steps': next_steps
|
||||||
'1. 在飞书开放平台配置事件订阅 URL',
|
|
||||||
f'2. URL: https://your-domain.com/api/feishu/webhook',
|
|
||||||
'3. 点击验证按钮完成 URL 验证'
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
|
|
@ -110,16 +136,19 @@ async def get_feishu_config(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
tenant_id: int = Depends(get_tenant_id)
|
tenant_id: int = Depends(get_tenant_id)
|
||||||
):
|
):
|
||||||
"""获取飞书配置状态(不返回敏感信息)"""
|
"""获取飞书配置状态。管理员可获取完整配置用于回填修改表单。"""
|
||||||
try:
|
try:
|
||||||
service = get_feishu_service(db)
|
service = get_feishu_service(db)
|
||||||
await service.load_config(tenant_id)
|
await service.load_config(tenant_id)
|
||||||
|
manager = get_feishu_long_connection_manager()
|
||||||
|
can_view_secrets = has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'is_configured': service.is_configured(),
|
'is_configured': service.is_configured(),
|
||||||
'app_id': service.app_id if service.is_configured() else None,
|
'app_id': service.app_id,
|
||||||
'has_encrypt_key': bool(service.encrypt_key),
|
'app_secret': service.app_secret if can_view_secrets else None,
|
||||||
'has_verification_token': bool(service.verification_token)
|
'event_mode': "long_connection",
|
||||||
|
'long_connection': manager.status() if manager else None,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -145,7 +174,7 @@ async def delete_feishu_config(
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Integration).where(
|
select(Integration).where(
|
||||||
Integration.tenant_id == tenant_id,
|
Integration.tenant_id == tenant_id,
|
||||||
Integration.provider == 'feishu'
|
Integration.type == 'feishu'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
integration = result.scalar_one_or_none()
|
integration = result.scalar_one_or_none()
|
||||||
|
|
@ -153,6 +182,9 @@ async def delete_feishu_config(
|
||||||
if integration:
|
if integration:
|
||||||
integration.is_active = False
|
integration.is_active = False
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
manager = get_feishu_long_connection_manager()
|
||||||
|
if manager:
|
||||||
|
await manager.restart()
|
||||||
|
|
||||||
return {'status': 'success', 'message': '飞书配置已删除'}
|
return {'status': 'success', 'message': '飞书配置已删除'}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -162,93 +194,45 @@ async def delete_feishu_config(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ==================== Webhook Endpoints ====================
|
@router.get("/long-connection/status")
|
||||||
|
async def feishu_long_connection_status(
|
||||||
@router.get("/webhook")
|
current_user: User = Depends(get_current_user),
|
||||||
async def verify_webhook_url(
|
|
||||||
challenge: str,
|
|
||||||
db: AsyncSession = Depends(get_db)
|
|
||||||
):
|
):
|
||||||
"""
|
"""查询飞书长连接状态。"""
|
||||||
验证飞书事件订阅 URL
|
manager = get_feishu_long_connection_manager()
|
||||||
|
if not manager:
|
||||||
飞书会发送 GET 请求来验证 URL 有效性
|
|
||||||
请求参数: challenge
|
|
||||||
返回: {"challenge": challenge}
|
|
||||||
|
|
||||||
注意:此端点不需要身份验证,因为飞书不会发送认证令牌
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
service = get_feishu_service(db)
|
|
||||||
# 使用默认租户 ID 1
|
|
||||||
await service.load_config(tenant_id=1)
|
|
||||||
|
|
||||||
if not service.is_configured():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="请先配置飞书"
|
|
||||||
)
|
|
||||||
|
|
||||||
challenge_response = service.verify_url(challenge)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"challenge": challenge_response
|
"status": "error",
|
||||||
|
"message": "Feishu 长连接管理器未初始化",
|
||||||
|
"mode": "long_connection",
|
||||||
|
"data": None,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"mode": "long_connection",
|
||||||
|
"data": manager.status(),
|
||||||
}
|
}
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/webhook")
|
@router.post("/long-connection/restart")
|
||||||
async def handle_feishu_event(
|
async def restart_feishu_long_connection(
|
||||||
request: Request,
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db)
|
|
||||||
):
|
):
|
||||||
"""
|
"""手动重启飞书长连接。"""
|
||||||
处理飞书事件推送
|
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
|
||||||
|
raise HTTPException(status_code=403, detail="只有管理员可以重启飞书长连接")
|
||||||
|
|
||||||
飞书会发送 POST 请求推送事件
|
manager = get_feishu_long_connection_manager()
|
||||||
需要验证签名确保请求来自飞书
|
if not manager:
|
||||||
"""
|
raise HTTPException(status_code=500, detail="Feishu 长连接管理器未初始化")
|
||||||
try:
|
|
||||||
# 获取请求头
|
|
||||||
timestamp = request.headers.get('X-Lark-Request-Timestamp', '')
|
|
||||||
nonce = request.headers.get('X-Lark-Request-Nonce', '')
|
|
||||||
signature = request.headers.get('X-Lark-Signature', '')
|
|
||||||
|
|
||||||
# 读取请求体
|
|
||||||
body_bytes = await request.body()
|
|
||||||
body = body_bytes.decode('utf-8')
|
|
||||||
|
|
||||||
# 解析 JSON 获取 tenant_id(如果有多租户)
|
|
||||||
body_json = json.loads(body) if body else {}
|
|
||||||
tenant_id = 1 # 默认租户
|
|
||||||
|
|
||||||
# 加载飞书配置
|
|
||||||
service = get_feishu_service(db)
|
|
||||||
await service.load_config(tenant_id)
|
|
||||||
|
|
||||||
# 验证签名
|
|
||||||
if not service.verify_signature(timestamp, nonce, body, signature):
|
|
||||||
raise HTTPException(status_code=401, detail="签名验证失败")
|
|
||||||
|
|
||||||
# 解析事件
|
|
||||||
envelope = json.loads(body)
|
|
||||||
event_data = envelope.get('event', {})
|
|
||||||
|
|
||||||
# 处理事件
|
|
||||||
result = await service.handle_message_event(event_data)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[Feishu] Webhook error: {e}")
|
|
||||||
# 飞书要求返回成功状态,避免重复推送
|
|
||||||
return {'code': 0, 'msg': 'received'}
|
|
||||||
|
|
||||||
|
await manager.restart()
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": "Feishu 长连接已重启",
|
||||||
|
"mode": "long_connection",
|
||||||
|
"data": manager.status(),
|
||||||
|
}
|
||||||
|
|
||||||
# ==================== Message Endpoints ====================
|
# ==================== Message Endpoints ====================
|
||||||
|
|
||||||
|
|
@ -333,6 +317,7 @@ async def test_feishu_connection(
|
||||||
try:
|
try:
|
||||||
service = get_feishu_service(db)
|
service = get_feishu_service(db)
|
||||||
await service.load_config(tenant_id)
|
await service.load_config(tenant_id)
|
||||||
|
manager = get_feishu_long_connection_manager()
|
||||||
|
|
||||||
if not service.is_configured():
|
if not service.is_configured():
|
||||||
return {
|
return {
|
||||||
|
|
@ -343,10 +328,39 @@ async def test_feishu_connection(
|
||||||
# 尝试获取 tenant_access_token 测试连接
|
# 尝试获取 tenant_access_token 测试连接
|
||||||
try:
|
try:
|
||||||
access_token = await service._get_tenant_access_token()
|
access_token = await service._get_tenant_access_token()
|
||||||
|
event_mode = "long_connection"
|
||||||
|
long_status = manager.status() if manager else None
|
||||||
|
|
||||||
|
if not long_status:
|
||||||
|
return {
|
||||||
|
'status': 'error',
|
||||||
|
'message': '长连接管理器未初始化',
|
||||||
|
'event_mode': event_mode,
|
||||||
|
}
|
||||||
|
if not long_status.get("sdk_available"):
|
||||||
|
return {
|
||||||
|
'status': 'error',
|
||||||
|
'message': '长连接 SDK 未安装(缺少 lark-oapi)',
|
||||||
|
'event_mode': event_mode,
|
||||||
|
'long_connection': long_status,
|
||||||
|
}
|
||||||
|
if not long_status.get("thread_alive"):
|
||||||
|
message, detail = _build_long_connection_failure(long_status)
|
||||||
|
return {
|
||||||
|
'status': 'error',
|
||||||
|
'message': message,
|
||||||
|
'event_mode': event_mode,
|
||||||
|
'long_connection': long_status,
|
||||||
|
'detail': detail,
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'message': '飞书连接正常',
|
'message': '飞书连接正常',
|
||||||
'has_token': bool(access_token)
|
'has_token': bool(access_token),
|
||||||
|
'mode': 'long_connection',
|
||||||
|
'event_mode': event_mode,
|
||||||
|
'long_connection': long_status,
|
||||||
}
|
}
|
||||||
except Exception as token_error:
|
except Exception as token_error:
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
"""Project Knowledge Graph API."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..core.response import success_response
|
||||||
|
from ..database import get_db
|
||||||
|
from ..dependencies.auth import get_current_user
|
||||||
|
from ..dependencies.tenant import get_tenant_id
|
||||||
|
from ..models import User
|
||||||
|
from ..services.auth import has_any_role
|
||||||
|
from ..services.project_knowledge_graph import ProjectKnowledgeGraphService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/ai-steward/knowledge-graph", tags=["AI Steward"])
|
||||||
|
|
||||||
|
|
||||||
|
class StructuredSyncRequest(BaseModel):
|
||||||
|
limit: int = Field(default=1000, ge=1, le=5000)
|
||||||
|
|
||||||
|
|
||||||
|
class CodeScanRequest(BaseModel):
|
||||||
|
base_dir: Optional[str] = Field(default=None, max_length=400)
|
||||||
|
|
||||||
|
|
||||||
|
class FeishuDeltaEnqueueRequest(BaseModel):
|
||||||
|
limit: int = Field(default=500, ge=1, le=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class EventProcessRequest(BaseModel):
|
||||||
|
max_batch: int = Field(default=200, ge=1, le=1000)
|
||||||
|
max_retry: int = Field(default=3, ge=0, le=10)
|
||||||
|
|
||||||
|
|
||||||
|
class IncrementalCycleRequest(BaseModel):
|
||||||
|
delta_limit: int = Field(default=1000, ge=1, le=5000)
|
||||||
|
event_batch: int = Field(default=200, ge=1, le=1000)
|
||||||
|
code_scan_hours: int = Field(default=6, ge=1, le=168)
|
||||||
|
code_scan_base_dir: Optional[str] = Field(default=None, max_length=400)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_manager(user: User) -> None:
|
||||||
|
if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]):
|
||||||
|
raise HTTPException(status_code=403, detail="仅管理员可操作知识图谱同步")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/overview")
|
||||||
|
async def get_kg_overview(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
_ = current_user
|
||||||
|
service = ProjectKnowledgeGraphService(db, tenant_id=int(getattr(current_user, "tenant_id", tenant_id) or tenant_id))
|
||||||
|
data = await service.get_overview()
|
||||||
|
return success_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sync-runs")
|
||||||
|
async def list_sync_runs(
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
run_type: Optional[str] = Query(default=None, max_length=40),
|
||||||
|
status: Optional[str] = Query(default=None, max_length=20),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
_require_manager(current_user)
|
||||||
|
service = ProjectKnowledgeGraphService(db, tenant_id=int(getattr(current_user, "tenant_id", tenant_id) or tenant_id))
|
||||||
|
data = await service.list_sync_runs(
|
||||||
|
limit=int(limit),
|
||||||
|
run_type=run_type,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
return success_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sync-slo")
|
||||||
|
async def get_sync_slo(
|
||||||
|
lookback_hours: int = Query(default=24, ge=1, le=24 * 30),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
_require_manager(current_user)
|
||||||
|
service = ProjectKnowledgeGraphService(db, tenant_id=int(getattr(current_user, "tenant_id", tenant_id) or tenant_id))
|
||||||
|
data = await service.get_sync_slo(lookback_hours=int(lookback_hours))
|
||||||
|
return success_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sync/structured")
|
||||||
|
async def sync_structured(
|
||||||
|
body: StructuredSyncRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
_require_manager(current_user)
|
||||||
|
service = ProjectKnowledgeGraphService(db, tenant_id=int(getattr(current_user, "tenant_id", tenant_id) or tenant_id))
|
||||||
|
data = await service.sync_structured_entities(limit=int(body.limit))
|
||||||
|
return success_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sync/codebase")
|
||||||
|
async def sync_codebase(
|
||||||
|
body: CodeScanRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
_require_manager(current_user)
|
||||||
|
service = ProjectKnowledgeGraphService(db, tenant_id=int(getattr(current_user, "tenant_id", tenant_id) or tenant_id))
|
||||||
|
|
||||||
|
root = Path("/app/backend").resolve()
|
||||||
|
requested = Path(body.base_dir).resolve() if body.base_dir else root
|
||||||
|
# 仅允许扫描 backend 根目录或其子目录
|
||||||
|
if root not in requested.parents and requested != root:
|
||||||
|
raise HTTPException(status_code=400, detail="base_dir 仅允许 /app/backend 范围内路径")
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = await service.sync_codebase_snapshot(base_dir=str(requested))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return success_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sync/feishu")
|
||||||
|
async def enqueue_feishu_delta(
|
||||||
|
body: FeishuDeltaEnqueueRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
_require_manager(current_user)
|
||||||
|
service = ProjectKnowledgeGraphService(db, tenant_id=int(getattr(current_user, "tenant_id", tenant_id) or tenant_id))
|
||||||
|
data = await service.enqueue_feishu_delta_events(limit=int(body.limit))
|
||||||
|
return success_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/events/process")
|
||||||
|
async def process_ingest_events(
|
||||||
|
body: EventProcessRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
_require_manager(current_user)
|
||||||
|
service = ProjectKnowledgeGraphService(db, tenant_id=int(getattr(current_user, "tenant_id", tenant_id) or tenant_id))
|
||||||
|
data = await service.process_pending_events(
|
||||||
|
max_batch=int(body.max_batch),
|
||||||
|
max_retry=int(body.max_retry),
|
||||||
|
)
|
||||||
|
return success_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sync/incremental")
|
||||||
|
async def run_incremental_cycle(
|
||||||
|
body: IncrementalCycleRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
_require_manager(current_user)
|
||||||
|
service = ProjectKnowledgeGraphService(db, tenant_id=int(getattr(current_user, "tenant_id", tenant_id) or tenant_id))
|
||||||
|
|
||||||
|
root = Path("/app/backend").resolve()
|
||||||
|
requested = Path(body.code_scan_base_dir).resolve() if body.code_scan_base_dir else None
|
||||||
|
if requested is not None and root not in requested.parents and requested != root:
|
||||||
|
raise HTTPException(status_code=400, detail="code_scan_base_dir 仅允许 /app/backend 范围内路径")
|
||||||
|
|
||||||
|
data = await service.run_incremental_cycle(
|
||||||
|
delta_limit=int(body.delta_limit),
|
||||||
|
event_batch=int(body.event_batch),
|
||||||
|
code_scan_hours=int(body.code_scan_hours),
|
||||||
|
code_scan_base_dir=str(requested) if requested else None,
|
||||||
|
run_source="manual_api",
|
||||||
|
)
|
||||||
|
return success_response(data)
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
"""LLM 引擎配置 API。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..core.response import success_response
|
||||||
|
from ..database import get_db
|
||||||
|
from ..dependencies.auth import get_current_user
|
||||||
|
from ..dependencies.tenant import get_tenant_id
|
||||||
|
from ..models import User
|
||||||
|
from ..services.auth import has_any_role
|
||||||
|
from ..services.llm import llm_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/ai-steward/llm-engines", tags=["AI Steward"])
|
||||||
|
|
||||||
|
|
||||||
|
class LLMEngineCreateRequest(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=80)
|
||||||
|
provider: Literal["deepseek", "glm", "openai", "ollama"]
|
||||||
|
api_key: Optional[str] = Field(default="", max_length=500)
|
||||||
|
model: Optional[str] = Field(default=None, max_length=120)
|
||||||
|
base_url: Optional[str] = Field(default=None, max_length=255)
|
||||||
|
set_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class LLMEngineUpdateRequest(BaseModel):
|
||||||
|
name: Optional[str] = Field(default=None, min_length=1, max_length=80)
|
||||||
|
provider: Optional[Literal["deepseek", "glm", "openai", "ollama"]] = None
|
||||||
|
api_key: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
model: Optional[str] = Field(default=None, max_length=120)
|
||||||
|
base_url: Optional[str] = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class LLMEngineTestRequest(BaseModel):
|
||||||
|
prompt: Optional[str] = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_manager(user: User) -> None:
|
||||||
|
if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]):
|
||||||
|
raise HTTPException(status_code=403, detail="仅管理员可操作 LLM 引擎配置")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_llm_engines(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
_ = current_user
|
||||||
|
data = await llm_service.list_engines(db, tenant_id=tenant_id)
|
||||||
|
return success_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def create_llm_engine(
|
||||||
|
body: LLMEngineCreateRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
_require_manager(current_user)
|
||||||
|
try:
|
||||||
|
engine = await llm_service.create_engine(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
name=body.name,
|
||||||
|
provider=body.provider,
|
||||||
|
api_key=body.api_key,
|
||||||
|
model=body.model,
|
||||||
|
base_url=body.base_url,
|
||||||
|
set_active=body.set_active,
|
||||||
|
)
|
||||||
|
all_items = await llm_service.list_engines(db, tenant_id=tenant_id)
|
||||||
|
return success_response({"engine": engine, **all_items})
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{engine_id}")
|
||||||
|
async def update_llm_engine(
|
||||||
|
engine_id: str,
|
||||||
|
body: LLMEngineUpdateRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
_require_manager(current_user)
|
||||||
|
try:
|
||||||
|
engine = await llm_service.update_engine(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
engine_id=engine_id,
|
||||||
|
name=body.name,
|
||||||
|
provider=body.provider,
|
||||||
|
api_key=body.api_key,
|
||||||
|
model=body.model,
|
||||||
|
base_url=body.base_url,
|
||||||
|
)
|
||||||
|
all_items = await llm_service.list_engines(db, tenant_id=tenant_id)
|
||||||
|
return success_response({"engine": engine, **all_items})
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{engine_id}/activate")
|
||||||
|
async def activate_llm_engine(
|
||||||
|
engine_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
_require_manager(current_user)
|
||||||
|
try:
|
||||||
|
result = await llm_service.activate_engine(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
engine_id=engine_id,
|
||||||
|
)
|
||||||
|
all_items = await llm_service.list_engines(db, tenant_id=tenant_id)
|
||||||
|
return success_response({**result, **all_items})
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test-active")
|
||||||
|
async def test_active_llm_engine(
|
||||||
|
body: LLMEngineTestRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
_require_manager(current_user)
|
||||||
|
try:
|
||||||
|
data = await llm_service.test_engine_connection(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
engine_id=None,
|
||||||
|
prompt=body.prompt,
|
||||||
|
)
|
||||||
|
return success_response(data)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{engine_id}/test")
|
||||||
|
async def test_llm_engine(
|
||||||
|
engine_id: str,
|
||||||
|
body: LLMEngineTestRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
_require_manager(current_user)
|
||||||
|
try:
|
||||||
|
data = await llm_service.test_engine_connection(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
engine_id=engine_id,
|
||||||
|
prompt=body.prompt,
|
||||||
|
)
|
||||||
|
return success_response(data)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{engine_id}")
|
||||||
|
async def delete_llm_engine(
|
||||||
|
engine_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
_require_manager(current_user)
|
||||||
|
try:
|
||||||
|
result = await llm_service.delete_engine(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
engine_id=engine_id,
|
||||||
|
)
|
||||||
|
all_items = await llm_service.list_engines(db, tenant_id=tenant_id)
|
||||||
|
return success_response({**result, **all_items})
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
"""站内通知 API"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import re
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import and_, desc, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..core.response import success_response
|
||||||
|
from ..database import get_db
|
||||||
|
from ..dependencies import get_current_user, get_tenant_id
|
||||||
|
from ..models import Notification, User
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_TARGET_RE = re.compile(r"/projects/(\d+)")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_project_id(row: Notification) -> Optional[int]:
|
||||||
|
payload = row.payload if isinstance(row.payload, dict) else {}
|
||||||
|
payload_project_id = payload.get("project_id")
|
||||||
|
if payload_project_id is not None:
|
||||||
|
try:
|
||||||
|
return int(payload_project_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
if row.entity_type == "project" and row.entity_id is not None:
|
||||||
|
return int(row.entity_id)
|
||||||
|
target_url = str(row.target_url or "").strip()
|
||||||
|
matched = PROJECT_TARGET_RE.search(target_url)
|
||||||
|
if matched:
|
||||||
|
try:
|
||||||
|
return int(matched.group(1))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_project_name(row: Notification) -> Optional[str]:
|
||||||
|
payload = row.payload if isinstance(row.payload, dict) else {}
|
||||||
|
name = payload.get("project_name")
|
||||||
|
if isinstance(name, str) and name.strip():
|
||||||
|
return name.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_notification(row: Notification) -> dict:
|
||||||
|
payload: Optional[dict[str, Any]] = row.payload if isinstance(row.payload, dict) else None
|
||||||
|
return {
|
||||||
|
"id": int(row.id),
|
||||||
|
"title": row.title,
|
||||||
|
"content": row.content,
|
||||||
|
"category": row.category,
|
||||||
|
"source": row.source,
|
||||||
|
"entity_type": row.entity_type,
|
||||||
|
"entity_id": int(row.entity_id) if row.entity_id is not None else None,
|
||||||
|
"target_url": row.target_url,
|
||||||
|
"kind": row.kind or "info",
|
||||||
|
"priority": row.priority,
|
||||||
|
"status": row.status or row.kind or "info",
|
||||||
|
"blocking_reason": row.blocking_reason,
|
||||||
|
"recommended_action": row.recommended_action,
|
||||||
|
"dedupe_key": row.dedupe_key,
|
||||||
|
"occurrence_count": int(row.occurrence_count or 1),
|
||||||
|
"payload": payload,
|
||||||
|
"project_id": _extract_project_id(row),
|
||||||
|
"project_name": _extract_project_name(row),
|
||||||
|
"is_read": bool(row.is_read),
|
||||||
|
"read_at": row.read_at.isoformat() if row.read_at else None,
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_notifications(
|
||||||
|
unread_only: bool = Query(default=False),
|
||||||
|
limit: int = Query(default=20, ge=1, le=100),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
query = select(Notification).where(
|
||||||
|
and_(
|
||||||
|
Notification.tenant_id == tenant_id,
|
||||||
|
Notification.user_id == int(current_user.id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if unread_only:
|
||||||
|
query = query.where(Notification.is_read.is_(False))
|
||||||
|
query = query.order_by(desc(Notification.updated_at), desc(Notification.created_at)).limit(limit)
|
||||||
|
|
||||||
|
rows = (await db.execute(query)).scalars().all()
|
||||||
|
unread_count = await db.scalar(
|
||||||
|
select(func.count(Notification.id)).where(
|
||||||
|
and_(
|
||||||
|
Notification.tenant_id == tenant_id,
|
||||||
|
Notification.user_id == int(current_user.id),
|
||||||
|
Notification.is_read.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return success_response(
|
||||||
|
data={
|
||||||
|
"items": [_serialize_notification(row) for row in rows],
|
||||||
|
"unread_count": int(unread_count or 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{notification_id}/read")
|
||||||
|
async def mark_notification_read(
|
||||||
|
notification_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
row = (
|
||||||
|
await db.execute(
|
||||||
|
select(Notification).where(
|
||||||
|
and_(
|
||||||
|
Notification.id == notification_id,
|
||||||
|
Notification.tenant_id == tenant_id,
|
||||||
|
Notification.user_id == int(current_user.id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="通知不存在")
|
||||||
|
|
||||||
|
if not row.is_read:
|
||||||
|
row.is_read = True
|
||||||
|
row.read_at = datetime.now(timezone.utc)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return success_response(data=_serialize_notification(row))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/read-all")
|
||||||
|
async def mark_all_notifications_read(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(Notification).where(
|
||||||
|
and_(
|
||||||
|
Notification.tenant_id == tenant_id,
|
||||||
|
Notification.user_id == int(current_user.id),
|
||||||
|
Notification.is_read.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for row in rows:
|
||||||
|
row.is_read = True
|
||||||
|
row.read_at = now
|
||||||
|
|
||||||
|
if rows:
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return success_response(data={"updated": len(rows)})
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
"""Operation Catalog API。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
|
from ..core.response import success_response
|
||||||
|
from ..dependencies.auth import get_current_user
|
||||||
|
from ..models import User
|
||||||
|
from ..services.auth import has_any_role
|
||||||
|
from ..services.operation_catalog_service import operation_catalog_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/ai-steward/operation-catalog", tags=["AI Steward"])
|
||||||
|
|
||||||
|
|
||||||
|
def _require_manager(user: User) -> None:
|
||||||
|
if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]):
|
||||||
|
raise HTTPException(status_code=403, detail="仅管理员可查看 Operation Catalog 质量报告")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_operation_catalog(
|
||||||
|
domain: Optional[str] = Query(default=None),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
_ = current_user
|
||||||
|
rows = operation_catalog_service.list_operations(domain=domain)
|
||||||
|
return success_response(
|
||||||
|
{
|
||||||
|
"items": rows,
|
||||||
|
"count": len(rows),
|
||||||
|
"domain": domain,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/lint")
|
||||||
|
async def lint_operation_catalog(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
_require_manager(current_user)
|
||||||
|
report = operation_catalog_service.lint_catalog()
|
||||||
|
return success_response(report)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{operation_code}")
|
||||||
|
async def get_operation_catalog_item(
|
||||||
|
operation_code: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
_ = current_user
|
||||||
|
row = operation_catalog_service.get_operation(operation_code=operation_code)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="operation_code not found")
|
||||||
|
return success_response(row)
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
import json
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
@ -12,6 +13,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..dependencies import get_current_user, get_tenant_id, require_roles
|
from ..dependencies import get_current_user, get_tenant_id, require_roles
|
||||||
from ..models import Project, ProjectSignalLink, Signal, Task, User
|
from ..models import Project, ProjectSignalLink, Signal, Task, User
|
||||||
|
from ..services.context_cache import context_cache_service
|
||||||
|
from ..utils.project_members import ensure_project_default_members
|
||||||
|
from ..utils.sequence import ensure_project_id_sequence
|
||||||
from ..utils.title import compact_title
|
from ..utils.title import compact_title
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||||
|
|
@ -19,6 +23,18 @@ router = APIRouter(prefix="/projects", tags=["projects"])
|
||||||
DONE_STATUSES = {"done", "completed", "closed", "resolved", "archived"}
|
DONE_STATUSES = {"done", "completed", "closed", "resolved", "archived"}
|
||||||
|
|
||||||
|
|
||||||
|
def _as_dict(value: object) -> dict[str, Any]:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
try:
|
||||||
|
parsed = json.loads(value)
|
||||||
|
return parsed if isinstance(parsed, dict) else {"text": value}
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return {"text": value}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
class ProjectCreateRequest(BaseModel):
|
class ProjectCreateRequest(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
|
@ -187,6 +203,7 @@ async def create_project(
|
||||||
title = compact_title(body.title, max_len=24)
|
title = compact_title(body.title, max_len=24)
|
||||||
if not title:
|
if not title:
|
||||||
raise HTTPException(status_code=400, detail="title required")
|
raise HTTPException(status_code=400, detail="title required")
|
||||||
|
await ensure_project_id_sequence(db)
|
||||||
project = Project(
|
project = Project(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
name=title,
|
name=title,
|
||||||
|
|
@ -199,8 +216,17 @@ async def create_project(
|
||||||
signal_count=0,
|
signal_count=0,
|
||||||
)
|
)
|
||||||
db.add(project)
|
db.add(project)
|
||||||
|
await db.flush()
|
||||||
|
await ensure_project_default_members(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
project_id=int(project.id),
|
||||||
|
owner_id=int(current_user.id),
|
||||||
|
assigned_role=project.assigned_role,
|
||||||
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(project)
|
await db.refresh(project)
|
||||||
|
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
||||||
return _serialize_project_row(project)
|
return _serialize_project_row(project)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -229,6 +255,7 @@ async def update_project(
|
||||||
project.assigned_role = body.assigned_role
|
project.assigned_role = body.assigned_role
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(project)
|
await db.refresh(project)
|
||||||
|
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
||||||
return _serialize_project_row(project)
|
return _serialize_project_row(project)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -266,46 +293,10 @@ async def delete_project(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
||||||
return {"message": "Project deleted"}
|
return {"message": "Project deleted"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/signals")
|
|
||||||
async def list_project_signals(
|
|
||||||
project_id: int,
|
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
|
|
||||||
|
|
||||||
rows = (
|
|
||||||
await db.execute(
|
|
||||||
select(ProjectSignalLink, Signal)
|
|
||||||
.join(Signal, Signal.id == ProjectSignalLink.signal_id)
|
|
||||||
.where(
|
|
||||||
and_(
|
|
||||||
ProjectSignalLink.tenant_id == tenant_id,
|
|
||||||
ProjectSignalLink.project_id == project_id,
|
|
||||||
Signal.tenant_id == tenant_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": str(signal.id),
|
|
||||||
"source": signal.source,
|
|
||||||
"content": signal.content,
|
|
||||||
"impact_score": signal.impact_score,
|
|
||||||
"priority": signal.priority,
|
|
||||||
"processed": bool(signal.processed),
|
|
||||||
"created_at": signal.created_at.isoformat() if signal.created_at else None,
|
|
||||||
"link_type": link.link_type,
|
|
||||||
"relevance_score": float(link.relevance_score or 0.0),
|
|
||||||
}
|
|
||||||
for link, signal in rows
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/signals/{signal_id}")
|
@router.post("/{project_id}/signals/{signal_id}")
|
||||||
async def link_signal_to_project(
|
async def link_signal_to_project(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
|
|
@ -361,6 +352,7 @@ async def link_signal_to_project(
|
||||||
db.add(link)
|
db.add(link)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(link)
|
await db.refresh(link)
|
||||||
|
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
||||||
return {
|
return {
|
||||||
"id": str(link.id),
|
"id": str(link.id),
|
||||||
"project_id": str(link.project_id),
|
"project_id": str(link.project_id),
|
||||||
|
|
@ -390,4 +382,5 @@ async def unlink_signal_from_project(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
||||||
return {"message": "Signal unlinked"}
|
return {"message": "Signal unlinked"}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
"""AI 角色 Prompt 配置 API。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..core.response import success_response
|
||||||
|
from ..database import get_db
|
||||||
|
from ..dependencies.auth import get_current_user
|
||||||
|
from ..dependencies.tenant import get_tenant_id
|
||||||
|
from ..models import User
|
||||||
|
from ..services.auth import has_any_role
|
||||||
|
from ..services.prompt_profile_service import prompt_profile_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/ai-steward/prompt-profiles", tags=["AI Steward"])
|
||||||
|
|
||||||
|
|
||||||
|
class PromptRoleItem(BaseModel):
|
||||||
|
role_key: str = Field(..., min_length=2, max_length=32, pattern=r"^[a-z0-9][a-z0-9_-]{1,31}$")
|
||||||
|
role_name: str = Field(..., min_length=1, max_length=32)
|
||||||
|
role_description: Optional[str] = Field(default="", max_length=300)
|
||||||
|
system_prompt: str = Field(..., min_length=20, max_length=8000)
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class PromptProfileUpdateRequest(BaseModel):
|
||||||
|
active_role_key: Optional[str] = Field(default=None, min_length=2, max_length=32)
|
||||||
|
roles: list[PromptRoleItem] = Field(..., min_length=1, max_length=12)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_manager(user: User) -> None:
|
||||||
|
if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]):
|
||||||
|
raise HTTPException(status_code=403, detail="仅管理员可修改 Prompt 配置")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def get_prompt_profiles(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
_ = current_user
|
||||||
|
data = await prompt_profile_service.get_profiles(db, tenant_id=tenant_id)
|
||||||
|
return success_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/active")
|
||||||
|
async def get_active_prompt_profile(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
_ = current_user
|
||||||
|
data = await prompt_profile_service.get_active_role(db, tenant_id=tenant_id)
|
||||||
|
return success_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("")
|
||||||
|
async def update_prompt_profiles(
|
||||||
|
body: PromptProfileUpdateRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
_require_manager(current_user)
|
||||||
|
try:
|
||||||
|
data = await prompt_profile_service.update_profiles(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
roles=[item.model_dump() for item in body.roles],
|
||||||
|
active_role_key=body.active_role_key,
|
||||||
|
)
|
||||||
|
return success_response(data)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
@ -0,0 +1,570 @@
|
||||||
|
"""Adaptive RAG API。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..core.response import success_response
|
||||||
|
from ..database import get_db
|
||||||
|
from ..dependencies.auth import get_current_user, require_roles
|
||||||
|
from ..dependencies.tenant import get_tenant_id
|
||||||
|
from ..models import User
|
||||||
|
from ..schemas.rag import (
|
||||||
|
EntityResolutionRequest,
|
||||||
|
KnowledgeDocCreateRequest,
|
||||||
|
KnowledgeURLCreateRequest,
|
||||||
|
MultiStepSearchRequest,
|
||||||
|
QAExportQuery,
|
||||||
|
QAFeedbackCreateRequest,
|
||||||
|
QAListQuery,
|
||||||
|
QASessionListQuery,
|
||||||
|
QASessionRenameRequest,
|
||||||
|
RAGAskRequest,
|
||||||
|
SymbolSearchRequest,
|
||||||
|
SymbolSyncRequest,
|
||||||
|
)
|
||||||
|
from ..services.adaptive_rag_service import AdaptiveRAGService
|
||||||
|
from ..services.symbol_index_service import SymbolIndexService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/rag", tags=["Adaptive RAG"])
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_tenant_id(user: User, fallback_tenant_id: int) -> int:
|
||||||
|
try:
|
||||||
|
return int(getattr(user, "tenant_id") or fallback_tenant_id or 1)
|
||||||
|
except Exception:
|
||||||
|
return int(fallback_tenant_id or 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_decode_bytes(raw: bytes) -> str:
|
||||||
|
for encoding in ("utf-8", "utf-8-sig", "gb18030", "latin-1"):
|
||||||
|
try:
|
||||||
|
return raw.decode(encoding)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return raw.decode("utf-8", errors="ignore")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_text_from_upload(filename: str, raw: bytes) -> tuple[str, str]:
|
||||||
|
name = str(filename or "uploaded.txt")
|
||||||
|
suffix = name.rsplit(".", 1)[-1].lower() if "." in name else "txt"
|
||||||
|
|
||||||
|
if suffix in {"txt", "md", "markdown", "csv", "json", "py", "ts", "tsx", "js", "jsx"}:
|
||||||
|
return ("md" if suffix in {"md", "markdown"} else "txt", _safe_decode_bytes(raw))
|
||||||
|
|
||||||
|
if suffix == "pdf":
|
||||||
|
try:
|
||||||
|
from pypdf import PdfReader # type: ignore
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=f"PDF 解析依赖缺失: {exc}") from exc
|
||||||
|
reader = PdfReader(io.BytesIO(raw))
|
||||||
|
parts: list[str] = []
|
||||||
|
for idx, page in enumerate(reader.pages, start=1):
|
||||||
|
page_text = (page.extract_text() or "").strip()
|
||||||
|
if not page_text:
|
||||||
|
continue
|
||||||
|
# 写入页码标记,向量分块时会保留 page_number 元数据,便于引用来源页码。
|
||||||
|
parts.append(f"[[PAGE:{idx}]]\n{page_text}")
|
||||||
|
text = "\n\n".join(parts)
|
||||||
|
return ("pdf", text)
|
||||||
|
|
||||||
|
if suffix in {"docx", "doc"}:
|
||||||
|
try:
|
||||||
|
from docx import Document # type: ignore
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Word 解析依赖缺失: {exc}") from exc
|
||||||
|
doc = Document(io.BytesIO(raw))
|
||||||
|
text = "\n".join([p.text for p in doc.paragraphs if p.text])
|
||||||
|
return ("word", text)
|
||||||
|
|
||||||
|
raise HTTPException(status_code=400, detail=f"不支持的文件类型: .{suffix}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ask")
|
||||||
|
async def rag_ask(
|
||||||
|
request: RAGAskRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||||
|
result = await service.answer_question(
|
||||||
|
question=request.question,
|
||||||
|
asked_by=int(current_user.id),
|
||||||
|
asked_via=request.asked_via,
|
||||||
|
project_id=request.project_id,
|
||||||
|
source_chat_id=request.source_chat_id,
|
||||||
|
session_id=request.session_id,
|
||||||
|
force_new_session=bool(request.new_session),
|
||||||
|
thinking_mode=request.thinking_mode,
|
||||||
|
action_mode=request.action_mode,
|
||||||
|
sync_requirement=bool(request.sync_requirement),
|
||||||
|
)
|
||||||
|
return success_response(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/manifest")
|
||||||
|
async def rag_manifest(
|
||||||
|
project_id: int | None = Query(default=None),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||||
|
data = await service.get_project_manifest(project_id=project_id)
|
||||||
|
return success_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/features/catalog")
|
||||||
|
async def rag_feature_catalog(
|
||||||
|
query: str | None = Query(default=None),
|
||||||
|
limit: int = Query(default=20, ge=1, le=200),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||||
|
data = await service.get_feature_catalog(query=query, limit=limit)
|
||||||
|
return success_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/entity-resolution")
|
||||||
|
async def rag_entity_resolution(
|
||||||
|
request: EntityResolutionRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||||
|
data = await service.entity_resolution(
|
||||||
|
question=request.question,
|
||||||
|
project_id=request.project_id,
|
||||||
|
limit=request.limit,
|
||||||
|
)
|
||||||
|
return success_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/multi-step-search")
|
||||||
|
async def rag_multi_step_search(
|
||||||
|
request: MultiStepSearchRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||||
|
data = await service.multi_step_search(
|
||||||
|
question=request.question,
|
||||||
|
project_id=request.project_id,
|
||||||
|
source_chat_id=request.source_chat_id,
|
||||||
|
session_state=None,
|
||||||
|
)
|
||||||
|
return success_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/symbols/sync")
|
||||||
|
async def rag_symbols_sync(
|
||||||
|
request: SymbolSyncRequest,
|
||||||
|
current_user: User = Depends(require_roles("ADMIN", "BOSS")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||||
|
data = await service.sync_codebase_symbols(
|
||||||
|
base_dir=request.base_dir,
|
||||||
|
max_files=request.max_files,
|
||||||
|
force_reindex=request.force_reindex,
|
||||||
|
)
|
||||||
|
return success_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/symbols/search")
|
||||||
|
async def rag_symbols_search(
|
||||||
|
request: SymbolSearchRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||||
|
data = await service.search_symbols(
|
||||||
|
query=request.query,
|
||||||
|
language=request.language,
|
||||||
|
symbol_type=request.symbol_type,
|
||||||
|
limit=request.limit,
|
||||||
|
)
|
||||||
|
return success_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/symbols/overview")
|
||||||
|
async def rag_symbols_overview(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||||
|
data = await service.get_overview(top_n_files=10, auto_sync_if_empty=False)
|
||||||
|
return success_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/symbols/{symbol_id}")
|
||||||
|
async def rag_symbol_detail(
|
||||||
|
symbol_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||||
|
try:
|
||||||
|
data = await service.get_symbol_detail(symbol_id=symbol_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
return success_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/knowledge-base/docs")
|
||||||
|
async def list_knowledge_docs(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
_ = current_user
|
||||||
|
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||||
|
return success_response(await service.list_knowledge_docs())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/knowledge-base/docs")
|
||||||
|
async def create_knowledge_doc(
|
||||||
|
request: KnowledgeDocCreateRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||||
|
result = await service.create_knowledge_doc(
|
||||||
|
title=request.title,
|
||||||
|
file_type=request.file_type,
|
||||||
|
raw_content=request.content,
|
||||||
|
uploaded_by=int(current_user.id),
|
||||||
|
source_type=request.source_type,
|
||||||
|
file_url=request.file_url,
|
||||||
|
project_id=request.project_id,
|
||||||
|
)
|
||||||
|
return success_response(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/knowledge-base/upload")
|
||||||
|
async def upload_knowledge_doc(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
project_id: int | None = Form(default=None),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
raw = await file.read()
|
||||||
|
if not raw:
|
||||||
|
raise HTTPException(status_code=400, detail="上传文件为空")
|
||||||
|
if len(raw) > 15 * 1024 * 1024:
|
||||||
|
raise HTTPException(status_code=400, detail="文件过大,限制 15MB")
|
||||||
|
|
||||||
|
file_type, text = _extract_text_from_upload(file.filename or "uploaded.txt", raw)
|
||||||
|
if not str(text or "").strip():
|
||||||
|
raise HTTPException(status_code=400, detail="无法提取有效文本内容")
|
||||||
|
|
||||||
|
effective_tenant_id = _resolve_tenant_id(current_user, tenant_id)
|
||||||
|
service = AdaptiveRAGService(db, tenant_id=effective_tenant_id)
|
||||||
|
doc = await service.create_knowledge_doc_placeholder(
|
||||||
|
title=(file.filename or "uploaded.txt")[:255],
|
||||||
|
file_type=file_type,
|
||||||
|
uploaded_by=int(current_user.id),
|
||||||
|
source_type="file",
|
||||||
|
file_url=None,
|
||||||
|
)
|
||||||
|
asyncio.create_task(
|
||||||
|
AdaptiveRAGService.process_knowledge_doc_content_async(
|
||||||
|
tenant_id=effective_tenant_id,
|
||||||
|
doc_id=int(doc.id),
|
||||||
|
raw_content=text,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return success_response(service._serialize_doc(doc))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/knowledge-base/urls")
|
||||||
|
async def create_knowledge_url(
|
||||||
|
request: KnowledgeURLCreateRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
url = str(request.url or "").strip()
|
||||||
|
if not re.match(r"^https?://", url, flags=re.IGNORECASE):
|
||||||
|
raise HTTPException(status_code=400, detail="URL 必须以 http:// 或 https:// 开头")
|
||||||
|
|
||||||
|
effective_tenant_id = _resolve_tenant_id(current_user, tenant_id)
|
||||||
|
service = AdaptiveRAGService(db, tenant_id=effective_tenant_id)
|
||||||
|
doc = await service.create_knowledge_doc_placeholder(
|
||||||
|
title=(request.title or url)[:255],
|
||||||
|
file_type="url",
|
||||||
|
uploaded_by=int(current_user.id),
|
||||||
|
source_type="url",
|
||||||
|
file_url=url,
|
||||||
|
)
|
||||||
|
asyncio.create_task(
|
||||||
|
AdaptiveRAGService.refresh_url_doc_async(
|
||||||
|
tenant_id=effective_tenant_id,
|
||||||
|
doc_id=int(doc.id),
|
||||||
|
project_id=request.project_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return success_response(service._serialize_doc(doc))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/knowledge-base/docs/{doc_id}/refresh")
|
||||||
|
async def refresh_knowledge_doc(
|
||||||
|
doc_id: int,
|
||||||
|
project_id: int | None = Query(default=None),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
effective_tenant_id = _resolve_tenant_id(current_user, tenant_id)
|
||||||
|
service = AdaptiveRAGService(db, tenant_id=effective_tenant_id)
|
||||||
|
try:
|
||||||
|
doc = await service.get_knowledge_doc(doc_id=doc_id)
|
||||||
|
if str(doc.get("source_type") or "").lower() != "url":
|
||||||
|
raise HTTPException(status_code=400, detail="仅 URL 类型条目支持 refresh")
|
||||||
|
marked = await service.mark_doc_processing(doc_id=doc_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
asyncio.create_task(
|
||||||
|
AdaptiveRAGService.refresh_url_doc_async(
|
||||||
|
tenant_id=effective_tenant_id,
|
||||||
|
doc_id=doc_id,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return success_response(marked)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/knowledge-base/docs/{doc_id}")
|
||||||
|
async def delete_knowledge_doc(
|
||||||
|
doc_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
_ = current_user
|
||||||
|
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||||
|
ok = await service.delete_knowledge_doc(doc_id=doc_id)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=404, detail="文档不存在")
|
||||||
|
return success_response({"deleted": True, "doc_id": doc_id})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/qa-records/{qa_id}/feedback")
|
||||||
|
async def submit_qa_feedback(
|
||||||
|
qa_id: int,
|
||||||
|
request: QAFeedbackCreateRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||||
|
try:
|
||||||
|
result = await service.submit_feedback(
|
||||||
|
qa_id=qa_id,
|
||||||
|
rating=request.rating,
|
||||||
|
helpfulness=request.helpfulness,
|
||||||
|
error_types=request.error_types,
|
||||||
|
correct_answer=request.correct_answer,
|
||||||
|
submitted_by=int(current_user.id),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
return success_response(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/qa-records")
|
||||||
|
async def list_qa_records(
|
||||||
|
project_id: int | None = Query(default=None),
|
||||||
|
date_from: datetime | None = Query(default=None),
|
||||||
|
date_to: datetime | None = Query(default=None),
|
||||||
|
rating: str | None = Query(default=None),
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=20, ge=1, le=100),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
_ = current_user
|
||||||
|
query = QAListQuery(
|
||||||
|
project_id=project_id,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
rating=rating,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||||
|
result = await service.list_qa_records(
|
||||||
|
project_id=query.project_id,
|
||||||
|
date_from=query.date_from,
|
||||||
|
date_to=query.date_to,
|
||||||
|
rating=query.rating,
|
||||||
|
page=query.page,
|
||||||
|
page_size=query.page_size,
|
||||||
|
)
|
||||||
|
return success_response(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/qa-sessions")
|
||||||
|
async def list_qa_sessions(
|
||||||
|
date_from: datetime | None = Query(default=None),
|
||||||
|
date_to: datetime | None = Query(default=None),
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=20, ge=1, le=100),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
query = QASessionListQuery(
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||||
|
result = await service.list_qa_sessions(
|
||||||
|
user_id=int(current_user.id),
|
||||||
|
date_from=query.date_from,
|
||||||
|
date_to=query.date_to,
|
||||||
|
page=query.page,
|
||||||
|
page_size=query.page_size,
|
||||||
|
)
|
||||||
|
return success_response(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/qa-sessions/{session_id}")
|
||||||
|
async def qa_session_detail(
|
||||||
|
session_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||||
|
try:
|
||||||
|
result = await service.get_qa_session_detail(session_id=session_id, user_id=int(current_user.id))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
return success_response(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/qa-sessions/{session_id}/state")
|
||||||
|
async def qa_session_state_replay(
|
||||||
|
session_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||||
|
try:
|
||||||
|
result = await service.get_session_state_replay(
|
||||||
|
session_id=session_id,
|
||||||
|
user_id=int(current_user.id),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
return success_response(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/qa-sessions/{session_id}/title")
|
||||||
|
async def rename_qa_session(
|
||||||
|
session_id: int,
|
||||||
|
request: QASessionRenameRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||||
|
try:
|
||||||
|
result = await service.rename_qa_session(
|
||||||
|
session_id=session_id,
|
||||||
|
user_id=int(current_user.id),
|
||||||
|
title=request.title,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
return success_response(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/qa-sessions/{session_id}")
|
||||||
|
async def delete_qa_session(
|
||||||
|
session_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||||
|
deleted = await service.delete_qa_session(session_id=session_id, user_id=int(current_user.id))
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="qa session 不存在")
|
||||||
|
return success_response({"deleted": True, "session_id": session_id})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/qa-records/stats")
|
||||||
|
async def qa_records_stats(
|
||||||
|
project_id: int | None = Query(default=None),
|
||||||
|
date_from: datetime | None = Query(default=None),
|
||||||
|
date_to: datetime | None = Query(default=None),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||||
|
result = await service.get_qa_stats(
|
||||||
|
project_id=project_id,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
)
|
||||||
|
return success_response(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/qa-records/export")
|
||||||
|
async def export_qa_records_jsonl(
|
||||||
|
include_unrated: bool = Query(default=False),
|
||||||
|
project_id: int | None = Query(default=None),
|
||||||
|
rating: str | None = Query(default=None),
|
||||||
|
domain: str | None = Query(default=None),
|
||||||
|
date_from: datetime | None = Query(default=None),
|
||||||
|
date_to: datetime | None = Query(default=None),
|
||||||
|
current_user: User = Depends(require_roles("ADMIN", "BOSS")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
_ = current_user
|
||||||
|
query = QAExportQuery(
|
||||||
|
include_unrated=include_unrated,
|
||||||
|
project_id=project_id,
|
||||||
|
rating=rating,
|
||||||
|
domain=domain,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
)
|
||||||
|
|
||||||
|
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||||
|
payload = await service.export_qa_jsonl(
|
||||||
|
include_unrated=query.include_unrated,
|
||||||
|
project_id=query.project_id,
|
||||||
|
rating=query.rating,
|
||||||
|
domain=query.domain,
|
||||||
|
date_from=query.date_from,
|
||||||
|
date_to=query.date_to,
|
||||||
|
)
|
||||||
|
return Response(content=payload, media_type="application/x-ndjson")
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
"""食谱 API - 支持热量识别与营养分析"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..dependencies.auth import get_current_tenant_id
|
||||||
|
from ..schemas.recipe import (
|
||||||
|
RecipeCreate,
|
||||||
|
RecipeUpdate,
|
||||||
|
RecipeResponse,
|
||||||
|
RecipeImageUploadRequest,
|
||||||
|
RecipeAnalyzeCaloriesResponse,
|
||||||
|
)
|
||||||
|
from ..services.recipe_service import get_recipe_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/recipes", tags=["食谱"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=RecipeResponse, summary="创建食谱")
|
||||||
|
async def create_recipe(
|
||||||
|
recipe_data: RecipeCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_current_tenant_id),
|
||||||
|
):
|
||||||
|
"""创建新食谱"""
|
||||||
|
service = get_recipe_service()
|
||||||
|
recipe = await service.create_recipe(db, recipe_data, tenant_id)
|
||||||
|
return recipe
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/analyze", response_model=RecipeAnalyzeCaloriesResponse, summary="AI 热量分析")
|
||||||
|
async def analyze_food_calories(
|
||||||
|
recipe_data: RecipeCreate,
|
||||||
|
food_description: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_current_tenant_id),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
创建食谱并进行 AI 热量分析
|
||||||
|
|
||||||
|
- food_description: 食物描述(可以是图片识别后的描述)
|
||||||
|
"""
|
||||||
|
service = get_recipe_service()
|
||||||
|
recipe, analysis = await service.create_recipe_with_ai_analysis(
|
||||||
|
db, recipe_data, food_description, tenant_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not analysis["success"]:
|
||||||
|
return RecipeAnalyzeCaloriesResponse(
|
||||||
|
success=False,
|
||||||
|
data=None,
|
||||||
|
message=analysis["message"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return RecipeAnalyzeCaloriesResponse(
|
||||||
|
success=True,
|
||||||
|
data=recipe,
|
||||||
|
message=analysis["message"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[RecipeResponse], summary="获取食谱列表")
|
||||||
|
async def list_recipes(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
cuisine: Optional[str] = None,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
is_favorite: Optional[bool] = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_current_tenant_id),
|
||||||
|
):
|
||||||
|
"""获取食谱列表,支持分类筛选"""
|
||||||
|
service = get_recipe_service()
|
||||||
|
recipes = await service.list_recipes(
|
||||||
|
db,
|
||||||
|
tenant_id,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
category=category,
|
||||||
|
cuisine=cuisine,
|
||||||
|
status=status,
|
||||||
|
is_favorite=is_favorite,
|
||||||
|
)
|
||||||
|
return recipes
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{recipe_id}", response_model=RecipeResponse, summary="获取单个食谱")
|
||||||
|
async def get_recipe(
|
||||||
|
recipe_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_current_tenant_id),
|
||||||
|
):
|
||||||
|
"""获取指定食谱详情"""
|
||||||
|
service = get_recipe_service()
|
||||||
|
recipe = await service.get_recipe(db, recipe_id, tenant_id)
|
||||||
|
if not recipe:
|
||||||
|
raise HTTPException(status_code=404, detail="食谱不存在")
|
||||||
|
return recipe
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{recipe_id}", response_model=RecipeResponse, summary="更新食谱")
|
||||||
|
async def update_recipe(
|
||||||
|
recipe_id: int,
|
||||||
|
recipe_data: RecipeUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_current_tenant_id),
|
||||||
|
):
|
||||||
|
"""更新食谱信息"""
|
||||||
|
service = get_recipe_service()
|
||||||
|
recipe = await service.update_recipe(db, recipe_id, recipe_data, tenant_id)
|
||||||
|
if not recipe:
|
||||||
|
raise HTTPException(status_code=404, detail="食谱不存在")
|
||||||
|
return recipe
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{recipe_id}", summary="删除食谱")
|
||||||
|
async def delete_recipe(
|
||||||
|
recipe_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_current_tenant_id),
|
||||||
|
):
|
||||||
|
"""删除食谱"""
|
||||||
|
service = get_recipe_service()
|
||||||
|
success = await service.delete_recipe(db, recipe_id, tenant_id)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=404, detail="食谱不存在")
|
||||||
|
return {"message": "删除成功"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{recipe_id}/favorite", response_model=RecipeResponse, summary="收藏/取消收藏食谱")
|
||||||
|
async def toggle_favorite(
|
||||||
|
recipe_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_current_tenant_id),
|
||||||
|
):
|
||||||
|
"""收藏或取消收藏食谱"""
|
||||||
|
service = get_recipe_service()
|
||||||
|
recipe = await service.get_recipe(db, recipe_id, tenant_id)
|
||||||
|
if not recipe:
|
||||||
|
raise HTTPException(status_code=404, detail="食谱不存在")
|
||||||
|
|
||||||
|
recipe.is_favorite = not recipe.is_favorite
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(recipe)
|
||||||
|
return recipe
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload-image", response_model=dict, summary="上传图片(示例)")
|
||||||
|
async def upload_recipe_image(
|
||||||
|
image_base64: str,
|
||||||
|
filename: Optional[str] = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_current_tenant_id),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
上传食谱图片
|
||||||
|
|
||||||
|
注意:这是示例实现,实际项目中需要集成对象存储服务(如 S3、OSS、COS 等)
|
||||||
|
"""
|
||||||
|
service = get_recipe_service()
|
||||||
|
image_url = await service.upload_image(image_base64, filename)
|
||||||
|
return {"image_url": image_url}
|
||||||
|
|
@ -14,13 +14,25 @@ from .tasks import router as tasks_router
|
||||||
from .webhooks import router as webhooks_router
|
from .webhooks import router as webhooks_router
|
||||||
from .auth import router as auth_router
|
from .auth import router as auth_router
|
||||||
from .users import router as users_router
|
from .users import router as users_router
|
||||||
from .projects import router as projects_router
|
|
||||||
from .endpoints.skill import router as skill_router
|
from .endpoints.skill import router as skill_router
|
||||||
from .workloads import router as workloads_router
|
from .workloads import router as workloads_router
|
||||||
from .skill_tags import router as skill_tags_router
|
from .skill_tags import router as skill_tags_router
|
||||||
from .sstc import router as sstc_router
|
from .sstc import router as sstc_router
|
||||||
|
from .projects import router as projects_router
|
||||||
from .scheduler import router as scheduler_router
|
from .scheduler import router as scheduler_router
|
||||||
from .ai_steward import router as ai_steward_router
|
from .ai_steward import router as ai_steward_router
|
||||||
|
from .feishu import router as feishu_router
|
||||||
|
from .wecom import router as wecom_router
|
||||||
|
from .notifications import router as notifications_router
|
||||||
|
from .llm_engines import router as llm_engines_router
|
||||||
|
from .ai_logs import router as ai_logs_router
|
||||||
|
from .prompt_profiles import router as prompt_profiles_router
|
||||||
|
from .rag import router as rag_router
|
||||||
|
from .knowledge_graph import router as knowledge_graph_router
|
||||||
|
from .recipes import router as recipes_router
|
||||||
|
from .signal_sources import router as signal_sources_router
|
||||||
|
from .operation_catalog import router as operation_catalog_router
|
||||||
|
from .ai_pmo import router as ai_pmo_router
|
||||||
|
|
||||||
# 创建主 API 路由器
|
# 创建主 API 路由器
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
@ -34,10 +46,22 @@ api_router.include_router(tasks_router)
|
||||||
api_router.include_router(webhooks_router)
|
api_router.include_router(webhooks_router)
|
||||||
api_router.include_router(auth_router)
|
api_router.include_router(auth_router)
|
||||||
api_router.include_router(users_router)
|
api_router.include_router(users_router)
|
||||||
api_router.include_router(projects_router)
|
|
||||||
api_router.include_router(skill_router)
|
api_router.include_router(skill_router)
|
||||||
api_router.include_router(workloads_router)
|
api_router.include_router(workloads_router)
|
||||||
api_router.include_router(skill_tags_router)
|
api_router.include_router(skill_tags_router)
|
||||||
api_router.include_router(sstc_router)
|
api_router.include_router(sstc_router)
|
||||||
|
api_router.include_router(projects_router)
|
||||||
api_router.include_router(scheduler_router)
|
api_router.include_router(scheduler_router)
|
||||||
api_router.include_router(ai_steward_router)
|
api_router.include_router(ai_steward_router)
|
||||||
|
api_router.include_router(feishu_router)
|
||||||
|
api_router.include_router(wecom_router)
|
||||||
|
api_router.include_router(notifications_router)
|
||||||
|
api_router.include_router(llm_engines_router)
|
||||||
|
api_router.include_router(ai_logs_router)
|
||||||
|
api_router.include_router(prompt_profiles_router)
|
||||||
|
api_router.include_router(rag_router)
|
||||||
|
api_router.include_router(knowledge_graph_router)
|
||||||
|
api_router.include_router(recipes_router)
|
||||||
|
api_router.include_router(signal_sources_router)
|
||||||
|
api_router.include_router(operation_catalog_router)
|
||||||
|
api_router.include_router(ai_pmo_router)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,18 @@
|
||||||
"""Scheduler API 路由。"""
|
"""Scheduler API 路由。"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import and_, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..dependencies import require_manager_user
|
from ..dependencies import require_manager_user
|
||||||
from ..models import User
|
from ..models import KnowledgeBaseDoc, Project, User
|
||||||
|
from ..services.adaptive_rag_service import AdaptiveRAGService
|
||||||
|
from ..services.ai_pmo_cadence_service import AIPMOCadenceService
|
||||||
|
from ..services.project_knowledge_graph import ProjectKnowledgeGraphService
|
||||||
|
from ..services.symbol_index_service import SymbolIndexService
|
||||||
from ..services import workload_service
|
from ..services import workload_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/scheduler", tags=["scheduler"])
|
router = APIRouter(prefix="/scheduler", tags=["scheduler"])
|
||||||
|
|
@ -35,3 +42,132 @@ async def trigger_workload_recalc(
|
||||||
"message": "success",
|
"message": "success",
|
||||||
"data": {"tenant_count": len(tenant_ids), "user_count": total},
|
"data": {"tenant_count": len(tenant_ids), "user_count": total},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/knowledge-base/urls/refresh")
|
||||||
|
async def trigger_knowledge_url_refresh(
|
||||||
|
current_user: User = Depends(require_manager_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""手动触发 URL 知识库回抓。"""
|
||||||
|
tenant_id = int(current_user.tenant_id)
|
||||||
|
cutoff = datetime.now() - timedelta(days=7)
|
||||||
|
docs = (
|
||||||
|
await db.execute(
|
||||||
|
select(KnowledgeBaseDoc).where(
|
||||||
|
and_(
|
||||||
|
KnowledgeBaseDoc.tenant_id == tenant_id,
|
||||||
|
KnowledgeBaseDoc.source_type == "url",
|
||||||
|
or_(
|
||||||
|
KnowledgeBaseDoc.last_fetched_at.is_(None),
|
||||||
|
KnowledgeBaseDoc.last_fetched_at <= cutoff,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
refreshed = 0
|
||||||
|
failed = 0
|
||||||
|
service = AdaptiveRAGService(db, tenant_id=tenant_id)
|
||||||
|
for doc in docs:
|
||||||
|
try:
|
||||||
|
await service.refresh_url_doc(doc_id=int(doc.id), project_id=None)
|
||||||
|
refreshed += 1
|
||||||
|
except Exception:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {"candidate_count": len(docs), "refreshed": refreshed, "failed": failed},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/knowledge-graph/incremental")
|
||||||
|
async def trigger_knowledge_graph_incremental(
|
||||||
|
current_user: User = Depends(require_manager_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""手动触发一次知识图谱增量流水线。"""
|
||||||
|
tenant_id = int(current_user.tenant_id)
|
||||||
|
service = ProjectKnowledgeGraphService(db, tenant_id=tenant_id)
|
||||||
|
data = await service.run_incremental_cycle(run_source="manual_scheduler_api")
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/symbols/sync")
|
||||||
|
async def trigger_symbol_index_sync(
|
||||||
|
current_user: User = Depends(require_manager_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""手动触发一次 Symbol 索引同步。"""
|
||||||
|
tenant_id = int(current_user.tenant_id)
|
||||||
|
service = SymbolIndexService(db, tenant_id=tenant_id)
|
||||||
|
data = await service.sync_codebase_symbols(max_files=800, force_reindex=False)
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/features/catalog/sync")
|
||||||
|
async def trigger_feature_catalog_sync(
|
||||||
|
current_user: User = Depends(require_manager_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""手动触发一次 Feature Catalog -> KG 同步。"""
|
||||||
|
tenant_id = int(current_user.tenant_id)
|
||||||
|
service = ProjectKnowledgeGraphService(db, tenant_id=tenant_id)
|
||||||
|
data = await service.sync_feature_catalog_now()
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ai-pmo/cadence/run")
|
||||||
|
async def trigger_ai_pmo_cadence_run(
|
||||||
|
cadence_type: str = "all",
|
||||||
|
project_id: int | None = None,
|
||||||
|
current_user: User = Depends(require_manager_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""手动触发 AI PMO cadence snapshot。"""
|
||||||
|
tenant_id = int(current_user.tenant_id)
|
||||||
|
service = AIPMOCadenceService(db, tenant_id=tenant_id)
|
||||||
|
if cadence_type != "all":
|
||||||
|
snapshot = await service.generate_snapshot(cadence_type=cadence_type, project_id=project_id)
|
||||||
|
await db.commit()
|
||||||
|
return {"code": 0, "message": "success", "data": {"count": 1, "items": [snapshot]}}
|
||||||
|
|
||||||
|
items = [await service.generate_snapshot(cadence_type="risk_review", project_id=project_id)]
|
||||||
|
if project_id is None:
|
||||||
|
items.append(await service.generate_snapshot(cadence_type="daily_standup", project_id=None))
|
||||||
|
items.append(await service.generate_snapshot(cadence_type="milestone_check", project_id=None))
|
||||||
|
if datetime.now().weekday() == 0:
|
||||||
|
items.append(await service.generate_snapshot(cadence_type="weekly_review", project_id=None))
|
||||||
|
project_ids = (
|
||||||
|
await db.execute(
|
||||||
|
select(Project.id).where(
|
||||||
|
and_(
|
||||||
|
Project.tenant_id == tenant_id,
|
||||||
|
Project.status.in_(["active", "planning", "doing", "todo"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
for pid in project_ids:
|
||||||
|
items.append(await service.generate_snapshot(cadence_type="risk_review", project_id=int(pid)))
|
||||||
|
else:
|
||||||
|
items.append(await service.generate_snapshot(cadence_type="daily_standup", project_id=int(project_id)))
|
||||||
|
items.append(await service.generate_snapshot(cadence_type="milestone_check", project_id=int(project_id)))
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return {"code": 0, "message": "success", "data": {"count": len(items), "items": items}}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,401 @@
|
||||||
|
"""食谱系统信号源 API
|
||||||
|
|
||||||
|
功能:
|
||||||
|
1. 接收食谱系统的 webhook 请求
|
||||||
|
2. 将食谱识别需求转换为 PMFlow 信号
|
||||||
|
3. 自动关联到指定项目(食谱系统项目)
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
import json
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..dependencies.tenant import get_tenant_id
|
||||||
|
from ..core.logging import get_logger
|
||||||
|
from ..models import Signal, ProjectSignalLink, Project
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/signal-sources", tags=["Signal Sources"])
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
RECIPE_PROJECT_KEYWORDS = ("食谱", "recipe", "sstc")
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_target_project_id(
|
||||||
|
*,
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: int,
|
||||||
|
project_id: Optional[int],
|
||||||
|
) -> int:
|
||||||
|
"""解析目标项目:优先显式 project_id,其次按关键字自动匹配。"""
|
||||||
|
from sqlalchemy import select, or_
|
||||||
|
|
||||||
|
if project_id is not None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Project.id).where(
|
||||||
|
Project.tenant_id == tenant_id,
|
||||||
|
Project.id == int(project_id),
|
||||||
|
).limit(1)
|
||||||
|
)
|
||||||
|
found = result.scalar_one_or_none()
|
||||||
|
if found is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"project_id={project_id} 不存在或无权限")
|
||||||
|
return int(found)
|
||||||
|
|
||||||
|
keyword_conditions = []
|
||||||
|
for keyword in RECIPE_PROJECT_KEYWORDS:
|
||||||
|
keyword_conditions.extend([
|
||||||
|
Project.title.ilike(f"%{keyword}%"),
|
||||||
|
Project.name.ilike(f"%{keyword}%"),
|
||||||
|
])
|
||||||
|
|
||||||
|
match_result = await db.execute(
|
||||||
|
select(Project.id).where(
|
||||||
|
Project.tenant_id == tenant_id,
|
||||||
|
or_(*keyword_conditions),
|
||||||
|
).limit(1)
|
||||||
|
)
|
||||||
|
matched = match_result.scalar_one_or_none()
|
||||||
|
if matched is not None:
|
||||||
|
return int(matched)
|
||||||
|
|
||||||
|
# 兜底:若租户仅存在一个活跃项目,自动绑定,避免“已调用但未入库”。
|
||||||
|
active_result = await db.execute(
|
||||||
|
select(Project.id).where(
|
||||||
|
Project.tenant_id == tenant_id,
|
||||||
|
Project.status == "active",
|
||||||
|
).limit(2)
|
||||||
|
)
|
||||||
|
active_ids = list(active_result.scalars().all())
|
||||||
|
if len(active_ids) == 1:
|
||||||
|
return int(active_ids[0])
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="未找到目标项目,请在请求中显式传入 project_id(或确保项目名包含 食谱/recipe/sstc)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 食谱系统信号源 Webhook
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class RecipeRecognitionRequest(BaseModel):
|
||||||
|
"""食谱识别请求"""
|
||||||
|
# 食谱信息
|
||||||
|
food_name: str = Field(..., description="食物名称")
|
||||||
|
food_description: Optional[str] = Field(None, description="食物描述")
|
||||||
|
image_url: Optional[str] = Field(None, description="图片URL")
|
||||||
|
category: Optional[str] = Field(None, description="分类")
|
||||||
|
cuisine: Optional[str] = Field(None, description="菜系")
|
||||||
|
|
||||||
|
# 营养信息(如果来源系统已分析)
|
||||||
|
calories: Optional[float] = Field(None, description="热量(千卡)")
|
||||||
|
protein: Optional[float] = Field(None, description="蛋白质(克)")
|
||||||
|
fat: Optional[float] = Field(None, description="脂肪(克)")
|
||||||
|
carbs: Optional[float] = Field(None, description="碳水化合物(克)")
|
||||||
|
fiber: Optional[float] = Field(None, description="膳食纤维(克)")
|
||||||
|
|
||||||
|
# 意图
|
||||||
|
intent: str = Field(
|
||||||
|
default="analyze",
|
||||||
|
description="意图:analyze-热量分析, save-保存食谱, recommend-推荐菜谱"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 用户信息(可选)
|
||||||
|
user_name: Optional[str] = Field(None, description="用户名")
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeRecognitionResponse(BaseModel):
|
||||||
|
"""响应"""
|
||||||
|
success: bool
|
||||||
|
signal_id: Optional[int] = None
|
||||||
|
project_id: Optional[int] = None
|
||||||
|
message: str
|
||||||
|
details: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/recipe/webhook", response_model=RecipeRecognitionResponse, summary="食谱系统 Webhook")
|
||||||
|
async def recipe_webhook(
|
||||||
|
request: RecipeRecognitionRequest,
|
||||||
|
project_id: Optional[int] = Query(None, description="项目ID,不传则自动查找食谱系统项目"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
接收食谱系统的识别请求
|
||||||
|
|
||||||
|
用法:
|
||||||
|
- 食谱系统完成拍照识别后,调用此 API 将需求推送到 PMFlow
|
||||||
|
- PMFlow 会自动创建信号并关联到食谱系统项目
|
||||||
|
|
||||||
|
请求示例:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"food_name": "红烧肉",
|
||||||
|
"food_description": "一块红烧肉,约200克,肥瘦相间",
|
||||||
|
"image_url": "https://example.com/image.jpg",
|
||||||
|
"category": "肉类",
|
||||||
|
"cuisine": "中式",
|
||||||
|
"calories": 350,
|
||||||
|
"protein": 20,
|
||||||
|
"fat": 25,
|
||||||
|
"carbs": 10,
|
||||||
|
"fiber": 0,
|
||||||
|
"intent": "analyze",
|
||||||
|
"user_name": "张三"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 1. 查找目标项目
|
||||||
|
target_project_id = await _resolve_target_project_id(
|
||||||
|
db=db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. 构建信号内容
|
||||||
|
content_parts = []
|
||||||
|
content_parts.append(f"食物:{request.food_name}")
|
||||||
|
|
||||||
|
if request.food_description:
|
||||||
|
content_parts.append(f"描述:{request.food_description}")
|
||||||
|
|
||||||
|
# 营养信息
|
||||||
|
if request.calories or request.protein or request.fat or request.carbs or request.fiber:
|
||||||
|
nutrition = []
|
||||||
|
if request.calories:
|
||||||
|
nutrition.append(f"热量 {request.calories} kcal")
|
||||||
|
if request.protein:
|
||||||
|
nutrition.append(f"蛋白 {request.protein}g")
|
||||||
|
if request.fat:
|
||||||
|
nutrition.append(f"脂肪 {request.fat}g")
|
||||||
|
if request.carbs:
|
||||||
|
nutrition.append(f"碳水 {request.carbs}g")
|
||||||
|
if request.fiber:
|
||||||
|
nutrition.append(f"纤维 {request.fiber}g")
|
||||||
|
content_parts.append("营养:" + ", ".join(nutrition))
|
||||||
|
|
||||||
|
if request.category:
|
||||||
|
content_parts.append(f"分类:{request.category}")
|
||||||
|
if request.cuisine:
|
||||||
|
content_parts.append(f"菜系:{request.cuisine}")
|
||||||
|
content_parts.append(f"意图:{request.intent}")
|
||||||
|
|
||||||
|
content = "\n".join(content_parts)
|
||||||
|
|
||||||
|
# 3. 构建标题
|
||||||
|
intent_text = {
|
||||||
|
"analyze": "热量分析",
|
||||||
|
"save": "保存食谱",
|
||||||
|
"recommend": "推荐菜谱"
|
||||||
|
}.get(request.intent, request.intent)
|
||||||
|
|
||||||
|
title = f"🍽️ 食谱{intent_text}:{request.food_name}"
|
||||||
|
|
||||||
|
# 4. 构建摘要
|
||||||
|
summary = f"食物:{request.food_name}"
|
||||||
|
if request.calories:
|
||||||
|
summary += f"\n热量:{request.calories} kcal"
|
||||||
|
if request.category:
|
||||||
|
summary += f"\n分类:{request.category}"
|
||||||
|
summary += f"\n意图:{intent_text}"
|
||||||
|
|
||||||
|
# 5. 构建信号元数据
|
||||||
|
signal_metadata = {
|
||||||
|
"source": "recipe_system",
|
||||||
|
"intent": request.intent,
|
||||||
|
"category": request.category,
|
||||||
|
"cuisine": request.cuisine,
|
||||||
|
"nutrition": {
|
||||||
|
"calories": request.calories,
|
||||||
|
"protein": request.protein,
|
||||||
|
"fat": request.fat,
|
||||||
|
"carbs": request.carbs,
|
||||||
|
"fiber": request.fiber,
|
||||||
|
},
|
||||||
|
"image_url": request.image_url,
|
||||||
|
"user_name": request.user_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 6. 创建信号
|
||||||
|
signal = Signal(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source="recipe_system",
|
||||||
|
title=title,
|
||||||
|
content=content,
|
||||||
|
summary=summary,
|
||||||
|
signal_type="recipe_recognition",
|
||||||
|
priority="medium",
|
||||||
|
impact_score=0.5,
|
||||||
|
status="待处理",
|
||||||
|
processed=False,
|
||||||
|
strategic_analysis=json.dumps(signal_metadata, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(signal)
|
||||||
|
await db.flush() # 获取 signal.id
|
||||||
|
|
||||||
|
# 7. 关联到项目
|
||||||
|
link = ProjectSignalLink(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
project_id=target_project_id,
|
||||||
|
signal_id=signal.id,
|
||||||
|
)
|
||||||
|
db.add(link)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(signal)
|
||||||
|
|
||||||
|
return RecipeRecognitionResponse(
|
||||||
|
success=True,
|
||||||
|
signal_id=signal.id,
|
||||||
|
project_id=target_project_id,
|
||||||
|
message="信号创建成功并已关联到食谱系统项目",
|
||||||
|
details={
|
||||||
|
"signal_id": signal.id,
|
||||||
|
"project_id": target_project_id,
|
||||||
|
"title": title,
|
||||||
|
"status": signal.status,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.exception(
|
||||||
|
"recipe_webhook_create_signal_failed",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
project_id=project_id,
|
||||||
|
food_name=request.food_name,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=500, detail=f"创建信号失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recipe/status/{signal_id}", summary="查询食谱信号状态")
|
||||||
|
async def get_recipe_signal_status(
|
||||||
|
signal_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
"""查询食谱识别信号的处理状态"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Signal).where(
|
||||||
|
Signal.id == signal_id,
|
||||||
|
Signal.tenant_id == tenant_id,
|
||||||
|
Signal.source == "recipe_system",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
signal = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not signal:
|
||||||
|
raise HTTPException(status_code=404, detail="信号不存在")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"signal_id": signal.id,
|
||||||
|
"title": signal.title,
|
||||||
|
"status": signal.status,
|
||||||
|
"processed": signal.processed,
|
||||||
|
"summary": signal.summary,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 批量导入食谱记录
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class RecipeBatchRequest(BaseModel):
|
||||||
|
"""批量导入请求"""
|
||||||
|
recipes: list[RecipeRecognitionRequest] = Field(..., description="食谱列表")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/recipe/batch", response_model=RecipeRecognitionResponse, summary="批量导入食谱")
|
||||||
|
async def recipe_batch_import(
|
||||||
|
request: RecipeBatchRequest,
|
||||||
|
project_id: Optional[int] = Query(None, description="项目ID"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
"""批量导入食谱记录,每个都会创建信号"""
|
||||||
|
try:
|
||||||
|
target_project_id = await _resolve_target_project_id(
|
||||||
|
db=db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
signal_ids = []
|
||||||
|
for recipe in request.recipes:
|
||||||
|
# 构建内容
|
||||||
|
content = f"食物:{recipe.food_name}"
|
||||||
|
if recipe.calories:
|
||||||
|
content += f"\n热量:{recipe.calories} kcal"
|
||||||
|
|
||||||
|
title = f"🍽️ 食谱记录:{recipe.food_name}"
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"source": "recipe_system",
|
||||||
|
"intent": recipe.intent,
|
||||||
|
"nutrition": {
|
||||||
|
"calories": recipe.calories,
|
||||||
|
"protein": recipe.protein,
|
||||||
|
"fat": recipe.fat,
|
||||||
|
"carbs": recipe.carbs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
signal = Signal(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source="recipe_system",
|
||||||
|
title=title,
|
||||||
|
content=content,
|
||||||
|
summary=f"食物:{recipe.food_name}",
|
||||||
|
signal_type="recipe_recognition",
|
||||||
|
priority="low",
|
||||||
|
impact_score=0.3,
|
||||||
|
status="待处理",
|
||||||
|
processed=True, # 批量导入的视为已处理
|
||||||
|
strategic_analysis=json.dumps(metadata),
|
||||||
|
)
|
||||||
|
db.add(signal)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# 关联项目
|
||||||
|
link = ProjectSignalLink(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
project_id=target_project_id,
|
||||||
|
signal_id=signal.id,
|
||||||
|
)
|
||||||
|
db.add(link)
|
||||||
|
signal_ids.append(signal.id)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return RecipeRecognitionResponse(
|
||||||
|
success=True,
|
||||||
|
signal_id=len(signal_ids), # 返回数量
|
||||||
|
project_id=target_project_id,
|
||||||
|
message=f"成功导入 {len(signal_ids)} 条食谱记录",
|
||||||
|
details={"signal_count": len(signal_ids)}
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.exception(
|
||||||
|
"recipe_batch_import_failed",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
project_id=project_id,
|
||||||
|
recipe_count=len(request.recipes),
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=500, detail=f"批量导入失败: {str(e)}")
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -10,6 +10,7 @@ from typing import Any, Optional
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import and_, delete, func, or_, select, update
|
from sqlalchemy import and_, delete, func, or_, select, update
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
|
|
@ -125,6 +126,15 @@ def _parse_int_list(raw: Optional[str]) -> list[int]:
|
||||||
return ids
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def _translate_user_integrity_error(exc: IntegrityError) -> str:
|
||||||
|
message = str(getattr(exc, "orig", exc)).lower()
|
||||||
|
if "users.username" in message or "username" in message:
|
||||||
|
return "用户名已存在"
|
||||||
|
if "users.email" in message or "email" in message:
|
||||||
|
return "邮箱已存在"
|
||||||
|
return "用户数据冲突,请检查用户名或邮箱是否重复"
|
||||||
|
|
||||||
|
|
||||||
async def _collect_user_enrichment(
|
async def _collect_user_enrichment(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
tenant_id: int,
|
tenant_id: int,
|
||||||
|
|
@ -213,6 +223,22 @@ async def _replace_user_projects(
|
||||||
for pid in unique_project_ids:
|
for pid in unique_project_ids:
|
||||||
db.add(UserProject(tenant_id=tenant_id, user_id=user_id, project_id=pid))
|
db.add(UserProject(tenant_id=tenant_id, user_id=user_id, project_id=pid))
|
||||||
|
|
||||||
|
# bulk delete(UserProject) 不会触发 ORM 对象事件,这里手动注册缓存失效意图。
|
||||||
|
cache_payload = db.sync_session.info.get("_pmflow_cache_invalidation")
|
||||||
|
if not isinstance(cache_payload, dict):
|
||||||
|
cache_payload = {
|
||||||
|
"tenant_ids": set(),
|
||||||
|
"invalidate_system": False,
|
||||||
|
"invalidate_project_list": False,
|
||||||
|
"invalidate_signal_pending": False,
|
||||||
|
"user_ids": set(),
|
||||||
|
}
|
||||||
|
db.sync_session.info["_pmflow_cache_invalidation"] = cache_payload
|
||||||
|
cache_payload["tenant_ids"].add(int(tenant_id))
|
||||||
|
cache_payload["invalidate_system"] = True
|
||||||
|
cache_payload["invalidate_project_list"] = True
|
||||||
|
cache_payload["user_ids"].add(int(user_id))
|
||||||
|
|
||||||
|
|
||||||
async def _create_user_with_defaults(
|
async def _create_user_with_defaults(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
|
|
@ -393,12 +419,12 @@ async def list_users(
|
||||||
query = query.where(func.coalesce(UserWorkload.workload_ratio, 0.0) > 100.0)
|
query = query.where(func.coalesce(UserWorkload.workload_ratio, 0.0) > 100.0)
|
||||||
|
|
||||||
total_result = await db.execute(
|
total_result = await db.execute(
|
||||||
select(func.count()).select_from(query.distinct(User.id).subquery())
|
select(func.count()).select_from(query.distinct().subquery())
|
||||||
)
|
)
|
||||||
total = int(total_result.scalar() or 0)
|
total = int(total_result.scalar() or 0)
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
query.distinct(User.id)
|
query.distinct()
|
||||||
.order_by(User.updated_at.desc(), User.id.desc())
|
.order_by(User.updated_at.desc(), User.id.desc())
|
||||||
.offset((page - 1) * page_size)
|
.offset((page - 1) * page_size)
|
||||||
.limit(page_size)
|
.limit(page_size)
|
||||||
|
|
@ -490,6 +516,9 @@ async def create_user(
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
raise HTTPException(status_code=400, detail=str(exc))
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
except IntegrityError as exc:
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(status_code=400, detail=_translate_user_integrity_error(exc))
|
||||||
except Exception:
|
except Exception:
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
raise
|
raise
|
||||||
|
|
@ -571,8 +600,12 @@ async def update_user(
|
||||||
before_data=before_data,
|
before_data=before_data,
|
||||||
after_data=user.to_dict(),
|
after_data=user.to_dict(),
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(user)
|
await db.refresh(user)
|
||||||
|
except IntegrityError as exc:
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(status_code=400, detail=_translate_user_integrity_error(exc))
|
||||||
|
|
||||||
workloads, skills, projects = await _collect_user_enrichment(db, tenant_id, [user])
|
workloads, skills, projects = await _collect_user_enrichment(db, tenant_id, [user])
|
||||||
return _serialize_user(user, workloads, skills, projects)
|
return _serialize_user(user, workloads, skills, projects)
|
||||||
|
|
@ -599,10 +632,10 @@ async def reset_password(
|
||||||
if not is_manager and int(current_user.id) != user_id:
|
if not is_manager and int(current_user.id) != user_id:
|
||||||
raise HTTPException(status_code=403, detail="No permission")
|
raise HTTPException(status_code=403, detail="No permission")
|
||||||
|
|
||||||
if len(body.new_password or "") < 6:
|
try:
|
||||||
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
|
||||||
|
|
||||||
user.password_hash = AuthService.hash_password(body.new_password)
|
user.password_hash = AuthService.hash_password(body.new_password)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
user.updated_at = datetime.utcnow()
|
user.updated_at = datetime.utcnow()
|
||||||
await log_operation(
|
await log_operation(
|
||||||
db,
|
db,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
"""Webhook 接收 API"""
|
"""Webhook 接收 API。"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
|
|
@ -12,10 +12,8 @@ from ..database import get_db
|
||||||
from ..models import Signal
|
from ..models import Signal
|
||||||
from ..services.llm import llm_service
|
from ..services.llm import llm_service
|
||||||
from ..services.plane import plane_service
|
from ..services.plane import plane_service
|
||||||
from ..services.lark import LarkService
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/webhooks", tags=["webhooks"])
|
router = APIRouter(prefix="/webhooks", tags=["webhooks"])
|
||||||
lark_service = LarkService()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/git")
|
@router.post("/git")
|
||||||
|
|
@ -114,58 +112,3 @@ async def handle_git_webhook(
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"status": "processed", "updated_tasks": updated_tasks}
|
return {"status": "processed", "updated_tasks": updated_tasks}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/lark")
|
|
||||||
async def handle_lark_webhook(
|
|
||||||
request: Request,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""接收飞书 Bot Webhook"""
|
|
||||||
try:
|
|
||||||
data = await request.json()
|
|
||||||
except Exception:
|
|
||||||
return {"status": "error", "message": "Invalid JSON"}
|
|
||||||
|
|
||||||
if "challenge" in data:
|
|
||||||
return {"challenge": data["challenge"]}
|
|
||||||
|
|
||||||
event = data.get("event", {})
|
|
||||||
if not event:
|
|
||||||
return {"status": "ignored"}
|
|
||||||
|
|
||||||
if event.get("type") == "message" or event.get("msg_type") == "text":
|
|
||||||
message_content = event.get("text_without_at_bot") or event.get(
|
|
||||||
"content", ""
|
|
||||||
)
|
|
||||||
if isinstance(message_content, str) and message_content.startswith("{"):
|
|
||||||
try:
|
|
||||||
content_obj = json.loads(message_content)
|
|
||||||
message_content = content_obj.get("text", "")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
message_content = message_content.strip()
|
|
||||||
chat_id = event.get("open_chat_id") or event.get("chat_id")
|
|
||||||
|
|
||||||
if not message_content or not chat_id:
|
|
||||||
return {"status": "ignored", "reason": "No content or chat_id"}
|
|
||||||
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
tasks = await llm_service.generate_task_breakdown_async(
|
|
||||||
message_content,
|
|
||||||
db=db,
|
|
||||||
tenant_id=1, # Webhook 来源尚未绑定租户,先按默认租户计费
|
|
||||||
)
|
|
||||||
except RuntimeError:
|
|
||||||
return {"status": "blocked", "reason": "budget_exceeded_hard_limit"}
|
|
||||||
if tasks:
|
|
||||||
await plane_service.sync_tasks(tasks)
|
|
||||||
await lark_service.send_card_message(
|
|
||||||
chat_id, message_content, tasks
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error processing Lark message: {e}")
|
|
||||||
|
|
||||||
return {"status": "processed"}
|
|
||||||
|
|
|
||||||
|
|
@ -101,17 +101,23 @@ async def get_wecom_config(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
tenant_id: int = Depends(get_tenant_id)
|
tenant_id: int = Depends(get_tenant_id)
|
||||||
):
|
):
|
||||||
"""获取企业微信配置状态(不返回敏感信息)"""
|
"""获取企业微信配置状态。管理员可获取完整配置用于回填修改表单。"""
|
||||||
try:
|
try:
|
||||||
service = get_wecom_service(db)
|
service = get_wecom_service(db)
|
||||||
await service.load_config(tenant_id)
|
await service.load_config(tenant_id)
|
||||||
|
can_view_secrets = has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'is_configured': service.is_configured(),
|
'is_configured': service.is_configured(),
|
||||||
'corp_id': service.corp_id if service.is_configured() else None,
|
'corp_id': service.corp_id,
|
||||||
'agent_id': service.agent_id if service.is_configured() else None,
|
'agent_id': service.agent_id,
|
||||||
|
'app_secret': service.app_secret if can_view_secrets else None,
|
||||||
'has_token': bool(service.token),
|
'has_token': bool(service.token),
|
||||||
'has_encoding_key': bool(service.encoding_aes_key)
|
'has_encoding_key': bool(service.encoding_aes_key),
|
||||||
|
'token_preview': f"{service.token[:4]}...{service.token[-4:]}" if service.token and len(service.token) > 8 else (service.token or None),
|
||||||
|
'encoding_aes_key_preview': f"{service.encoding_aes_key[:4]}...{service.encoding_aes_key[-4:]}" if service.encoding_aes_key and len(service.encoding_aes_key) > 8 else (service.encoding_aes_key or None),
|
||||||
|
'token': service.token if can_view_secrets else None,
|
||||||
|
'encoding_aes_key': service.encoding_aes_key if can_view_secrets else None,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -137,7 +143,7 @@ async def delete_wecom_config(
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Integration).where(
|
select(Integration).where(
|
||||||
Integration.tenant_id == tenant_id,
|
Integration.tenant_id == tenant_id,
|
||||||
Integration.provider == 'wecom'
|
Integration.type == 'wecom'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
integration = result.scalar_one_or_none()
|
integration = result.scalar_one_or_none()
|
||||||
|
|
@ -157,6 +163,7 @@ async def delete_wecom_config(
|
||||||
# ==================== Webhook Endpoints ====================
|
# ==================== Webhook Endpoints ====================
|
||||||
|
|
||||||
@router.get("/webhook")
|
@router.get("/webhook")
|
||||||
|
@router.get("/webhook/", include_in_schema=False)
|
||||||
async def verify_webhook_url(
|
async def verify_webhook_url(
|
||||||
msg_signature: str,
|
msg_signature: str,
|
||||||
timestamp: str,
|
timestamp: str,
|
||||||
|
|
@ -193,6 +200,7 @@ async def verify_webhook_url(
|
||||||
|
|
||||||
|
|
||||||
@router.post("/webhook")
|
@router.post("/webhook")
|
||||||
|
@router.post("/webhook/", include_in_schema=False)
|
||||||
async def handle_wecom_event(
|
async def handle_wecom_event(
|
||||||
request: Request,
|
request: Request,
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,393 @@ Output JSON:
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
PROMPT_FEISHU_REQUIREMENT_ANALYSIS = """
|
||||||
|
你是 PMFlow 的需求信号分析器。请结合团队现有项目,判断一条飞书群消息是否是“可执行需求”,并判定该需求更可能属于“新项目”还是“已有项目”。
|
||||||
|
|
||||||
|
团队活跃项目列表(每行一个项目):
|
||||||
|
{project_summaries}
|
||||||
|
|
||||||
|
群消息:
|
||||||
|
{message}
|
||||||
|
|
||||||
|
输出 JSON,字段如下:
|
||||||
|
{{
|
||||||
|
"is_valuable": true/false,
|
||||||
|
"reason": "简短原因",
|
||||||
|
"impact_score": 0.0-1.0,
|
||||||
|
"priority": "high|medium|low",
|
||||||
|
"requirement_project_scope": "new_project|existing_project|non_requirement",
|
||||||
|
"target_project_hints": ["可能关联的项目名称或关键词"],
|
||||||
|
"analysis": {{
|
||||||
|
"intent": "需求意图摘要",
|
||||||
|
"evidence": ["判定依据1", "判定依据2"]
|
||||||
|
}},
|
||||||
|
"suggested_tasks": [
|
||||||
|
{{"task_name": "任务名称", "type": "backend|frontend|database|ai|devops", "estimated_hours": 4}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
|
||||||
|
判定规则:
|
||||||
|
1. 纯表情、寒暄、确认类回复(如“收到”“好的”“1”)一律判定为 non_requirement。
|
||||||
|
2. 若明确是全新方向、全新业务目标、全新系统建设,判定为 new_project。
|
||||||
|
3. 若明确是现有功能优化、Bug 修复、已有模块迭代,判定为 existing_project。
|
||||||
|
4. 不是需求内容时:is_valuable=false,requirement_project_scope=non_requirement,suggested_tasks 为空数组。
|
||||||
|
|
||||||
|
只输出 JSON,不要输出任何解释文本或 Markdown。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION = """
|
||||||
|
你是 PMFlow 的项目需求信号分流器。
|
||||||
|
请基于“项目列表上下文”和“飞书消息”,判断该消息是否为有效需求信号,并识别它属于哪个已有项目。
|
||||||
|
|
||||||
|
项目列表(JSON 数组):
|
||||||
|
{projects_json}
|
||||||
|
|
||||||
|
飞书消息:
|
||||||
|
{message}
|
||||||
|
|
||||||
|
输出 JSON:
|
||||||
|
{{
|
||||||
|
"is_requirement": true/false,
|
||||||
|
"reason": "判定原因",
|
||||||
|
"confidence": 0.0-1.0,
|
||||||
|
"classification": "existing_project|new_project|pending_classification|non_requirement",
|
||||||
|
"matched_project_ids": [1,2],
|
||||||
|
"matched_project_titles": ["项目A","项目B"],
|
||||||
|
"impact_score": 0.0-1.0,
|
||||||
|
"priority": "high|medium|low",
|
||||||
|
"summary": "需求摘要(简短)"
|
||||||
|
}}
|
||||||
|
|
||||||
|
规则:
|
||||||
|
1. 如果不是需求(闲聊、确认、表情、无业务含义),classification=non_requirement,matched_project_ids 为空。
|
||||||
|
2. 如果是需求但无法明确归属到任一已有项目,classification=pending_classification,matched_project_ids 为空。
|
||||||
|
3. 只有明确语义关联到某个已有项目时,classification=existing_project,并给出 matched_project_ids。
|
||||||
|
4. 如果消息明确在描述“全新立项/新项目”,classification=new_project,matched_project_ids 为空。
|
||||||
|
5. 不允许猜测项目归属;不确定时必须给 pending_classification。
|
||||||
|
|
||||||
|
只输出 JSON,不要输出任何其他文字。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
PROMPT_FEISHU_LEVEL_CLASSIFICATION = """
|
||||||
|
你是 PMFlow 的飞书消息分流器。请按四维度对消息做 0-1 评分,并判断是 project-level 还是 signal-level。
|
||||||
|
|
||||||
|
输入:
|
||||||
|
- 飞书消息:{message}
|
||||||
|
- 活跃项目列表(JSON):{projects_json}
|
||||||
|
|
||||||
|
输出 JSON:
|
||||||
|
{{
|
||||||
|
"build_intent_score": 0.0-1.0,
|
||||||
|
"scope_score": 0.0-1.0,
|
||||||
|
"timeline_score": 0.0-1.0,
|
||||||
|
"owner_score": 0.0-1.0,
|
||||||
|
"project_score": 0.0-1.0,
|
||||||
|
"level": "project|signal",
|
||||||
|
"level_confidence": 0.0-1.0,
|
||||||
|
"level_reasoning": "1-2句解释",
|
||||||
|
"should_split": true/false,
|
||||||
|
"split_items": [
|
||||||
|
{{"level":"project","content":"拆分后的项目部分"}},
|
||||||
|
{{"level":"signal","content":"拆分后的信号部分"}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
|
||||||
|
约束:
|
||||||
|
1. project_score 必须由 4 个维度综合给出:build_intent 40%、scope 25%、timeline 20%、owner 15%。
|
||||||
|
2. 若消息同时包含明确立项意图与明确信号事实(竞品发布/用户反馈/风险事件),should_split=true。
|
||||||
|
3. 不确定是否拆分时 should_split=false,且默认 level=signal。
|
||||||
|
4. 只输出 JSON。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
PROMPT_FEISHU_SIGNAL_EXTRACTION = """
|
||||||
|
你是 PMFlow 的信号结构化助手。请从飞书消息中提取信号字段。
|
||||||
|
|
||||||
|
输入:
|
||||||
|
- 飞书消息:{message}
|
||||||
|
- 活跃项目列表(JSON):{projects_json}
|
||||||
|
|
||||||
|
输出 JSON:
|
||||||
|
{{
|
||||||
|
"title": "一句话标题",
|
||||||
|
"type": "竞品动态|用户反馈|项目风险|新需求",
|
||||||
|
"priority": "P0|P1|P2|P3",
|
||||||
|
"matched_project_id": 1,
|
||||||
|
"summary": "2-3句摘要",
|
||||||
|
"reasoning": ["依据1","依据2","依据3"],
|
||||||
|
"confidence": 0.0-1.0,
|
||||||
|
"recommended_action": "enter_only|create_charter|trigger_alert"
|
||||||
|
}}
|
||||||
|
|
||||||
|
约束:
|
||||||
|
1. 无法确定 matched_project_id 时返回 null。
|
||||||
|
2. reasoning 必须是 3 条简短 bullet。
|
||||||
|
3. 只输出 JSON。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
PROMPT_FEISHU_PROJECT_PREFILL = """
|
||||||
|
你是 PMFlow 的项目预填助手。请从飞书消息中抽取新项目建议信息。
|
||||||
|
|
||||||
|
输入:
|
||||||
|
- 飞书消息:{message}
|
||||||
|
|
||||||
|
输出 JSON:
|
||||||
|
{{
|
||||||
|
"suggested_name": "项目名",
|
||||||
|
"suggested_description": "1-2句描述",
|
||||||
|
"suggested_owner": "负责人名或角色",
|
||||||
|
"suggested_timeline": "截止日期或周期",
|
||||||
|
"confidence": 0.0-1.0,
|
||||||
|
"reasoning": ["依据1","依据2","依据3"]
|
||||||
|
}}
|
||||||
|
|
||||||
|
约束:
|
||||||
|
1. reasoning 必须是 3 条简短 bullet。
|
||||||
|
2. 没有明确字段时可合理推断并在 reasoning 说明。
|
||||||
|
3. 只输出 JSON。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
PROMPT_FEISHU_TASK_ORCHESTRATION = """
|
||||||
|
你是 PMFlow 的任务编排与人员分配 AI。
|
||||||
|
请基于飞书需求信号,为指定项目产出“可执行任务 + 负责人建议”。
|
||||||
|
|
||||||
|
输入:
|
||||||
|
- project_json: {project_json}
|
||||||
|
- signal_json: {signal_json}
|
||||||
|
- members_json: {members_json}
|
||||||
|
|
||||||
|
输出 JSON:
|
||||||
|
{{
|
||||||
|
"overall_summary": "一句话总结本次编排策略",
|
||||||
|
"tasks": [
|
||||||
|
{{
|
||||||
|
"title": "任务标题(8-32字)",
|
||||||
|
"description": "任务描述(40-200字)",
|
||||||
|
"priority": "high|medium|low",
|
||||||
|
"estimated_hours": 1-80,
|
||||||
|
"required_skill_names": ["FastAPI", "React"],
|
||||||
|
"recommended_assignee_id": 123,
|
||||||
|
"assignee_reason": "为什么推荐该成员",
|
||||||
|
"confidence": 0.0-1.0
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
|
||||||
|
约束:
|
||||||
|
1. 任务数量 2-8 条,不要空数组。
|
||||||
|
2. 优先覆盖“立即可执行”的任务,避免泛化管理话术。
|
||||||
|
3. `recommended_assignee_id` 必须来自 members_json.user_id;无法确认时填 null。
|
||||||
|
4. 推荐负责人时同时考虑技能匹配与 workload_ratio,避免把任务继续压给高负载成员。
|
||||||
|
5. 只输出 JSON,不要 Markdown,不要解释。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# OpenClaw 指挥层
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
PROMPT_OPENCLAW_COMMAND_DISPATCH = """
|
||||||
|
首字符必须是 {{,末字符必须是 }}。
|
||||||
|
你是 PMFlow 的 OpenClaw 指挥层。只做一件事:根据输入生成可执行 operations。
|
||||||
|
|
||||||
|
输入:
|
||||||
|
- command_json: {command_json}
|
||||||
|
- project_json: {project_json}
|
||||||
|
- tasks_json: {tasks_json}
|
||||||
|
- members_json: {members_json}
|
||||||
|
- operation_catalog_json: {operation_catalog_json}
|
||||||
|
|
||||||
|
只输出 JSON:
|
||||||
|
{{
|
||||||
|
"summary": "一句话总结本次指挥决策",
|
||||||
|
"confidence": 0.0-1.0,
|
||||||
|
"operations": [
|
||||||
|
{{
|
||||||
|
"operation_code": "project.create|project.update|project.archive|project.reopen|task.create|task.assign|task.prioritize|task.status_transition|notification.create|personnel.workload_update",
|
||||||
|
"payload": {{}},
|
||||||
|
"reason": "为什么执行这一步"
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
|
||||||
|
规则:
|
||||||
|
1. operation_code 只能来自 operation_catalog_json。
|
||||||
|
2. 总操作数 <= {max_operations}。
|
||||||
|
3. 不编造 task_id / user_id / project_id。
|
||||||
|
4. 若 project_json.scope=global,当前项目为空时可从 project_json.candidate_projects 里选择 project_id;需要新建项目时使用 project.create。
|
||||||
|
5. project.create 缺少 title 时不要生成该操作。
|
||||||
|
6. project.update / project.archive / project.reopen 只有在 project_id 明确时才能生成。
|
||||||
|
7. 信息不足时返回空 operations,并在 summary 说明阻塞原因。
|
||||||
|
8. reason 要短、直接、可执行。
|
||||||
|
9. confidence 必须是 number,不能是字符串。
|
||||||
|
10. 禁止输出 thinking、analysis、解释句、Markdown、代码块。
|
||||||
|
11. 字段未知时返回空字符串、空数组或 null。
|
||||||
|
12. 信息不足时也必须返回合法 JSON,operations=[]。
|
||||||
|
13. 不要 Markdown,不要解释,只返回 JSON。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
PROMPT_SIGNAL_SOURCE_ONBOARDING = """
|
||||||
|
首字符必须是 {{,末字符必须是 }}。
|
||||||
|
你是 PMFlow 的信号源接入规划器。判断当前请求是不是“新增/接入一个信号源”,并返回结构化计划。
|
||||||
|
|
||||||
|
输入:
|
||||||
|
- request_json: {request_json}
|
||||||
|
- project_json: {project_json}
|
||||||
|
- signal_json: {signal_json}
|
||||||
|
|
||||||
|
只输出 JSON:
|
||||||
|
{{
|
||||||
|
"should_onboard": true,
|
||||||
|
"summary": "一句话说明为什么要接入该信号源",
|
||||||
|
"source": {{
|
||||||
|
"source_kind": "feishu|pmflow|github|webhook|url|browser|custom",
|
||||||
|
"source_name": "信号源名称",
|
||||||
|
"source_identifier": "稳定标识;无明确标识时可基于名称做简短稳定键",
|
||||||
|
"description": "这个信号源要监控什么",
|
||||||
|
"config": {{}}
|
||||||
|
}},
|
||||||
|
"worker_roles": ["pm_dispatcher", "tech_architect", "delivery_coach"],
|
||||||
|
"openclaw_instruction": "交给 OpenClaw 的自然语言指令"
|
||||||
|
}}
|
||||||
|
|
||||||
|
若不是信号源接入请求,输出:
|
||||||
|
{{
|
||||||
|
"should_onboard": false,
|
||||||
|
"summary": "不是信号源接入请求的原因",
|
||||||
|
"source": null,
|
||||||
|
"worker_roles": [],
|
||||||
|
"openclaw_instruction": ""
|
||||||
|
}}
|
||||||
|
|
||||||
|
规则:
|
||||||
|
1. 只有明确新增/接入/配置信号源时才返回 should_onboard=true。
|
||||||
|
2. 不编造 url / chat_id / repo / webhook。
|
||||||
|
3. worker_roles 只允许 pm_dispatcher / tech_architect / delivery_coach。
|
||||||
|
4. openclaw_instruction 保持简短,只说接入准备、任务拆解、负责人通知。
|
||||||
|
5. 无法确定 source_identifier 时,返回短且稳定的键,不要输出解释。
|
||||||
|
6. 禁止输出 thinking、analysis、解释句、Markdown、代码块。
|
||||||
|
7. 字段未知时返回空字符串、空数组或 null。
|
||||||
|
8. 不要 Markdown,不要解释,只返回 JSON。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION = """
|
||||||
|
首字符必须是 {{,末字符必须是 }}。
|
||||||
|
你是 PMFlow 的下级 AI worker,正在执行一个“signal source onboarding”工作项。
|
||||||
|
|
||||||
|
输入:
|
||||||
|
- role_json: {role_json}
|
||||||
|
- project_json: {project_json}
|
||||||
|
- source_json: {source_json}
|
||||||
|
- signal_json: {signal_json}
|
||||||
|
- commander_json: {commander_json}
|
||||||
|
|
||||||
|
输出 JSON:
|
||||||
|
{{
|
||||||
|
"summary": "本角色已完成的核心产出",
|
||||||
|
"deliverables": ["产出1", "产出2"],
|
||||||
|
"risks": ["风险1", "风险2"],
|
||||||
|
"next_actions": ["下一步1", "下一步2"]
|
||||||
|
}}
|
||||||
|
|
||||||
|
约束:
|
||||||
|
1. 输出必须体现当前 role_json 对应角色的职责边界。
|
||||||
|
2. `deliverables` / `risks` / `next_actions` 各自最多 4 条。
|
||||||
|
3. 不要编造数据库 ID,不要输出代码块。
|
||||||
|
4. 顶层必须始终包含 summary / deliverables / risks / next_actions 四个键,即使数组为空。
|
||||||
|
5. 字段未知时返回空字符串、空数组或 null。
|
||||||
|
6. 禁止输出 thinking、analysis、解释句、Markdown、代码块。
|
||||||
|
7. 只输出 JSON,不要 Markdown,不要解释文字。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Full Adaptive RAG
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
PROMPT_RAG_INTENT_CLASSIFICATION = """
|
||||||
|
你是 PMFlow 的意图分类器。请判断输入文本属于:question / statement / mixed。
|
||||||
|
|
||||||
|
输入:
|
||||||
|
{message}
|
||||||
|
|
||||||
|
输出 JSON:
|
||||||
|
{{
|
||||||
|
"intent": "question|statement|mixed",
|
||||||
|
"confidence": 0.0,
|
||||||
|
"question_part": "若无则空字符串",
|
||||||
|
"statement_part": "若无则空字符串"
|
||||||
|
}}
|
||||||
|
|
||||||
|
要求:只输出 JSON。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
PROMPT_RAG_ANSWER = """
|
||||||
|
你是 PMFlow 的 RAG 问答助手,负责项目与人员协同管理。
|
||||||
|
要求:
|
||||||
|
1. 只基于给定上下文回答,不要编造。
|
||||||
|
2. 若证据不足,明确说“不确定/信息不足”。
|
||||||
|
3. 回答后用“来源:”列出引用的来源编号。
|
||||||
|
4. 优先从 System Snapshot 与 User Snapshot 作答,不要忽略结构化业务数据。
|
||||||
|
5. 当问题涉及“我/当前用户/我的任务”时,只使用 User Snapshot 对应数据回答。
|
||||||
|
6. 不允许出现“无法访问项目数据”这类泛化托辞,若缺失请明确指出缺失字段。
|
||||||
|
7. 当问题是“你是谁/你能做什么”时,必须优先使用 System Snapshot.assistant_profile 回答。
|
||||||
|
8. 当问题涉及“系统架构/运行机制/技术栈”时,必须优先使用 System Snapshot.platform_profile 回答。
|
||||||
|
9. 即使检索上下文为空,也要基于 Snapshot 给出当前系统可验证信息,禁止输出默认兜底句。
|
||||||
|
10. 若字段缺失,显式列出缺失字段名,不要假设。
|
||||||
|
11. 当问题涉及“成员/人员/负责人/谁在做”时,优先使用 System Snapshot.team_members、User Snapshot 与结构化数据库结果,禁止默认返回项目列表。
|
||||||
|
|
||||||
|
当前项目ID:{project_id}
|
||||||
|
问题:{question}
|
||||||
|
检索最大相关度:{relevance_max}
|
||||||
|
|
||||||
|
System Snapshot(全局):
|
||||||
|
{system_snapshot}
|
||||||
|
|
||||||
|
User Snapshot(当前用户):
|
||||||
|
{user_snapshot}
|
||||||
|
|
||||||
|
最近会话(最多10轮):
|
||||||
|
{conversation}
|
||||||
|
|
||||||
|
检索上下文:
|
||||||
|
{context_blocks}
|
||||||
|
"""
|
||||||
|
|
||||||
|
PROMPT_RAG_DB_PROJECT_QA = """
|
||||||
|
你是 PMFlow 的项目数据语义检索助手。
|
||||||
|
你会收到“用户问题”和“项目索引(JSON)”,请基于索引做语义匹配后回答。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
1. 优先语义理解,不要机械关键词匹配;要容错同义词、口语、轻微错别字(如“键身”≈“健身”)。
|
||||||
|
2. 若用户问“有没有/是否存在某类项目”,必须给出明确结论(有/没有)并列出匹配项目。
|
||||||
|
3. 若用户问“进度/阶段/完成率”,请综合 task_total/done_count/doing_count/pending_count 输出量化结论。
|
||||||
|
4. 若证据不足,请明确说明不足点,不要编造。
|
||||||
|
5. 若问题焦点是成员/人员/负责人/谁在做(人员管理),该问题不应由项目索引直接回答,必须返回 handled=false。
|
||||||
|
|
||||||
|
输出 JSON(只输出 JSON,不要 Markdown):
|
||||||
|
{{
|
||||||
|
"handled": true,
|
||||||
|
"answer": "给用户展示的最终中文回答",
|
||||||
|
"matched_project_ids": [1, 2],
|
||||||
|
"confidence": 0.0
|
||||||
|
}}
|
||||||
|
|
||||||
|
用户问题:
|
||||||
|
{question}
|
||||||
|
|
||||||
|
项目索引(JSON):
|
||||||
|
{project_index}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
PROMPT_GENERATE_CHARTER = """
|
PROMPT_GENERATE_CHARTER = """
|
||||||
你是一位资深产品经理。根据以下市场/技术信号,生成一份结构化的需求宪章草案。
|
你是一位资深产品经理。根据以下市场/技术信号,生成一份结构化的需求宪章草案。
|
||||||
|
|
||||||
|
|
@ -497,3 +884,117 @@ PROMPT_WORKLOAD_OPTIMIZATION = """
|
||||||
]
|
]
|
||||||
}}
|
}}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 食谱热量识别 Prompts
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
PROMPT_ANALYZE_FOOD_CALORIES = """
|
||||||
|
你是一位专业的营养分析师,擅长通过图片识别食物并估算热量和营养成分。
|
||||||
|
|
||||||
|
请基于提供的食物描述或特征,识别食物类型并估算营养信息。
|
||||||
|
|
||||||
|
输入信息:
|
||||||
|
{food_description}
|
||||||
|
|
||||||
|
请输出 JSON 格式,包含以下字段:
|
||||||
|
{{
|
||||||
|
"food_name": "食物名称",
|
||||||
|
"confidence": 0.0-1.0,
|
||||||
|
"estimated_portion_size": "份量大小描述(如:一份、100克、半碗等)",
|
||||||
|
"calories": 数值,
|
||||||
|
"protein": 数值,
|
||||||
|
"fat": 数值,
|
||||||
|
"carbs": 数值,
|
||||||
|
"fiber": 数值,
|
||||||
|
"category": "分类(如:主食、蔬菜、肉类、汤品、甜品、饮品等)",
|
||||||
|
"cuisine": "菜系(如:中式、西式、日式、韩式等)",
|
||||||
|
"description": "简短的食物描述",
|
||||||
|
"analysis_notes": ["分析要点1", "分析要点2"],
|
||||||
|
"uncertainty_factors": ["不确定因素1(如有)"]
|
||||||
|
}}
|
||||||
|
|
||||||
|
估算规则:
|
||||||
|
1. 热量估算范围:考虑到份量不确定性,给出合理的中位值
|
||||||
|
2. 营养成分:基于常见食物数据库估算
|
||||||
|
3. confidence 根据描述清晰度和食物识别难度给出
|
||||||
|
4. 若无法识别,confidence 应 < 0.5,并在 uncertainty_factors 中说明
|
||||||
|
|
||||||
|
只输出 JSON,不要其他文字或 Markdown。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# AI 角色 Prompt 配置(支持前端可视化设置)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def build_default_role_prompt_bundle() -> dict:
|
||||||
|
"""返回默认角色 Prompt 配置(每次调用返回新对象)。"""
|
||||||
|
return {
|
||||||
|
"version": 1,
|
||||||
|
"active_role_key": "pm_dispatcher",
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"role_key": "pm_dispatcher",
|
||||||
|
"role_name": "产品经理(任务分派)",
|
||||||
|
"role_description": "负责澄清需求并给出可执行任务分派建议",
|
||||||
|
"enabled": True,
|
||||||
|
"system_prompt": """
|
||||||
|
你是 PMFlow 的任务分派产品经理。
|
||||||
|
|
||||||
|
职责定位:
|
||||||
|
1. 先理解需求背景,再输出执行计划。
|
||||||
|
2. 重点给出可落地的任务分派建议,避免空泛讨论。
|
||||||
|
3. 回复风格要专业、直接、有人味,优先给结论。
|
||||||
|
|
||||||
|
输出要求:
|
||||||
|
1. 先用 1-2 句话复述需求并确认目标。
|
||||||
|
2. 再给 3 条以内“下一步动作”,每条都要可执行。
|
||||||
|
3. 若信息不足,只提关键澄清问题(最多 2 个),不要臆测。
|
||||||
|
4. 如果涉及风险、依赖或排期冲突,要明确提醒。
|
||||||
|
|
||||||
|
禁止项:
|
||||||
|
- 不要输出与项目无关的泛化建议。
|
||||||
|
- 不要使用命令式生硬语气。
|
||||||
|
- 不要编造不存在的数据或结论。
|
||||||
|
""".strip(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role_key": "tech_architect",
|
||||||
|
"role_name": "技术架构师",
|
||||||
|
"role_description": "负责技术方案约束、风险评估与实现边界",
|
||||||
|
"enabled": True,
|
||||||
|
"system_prompt": """
|
||||||
|
你是 PMFlow 的技术架构师。
|
||||||
|
|
||||||
|
你需要基于需求识别实现边界、关键风险和技术依赖,给出可执行的技术建议。
|
||||||
|
输出要点:
|
||||||
|
1. 先给结论(可行/有风险/需拆分)。
|
||||||
|
2. 给出技术风险与约束(性能、安全、兼容、成本)。
|
||||||
|
3. 提供建议的实现路径与最小可行步骤。
|
||||||
|
4. 结尾给出是否建议立即进入开发。
|
||||||
|
|
||||||
|
风格:简洁、严谨、可执行,不讲空话。
|
||||||
|
""".strip(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role_key": "delivery_coach",
|
||||||
|
"role_name": "交付推进官",
|
||||||
|
"role_description": "负责推进节奏、协调分工与交付闭环",
|
||||||
|
"enabled": True,
|
||||||
|
"system_prompt": """
|
||||||
|
你是 PMFlow 的交付推进官。
|
||||||
|
|
||||||
|
你要把需求转成可执行的交付动作,强调优先级、负责人和时间边界。
|
||||||
|
输出格式:
|
||||||
|
1. 当前状态判断(一句话)。
|
||||||
|
2. 建议动作(不超过 3 条,动词开头)。
|
||||||
|
3. 风险提示(如有)。
|
||||||
|
|
||||||
|
风格:明确、友好、推动协作,避免过度技术细节。
|
||||||
|
""".strip(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,455 @@
|
||||||
|
"""Operation Catalog(系统写操作目录).
|
||||||
|
|
||||||
|
规则:
|
||||||
|
1. LLM 仅输出结构化意图,真实写库必须走 Tool/Service 层。
|
||||||
|
2. 每个操作必须声明 operation_code/input_schema/rbac_policy/idempotency_key/audit_fields/side_effect_guard。
|
||||||
|
3. 执行态统一遵循 commit_then_outbox,便于副作用可追溯与重放。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class OperationIdempotencySpec(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
fields: list[str] = Field(default_factory=list, min_length=1)
|
||||||
|
scope: Literal["tenant", "project", "global"] = "tenant"
|
||||||
|
ttl_seconds: int = Field(default=86400, ge=60, le=604800)
|
||||||
|
|
||||||
|
|
||||||
|
class OperationSideEffectGuard(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
require_tool_executor: bool = True
|
||||||
|
require_schema_validation: bool = True
|
||||||
|
deny_free_text_write: bool = True
|
||||||
|
transaction_mode: Literal["commit_then_outbox", "transactional"] = "commit_then_outbox"
|
||||||
|
allowed_paths: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class OperationCatalogEntry(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
operation_code: str = Field(min_length=3, max_length=120)
|
||||||
|
domain: Literal["project", "task", "personnel", "knowledge", "notification", "file"]
|
||||||
|
description: str = Field(min_length=4, max_length=300)
|
||||||
|
input_schema: dict[str, Any]
|
||||||
|
rbac_policy: list[str] = Field(default_factory=list, min_length=1)
|
||||||
|
idempotency_key: OperationIdempotencySpec
|
||||||
|
audit_fields: list[str] = Field(default_factory=list, min_length=1)
|
||||||
|
side_effect_guard: OperationSideEffectGuard
|
||||||
|
|
||||||
|
|
||||||
|
def _entry(
|
||||||
|
*,
|
||||||
|
operation_code: str,
|
||||||
|
domain: Literal["project", "task", "personnel", "knowledge", "notification", "file"],
|
||||||
|
description: str,
|
||||||
|
input_schema: dict[str, Any],
|
||||||
|
rbac_policy: list[str],
|
||||||
|
idempotency_fields: list[str],
|
||||||
|
audit_fields: list[str],
|
||||||
|
allowed_paths: list[str] | None = None,
|
||||||
|
) -> OperationCatalogEntry:
|
||||||
|
return OperationCatalogEntry(
|
||||||
|
operation_code=operation_code,
|
||||||
|
domain=domain,
|
||||||
|
description=description,
|
||||||
|
input_schema=input_schema,
|
||||||
|
rbac_policy=rbac_policy,
|
||||||
|
idempotency_key=OperationIdempotencySpec(fields=idempotency_fields),
|
||||||
|
audit_fields=audit_fields,
|
||||||
|
side_effect_guard=OperationSideEffectGuard(
|
||||||
|
allowed_paths=allowed_paths or [],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_COMMON_AUDIT_FIELDS = [
|
||||||
|
"tenant_id",
|
||||||
|
"user_id",
|
||||||
|
"trace_id",
|
||||||
|
"operation_code",
|
||||||
|
"idempotency_key",
|
||||||
|
"target_type",
|
||||||
|
"target_id",
|
||||||
|
"before_snapshot",
|
||||||
|
"after_snapshot",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
OPERATION_CATALOG: tuple[OperationCatalogEntry, ...] = (
|
||||||
|
_entry(
|
||||||
|
operation_code="project.create",
|
||||||
|
domain="project",
|
||||||
|
description="创建项目",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["title"],
|
||||||
|
"properties": {
|
||||||
|
"title": {"type": "string", "minLength": 1, "maxLength": 80},
|
||||||
|
"description": {"type": "string", "maxLength": 4000},
|
||||||
|
"owner_id": {"type": "integer"},
|
||||||
|
},
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
rbac_policy=["BOSS", "ADMIN"],
|
||||||
|
idempotency_fields=["tenant_id", "title"],
|
||||||
|
audit_fields=_COMMON_AUDIT_FIELDS,
|
||||||
|
),
|
||||||
|
_entry(
|
||||||
|
operation_code="project.update",
|
||||||
|
domain="project",
|
||||||
|
description="更新项目元数据",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["project_id"],
|
||||||
|
"properties": {
|
||||||
|
"project_id": {"type": "integer"},
|
||||||
|
"title": {"type": "string", "maxLength": 80},
|
||||||
|
"description": {"type": "string", "maxLength": 4000},
|
||||||
|
"status": {"type": "string", "enum": ["active", "archived", "done"]},
|
||||||
|
},
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
rbac_policy=["BOSS", "ADMIN"],
|
||||||
|
idempotency_fields=["tenant_id", "project_id", "updated_at"],
|
||||||
|
audit_fields=_COMMON_AUDIT_FIELDS,
|
||||||
|
),
|
||||||
|
_entry(
|
||||||
|
operation_code="project.archive",
|
||||||
|
domain="project",
|
||||||
|
description="归档项目",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["project_id"],
|
||||||
|
"properties": {"project_id": {"type": "integer"}},
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
rbac_policy=["BOSS", "ADMIN"],
|
||||||
|
idempotency_fields=["tenant_id", "project_id", "status"],
|
||||||
|
audit_fields=_COMMON_AUDIT_FIELDS,
|
||||||
|
),
|
||||||
|
_entry(
|
||||||
|
operation_code="project.reopen",
|
||||||
|
domain="project",
|
||||||
|
description="重新打开项目",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["project_id"],
|
||||||
|
"properties": {"project_id": {"type": "integer"}},
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
rbac_policy=["BOSS", "ADMIN"],
|
||||||
|
idempotency_fields=["tenant_id", "project_id", "status"],
|
||||||
|
audit_fields=_COMMON_AUDIT_FIELDS,
|
||||||
|
),
|
||||||
|
_entry(
|
||||||
|
operation_code="task.create",
|
||||||
|
domain="task",
|
||||||
|
description="创建任务",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["project_id", "title"],
|
||||||
|
"properties": {
|
||||||
|
"project_id": {"type": "integer"},
|
||||||
|
"title": {"type": "string", "minLength": 1, "maxLength": 120},
|
||||||
|
"description": {"type": "string", "maxLength": 5000},
|
||||||
|
"assignee_id": {"type": "integer"},
|
||||||
|
"required_skill_tag_ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "integer"},
|
||||||
|
"maxItems": 20,
|
||||||
|
},
|
||||||
|
"priority": {"type": "string", "enum": ["high", "medium", "low", "P0", "P1", "P2", "P3"]},
|
||||||
|
"estimated_hours": {"type": "number", "minimum": 0, "maximum": 200},
|
||||||
|
"due_date": {"type": "string", "maxLength": 40},
|
||||||
|
},
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
rbac_policy=["BOSS", "ADMIN", "PM"],
|
||||||
|
idempotency_fields=["tenant_id", "project_id", "title"],
|
||||||
|
audit_fields=_COMMON_AUDIT_FIELDS,
|
||||||
|
),
|
||||||
|
_entry(
|
||||||
|
operation_code="task.update",
|
||||||
|
domain="task",
|
||||||
|
description="更新任务内容",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["task_id"],
|
||||||
|
"properties": {
|
||||||
|
"task_id": {"type": "integer"},
|
||||||
|
"title": {"type": "string", "maxLength": 120},
|
||||||
|
"description": {"type": "string", "maxLength": 5000},
|
||||||
|
},
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
rbac_policy=["BOSS", "ADMIN", "PM"],
|
||||||
|
idempotency_fields=["tenant_id", "task_id", "updated_at"],
|
||||||
|
audit_fields=_COMMON_AUDIT_FIELDS,
|
||||||
|
),
|
||||||
|
_entry(
|
||||||
|
operation_code="task.assign",
|
||||||
|
domain="task",
|
||||||
|
description="分配任务负责人",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["task_id", "assignee_id"],
|
||||||
|
"properties": {
|
||||||
|
"task_id": {"type": "integer"},
|
||||||
|
"assignee_id": {"type": "integer"},
|
||||||
|
},
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
rbac_policy=["BOSS", "ADMIN", "PM"],
|
||||||
|
idempotency_fields=["tenant_id", "task_id", "assignee_id"],
|
||||||
|
audit_fields=_COMMON_AUDIT_FIELDS,
|
||||||
|
),
|
||||||
|
_entry(
|
||||||
|
operation_code="task.prioritize",
|
||||||
|
domain="task",
|
||||||
|
description="调整任务优先级",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["task_id", "priority"],
|
||||||
|
"properties": {
|
||||||
|
"task_id": {"type": "integer"},
|
||||||
|
"priority": {"type": "string", "enum": ["high", "medium", "low", "P0", "P1", "P2", "P3"]},
|
||||||
|
},
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
rbac_policy=["BOSS", "ADMIN", "PM"],
|
||||||
|
idempotency_fields=["tenant_id", "task_id", "priority"],
|
||||||
|
audit_fields=_COMMON_AUDIT_FIELDS,
|
||||||
|
),
|
||||||
|
_entry(
|
||||||
|
operation_code="task.status_transition",
|
||||||
|
domain="task",
|
||||||
|
description="任务状态迁移",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["task_id", "status"],
|
||||||
|
"properties": {
|
||||||
|
"task_id": {"type": "integer"},
|
||||||
|
"status": {"type": "string", "enum": ["todo", "in_progress", "review", "done", "blocked", "closed"]},
|
||||||
|
},
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
rbac_policy=["BOSS", "ADMIN", "PM", "AI_ENGINEER"],
|
||||||
|
idempotency_fields=["tenant_id", "task_id", "status"],
|
||||||
|
audit_fields=_COMMON_AUDIT_FIELDS,
|
||||||
|
),
|
||||||
|
_entry(
|
||||||
|
operation_code="task.delete",
|
||||||
|
domain="task",
|
||||||
|
description="删除任务",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["task_id"],
|
||||||
|
"properties": {"task_id": {"type": "integer"}},
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
rbac_policy=["BOSS", "ADMIN"],
|
||||||
|
idempotency_fields=["tenant_id", "task_id"],
|
||||||
|
audit_fields=_COMMON_AUDIT_FIELDS,
|
||||||
|
),
|
||||||
|
_entry(
|
||||||
|
operation_code="personnel.role_update",
|
||||||
|
domain="personnel",
|
||||||
|
description="更新人员角色",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["user_id", "role"],
|
||||||
|
"properties": {
|
||||||
|
"user_id": {"type": "integer"},
|
||||||
|
"role": {"type": "string", "maxLength": 50},
|
||||||
|
},
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
rbac_policy=["BOSS", "ADMIN"],
|
||||||
|
idempotency_fields=["tenant_id", "user_id", "role"],
|
||||||
|
audit_fields=_COMMON_AUDIT_FIELDS,
|
||||||
|
),
|
||||||
|
_entry(
|
||||||
|
operation_code="personnel.workload_update",
|
||||||
|
domain="personnel",
|
||||||
|
description="更新人员每周可用工时",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["user_id", "max_weekly_hours"],
|
||||||
|
"properties": {
|
||||||
|
"user_id": {"type": "integer"},
|
||||||
|
"max_weekly_hours": {"type": "number", "minimum": 1, "maximum": 120},
|
||||||
|
},
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
rbac_policy=["BOSS", "ADMIN", "PM"],
|
||||||
|
idempotency_fields=["tenant_id", "user_id", "max_weekly_hours", "date"],
|
||||||
|
audit_fields=_COMMON_AUDIT_FIELDS,
|
||||||
|
),
|
||||||
|
_entry(
|
||||||
|
operation_code="personnel.availability_update",
|
||||||
|
domain="personnel",
|
||||||
|
description="更新人员可用性",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["user_id", "status"],
|
||||||
|
"properties": {
|
||||||
|
"user_id": {"type": "integer"},
|
||||||
|
"status": {"type": "string", "enum": ["active", "away", "offline"]},
|
||||||
|
},
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
rbac_policy=["BOSS", "ADMIN", "PM"],
|
||||||
|
idempotency_fields=["tenant_id", "user_id", "status", "date"],
|
||||||
|
audit_fields=_COMMON_AUDIT_FIELDS,
|
||||||
|
),
|
||||||
|
_entry(
|
||||||
|
operation_code="knowledge.doc_upsert",
|
||||||
|
domain="knowledge",
|
||||||
|
description="新增或更新知识文档",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["title", "content"],
|
||||||
|
"properties": {
|
||||||
|
"doc_id": {"type": "integer"},
|
||||||
|
"title": {"type": "string", "minLength": 1, "maxLength": 255},
|
||||||
|
"content": {"type": "string", "minLength": 1},
|
||||||
|
},
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
rbac_policy=["BOSS", "ADMIN", "PM", "AI_ENGINEER"],
|
||||||
|
idempotency_fields=["tenant_id", "doc_id", "content_hash"],
|
||||||
|
audit_fields=_COMMON_AUDIT_FIELDS,
|
||||||
|
),
|
||||||
|
_entry(
|
||||||
|
operation_code="knowledge.chunk_upsert",
|
||||||
|
domain="knowledge",
|
||||||
|
description="新增或更新知识分片",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["doc_id", "chunk_text"],
|
||||||
|
"properties": {
|
||||||
|
"doc_id": {"type": "integer"},
|
||||||
|
"chunk_id": {"type": "integer"},
|
||||||
|
"chunk_text": {"type": "string", "minLength": 1},
|
||||||
|
},
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
rbac_policy=["BOSS", "ADMIN", "PM", "AI_ENGINEER"],
|
||||||
|
idempotency_fields=["tenant_id", "doc_id", "chunk_hash"],
|
||||||
|
audit_fields=_COMMON_AUDIT_FIELDS,
|
||||||
|
),
|
||||||
|
_entry(
|
||||||
|
operation_code="knowledge.link_upsert",
|
||||||
|
domain="knowledge",
|
||||||
|
description="维护知识链接(文档/实体/项目关系)",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["source_type", "source_id", "target_type", "target_id"],
|
||||||
|
"properties": {
|
||||||
|
"source_type": {"type": "string", "maxLength": 40},
|
||||||
|
"source_id": {"type": "integer"},
|
||||||
|
"target_type": {"type": "string", "maxLength": 40},
|
||||||
|
"target_id": {"type": "integer"},
|
||||||
|
},
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
rbac_policy=["BOSS", "ADMIN", "PM", "AI_ENGINEER"],
|
||||||
|
idempotency_fields=["tenant_id", "source_type", "source_id", "target_type", "target_id"],
|
||||||
|
audit_fields=_COMMON_AUDIT_FIELDS,
|
||||||
|
),
|
||||||
|
_entry(
|
||||||
|
operation_code="knowledge.reindex",
|
||||||
|
domain="knowledge",
|
||||||
|
description="触发知识库重建索引",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["scope"],
|
||||||
|
"properties": {
|
||||||
|
"scope": {"type": "string", "enum": ["tenant", "project", "document"]},
|
||||||
|
"project_id": {"type": "integer"},
|
||||||
|
"doc_id": {"type": "integer"},
|
||||||
|
},
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
rbac_policy=["BOSS", "ADMIN", "AI_ENGINEER"],
|
||||||
|
idempotency_fields=["tenant_id", "scope", "project_id", "doc_id", "date_hour"],
|
||||||
|
audit_fields=_COMMON_AUDIT_FIELDS,
|
||||||
|
),
|
||||||
|
_entry(
|
||||||
|
operation_code="notification.create",
|
||||||
|
domain="notification",
|
||||||
|
description="创建通知",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["user_id", "title", "content"],
|
||||||
|
"properties": {
|
||||||
|
"user_id": {"type": "integer"},
|
||||||
|
"title": {"type": "string", "minLength": 1, "maxLength": 150},
|
||||||
|
"content": {"type": "string", "minLength": 1, "maxLength": 2000},
|
||||||
|
},
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
rbac_policy=["BOSS", "ADMIN", "PM"],
|
||||||
|
idempotency_fields=["tenant_id", "project_id", "user_id", "title", "content_hash"],
|
||||||
|
audit_fields=_COMMON_AUDIT_FIELDS,
|
||||||
|
),
|
||||||
|
_entry(
|
||||||
|
operation_code="notification.ack",
|
||||||
|
domain="notification",
|
||||||
|
description="确认通知",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["notification_id"],
|
||||||
|
"properties": {"notification_id": {"type": "integer"}},
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
rbac_policy=["BOSS", "ADMIN", "PM", "AI_ENGINEER"],
|
||||||
|
idempotency_fields=["tenant_id", "notification_id", "is_read"],
|
||||||
|
audit_fields=_COMMON_AUDIT_FIELDS,
|
||||||
|
),
|
||||||
|
_entry(
|
||||||
|
operation_code="notification.close",
|
||||||
|
domain="notification",
|
||||||
|
description="关闭通知",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["notification_id"],
|
||||||
|
"properties": {"notification_id": {"type": "integer"}},
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
rbac_policy=["BOSS", "ADMIN", "PM"],
|
||||||
|
idempotency_fields=["tenant_id", "notification_id", "status"],
|
||||||
|
audit_fields=_COMMON_AUDIT_FIELDS,
|
||||||
|
),
|
||||||
|
_entry(
|
||||||
|
operation_code="file.write_whitelist",
|
||||||
|
domain="file",
|
||||||
|
description="写入白名单文件路径",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["path", "content"],
|
||||||
|
"properties": {
|
||||||
|
"path": {"type": "string", "minLength": 1, "maxLength": 400},
|
||||||
|
"content": {"type": "string"},
|
||||||
|
"encoding": {"type": "string", "enum": ["utf-8", "ascii"]},
|
||||||
|
},
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
rbac_policy=["BOSS", "ADMIN", "PM"],
|
||||||
|
idempotency_fields=["tenant_id", "path", "content_hash"],
|
||||||
|
audit_fields=_COMMON_AUDIT_FIELDS,
|
||||||
|
allowed_paths=[
|
||||||
|
"/app/backend/reports",
|
||||||
|
"/app/backend/exports",
|
||||||
|
"/app/backend/tmp",
|
||||||
|
"/tmp/pmflow",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
@ -5,10 +5,19 @@
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
# 统一从 backend/.env 加载配置,避免因启动目录不同读到错误环境
|
||||||
|
BACKEND_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
PROJECT_ROOT = BACKEND_DIR.parent
|
||||||
|
load_dotenv(BACKEND_DIR / ".env", override=False)
|
||||||
|
load_dotenv(PROJECT_ROOT / ".env", override=False)
|
||||||
|
|
||||||
|
# 默认数据库固定到 backend/neural_sync.db,避免相对路径导致多库混用
|
||||||
|
DEFAULT_SQLITE_DB = BACKEND_DIR / "neural_sync.db"
|
||||||
|
DEFAULT_DATABASE_URL = f"sqlite+aiosqlite:///{DEFAULT_SQLITE_DB.as_posix()}"
|
||||||
|
|
||||||
|
|
||||||
class Settings:
|
class Settings:
|
||||||
|
|
@ -19,7 +28,11 @@ class Settings:
|
||||||
debug: bool = os.getenv("DEBUG", "false").lower() in ("true", "1", "yes")
|
debug: bool = os.getenv("DEBUG", "false").lower() in ("true", "1", "yes")
|
||||||
|
|
||||||
# 数据库
|
# 数据库
|
||||||
database_url: str = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./neural_sync.db")
|
database_url: str = os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL)
|
||||||
|
db_pool_size: int = int(os.getenv("DB_POOL_SIZE", "20"))
|
||||||
|
db_max_overflow: int = int(os.getenv("DB_MAX_OVERFLOW", "40"))
|
||||||
|
db_pool_timeout: int = int(os.getenv("DB_POOL_TIMEOUT", "30"))
|
||||||
|
db_pool_recycle: int = int(os.getenv("DB_POOL_RECYCLE", "1800"))
|
||||||
|
|
||||||
# JWT
|
# JWT
|
||||||
secret_key: str = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
|
secret_key: str = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
# 尝试使用 structlog,若未安装则回退到标准 logging
|
# 尝试使用 structlog,若未安装则回退到标准 logging
|
||||||
|
|
@ -52,8 +53,63 @@ def configure_logging(debug: bool = False) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _StdLoggerCompat:
|
||||||
|
"""兼容 structlog 风格 kwargs 的标准 logging 适配器。"""
|
||||||
|
|
||||||
|
_PASSTHROUGH_KEYS = {"exc_info", "stack_info", "stacklevel", "extra"}
|
||||||
|
|
||||||
|
def __init__(self, logger: logging.Logger):
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
def _split_kwargs(self, kwargs: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||||
|
passthrough: dict[str, Any] = {}
|
||||||
|
fields: dict[str, Any] = {}
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if key in self._PASSTHROUGH_KEYS:
|
||||||
|
passthrough[key] = value
|
||||||
|
else:
|
||||||
|
fields[key] = value
|
||||||
|
return passthrough, fields
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_fields(fields: dict[str, Any]) -> str:
|
||||||
|
try:
|
||||||
|
return json.dumps(fields, ensure_ascii=False, default=str)
|
||||||
|
except Exception:
|
||||||
|
return str(fields)
|
||||||
|
|
||||||
|
def _log(self, method: str, msg: Any, *args: Any, **kwargs: Any) -> None:
|
||||||
|
passthrough, fields = self._split_kwargs(kwargs)
|
||||||
|
text = str(msg)
|
||||||
|
if fields:
|
||||||
|
text = f"{text} | {self._format_fields(fields)}"
|
||||||
|
getattr(self._logger, method)(text, *args, **passthrough)
|
||||||
|
|
||||||
|
def debug(self, msg: Any, *args: Any, **kwargs: Any) -> None:
|
||||||
|
self._log("debug", msg, *args, **kwargs)
|
||||||
|
|
||||||
|
def info(self, msg: Any, *args: Any, **kwargs: Any) -> None:
|
||||||
|
self._log("info", msg, *args, **kwargs)
|
||||||
|
|
||||||
|
def warning(self, msg: Any, *args: Any, **kwargs: Any) -> None:
|
||||||
|
self._log("warning", msg, *args, **kwargs)
|
||||||
|
|
||||||
|
def error(self, msg: Any, *args: Any, **kwargs: Any) -> None:
|
||||||
|
self._log("error", msg, *args, **kwargs)
|
||||||
|
|
||||||
|
def exception(self, msg: Any, *args: Any, **kwargs: Any) -> None:
|
||||||
|
kwargs.setdefault("exc_info", True)
|
||||||
|
self._log("error", msg, *args, **kwargs)
|
||||||
|
|
||||||
|
def critical(self, msg: Any, *args: Any, **kwargs: Any) -> None:
|
||||||
|
self._log("critical", msg, *args, **kwargs)
|
||||||
|
|
||||||
|
def __getattr__(self, item: str) -> Any:
|
||||||
|
return getattr(self._logger, item)
|
||||||
|
|
||||||
|
|
||||||
def get_logger(name: str):
|
def get_logger(name: str):
|
||||||
"""获取 logger 实例"""
|
"""获取 logger 实例"""
|
||||||
if USE_STRUCTLOG:
|
if USE_STRUCTLOG:
|
||||||
return structlog.get_logger(name)
|
return structlog.get_logger(name)
|
||||||
return logging.getLogger(name)
|
return _StdLoggerCompat(logging.getLogger(name))
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ from sqlalchemy.ext.asyncio import (
|
||||||
async_sessionmaker,
|
async_sessionmaker,
|
||||||
create_async_engine,
|
create_async_engine,
|
||||||
)
|
)
|
||||||
|
from sqlalchemy import event
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
from .core.config import settings
|
from .core.config import settings
|
||||||
|
|
||||||
|
|
@ -20,13 +22,43 @@ def _is_sqlite() -> bool:
|
||||||
return DATABASE_URL.lower().startswith("sqlite")
|
return DATABASE_URL.lower().startswith("sqlite")
|
||||||
|
|
||||||
|
|
||||||
|
engine_kwargs = {
|
||||||
|
"echo": settings.debug,
|
||||||
|
"future": True,
|
||||||
|
"pool_pre_ping": not _is_sqlite(),
|
||||||
|
}
|
||||||
|
if _is_sqlite():
|
||||||
|
engine_kwargs["connect_args"] = {
|
||||||
|
"timeout": 30,
|
||||||
|
"check_same_thread": False,
|
||||||
|
}
|
||||||
|
engine_kwargs["poolclass"] = NullPool
|
||||||
|
if not _is_sqlite():
|
||||||
|
engine_kwargs.update(
|
||||||
|
{
|
||||||
|
"pool_size": max(5, int(settings.db_pool_size)),
|
||||||
|
"max_overflow": max(0, int(settings.db_max_overflow)),
|
||||||
|
"pool_timeout": max(5, int(settings.db_pool_timeout)),
|
||||||
|
"pool_recycle": max(60, int(settings.db_pool_recycle)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
engine: AsyncEngine = create_async_engine(
|
engine: AsyncEngine = create_async_engine(
|
||||||
DATABASE_URL,
|
DATABASE_URL,
|
||||||
echo=settings.debug,
|
**engine_kwargs,
|
||||||
future=True,
|
|
||||||
pool_pre_ping=not _is_sqlite(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if _is_sqlite():
|
||||||
|
@event.listens_for(engine.sync_engine, "connect")
|
||||||
|
def _configure_sqlite_pragmas(dbapi_connection, _connection_record) -> None: # pragma: no cover
|
||||||
|
cursor = dbapi_connection.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute("PRAGMA journal_mode=WAL")
|
||||||
|
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||||
|
cursor.execute("PRAGMA busy_timeout=30000")
|
||||||
|
finally:
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
AsyncSessionLocal = async_sessionmaker(
|
AsyncSessionLocal = async_sessionmaker(
|
||||||
bind=engine,
|
bind=engine,
|
||||||
class_=AsyncSession,
|
class_=AsyncSession,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
|
from ..dependencies.tenant import get_tenant_id
|
||||||
from ..models import User, UserStatus # noqa: E402
|
from ..models import User, UserStatus # noqa: E402
|
||||||
from ..services.auth import AuthService, has_any_role
|
from ..services.auth import AuthService, has_any_role
|
||||||
|
|
||||||
|
|
@ -71,3 +72,17 @@ def require_roles(*roles: str):
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
return role_checker
|
return role_checker
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_tenant_id(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
header_tenant_id: int = Depends(get_tenant_id),
|
||||||
|
) -> int:
|
||||||
|
"""兼容旧接口:优先取 JWT 中 tenant_id,没有则回落 Header。"""
|
||||||
|
try:
|
||||||
|
tenant_id = int(getattr(current_user, "tenant_id", 0) or 0)
|
||||||
|
if tenant_id > 0:
|
||||||
|
return tenant_id
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return int(header_tenant_id or 1)
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,13 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from .api.router import api_router
|
from .api.router import api_router
|
||||||
from .core.config import settings
|
from .core.config import settings
|
||||||
|
|
@ -15,10 +17,310 @@ from .core.response import success_response
|
||||||
from .database import AsyncSessionLocal, engine
|
from .database import AsyncSessionLocal, engine
|
||||||
from .models import Base
|
from .models import Base
|
||||||
from .services.scheduler_center import WorkloadRecalcScheduler
|
from .services.scheduler_center import WorkloadRecalcScheduler
|
||||||
|
from .services.scheduler_center import KnowledgeUrlRefreshScheduler
|
||||||
|
from .services.scheduler_center import KnowledgeGraphIncrementalScheduler
|
||||||
|
from .services.scheduler_center import AIPMOCadenceScheduler
|
||||||
|
from .services.ai_orchestration_run_service import AIOrchestrationRunService
|
||||||
from .services.skill_service import init_system_skill_tags
|
from .services.skill_service import init_system_skill_tags
|
||||||
|
from .services.feishu_long_connection import init_feishu_long_connection_manager
|
||||||
|
from .services.feishu_integration import dispatch_pending_feishu_replies
|
||||||
|
from .services.context_cache import register_context_cache_invalidation_hooks
|
||||||
|
|
||||||
configure_logging(settings.debug)
|
configure_logging(settings.debug)
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
register_context_cache_invalidation_hooks()
|
||||||
|
|
||||||
|
|
||||||
|
def _sqlite_backfill_columns(sync_conn) -> None:
|
||||||
|
"""为历史 SQLite 库补齐新增列,避免运行时 500。"""
|
||||||
|
dialect_name = str(sync_conn.dialect.name or "").lower()
|
||||||
|
if dialect_name != "sqlite":
|
||||||
|
return
|
||||||
|
|
||||||
|
def _columns(table_name: str) -> set[str]:
|
||||||
|
try:
|
||||||
|
rows = sync_conn.exec_driver_sql(f"PRAGMA table_info({table_name})").fetchall()
|
||||||
|
return {str(row[1]) for row in rows}
|
||||||
|
except Exception:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
def _add_column(table_name: str, column_name: str, column_ddl: str) -> None:
|
||||||
|
cols = _columns(table_name)
|
||||||
|
if not cols or column_name in cols:
|
||||||
|
return
|
||||||
|
sync_conn.exec_driver_sql(
|
||||||
|
f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_ddl}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# truth_nodes
|
||||||
|
_add_column("truth_nodes", "signal_id", "BIGINT")
|
||||||
|
|
||||||
|
# signals
|
||||||
|
_add_column("signals", "feishu_item_id", "BIGINT")
|
||||||
|
_add_column("signals", "matched_project_id", "BIGINT")
|
||||||
|
_add_column("signals", "title", "VARCHAR(200)")
|
||||||
|
_add_column("signals", "signal_type", "VARCHAR(50)")
|
||||||
|
_add_column("signals", "summary", "TEXT")
|
||||||
|
_add_column("signals", "reasoning", "TEXT")
|
||||||
|
_add_column("signals", "confidence", "FLOAT")
|
||||||
|
_add_column("signals", "recommended_action", "VARCHAR(50)")
|
||||||
|
_add_column("signals", "status", "VARCHAR(50) NOT NULL DEFAULT '待处理'")
|
||||||
|
|
||||||
|
# qa_records
|
||||||
|
_add_column("qa_records", "session_id", "INTEGER")
|
||||||
|
_add_column("qa_records", "turn_index", "INTEGER NOT NULL DEFAULT 1")
|
||||||
|
|
||||||
|
# ai_processing_logs
|
||||||
|
_add_column("ai_processing_logs", "normalized_text", "TEXT")
|
||||||
|
_add_column("ai_processing_logs", "route", "VARCHAR(80)")
|
||||||
|
_add_column("ai_processing_logs", "intent", "VARCHAR(40)")
|
||||||
|
_add_column("ai_processing_logs", "executor", "VARCHAR(40)")
|
||||||
|
_add_column("ai_processing_logs", "operation_code", "VARCHAR(80)")
|
||||||
|
_add_column("ai_processing_logs", "operation_result", "JSON")
|
||||||
|
_add_column("ai_processing_logs", "reply_source", "VARCHAR(40)")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_dialect(value: str) -> str:
|
||||||
|
raw = str(value or "").strip().lower()
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
if raw.startswith("postgres"):
|
||||||
|
return "postgresql"
|
||||||
|
if raw.startswith("sqlite"):
|
||||||
|
return "sqlite"
|
||||||
|
if raw.startswith("mysql"):
|
||||||
|
return "mysql"
|
||||||
|
if "+" in raw:
|
||||||
|
raw = raw.split("+", 1)[0]
|
||||||
|
if ":" in raw:
|
||||||
|
raw = raw.split(":", 1)[0]
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_db_url(url: str) -> str:
|
||||||
|
value = str(url or "").strip()
|
||||||
|
if "@" not in value:
|
||||||
|
return value
|
||||||
|
prefix, suffix = value.split("@", 1)
|
||||||
|
if "://" not in prefix:
|
||||||
|
return f"***@{suffix}"
|
||||||
|
scheme, creds = prefix.split("://", 1)
|
||||||
|
user = creds.split(":", 1)[0] if ":" in creds else creds
|
||||||
|
return f"{scheme}://{user}:***@{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def _check_database_dialect_guard(sync_conn) -> None:
|
||||||
|
configured = _normalize_dialect(settings.database_url)
|
||||||
|
actual = _normalize_dialect(sync_conn.dialect.name)
|
||||||
|
expected = _normalize_dialect(os.getenv("PMFLOW_EXPECTED_DB_DIALECT", ""))
|
||||||
|
|
||||||
|
env_urls = [
|
||||||
|
os.getenv("DATABASE_URL", ""),
|
||||||
|
os.getenv("SQLALCHEMY_DATABASE_URL", ""),
|
||||||
|
os.getenv("SYNC_DATABASE_URL", ""),
|
||||||
|
]
|
||||||
|
configured_families = {_normalize_dialect(item) for item in env_urls if str(item or "").strip()}
|
||||||
|
configured_families.discard("")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"database_dialect_guard",
|
||||||
|
configured=configured,
|
||||||
|
actual=actual,
|
||||||
|
expected=expected or "unset",
|
||||||
|
database_url=_mask_db_url(settings.database_url),
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"[DB-Guard] "
|
||||||
|
f"configured={configured}, actual={actual}, expected={expected or 'unset'}, "
|
||||||
|
f"url={_mask_db_url(settings.database_url)}"
|
||||||
|
)
|
||||||
|
if configured and actual and configured != actual:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"数据库方言不一致: configured={configured}, actual={actual}。"
|
||||||
|
"请确保同一服务只连接单一数据库方言。"
|
||||||
|
)
|
||||||
|
if expected and actual and expected != actual:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"数据库方言与 PMFLOW_EXPECTED_DB_DIALECT 不一致: expected={expected}, actual={actual}。"
|
||||||
|
)
|
||||||
|
if len(configured_families) > 1:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"检测到多种数据库方言配置: {sorted(configured_families)}。"
|
||||||
|
"禁止同一服务混用 SQLite/Postgres。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_feishu_integrity_guards(sync_conn) -> None:
|
||||||
|
dialect = _normalize_dialect(sync_conn.dialect.name)
|
||||||
|
if dialect not in {"postgresql", "sqlite"}:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Outbox 表兜底,兼容未跑 Alembic 的环境。
|
||||||
|
outbox_meta = sa.MetaData()
|
||||||
|
outbox_table = sa.Table(
|
||||||
|
"feishu_reply_outbox",
|
||||||
|
outbox_meta,
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default=sa.text("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=sa.text("'pending'")),
|
||||||
|
sa.Column("attempts", sa.Integer(), nullable=False, server_default=sa.text("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),
|
||||||
|
)
|
||||||
|
outbox_table.create(sync_conn, checkfirst=True)
|
||||||
|
sync_conn.exec_driver_sql(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_tenant_id ON feishu_reply_outbox (tenant_id)"
|
||||||
|
)
|
||||||
|
sync_conn.exec_driver_sql(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_trace_id ON feishu_reply_outbox (trace_id)"
|
||||||
|
)
|
||||||
|
sync_conn.exec_driver_sql(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_chat_id ON feishu_reply_outbox (chat_id)"
|
||||||
|
)
|
||||||
|
sync_conn.exec_driver_sql(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_status ON feishu_reply_outbox (status)"
|
||||||
|
)
|
||||||
|
sync_conn.exec_driver_sql(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_next_retry_at ON feishu_reply_outbox (next_retry_at)"
|
||||||
|
)
|
||||||
|
|
||||||
|
inspector = sa.inspect(sync_conn)
|
||||||
|
if not inspector.has_table("feishu_items"):
|
||||||
|
return
|
||||||
|
|
||||||
|
duplicate_groups = sync_conn.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()
|
||||||
|
|
||||||
|
table_exists = {
|
||||||
|
"signals": inspector.has_table("signals"),
|
||||||
|
"project_suggestions": inspector.has_table("project_suggestions"),
|
||||||
|
"ai_processing_logs": inspector.has_table("ai_processing_logs"),
|
||||||
|
"feishu_items": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
for tenant_id, message_id, split_from_item_id, keep_id in duplicate_groups:
|
||||||
|
if split_from_item_id is None:
|
||||||
|
duplicate_ids = sync_conn.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": int(tenant_id),
|
||||||
|
"message_id": str(message_id),
|
||||||
|
"keep_id": int(keep_id),
|
||||||
|
},
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
duplicate_ids = sync_conn.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": int(tenant_id),
|
||||||
|
"message_id": str(message_id),
|
||||||
|
"split_from_item_id": int(split_from_item_id),
|
||||||
|
"keep_id": int(keep_id),
|
||||||
|
},
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for (duplicate_id,) in duplicate_ids:
|
||||||
|
duplicate_id = int(duplicate_id)
|
||||||
|
canonical_id = int(keep_id)
|
||||||
|
if table_exists["feishu_items"]:
|
||||||
|
sync_conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE feishu_items SET split_from_item_id = :new_id "
|
||||||
|
"WHERE split_from_item_id = :old_id"
|
||||||
|
),
|
||||||
|
{"new_id": canonical_id, "old_id": duplicate_id},
|
||||||
|
)
|
||||||
|
if table_exists["signals"]:
|
||||||
|
sync_conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE signals SET feishu_item_id = :new_id "
|
||||||
|
"WHERE feishu_item_id = :old_id"
|
||||||
|
),
|
||||||
|
{"new_id": canonical_id, "old_id": duplicate_id},
|
||||||
|
)
|
||||||
|
if table_exists["project_suggestions"]:
|
||||||
|
sync_conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE project_suggestions SET feishu_item_id = :new_id "
|
||||||
|
"WHERE feishu_item_id = :old_id"
|
||||||
|
),
|
||||||
|
{"new_id": canonical_id, "old_id": duplicate_id},
|
||||||
|
)
|
||||||
|
if table_exists["ai_processing_logs"]:
|
||||||
|
sync_conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE ai_processing_logs SET feishu_item_id = :new_id "
|
||||||
|
"WHERE feishu_item_id = :old_id"
|
||||||
|
),
|
||||||
|
{"new_id": canonical_id, "old_id": duplicate_id},
|
||||||
|
)
|
||||||
|
sync_conn.execute(
|
||||||
|
sa.text("DELETE FROM feishu_items WHERE id = :id"),
|
||||||
|
{"id": duplicate_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 幂等唯一约束(含 split 维度)硬化;若存在重复历史数据将直接失败,避免静默错乱。
|
||||||
|
sync_conn.exec_driver_sql(
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_feishu_items_tenant_message_root
|
||||||
|
ON feishu_items (tenant_id, feishu_message_id)
|
||||||
|
WHERE split_from_item_id IS NULL AND feishu_message_id IS NOT NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
sync_conn.exec_driver_sql(
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_feishu_items_tenant_message_split
|
||||||
|
ON feishu_items (tenant_id, feishu_message_id, split_from_item_id)
|
||||||
|
WHERE split_from_item_id IS NOT NULL AND feishu_message_id IS NOT NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _feishu_outbox_drain_loop() -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
dispatched = await dispatch_pending_feishu_replies(limit=50)
|
||||||
|
await asyncio.sleep(0.3 if dispatched > 0 else 2.0)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("feishu_outbox_drain_failed", error=str(exc))
|
||||||
|
await asyncio.sleep(2.0)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
|
@ -29,9 +331,12 @@ async def lifespan(_: FastAPI):
|
||||||
"true",
|
"true",
|
||||||
"yes",
|
"yes",
|
||||||
}
|
}
|
||||||
if auto_create_tables:
|
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(_check_database_dialect_guard)
|
||||||
|
if auto_create_tables:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
await conn.run_sync(_sqlite_backfill_columns)
|
||||||
|
await conn.run_sync(_ensure_feishu_integrity_guards)
|
||||||
|
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
try:
|
try:
|
||||||
|
|
@ -40,11 +345,39 @@ async def lifespan(_: FastAPI):
|
||||||
logger.info("system_skill_tags_seeded", created=created)
|
logger.info("system_skill_tags_seeded", created=created)
|
||||||
except Exception as exc: # pragma: no cover
|
except Exception as exc: # pragma: no cover
|
||||||
logger.warning("seed_system_skills_failed", error=str(exc))
|
logger.warning("seed_system_skills_failed", error=str(exc))
|
||||||
|
try:
|
||||||
|
interrupted = await AIOrchestrationRunService(db).mark_stale_runs_interrupted(older_than_minutes=10)
|
||||||
|
if interrupted:
|
||||||
|
await db.commit()
|
||||||
|
logger.info("stale_ai_orchestration_runs_interrupted", count=interrupted)
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
logger.warning("interrupt_stale_ai_orchestration_runs_failed", error=str(exc))
|
||||||
|
|
||||||
scheduler = WorkloadRecalcScheduler(AsyncSessionLocal)
|
scheduler = WorkloadRecalcScheduler(AsyncSessionLocal)
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
|
knowledge_scheduler = KnowledgeUrlRefreshScheduler(AsyncSessionLocal)
|
||||||
|
knowledge_scheduler.start()
|
||||||
|
kg_scheduler = KnowledgeGraphIncrementalScheduler(AsyncSessionLocal)
|
||||||
|
kg_scheduler.start()
|
||||||
|
ai_pmo_cadence_scheduler = AIPMOCadenceScheduler(AsyncSessionLocal)
|
||||||
|
ai_pmo_cadence_scheduler.start()
|
||||||
|
feishu_lc = init_feishu_long_connection_manager(AsyncSessionLocal)
|
||||||
|
await feishu_lc.start()
|
||||||
|
outbox_task = asyncio.create_task(
|
||||||
|
_feishu_outbox_drain_loop(),
|
||||||
|
name="feishu-outbox-drain-loop",
|
||||||
|
)
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
outbox_task.cancel()
|
||||||
|
try:
|
||||||
|
await outbox_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
await feishu_lc.stop()
|
||||||
|
await ai_pmo_cadence_scheduler.stop()
|
||||||
|
await kg_scheduler.stop()
|
||||||
|
await knowledge_scheduler.stop()
|
||||||
await scheduler.stop()
|
await scheduler.stop()
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,11 @@ from .skill import SkillTag
|
||||||
from .user_skill_binding import UserSkillBinding
|
from .user_skill_binding import UserSkillBinding
|
||||||
from .user_workload import UserWorkload
|
from .user_workload import UserWorkload
|
||||||
from .signal import Signal
|
from .signal import Signal
|
||||||
|
from .signal_edit import SignalEdit
|
||||||
from .signal_feedback import SignalFeedback
|
from .signal_feedback import SignalFeedback
|
||||||
|
from .feishu_item import FeishuItem
|
||||||
|
from .feishu_reply_outbox import FeishuReplyOutbox
|
||||||
|
from .project_suggestion import ProjectSuggestion
|
||||||
from .context_node import ContextNode
|
from .context_node import ContextNode
|
||||||
from .experiment import Experiment
|
from .experiment import Experiment
|
||||||
from .project_domain import ProjectDomain
|
from .project_domain import ProjectDomain
|
||||||
|
|
@ -29,8 +33,34 @@ from .model_capability_matrix import (
|
||||||
BudgetAlert,
|
BudgetAlert,
|
||||||
)
|
)
|
||||||
from .integration import Integration
|
from .integration import Integration
|
||||||
|
from .notification import Notification
|
||||||
from .ai_call_log import AICallLog
|
from .ai_call_log import AICallLog
|
||||||
|
from .ai_processing_log import AIProcessingLog
|
||||||
from .operation_log import OperationLog
|
from .operation_log import OperationLog
|
||||||
|
from .operation_execution_ledger import OperationExecutionLedger
|
||||||
|
from .signal_source_registry import SignalSourceRegistry
|
||||||
|
from .ai_worker_execution import AIWorkerExecution
|
||||||
|
from .ai_orchestration_run import AIOrchestrationRun
|
||||||
|
from .knowledge_base_doc import KnowledgeBaseDoc
|
||||||
|
from .knowledge_base_chunk import KnowledgeBaseChunk
|
||||||
|
from .qa_record import QARecord
|
||||||
|
from .qa_feedback import QAFeedback
|
||||||
|
from .qa_session import QASession
|
||||||
|
from .qa_session_state import QASessionState
|
||||||
|
from .kg_entity import KGEntity
|
||||||
|
from .kg_relation import KGRelation
|
||||||
|
from .kg_evidence import KGEvidence
|
||||||
|
from .kg_sync_run import KGSyncRun
|
||||||
|
from .ingest_event import IngestEvent
|
||||||
|
from .ingest_offset import IngestOffset
|
||||||
|
from .symbol_definition import SymbolDefinition
|
||||||
|
from .symbol_reference import SymbolReference
|
||||||
|
from .recipe import Recipe
|
||||||
|
from .work_item import WorkItem
|
||||||
|
from .decision_record import DecisionRecord
|
||||||
|
from .plan_bundle import PlanBundle
|
||||||
|
from .reply_receipt import ReplyReceipt
|
||||||
|
from .portfolio_snapshot import PortfolioSnapshot
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
|
|
@ -45,7 +75,11 @@ __all__ = [
|
||||||
"UserSkillBinding",
|
"UserSkillBinding",
|
||||||
"UserWorkload",
|
"UserWorkload",
|
||||||
"Signal",
|
"Signal",
|
||||||
|
"SignalEdit",
|
||||||
"SignalFeedback",
|
"SignalFeedback",
|
||||||
|
"FeishuItem",
|
||||||
|
"FeishuReplyOutbox",
|
||||||
|
"ProjectSuggestion",
|
||||||
"ContextNode",
|
"ContextNode",
|
||||||
"Experiment",
|
"Experiment",
|
||||||
"ProjectDomain",
|
"ProjectDomain",
|
||||||
|
|
@ -64,6 +98,32 @@ __all__ = [
|
||||||
"Budget",
|
"Budget",
|
||||||
"BudgetAlert",
|
"BudgetAlert",
|
||||||
"Integration",
|
"Integration",
|
||||||
|
"Notification",
|
||||||
"AICallLog",
|
"AICallLog",
|
||||||
|
"AIProcessingLog",
|
||||||
"OperationLog",
|
"OperationLog",
|
||||||
|
"OperationExecutionLedger",
|
||||||
|
"SignalSourceRegistry",
|
||||||
|
"AIWorkerExecution",
|
||||||
|
"AIOrchestrationRun",
|
||||||
|
"KnowledgeBaseDoc",
|
||||||
|
"KnowledgeBaseChunk",
|
||||||
|
"QARecord",
|
||||||
|
"QAFeedback",
|
||||||
|
"QASession",
|
||||||
|
"QASessionState",
|
||||||
|
"KGEntity",
|
||||||
|
"KGRelation",
|
||||||
|
"KGEvidence",
|
||||||
|
"KGSyncRun",
|
||||||
|
"IngestEvent",
|
||||||
|
"IngestOffset",
|
||||||
|
"SymbolDefinition",
|
||||||
|
"SymbolReference",
|
||||||
|
"Recipe",
|
||||||
|
"WorkItem",
|
||||||
|
"DecisionRecord",
|
||||||
|
"PlanBundle",
|
||||||
|
"ReplyReceipt",
|
||||||
|
"PortfolioSnapshot",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""AI 编排运行账本。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, ForeignKey, Integer, Text, UniqueConstraint, VARCHAR, TIMESTAMP
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
from .types import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
class AIOrchestrationRun(Base):
|
||||||
|
__tablename__ = "ai_orchestration_runs"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "trace_id", name="uq_ai_orchestration_run_trace_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
trace_id: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=True)
|
||||||
|
run_type: Mapped[str] = mapped_column(VARCHAR(40), nullable=False, index=True)
|
||||||
|
project_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=False, index=True)
|
||||||
|
signal_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("signals.id"), nullable=True, index=True)
|
||||||
|
response_mode: Mapped[str] = mapped_column(VARCHAR(16), nullable=False, default="fast", index=True)
|
||||||
|
status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="queued", index=True)
|
||||||
|
planner_status: Mapped[Optional[str]] = mapped_column(VARCHAR(20), nullable=True, index=True)
|
||||||
|
openclaw_status: Mapped[Optional[str]] = mapped_column(VARCHAR(20), nullable=True, index=True)
|
||||||
|
worker_total: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
worker_completed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
worker_failed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
planner_diagnostics: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||||
|
result_payload: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||||
|
created_by_user_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
BigInteger,
|
||||||
|
ForeignKey("users.id"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
started_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True)
|
||||||
|
completed_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True)
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
"""AI 处理可视化日志模型。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, Float, ForeignKey, Integer, JSON, TEXT, VARCHAR
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AIProcessingLog(Base):
|
||||||
|
__tablename__ = "ai_processing_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
feishu_message_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True)
|
||||||
|
feishu_item_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("feishu_items.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
raw_message: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
|
||||||
|
ai_raw_output: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
|
||||||
|
project_score: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||||
|
signal_confidence: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||||
|
level: Mapped[Optional[str]] = mapped_column(VARCHAR(20), nullable=True)
|
||||||
|
normalized_text: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
|
||||||
|
route: Mapped[Optional[str]] = mapped_column(VARCHAR(80), nullable=True, index=True)
|
||||||
|
intent: Mapped[Optional[str]] = mapped_column(VARCHAR(40), nullable=True, index=True)
|
||||||
|
executor: Mapped[Optional[str]] = mapped_column(VARCHAR(40), nullable=True)
|
||||||
|
operation_code: Mapped[Optional[str]] = mapped_column(VARCHAR(80), nullable=True, index=True)
|
||||||
|
operation_result: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
reply_source: Mapped[Optional[str]] = mapped_column(VARCHAR(40), nullable=True)
|
||||||
|
routing_decision: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=True)
|
||||||
|
routing_reason: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
|
||||||
|
reasoning_bullets: Mapped[Optional[list[str]]] = mapped_column(JSON, nullable=True)
|
||||||
|
processing_status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="processing", index=True)
|
||||||
|
error_message: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
|
||||||
|
duration_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""AI worker 执行账本。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, ForeignKey, Integer, Text, UniqueConstraint, VARCHAR, TIMESTAMP
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
from .types import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
class AIWorkerExecution(Base):
|
||||||
|
__tablename__ = "ai_worker_executions"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"trace_id",
|
||||||
|
"worker_role_key",
|
||||||
|
name="uq_ai_worker_execution_trace_role",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
project_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("projects.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
signal_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("signals.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
signal_source_registry_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("signal_source_registries.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
operator_user_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("users.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
trace_id: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=True)
|
||||||
|
operation_trace_id: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True)
|
||||||
|
execution_type: Mapped[str] = mapped_column(VARCHAR(40), nullable=False, default="signal_source_onboarding", index=True)
|
||||||
|
worker_role_key: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True)
|
||||||
|
worker_role_name: Mapped[str] = mapped_column(VARCHAR(64), nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="planned", index=True)
|
||||||
|
summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
input_payload: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||||
|
output_payload: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||||
|
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
started_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True)
|
||||||
|
completed_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True)
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
"""AI PMO 决策记录模型。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, Boolean, Float, ForeignKey, Integer, Text, VARCHAR
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
from .types import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
class DecisionRecord(Base):
|
||||||
|
__tablename__ = "decision_records"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
work_item_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger,
|
||||||
|
ForeignKey("work_items.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
decision_object_type: Mapped[str] = mapped_column(VARCHAR(40), nullable=False, default="portfolio")
|
||||||
|
decision_object_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
|
||||||
|
decision_level: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="portfolio", index=True)
|
||||||
|
context_snapshot: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||||
|
rationale: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
confidence: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||||
|
risk_score: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||||
|
budget_impact: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||||
|
resource_impact: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||||
|
simulation_result: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||||
|
policy_gate_hit: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||||
|
policy_gate_code: Mapped[Optional[str]] = mapped_column(VARCHAR(80), nullable=True, index=True)
|
||||||
|
action_bundle: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||||
|
approval_requirement: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||||
|
execution_result: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||||
|
final_outcome: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True, index=True)
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
"""飞书分流队列项模型"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
BigInteger,
|
||||||
|
Boolean,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
TEXT,
|
||||||
|
TIMESTAMP,
|
||||||
|
VARCHAR,
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
from .types import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
class FeishuItem(Base):
|
||||||
|
__tablename__ = "feishu_items"
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"uq_feishu_items_tenant_message_root",
|
||||||
|
"tenant_id",
|
||||||
|
"feishu_message_id",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("split_from_item_id IS NULL AND feishu_message_id IS NOT NULL"),
|
||||||
|
sqlite_where=text("split_from_item_id IS NULL AND feishu_message_id IS NOT NULL"),
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"uq_feishu_items_tenant_message_split",
|
||||||
|
"tenant_id",
|
||||||
|
"feishu_message_id",
|
||||||
|
"split_from_item_id",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("split_from_item_id IS NOT NULL AND feishu_message_id IS NOT NULL"),
|
||||||
|
sqlite_where=text("split_from_item_id IS NOT NULL AND feishu_message_id IS NOT NULL"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
feishu_message_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True)
|
||||||
|
project_score: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||||
|
signal_confidence: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||||
|
raw_message: Mapped[str] = mapped_column(TEXT, nullable=False)
|
||||||
|
split_from_item_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("feishu_items.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
level: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, index=True)
|
||||||
|
level_confidence: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||||
|
level_reasoning: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
|
||||||
|
card_type: Mapped[str] = mapped_column(VARCHAR(30), nullable=False, default="signal", index=True)
|
||||||
|
route_status: Mapped[str] = mapped_column(VARCHAR(30), nullable=False, default="pending", index=True)
|
||||||
|
matched_project_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
|
||||||
|
source_chat_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True)
|
||||||
|
source_sender: Mapped[Optional[str]] = mapped_column(VARCHAR(120), nullable=True)
|
||||||
|
source_sender_open_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True)
|
||||||
|
source_occurred_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)
|
||||||
|
ai_output: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
|
||||||
|
decision_reason: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
|
||||||
|
blocking_reason: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
|
||||||
|
safe_to_auto_execute: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||||
|
auto_execute_state: Mapped[Optional[str]] = mapped_column(VARCHAR(30), nullable=True, index=True)
|
||||||
|
dedupe_key: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True, index=True)
|
||||||
|
message_class: Mapped[Optional[str]] = mapped_column(VARCHAR(30), nullable=True, index=True)
|
||||||
|
occurrence_count: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
next_step_text: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
|
||||||
|
undoable_until: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)
|
||||||
|
result_payload: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||||
|
queue_visible: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||||
|
processed_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)
|
||||||
|
dismissed_reason: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""飞书回执 Outbox 模型。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Integer, TIMESTAMP, TEXT, VARCHAR
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class FeishuReplyOutbox(Base):
|
||||||
|
__tablename__ = "feishu_reply_outbox"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
trace_id: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True)
|
||||||
|
chat_id: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, index=True)
|
||||||
|
message_text: Mapped[str] = mapped_column(TEXT, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="pending", index=True)
|
||||||
|
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
next_retry_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True, index=True)
|
||||||
|
sent_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)
|
||||||
|
last_error: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
"""Incremental ingest event model."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Integer, TEXT, TIMESTAMP, VARCHAR, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
from .types import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
class IngestEvent(Base):
|
||||||
|
__tablename__ = "ingest_events"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "channel", "event_key", name="uq_ingest_events_tenant_channel_key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
channel: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True)
|
||||||
|
event_key: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, index=True)
|
||||||
|
status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="pending", index=True)
|
||||||
|
payload: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||||
|
retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
error_message: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
|
||||||
|
processed_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True, index=True)
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""Ingest offset model for incremental sync checkpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Integer, VARCHAR, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
from .types import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
class IngestOffset(Base):
|
||||||
|
__tablename__ = "ingest_offsets"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "channel", "offset_key", name="uq_ingest_offsets_tenant_channel_key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
channel: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True)
|
||||||
|
offset_key: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, index=True)
|
||||||
|
offset_value: Mapped[str] = mapped_column(VARCHAR(255), nullable=False)
|
||||||
|
payload: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""Project Knowledge Graph entity model."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Integer, TIMESTAMP, VARCHAR, UniqueConstraint, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
from .types import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
class KGEntity(Base):
|
||||||
|
__tablename__ = "kg_entities"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "entity_type", "entity_key", name="uq_kg_entities_tenant_type_key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
entity_type: Mapped[str] = mapped_column(VARCHAR(40), nullable=False, index=True)
|
||||||
|
entity_key: Mapped[str] = mapped_column(VARCHAR(255), nullable=False, index=True)
|
||||||
|
display_name: Mapped[str] = mapped_column(VARCHAR(255), nullable=False)
|
||||||
|
payload: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||||
|
source_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="system", index=True)
|
||||||
|
source_ref: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True)
|
||||||
|
last_seen_at: Mapped[Optional[TIMESTAMP]] = mapped_column(
|
||||||
|
TIMESTAMP,
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
"""Project Knowledge Graph evidence model."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Integer, TEXT, TIMESTAMP, VARCHAR, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
from .types import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
class KGEvidence(Base):
|
||||||
|
__tablename__ = "kg_evidences"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
entity_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("kg_entities.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
relation_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("kg_relations.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
evidence_type: Mapped[str] = mapped_column(VARCHAR(40), nullable=False, index=True)
|
||||||
|
content_hash: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=True)
|
||||||
|
content_excerpt: Mapped[str] = mapped_column(TEXT, nullable=False)
|
||||||
|
source_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="system", index=True)
|
||||||
|
source_ref: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True)
|
||||||
|
meta_payload: Mapped[Optional[Any]] = mapped_column("metadata", JSONB, nullable=True)
|
||||||
|
occurred_at: Mapped[Optional[TIMESTAMP]] = mapped_column(
|
||||||
|
TIMESTAMP,
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
"""Project Knowledge Graph relation model."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Float, ForeignKey, Integer, VARCHAR, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
from .types import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
class KGRelation(Base):
|
||||||
|
__tablename__ = "kg_relations"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"relation_type",
|
||||||
|
"subject_entity_id",
|
||||||
|
"object_entity_id",
|
||||||
|
name="uq_kg_relations_tenant_type_subject_object",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
relation_type: Mapped[str] = mapped_column(VARCHAR(40), nullable=False, index=True)
|
||||||
|
subject_entity_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("kg_entities.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
object_entity_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("kg_entities.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
weight: Mapped[float] = mapped_column(Float, nullable=False, default=1.0)
|
||||||
|
payload: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||||
|
source_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="system", index=True)
|
||||||
|
source_ref: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True)
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""Knowledge Graph 增量同步运行日志。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Integer, TEXT, TIMESTAMP, VARCHAR
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
from .types import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
class KGSyncRun(Base):
|
||||||
|
__tablename__ = "kg_sync_runs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
run_type: Mapped[str] = mapped_column(VARCHAR(40), nullable=False, index=True)
|
||||||
|
trigger_source: Mapped[str] = mapped_column(VARCHAR(40), nullable=False, default="system", index=True)
|
||||||
|
status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="running", index=True)
|
||||||
|
|
||||||
|
started_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=False, index=True)
|
||||||
|
ended_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True, index=True)
|
||||||
|
duration_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
|
||||||
|
input_payload: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||||
|
result_payload: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||||
|
error_message: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""知识库分块向量模型。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, ForeignKey, Integer, Text, VARCHAR
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
from .types import JSONB, Vector
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBaseChunk(Base):
|
||||||
|
__tablename__ = "knowledge_base_chunks"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
doc_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("knowledge_base_docs.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
source_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="doc", index=True)
|
||||||
|
project_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
|
||||||
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
embedding: Mapped[Optional[Any]] = mapped_column(Vector(768), nullable=True)
|
||||||
|
chunk_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
chunk_metadata: Mapped[Optional[Any]] = mapped_column("metadata", JSONB, nullable=True)
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
"""知识库文档元数据模型。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, Integer, TIMESTAMP, VARCHAR
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBaseDoc(Base):
|
||||||
|
__tablename__ = "knowledge_base_docs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
title: Mapped[str] = mapped_column(VARCHAR(255), nullable=False)
|
||||||
|
file_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False)
|
||||||
|
source_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="file")
|
||||||
|
file_url: Mapped[Optional[str]] = mapped_column(VARCHAR(1024), nullable=True)
|
||||||
|
uploaded_by: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="processing")
|
||||||
|
last_fetched_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)
|
||||||
|
error_message: Mapped[Optional[str]] = mapped_column(VARCHAR(500), nullable=True)
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""站内通知模型"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, Boolean, ForeignKey, Integer, TIMESTAMP, TEXT, VARCHAR
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
from .types import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(Base):
|
||||||
|
__tablename__ = "notifications"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("users.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
category: Mapped[str] = mapped_column(VARCHAR(50), nullable=False, default="system", index=True)
|
||||||
|
title: Mapped[str] = mapped_column(VARCHAR(200), nullable=False)
|
||||||
|
content: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
|
||||||
|
source: Mapped[Optional[str]] = mapped_column(VARCHAR(50), nullable=True, index=True)
|
||||||
|
entity_type: Mapped[Optional[str]] = mapped_column(VARCHAR(50), nullable=True, index=True)
|
||||||
|
entity_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
|
||||||
|
target_url: Mapped[Optional[str]] = mapped_column(VARCHAR(500), nullable=True)
|
||||||
|
kind: Mapped[Optional[str]] = mapped_column(VARCHAR(30), nullable=True, index=True)
|
||||||
|
priority: Mapped[Optional[str]] = mapped_column(VARCHAR(20), nullable=True, index=True)
|
||||||
|
status: Mapped[Optional[str]] = mapped_column(VARCHAR(30), nullable=True, index=True)
|
||||||
|
blocking_reason: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
|
||||||
|
recommended_action: Mapped[Optional[str]] = mapped_column(VARCHAR(80), nullable=True)
|
||||||
|
dedupe_key: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True, index=True)
|
||||||
|
occurrence_count: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
payload: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||||
|
is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||||
|
read_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""操作执行幂等账本。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, Integer, ForeignKey, Text, UniqueConstraint, VARCHAR
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
from .types import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
class OperationExecutionLedger(Base):
|
||||||
|
__tablename__ = "operation_execution_ledger"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"operation_code",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_operation_execution_ledger_key",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
operation_code: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=True)
|
||||||
|
project_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("projects.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
idempotency_key: Mapped[str] = mapped_column(VARCHAR(128), nullable=False)
|
||||||
|
trace_id: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True)
|
||||||
|
status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="processing", index=True)
|
||||||
|
payload: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||||
|
result_payload: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||||
|
entity_type: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True)
|
||||||
|
entity_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True)
|
||||||
|
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
"""AI PMO 计划包模型。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, ForeignKey, Integer, Text, VARCHAR
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
from .types import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
class PlanBundle(Base):
|
||||||
|
__tablename__ = "plan_bundles"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
work_item_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger,
|
||||||
|
ForeignKey("work_items.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
plan_type: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, index=True)
|
||||||
|
summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
payload: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||||
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
"""AI PMO 项目群快照模型。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Integer, VARCHAR
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
from .types import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioSnapshot(Base):
|
||||||
|
__tablename__ = "portfolio_snapshots"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
scope_type: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="tenant", index=True)
|
||||||
|
scope_ref: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=True)
|
||||||
|
summary: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||||
|
payload: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
"""飞书项目建议模型"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, Float, ForeignKey, Integer, TEXT, VARCHAR
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectSuggestion(Base):
|
||||||
|
__tablename__ = "project_suggestions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
feishu_item_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("feishu_items.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
suggested_name: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True)
|
||||||
|
suggested_description: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
|
||||||
|
suggested_owner: Mapped[Optional[str]] = mapped_column(VARCHAR(120), nullable=True)
|
||||||
|
suggested_timeline: Mapped[Optional[str]] = mapped_column(VARCHAR(120), nullable=True)
|
||||||
|
confidence: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||||
|
reasoning: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(VARCHAR(30), nullable=False, default="pending", index=True)
|
||||||
|
created_project_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""问答反馈模型。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, ForeignKey, Integer, Text, VARCHAR
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
from .types import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
class QAFeedback(Base):
|
||||||
|
__tablename__ = "qa_feedback"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
qa_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("qa_records.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
rating: Mapped[str] = mapped_column(VARCHAR(16), nullable=False, index=True)
|
||||||
|
helpfulness: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True)
|
||||||
|
error_types: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||||
|
correct_answer: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
submitted_by: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
"""RAG 问答记录模型。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, ForeignKey, Integer, Text, VARCHAR
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
from .types import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
class QARecord(Base):
|
||||||
|
__tablename__ = "qa_records"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
question: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
answer: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
sources: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||||
|
intent: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="question")
|
||||||
|
domains_used: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||||
|
response_time_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
asked_by: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
|
||||||
|
asked_via: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="pmflow", index=True)
|
||||||
|
project_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
|
||||||
|
session_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("qa_sessions.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
turn_index: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
"""QA 会话模型。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, Integer, TIMESTAMP, VARCHAR
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class QASession(Base):
|
||||||
|
__tablename__ = "qa_sessions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
|
||||||
|
title: Mapped[str] = mapped_column(VARCHAR(120), nullable=False, default="新会话")
|
||||||
|
project_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
|
||||||
|
started_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True, index=True)
|
||||||
|
last_message_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True, index=True)
|
||||||
|
message_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""QA 会话语义状态模型。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Integer, VARCHAR
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
from .types import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
class QASessionState(Base):
|
||||||
|
__tablename__ = "qa_session_states"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
session_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("qa_sessions.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
last_subject: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True, index=True)
|
||||||
|
last_entity_ids: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||||
|
last_intent: Mapped[Optional[str]] = mapped_column(VARCHAR(20), nullable=True, index=True)
|
||||||
|
ambiguity: Mapped[Optional[str]] = mapped_column(VARCHAR(20), nullable=True, index=True)
|
||||||
|
rewrite_history: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""食谱模型 - 支持热量识别与营养分析"""
|
||||||
|
from .base import Base
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy import BigInteger, Integer, VARCHAR, Text, Float, JSON
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class Recipe(Base):
|
||||||
|
__tablename__ = "recipes"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(VARCHAR(200), nullable=False)
|
||||||
|
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
image_url: Mapped[Optional[str]] = mapped_column(VARCHAR(500), nullable=True)
|
||||||
|
# 营养信息
|
||||||
|
calories: Mapped[Optional[float]] = mapped_column(Float, nullable=True, comment="热量(千卡)")
|
||||||
|
protein: Mapped[Optional[float]] = mapped_column(Float, nullable=True, comment="蛋白质(克)")
|
||||||
|
fat: Mapped[Optional[float]] = mapped_column(Float, nullable=True, comment="脂肪(克)")
|
||||||
|
carbs: Mapped[Optional[float]] = mapped_column(Float, nullable=True, comment="碳水化合物(克)")
|
||||||
|
fiber: Mapped[Optional[float]] = mapped_column(Float, nullable=True, comment="膳食纤维(克)")
|
||||||
|
# AI 识别相关
|
||||||
|
ai_recognition_confidence: Mapped[Optional[float]] = mapped_column(
|
||||||
|
Float, nullable=True, comment="AI 识别置信度 0-1"
|
||||||
|
)
|
||||||
|
ai_analysis_result: Mapped[Optional[dict]] = mapped_column(
|
||||||
|
JSON, nullable=True, comment="AI 识别完整结果"
|
||||||
|
)
|
||||||
|
# 分类
|
||||||
|
category: Mapped[Optional[str]] = mapped_column(VARCHAR(50), nullable=True, comment="菜品分类")
|
||||||
|
cuisine: Mapped[Optional[str]] = mapped_column(VARCHAR(50), nullable=True, comment="菜系")
|
||||||
|
# 状态
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
VARCHAR(20), nullable=False, default="draft", comment="draft/published/archived"
|
||||||
|
)
|
||||||
|
# 用户标注
|
||||||
|
user_notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="用户备注")
|
||||||
|
is_favorite: Mapped[bool] = mapped_column(default=False, comment="是否收藏")
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
"""AI PMO 回执账本。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, ForeignKey, Integer, UniqueConstraint, VARCHAR
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
from .types import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
class ReplyReceipt(Base):
|
||||||
|
__tablename__ = "reply_receipts"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"source_system",
|
||||||
|
"source_message_id",
|
||||||
|
"receipt_phase",
|
||||||
|
name="uq_reply_receipts_source_phase",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
work_item_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
BigInteger,
|
||||||
|
ForeignKey("work_items.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
source_system: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True)
|
||||||
|
source_message_id: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, index=True)
|
||||||
|
receipt_phase: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True)
|
||||||
|
template_key: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True)
|
||||||
|
payload: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="pending", index=True)
|
||||||
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""信号模型"""
|
"""信号模型"""
|
||||||
from .base import Base
|
from .base import Base
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy import BigInteger, Integer, VARCHAR, Text, Float, Boolean
|
from sqlalchemy import BigInteger, Integer, VARCHAR, Text, Float, Boolean, ForeignKey
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -16,5 +16,16 @@ class Signal(Base):
|
||||||
processed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
processed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
strategic_analysis: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
strategic_analysis: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
tasks_generated: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
tasks_generated: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
feishu_item_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("feishu_items.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
matched_project_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
|
||||||
|
title: Mapped[Optional[str]] = mapped_column(VARCHAR(200), nullable=True)
|
||||||
|
signal_type: Mapped[Optional[str]] = mapped_column(VARCHAR(50), nullable=True, index=True)
|
||||||
|
summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
reasoning: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
confidence: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||||
|
recommended_action: Mapped[Optional[str]] = mapped_column(VARCHAR(50), nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(VARCHAR(50), nullable=False, default="待处理", index=True)
|
||||||
|
|
||||||
feedbacks = relationship("SignalFeedback", back_populates="signal", cascade="all, delete-orphan")
|
feedbacks = relationship("SignalFeedback", back_populates="signal", cascade="all, delete-orphan")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""信号编辑历史模型"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, ForeignKey, Integer, TEXT, TIMESTAMP, VARCHAR
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class SignalEdit(Base):
|
||||||
|
__tablename__ = "signal_edits"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
signal_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("signals.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
field: Mapped[str] = mapped_column(VARCHAR(80), nullable=False)
|
||||||
|
old_value: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
|
||||||
|
new_value: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
|
||||||
|
edited_by: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("users.id"), nullable=True, index=True)
|
||||||
|
edited_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
"""信号源注册表。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, ForeignKey, Integer, Text, UniqueConstraint, VARCHAR, TIMESTAMP
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
from .types import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
class SignalSourceRegistry(Base):
|
||||||
|
__tablename__ = "signal_source_registries"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"project_id",
|
||||||
|
"source_kind",
|
||||||
|
"source_identifier",
|
||||||
|
name="uq_signal_source_registry_key",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
project_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("projects.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
signal_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("signals.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
created_by_user_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("users.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
source_kind: Mapped[str] = mapped_column(VARCHAR(40), nullable=False, index=True)
|
||||||
|
source_name: Mapped[str] = mapped_column(VARCHAR(120), nullable=False)
|
||||||
|
source_identifier: Mapped[str] = mapped_column(VARCHAR(180), nullable=False)
|
||||||
|
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
requested_via: Mapped[str] = mapped_column(VARCHAR(40), nullable=False, default="system", index=True)
|
||||||
|
status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="active", index=True)
|
||||||
|
onboarding_status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="pending", index=True)
|
||||||
|
config: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||||
|
raw_context: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||||
|
last_signal_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True)
|
||||||
|
last_worker_run_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True)
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
"""代码符号定义模型。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Integer, TIMESTAMP, VARCHAR, UniqueConstraint, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
from .types import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
class SymbolDefinition(Base):
|
||||||
|
__tablename__ = "symbol_definitions"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"file_path",
|
||||||
|
"symbol_type",
|
||||||
|
"symbol_name",
|
||||||
|
"start_line",
|
||||||
|
name="uq_symbol_definitions_tenant_file_type_name_line",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
file_path: Mapped[str] = mapped_column(VARCHAR(600), nullable=False, index=True)
|
||||||
|
language: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, index=True)
|
||||||
|
symbol_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True)
|
||||||
|
symbol_name: Mapped[str] = mapped_column(VARCHAR(255), nullable=False, index=True)
|
||||||
|
qualified_name: Mapped[Optional[str]] = mapped_column(VARCHAR(700), nullable=True, index=True)
|
||||||
|
start_line: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
|
||||||
|
end_line: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
signature: Mapped[Optional[str]] = mapped_column(VARCHAR(1000), nullable=True)
|
||||||
|
docstring: Mapped[Optional[str]] = mapped_column(VARCHAR(2000), nullable=True)
|
||||||
|
file_hash: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True)
|
||||||
|
source_mtime: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="active", index=True)
|
||||||
|
payload: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||||
|
last_indexed_at: Mapped[Optional[TIMESTAMP]] = mapped_column(
|
||||||
|
TIMESTAMP,
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
"""代码符号引用关系模型。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Integer, VARCHAR, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
from .types import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
class SymbolReference(Base):
|
||||||
|
__tablename__ = "symbol_references"
|
||||||
|
__table_args__ = (
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
source_symbol_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("symbol_definitions.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
file_path: Mapped[str] = mapped_column(VARCHAR(600), nullable=False, index=True)
|
||||||
|
relation_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True)
|
||||||
|
target_symbol_name: Mapped[str] = mapped_column(VARCHAR(255), nullable=False, index=True)
|
||||||
|
target_qualified_name: Mapped[Optional[str]] = mapped_column(VARCHAR(700), nullable=True, index=True)
|
||||||
|
start_line: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
|
||||||
|
end_line: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
payload: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||||
|
|
@ -15,6 +15,9 @@ class TruthNode(Base):
|
||||||
BigInteger, ForeignKey("truth_nodes.id"), nullable=True
|
BigInteger, ForeignKey("truth_nodes.id"), nullable=True
|
||||||
)
|
)
|
||||||
project_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True)
|
project_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True)
|
||||||
|
signal_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("signals.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
created_by: Mapped[int] = mapped_column(
|
created_by: Mapped[int] = mapped_column(
|
||||||
BigInteger, ForeignKey("users.id"), nullable=False
|
BigInteger, ForeignKey("users.id"), nullable=False
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
"""AI PMO 工作项模型。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, ForeignKey, Integer, UniqueConstraint, VARCHAR
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
from .types import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
class WorkItem(Base):
|
||||||
|
__tablename__ = "work_items"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "source_system", "source_message_id", name="uq_work_items_source_message"),
|
||||||
|
UniqueConstraint("tenant_id", "ingest_event_id", name="uq_work_items_ingest_event"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
ingest_event_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger,
|
||||||
|
ForeignKey("ingest_events.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
source_system: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True)
|
||||||
|
source_message_id: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, index=True)
|
||||||
|
state: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="received", index=True)
|
||||||
|
intent: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True)
|
||||||
|
decision_level: Mapped[Optional[str]] = mapped_column(VARCHAR(20), nullable=True, index=True)
|
||||||
|
project_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
BigInteger,
|
||||||
|
ForeignKey("projects.id"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
signal_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
BigInteger,
|
||||||
|
ForeignKey("signals.id"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
owner_user_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
BigInteger,
|
||||||
|
ForeignKey("users.id"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
dedupe_key: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True, index=True)
|
||||||
|
context_snapshot: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||||
|
action_bundle: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||||
|
execution_result: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||||
|
review_summary: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||||
|
reply_template_key: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True)
|
||||||
|
|
||||||
|
|
@ -42,6 +42,13 @@ from .sstc import (
|
||||||
ReviewCreateRequest,
|
ReviewCreateRequest,
|
||||||
ReviewArchiveRequest,
|
ReviewArchiveRequest,
|
||||||
)
|
)
|
||||||
|
from .recipe import (
|
||||||
|
RecipeCreate,
|
||||||
|
RecipeUpdate,
|
||||||
|
RecipeResponse,
|
||||||
|
RecipeImageUploadRequest,
|
||||||
|
RecipeAnalyzeCaloriesResponse,
|
||||||
|
)
|
||||||
from . import ai as ai_schemas
|
from . import ai as ai_schemas
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|
@ -87,4 +94,9 @@ __all__ = [
|
||||||
"ReleaseStatusPatchRequest",
|
"ReleaseStatusPatchRequest",
|
||||||
"ReviewCreateRequest",
|
"ReviewCreateRequest",
|
||||||
"ReviewArchiveRequest",
|
"ReviewArchiveRequest",
|
||||||
|
"RecipeCreate",
|
||||||
|
"RecipeUpdate",
|
||||||
|
"RecipeResponse",
|
||||||
|
"RecipeImageUploadRequest",
|
||||||
|
"RecipeAnalyzeCaloriesResponse",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
"""RAG / 知识库 / QA 记录请求模型。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class RAGAskRequest(BaseModel):
|
||||||
|
question: str = Field(..., min_length=1, max_length=4000)
|
||||||
|
project_id: Optional[int] = Field(default=None)
|
||||||
|
asked_via: str = Field(default="pmflow", pattern="^(pmflow|feishu)$")
|
||||||
|
source_chat_id: Optional[str] = Field(default=None, max_length=128)
|
||||||
|
session_id: Optional[int] = Field(default=None, ge=1)
|
||||||
|
new_session: bool = Field(default=False)
|
||||||
|
thinking_mode: str = Field(default="normal", pattern="^(normal|deep)$")
|
||||||
|
action_mode: str = Field(default="auto", pattern="^(auto|read|write|read_write)$")
|
||||||
|
sync_requirement: bool = Field(default=True)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeDocCreateRequest(BaseModel):
|
||||||
|
title: str = Field(..., min_length=1, max_length=255)
|
||||||
|
file_type: str = Field(default="txt", max_length=32)
|
||||||
|
content: str = Field(..., min_length=1, max_length=200000)
|
||||||
|
source_type: str = Field(default="file", max_length=32)
|
||||||
|
file_url: Optional[str] = Field(default=None, max_length=1024)
|
||||||
|
project_id: Optional[int] = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeURLCreateRequest(BaseModel):
|
||||||
|
url: str = Field(..., min_length=5, max_length=1024)
|
||||||
|
title: Optional[str] = Field(default=None, max_length=255)
|
||||||
|
project_id: Optional[int] = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
class QAFeedbackCreateRequest(BaseModel):
|
||||||
|
rating: str = Field(..., pattern="^(positive|negative)$")
|
||||||
|
helpfulness: Optional[str] = Field(default=None, pattern="^(fully|partially|could_be_better)$")
|
||||||
|
error_types: list[str] = Field(default_factory=list)
|
||||||
|
correct_answer: Optional[str] = Field(default=None, max_length=4000)
|
||||||
|
|
||||||
|
|
||||||
|
class QAListQuery(BaseModel):
|
||||||
|
project_id: Optional[int] = None
|
||||||
|
date_from: Optional[datetime] = None
|
||||||
|
date_to: Optional[datetime] = None
|
||||||
|
rating: Optional[str] = Field(default=None, pattern="^(positive|negative)$")
|
||||||
|
page: int = Field(default=1, ge=1)
|
||||||
|
page_size: int = Field(default=20, ge=1, le=100)
|
||||||
|
|
||||||
|
|
||||||
|
class QAExportQuery(BaseModel):
|
||||||
|
include_unrated: bool = False
|
||||||
|
project_id: Optional[int] = None
|
||||||
|
rating: Optional[str] = Field(default=None, pattern="^(positive|negative|unrated)$")
|
||||||
|
domain: Optional[str] = None
|
||||||
|
date_from: Optional[datetime] = None
|
||||||
|
date_to: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class QASessionListQuery(BaseModel):
|
||||||
|
date_from: Optional[datetime] = None
|
||||||
|
date_to: Optional[datetime] = None
|
||||||
|
page: int = Field(default=1, ge=1)
|
||||||
|
page_size: int = Field(default=20, ge=1, le=100)
|
||||||
|
|
||||||
|
|
||||||
|
class QASessionRenameRequest(BaseModel):
|
||||||
|
title: str = Field(..., min_length=1, max_length=120)
|
||||||
|
|
||||||
|
|
||||||
|
class EntityResolutionRequest(BaseModel):
|
||||||
|
question: str = Field(..., min_length=1, max_length=4000)
|
||||||
|
project_id: Optional[int] = Field(default=None)
|
||||||
|
limit: int = Field(default=10, ge=1, le=50)
|
||||||
|
|
||||||
|
|
||||||
|
class MultiStepSearchRequest(BaseModel):
|
||||||
|
question: str = Field(..., min_length=1, max_length=4000)
|
||||||
|
project_id: Optional[int] = Field(default=None)
|
||||||
|
source_chat_id: Optional[str] = Field(default=None, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class SymbolSyncRequest(BaseModel):
|
||||||
|
base_dir: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
max_files: int = Field(default=500, ge=1, le=3000)
|
||||||
|
force_reindex: bool = Field(default=False)
|
||||||
|
|
||||||
|
|
||||||
|
class SymbolSearchRequest(BaseModel):
|
||||||
|
query: str = Field(..., min_length=1, max_length=255)
|
||||||
|
language: Optional[str] = Field(default=None, max_length=20)
|
||||||
|
symbol_type: Optional[str] = Field(default=None, max_length=32)
|
||||||
|
limit: int = Field(default=30, ge=1, le=200)
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
"""食谱相关 Pydantic 模型"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeCreate(BaseModel):
|
||||||
|
"""创建食谱请求"""
|
||||||
|
name: str = Field(..., min_length=1, max_length=200, description="菜名")
|
||||||
|
description: Optional[str] = Field(None, description="描述")
|
||||||
|
image_url: Optional[str] = Field(None, description="图片URL")
|
||||||
|
category: Optional[str] = Field(None, description="菜品分类")
|
||||||
|
cuisine: Optional[str] = Field(None, description="菜系")
|
||||||
|
user_notes: Optional[str] = Field(None, description="用户备注")
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeUpdate(BaseModel):
|
||||||
|
"""更新食谱请求"""
|
||||||
|
name: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||||
|
description: Optional[str] = None
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
category: Optional[str] = None
|
||||||
|
cuisine: Optional[str] = None
|
||||||
|
status: Optional[str] = Field(None, pattern="^(draft|published|archived)$")
|
||||||
|
user_notes: Optional[str] = None
|
||||||
|
is_favorite: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeResponse(BaseModel):
|
||||||
|
"""食谱响应"""
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: Optional[str]
|
||||||
|
image_url: Optional[str]
|
||||||
|
# 营养信息
|
||||||
|
calories: Optional[float]
|
||||||
|
protein: Optional[float]
|
||||||
|
fat: Optional[float]
|
||||||
|
carbs: Optional[float]
|
||||||
|
fiber: Optional[float]
|
||||||
|
# AI 识别相关
|
||||||
|
ai_recognition_confidence: Optional[float]
|
||||||
|
ai_analysis_result: Optional[Dict[str, Any]]
|
||||||
|
# 分类
|
||||||
|
category: Optional[str]
|
||||||
|
cuisine: Optional[str]
|
||||||
|
# 状态
|
||||||
|
status: str
|
||||||
|
# 用户标注
|
||||||
|
user_notes: Optional[str]
|
||||||
|
is_favorite: bool
|
||||||
|
# 时间戳
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeImageUploadRequest(BaseModel):
|
||||||
|
"""图片上传请求"""
|
||||||
|
image_base64: str = Field(..., description="Base64 编码的图片数据")
|
||||||
|
filename: Optional[str] = Field(None, description="原始文件名")
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeAnalyzeCaloriesResponse(BaseModel):
|
||||||
|
"""热量分析响应"""
|
||||||
|
success: bool
|
||||||
|
data: Optional[RecipeResponse] = None
|
||||||
|
message: Optional[str] = None
|
||||||
|
|
@ -36,11 +36,14 @@ class StageStatus(str, Enum):
|
||||||
EXEC_DONE = "已完成"
|
EXEC_DONE = "已完成"
|
||||||
# release
|
# release
|
||||||
RELEASE_LOCKED = "未解锁"
|
RELEASE_LOCKED = "未解锁"
|
||||||
|
RELEASE_PREP = "可预发布"
|
||||||
RELEASE_PENDING = "待发布"
|
RELEASE_PENDING = "待发布"
|
||||||
|
RELEASE_RELEASING = "发布中"
|
||||||
RELEASE_LIVE = "已上线"
|
RELEASE_LIVE = "已上线"
|
||||||
RELEASE_VERIFIED = "已验证"
|
RELEASE_VERIFIED = "已验证"
|
||||||
# review
|
# review
|
||||||
REVIEW_LOCKED = "未解锁"
|
REVIEW_LOCKED = "未解锁"
|
||||||
|
REVIEW_PREP = "可阶段复盘"
|
||||||
REVIEW_DRAFT = "草稿"
|
REVIEW_DRAFT = "草稿"
|
||||||
REVIEW_ARCHIVED = "已归档"
|
REVIEW_ARCHIVED = "已归档"
|
||||||
|
|
||||||
|
|
@ -91,6 +94,11 @@ class TaskStatusPatchRequest(BaseModel):
|
||||||
status: Literal["todo", "in_progress", "blocked", "review", "done", "completed"]
|
status: Literal["todo", "in_progress", "blocked", "review", "done", "completed"]
|
||||||
|
|
||||||
|
|
||||||
|
class WorkloadOptimizeRequest(BaseModel):
|
||||||
|
mode: Literal["unassigned", "rebalance"] = "rebalance"
|
||||||
|
max_moves: int = Field(default=200, ge=1, le=500)
|
||||||
|
|
||||||
|
|
||||||
class ReleaseCreateRequest(BaseModel):
|
class ReleaseCreateRequest(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
charter_id: int
|
charter_id: int
|
||||||
|
|
@ -112,3 +120,22 @@ class ReviewCreateRequest(BaseModel):
|
||||||
class ReviewArchiveRequest(BaseModel):
|
class ReviewArchiveRequest(BaseModel):
|
||||||
archive: bool = True
|
archive: bool = True
|
||||||
note: Optional[str] = None
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseVerificationItemPatchRequest(BaseModel):
|
||||||
|
status: Literal["pending", "done"]
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewContentPatchRequest(BaseModel):
|
||||||
|
content: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewImprovementToTaskRequest(BaseModel):
|
||||||
|
improvement_index: int = Field(..., ge=0)
|
||||||
|
charter_id: Optional[int] = None
|
||||||
|
title: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
priority: Literal["high", "medium", "low"] = "medium"
|
||||||
|
estimated_hours: Optional[float] = Field(default=None, ge=0)
|
||||||
|
due_date: Optional[str] = None
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,179 @@
|
||||||
|
"""AI 编排运行账本服务。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import and_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..models import AIOrchestrationRun
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now_naive() -> datetime:
|
||||||
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
class AIOrchestrationRunService:
|
||||||
|
def __init__(self, db: AsyncSession) -> None:
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def create_run(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
trace_id: str,
|
||||||
|
run_type: str,
|
||||||
|
project_id: int,
|
||||||
|
signal_id: Optional[int],
|
||||||
|
response_mode: str,
|
||||||
|
status: str,
|
||||||
|
planner_status: Optional[str],
|
||||||
|
openclaw_status: Optional[str],
|
||||||
|
worker_total: int,
|
||||||
|
worker_completed: int,
|
||||||
|
worker_failed: int,
|
||||||
|
summary: str,
|
||||||
|
planner_diagnostics: Optional[dict[str, Any]],
|
||||||
|
result_payload: Optional[dict[str, Any]],
|
||||||
|
created_by_user_id: Optional[int],
|
||||||
|
started_at: Optional[datetime] = None,
|
||||||
|
completed_at: Optional[datetime] = None,
|
||||||
|
) -> AIOrchestrationRun:
|
||||||
|
row = AIOrchestrationRun(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
trace_id=trace_id,
|
||||||
|
run_type=run_type,
|
||||||
|
project_id=project_id,
|
||||||
|
signal_id=signal_id,
|
||||||
|
response_mode=response_mode,
|
||||||
|
status=status,
|
||||||
|
planner_status=planner_status,
|
||||||
|
openclaw_status=openclaw_status,
|
||||||
|
worker_total=max(0, int(worker_total)),
|
||||||
|
worker_completed=max(0, int(worker_completed)),
|
||||||
|
worker_failed=max(0, int(worker_failed)),
|
||||||
|
summary=str(summary or "").strip()[:1000] or None,
|
||||||
|
planner_diagnostics=planner_diagnostics if isinstance(planner_diagnostics, dict) else None,
|
||||||
|
result_payload=result_payload if isinstance(result_payload, dict) else None,
|
||||||
|
created_by_user_id=created_by_user_id,
|
||||||
|
started_at=started_at or _utc_now_naive(),
|
||||||
|
completed_at=completed_at,
|
||||||
|
)
|
||||||
|
self.db.add(row)
|
||||||
|
await self.db.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
async def get_run(self, *, tenant_id: int, trace_id: str) -> Optional[AIOrchestrationRun]:
|
||||||
|
return (
|
||||||
|
await self.db.execute(
|
||||||
|
select(AIOrchestrationRun).where(
|
||||||
|
and_(
|
||||||
|
AIOrchestrationRun.tenant_id == int(tenant_id),
|
||||||
|
AIOrchestrationRun.trace_id == str(trace_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
async def update_run(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
trace_id: str,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
planner_status: Optional[str] = None,
|
||||||
|
openclaw_status: Optional[str] = None,
|
||||||
|
worker_total: Optional[int] = None,
|
||||||
|
worker_completed: Optional[int] = None,
|
||||||
|
worker_failed: Optional[int] = None,
|
||||||
|
summary: Optional[str] = None,
|
||||||
|
planner_diagnostics: Optional[dict[str, Any]] = None,
|
||||||
|
result_payload: Optional[dict[str, Any]] = None,
|
||||||
|
completed_at: Optional[datetime] = None,
|
||||||
|
) -> Optional[AIOrchestrationRun]:
|
||||||
|
row = await self.get_run(tenant_id=tenant_id, trace_id=trace_id)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
if status is not None:
|
||||||
|
row.status = str(status or "").strip()[:20]
|
||||||
|
if planner_status is not None:
|
||||||
|
row.planner_status = str(planner_status or "").strip()[:20] or None
|
||||||
|
if openclaw_status is not None:
|
||||||
|
row.openclaw_status = str(openclaw_status or "").strip()[:20] or None
|
||||||
|
if worker_total is not None:
|
||||||
|
row.worker_total = max(0, int(worker_total))
|
||||||
|
if worker_completed is not None:
|
||||||
|
row.worker_completed = max(0, int(worker_completed))
|
||||||
|
if worker_failed is not None:
|
||||||
|
row.worker_failed = max(0, int(worker_failed))
|
||||||
|
if summary is not None:
|
||||||
|
row.summary = str(summary or "").strip()[:1000] or None
|
||||||
|
if planner_diagnostics is not None:
|
||||||
|
row.planner_diagnostics = planner_diagnostics
|
||||||
|
if result_payload is not None:
|
||||||
|
row.result_payload = result_payload
|
||||||
|
if completed_at is not None:
|
||||||
|
row.completed_at = completed_at
|
||||||
|
return row
|
||||||
|
|
||||||
|
async def mark_stale_runs_interrupted(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
older_than_minutes: int = 10,
|
||||||
|
) -> int:
|
||||||
|
threshold = _utc_now_naive() - timedelta(minutes=max(1, int(older_than_minutes)))
|
||||||
|
rows = (
|
||||||
|
await self.db.execute(
|
||||||
|
select(AIOrchestrationRun).where(
|
||||||
|
and_(
|
||||||
|
AIOrchestrationRun.status.in_(("queued", "running")),
|
||||||
|
AIOrchestrationRun.started_at.is_not(None),
|
||||||
|
AIOrchestrationRun.started_at < threshold,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
count = 0
|
||||||
|
for row in rows:
|
||||||
|
payload = row.result_payload if isinstance(row.result_payload, dict) else {}
|
||||||
|
payload["run_status"] = "interrupted"
|
||||||
|
payload["pending_workers"] = payload.get("pending_workers") or []
|
||||||
|
row.status = "interrupted"
|
||||||
|
row.completed_at = _utc_now_naive()
|
||||||
|
row.result_payload = payload
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def serialize_run(row: AIOrchestrationRun) -> dict[str, Any]:
|
||||||
|
payload = row.result_payload if isinstance(row.result_payload, dict) else {}
|
||||||
|
pending_workers = payload.get("pending_workers") if isinstance(payload.get("pending_workers"), list) else []
|
||||||
|
worker_total = int(row.worker_total or 0)
|
||||||
|
worker_completed = int(row.worker_completed or 0)
|
||||||
|
worker_failed = int(row.worker_failed or 0)
|
||||||
|
worker_pending = max(0, worker_total - worker_completed - worker_failed)
|
||||||
|
if not pending_workers and worker_pending > 0:
|
||||||
|
pending_workers = [f"pending_{idx}" for idx in range(1, worker_pending + 1)]
|
||||||
|
return {
|
||||||
|
"trace_id": str(row.trace_id or ""),
|
||||||
|
"run_type": str(row.run_type or ""),
|
||||||
|
"project_id": int(row.project_id),
|
||||||
|
"signal_id": int(row.signal_id) if row.signal_id is not None else None,
|
||||||
|
"response_mode": str(row.response_mode or ""),
|
||||||
|
"run_status": str(row.status or ""),
|
||||||
|
"planner_status": str(row.planner_status or ""),
|
||||||
|
"openclaw_status": str(row.openclaw_status or ""),
|
||||||
|
"worker_total": worker_total,
|
||||||
|
"worker_completed": worker_completed,
|
||||||
|
"worker_failed": worker_failed,
|
||||||
|
"worker_pending": worker_pending,
|
||||||
|
"pending_workers": pending_workers,
|
||||||
|
"summary": str(row.summary or payload.get("summary") or "").strip(),
|
||||||
|
"planner_diagnostics": row.planner_diagnostics if isinstance(row.planner_diagnostics, dict) else None,
|
||||||
|
"result_snapshot": payload,
|
||||||
|
"created_by_user_id": int(row.created_by_user_id) if row.created_by_user_id is not None else None,
|
||||||
|
"started_at": row.started_at.isoformat() if row.started_at else None,
|
||||||
|
"completed_at": row.completed_at.isoformat() if row.completed_at else None,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,483 @@
|
||||||
|
"""AI PMO cadence engine。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import and_, func, or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..models import Budget, Notification, PortfolioSnapshot, Project, ProjectDomain, ProjectSignalLink, Signal, SignalSourceRegistry, Task, UserProject, UserWorkload, WorkItem
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now_naive() -> datetime:
|
||||||
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
class AIPMOCadenceService:
|
||||||
|
"""日会 / 周会 / 风险例会 / 里程碑检查 / 项目群视角。"""
|
||||||
|
|
||||||
|
SUPPORTED_CADENCE_TYPES = {"daily_standup", "weekly_review", "risk_review", "milestone_check"}
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession, tenant_id: int) -> None:
|
||||||
|
self.db = db
|
||||||
|
self.tenant_id = int(tenant_id)
|
||||||
|
|
||||||
|
async def generate_snapshot(self, *, cadence_type: str, project_id: Optional[int] = None) -> dict[str, Any]:
|
||||||
|
normalized_type = str(cadence_type or "").strip().lower()
|
||||||
|
if normalized_type not in self.SUPPORTED_CADENCE_TYPES:
|
||||||
|
raise ValueError("unsupported cadence_type")
|
||||||
|
if normalized_type == "daily_standup":
|
||||||
|
payload = await self._build_daily_standup(project_id=project_id)
|
||||||
|
elif normalized_type == "weekly_review":
|
||||||
|
payload = await self._build_weekly_review(project_id=project_id)
|
||||||
|
elif normalized_type == "risk_review":
|
||||||
|
payload = await self._build_risk_review(project_id=project_id)
|
||||||
|
else:
|
||||||
|
payload = await self._build_milestone_check(project_id=project_id)
|
||||||
|
|
||||||
|
scope_ref = f"project:{int(project_id)}" if project_id is not None else f"tenant:{self.tenant_id}"
|
||||||
|
row = PortfolioSnapshot(
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
scope_type=normalized_type[:20],
|
||||||
|
scope_ref=scope_ref[:64],
|
||||||
|
summary=payload.get("summary"),
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
self.db.add(row)
|
||||||
|
await self.db.flush()
|
||||||
|
return {
|
||||||
|
"id": int(row.id),
|
||||||
|
"scope_type": row.scope_type,
|
||||||
|
"scope_ref": row.scope_ref,
|
||||||
|
"summary": row.summary,
|
||||||
|
"payload": row.payload,
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_latest_snapshot(self, *, cadence_type: str, project_id: Optional[int] = None) -> Optional[dict[str, Any]]:
|
||||||
|
normalized_type = str(cadence_type or "").strip().lower()
|
||||||
|
scope_ref = f"project:{int(project_id)}" if project_id is not None else f"tenant:{self.tenant_id}"
|
||||||
|
row = (
|
||||||
|
await self.db.execute(
|
||||||
|
select(PortfolioSnapshot).where(
|
||||||
|
and_(
|
||||||
|
PortfolioSnapshot.tenant_id == self.tenant_id,
|
||||||
|
PortfolioSnapshot.scope_type == normalized_type,
|
||||||
|
PortfolioSnapshot.scope_ref == scope_ref,
|
||||||
|
)
|
||||||
|
).order_by(PortfolioSnapshot.id.desc()).limit(1)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": int(row.id),
|
||||||
|
"scope_type": row.scope_type,
|
||||||
|
"scope_ref": row.scope_ref,
|
||||||
|
"summary": row.summary,
|
||||||
|
"payload": row.payload,
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def build_program_view(self) -> dict[str, Any]:
|
||||||
|
projects = (
|
||||||
|
await self.db.execute(
|
||||||
|
select(Project, ProjectDomain)
|
||||||
|
.outerjoin(ProjectDomain, and_(ProjectDomain.id == Project.domain_id, ProjectDomain.tenant_id == self.tenant_id))
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Project.tenant_id == self.tenant_id,
|
||||||
|
Project.status.in_(["active", "planning", "doing", "todo"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Project.id.asc())
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
domain_groups: dict[str, dict[str, Any]] = {}
|
||||||
|
for project, domain in projects:
|
||||||
|
key = str((domain.slug if domain is not None else "unscoped") or "unscoped")
|
||||||
|
group = domain_groups.setdefault(
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
"program_key": key,
|
||||||
|
"program_name": str((domain.name if domain is not None else "未分组项目") or "未分组项目"),
|
||||||
|
"project_count": 0,
|
||||||
|
"project_ids": [],
|
||||||
|
"blocked_task_count": 0,
|
||||||
|
"pending_manual_count": 0,
|
||||||
|
"budget_usage_ratio_max": 0.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
group["project_count"] += 1
|
||||||
|
group["project_ids"].append(int(project.id))
|
||||||
|
|
||||||
|
if not domain_groups:
|
||||||
|
return {"summary": {"program_count": 0, "active_project_count": 0}, "programs": []}
|
||||||
|
|
||||||
|
project_ids = [pid for group in domain_groups.values() for pid in group["project_ids"]]
|
||||||
|
task_rows = (
|
||||||
|
await self.db.execute(
|
||||||
|
select(Task.project_id, func.count(Task.id))
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Task.tenant_id == self.tenant_id,
|
||||||
|
Task.project_id.in_(project_ids),
|
||||||
|
Task.status == "blocked",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.group_by(Task.project_id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
task_map = {int(project_id): int(count or 0) for project_id, count in task_rows}
|
||||||
|
|
||||||
|
work_rows = (
|
||||||
|
await self.db.execute(
|
||||||
|
select(WorkItem.project_id, func.count(WorkItem.id))
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
WorkItem.tenant_id == self.tenant_id,
|
||||||
|
WorkItem.project_id.in_(project_ids),
|
||||||
|
WorkItem.state == "waiting_manual",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.group_by(WorkItem.project_id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
work_map = {int(project_id): int(count or 0) for project_id, count in work_rows}
|
||||||
|
|
||||||
|
budget_rows = (
|
||||||
|
await self.db.execute(
|
||||||
|
select(Budget.project_id, Budget.budget_amount_usd, Budget.current_usage_usd)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Budget.tenant_id == self.tenant_id,
|
||||||
|
Budget.project_id.in_(project_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
budget_ratio_map = {
|
||||||
|
int(project_id): (float(usage or 0.0) / float(amount or 1.0)) if float(amount or 0.0) > 0 else 0.0
|
||||||
|
for project_id, amount, usage in budget_rows
|
||||||
|
if project_id is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
for group in domain_groups.values():
|
||||||
|
group["blocked_task_count"] = sum(task_map.get(pid, 0) for pid in group["project_ids"])
|
||||||
|
group["pending_manual_count"] = sum(work_map.get(pid, 0) for pid in group["project_ids"])
|
||||||
|
ratios = [budget_ratio_map.get(pid, 0.0) for pid in group["project_ids"]]
|
||||||
|
group["budget_usage_ratio_max"] = round(max(ratios, default=0.0), 4)
|
||||||
|
|
||||||
|
programs = sorted(domain_groups.values(), key=lambda item: (item["pending_manual_count"], item["blocked_task_count"], item["project_count"]), reverse=True)
|
||||||
|
return {
|
||||||
|
"summary": {
|
||||||
|
"program_count": len(programs),
|
||||||
|
"active_project_count": len(project_ids),
|
||||||
|
"pending_manual_total": sum(item["pending_manual_count"] for item in programs),
|
||||||
|
"blocked_task_total": sum(item["blocked_task_count"] for item in programs),
|
||||||
|
},
|
||||||
|
"programs": programs,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _build_daily_standup(self, *, project_id: Optional[int]) -> dict[str, Any]:
|
||||||
|
now = _utc_now_naive()
|
||||||
|
since = now - timedelta(days=1)
|
||||||
|
work_items = await self._list_recent_work_items(since=since, project_id=project_id)
|
||||||
|
blocked_tasks = await self._list_blocked_tasks(project_id=project_id)
|
||||||
|
recent_signals = await self._list_recent_signals(since=since, project_id=project_id)
|
||||||
|
pending_manual_count = await self._count_work_items(state="waiting_manual", project_id=project_id)
|
||||||
|
return {
|
||||||
|
"summary": {
|
||||||
|
"cadence_type": "daily_standup",
|
||||||
|
"recent_work_item_count": len(work_items),
|
||||||
|
"pending_manual_count": pending_manual_count,
|
||||||
|
"blocked_task_count": len(blocked_tasks),
|
||||||
|
"recent_signal_count": len(recent_signals),
|
||||||
|
},
|
||||||
|
"work_items": work_items[:10],
|
||||||
|
"blocked_tasks": blocked_tasks[:10],
|
||||||
|
"recent_signals": recent_signals[:10],
|
||||||
|
"focus_actions": self._build_focus_actions(pending_manual_count=pending_manual_count, blocked_task_count=len(blocked_tasks)),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _build_weekly_review(self, *, project_id: Optional[int]) -> dict[str, Any]:
|
||||||
|
now = _utc_now_naive()
|
||||||
|
since = now - timedelta(days=7)
|
||||||
|
weekly_rows = await self._list_recent_work_items(since=since, project_id=project_id)
|
||||||
|
budgets = await self._list_budget_hotspots(project_id=project_id)
|
||||||
|
capacity = await self._list_capacity_hotspots(project_id=project_id)
|
||||||
|
program_view = await self.build_program_view() if project_id is None else None
|
||||||
|
by_state: dict[str, int] = {}
|
||||||
|
for item in weekly_rows:
|
||||||
|
state = str(item.get("state") or "unknown")
|
||||||
|
by_state[state] = by_state.get(state, 0) + 1
|
||||||
|
return {
|
||||||
|
"summary": {
|
||||||
|
"cadence_type": "weekly_review",
|
||||||
|
"work_item_total": len(weekly_rows),
|
||||||
|
"by_state": by_state,
|
||||||
|
"budget_hotspot_count": len(budgets),
|
||||||
|
"capacity_hotspot_count": len(capacity),
|
||||||
|
},
|
||||||
|
"work_items": weekly_rows[:20],
|
||||||
|
"budget_hotspots": budgets[:10],
|
||||||
|
"capacity_hotspots": capacity[:10],
|
||||||
|
"program_view": program_view,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _build_risk_review(self, *, project_id: Optional[int]) -> dict[str, Any]:
|
||||||
|
blocked_tasks = await self._list_blocked_tasks(project_id=project_id)
|
||||||
|
signal_sources = await self._list_pending_signal_sources(project_id=project_id)
|
||||||
|
notifications = await self._list_unread_risk_notifications(project_id=project_id)
|
||||||
|
return {
|
||||||
|
"summary": {
|
||||||
|
"cadence_type": "risk_review",
|
||||||
|
"blocked_task_count": len(blocked_tasks),
|
||||||
|
"pending_signal_source_count": len(signal_sources),
|
||||||
|
"unread_risk_notification_count": len(notifications),
|
||||||
|
},
|
||||||
|
"blocked_tasks": blocked_tasks[:20],
|
||||||
|
"pending_signal_sources": signal_sources[:20],
|
||||||
|
"risk_notifications": notifications[:20],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _build_milestone_check(self, *, project_id: Optional[int]) -> dict[str, Any]:
|
||||||
|
overdue_tasks = await self._list_overdue_tasks(project_id=project_id)
|
||||||
|
work_items = await self._count_work_items(state="waiting_manual", project_id=project_id)
|
||||||
|
stmt = select(Project.id, Project.title, Project.status).where(Project.tenant_id == self.tenant_id)
|
||||||
|
if project_id is not None:
|
||||||
|
stmt = stmt.where(Project.id == int(project_id))
|
||||||
|
projects = (await self.db.execute(stmt)).all()
|
||||||
|
return {
|
||||||
|
"summary": {
|
||||||
|
"cadence_type": "milestone_check",
|
||||||
|
"project_count": len(projects),
|
||||||
|
"overdue_task_count": len(overdue_tasks),
|
||||||
|
"pending_manual_count": work_items,
|
||||||
|
},
|
||||||
|
"projects": [
|
||||||
|
{"project_id": int(pid), "title": str(title or ""), "status": str(status or "")}
|
||||||
|
for pid, title, status in projects
|
||||||
|
],
|
||||||
|
"overdue_tasks": overdue_tasks[:20],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _list_recent_work_items(self, *, since: datetime, project_id: Optional[int]) -> list[dict[str, Any]]:
|
||||||
|
stmt = select(WorkItem).where(
|
||||||
|
and_(
|
||||||
|
WorkItem.tenant_id == self.tenant_id,
|
||||||
|
WorkItem.created_at >= since,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if project_id is not None:
|
||||||
|
stmt = stmt.where(WorkItem.project_id == int(project_id))
|
||||||
|
rows = (await self.db.execute(stmt.order_by(WorkItem.created_at.desc()).limit(50))).scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": int(row.id),
|
||||||
|
"state": row.state,
|
||||||
|
"intent": row.intent,
|
||||||
|
"project_id": row.project_id,
|
||||||
|
"signal_id": row.signal_id,
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _count_work_items(self, *, state: str, project_id: Optional[int]) -> int:
|
||||||
|
stmt = select(func.count(WorkItem.id)).where(
|
||||||
|
and_(
|
||||||
|
WorkItem.tenant_id == self.tenant_id,
|
||||||
|
WorkItem.state == state,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if project_id is not None:
|
||||||
|
stmt = stmt.where(WorkItem.project_id == int(project_id))
|
||||||
|
return int((await self.db.execute(stmt)).scalar_one() or 0)
|
||||||
|
|
||||||
|
async def _list_blocked_tasks(self, *, project_id: Optional[int]) -> list[dict[str, Any]]:
|
||||||
|
stmt = select(Task).where(
|
||||||
|
and_(
|
||||||
|
Task.tenant_id == self.tenant_id,
|
||||||
|
Task.status == "blocked",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if project_id is not None:
|
||||||
|
stmt = stmt.where(Task.project_id == int(project_id))
|
||||||
|
rows = (await self.db.execute(stmt.order_by(Task.updated_at.desc()).limit(50))).scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": int(row.id),
|
||||||
|
"project_id": int(row.project_id),
|
||||||
|
"title": str(row.title or ""),
|
||||||
|
"priority": str(row.priority or ""),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _list_overdue_tasks(self, *, project_id: Optional[int]) -> list[dict[str, Any]]:
|
||||||
|
now = _utc_now_naive()
|
||||||
|
stmt = select(Task).where(
|
||||||
|
and_(
|
||||||
|
Task.tenant_id == self.tenant_id,
|
||||||
|
Task.due_date.is_not(None),
|
||||||
|
Task.due_date < now,
|
||||||
|
Task.status.not_in(["done", "completed", "closed"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if project_id is not None:
|
||||||
|
stmt = stmt.where(Task.project_id == int(project_id))
|
||||||
|
rows = (await self.db.execute(stmt.order_by(Task.due_date.asc()).limit(50))).scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": int(row.id),
|
||||||
|
"project_id": int(row.project_id),
|
||||||
|
"title": str(row.title or ""),
|
||||||
|
"due_date": row.due_date.isoformat() if row.due_date else None,
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _list_recent_signals(self, *, since: datetime, project_id: Optional[int]) -> list[dict[str, Any]]:
|
||||||
|
stmt = select(Signal).where(
|
||||||
|
and_(
|
||||||
|
Signal.tenant_id == self.tenant_id,
|
||||||
|
Signal.created_at >= since,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if project_id is not None:
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
Signal.matched_project_id == int(project_id),
|
||||||
|
Signal.id.in_(
|
||||||
|
select(ProjectSignalLink.signal_id).where(
|
||||||
|
and_(
|
||||||
|
ProjectSignalLink.tenant_id == self.tenant_id,
|
||||||
|
ProjectSignalLink.project_id == int(project_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows = (await self.db.execute(stmt.order_by(Signal.created_at.desc()).limit(50))).scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": int(row.id),
|
||||||
|
"title": str(row.title or ""),
|
||||||
|
"status": str(row.status or ""),
|
||||||
|
"signal_type": str(row.signal_type or ""),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _list_budget_hotspots(self, *, project_id: Optional[int]) -> list[dict[str, Any]]:
|
||||||
|
stmt = select(Budget).where(Budget.tenant_id == self.tenant_id)
|
||||||
|
if project_id is not None:
|
||||||
|
stmt = stmt.where(Budget.project_id == int(project_id))
|
||||||
|
rows = (await self.db.execute(stmt)).scalars().all()
|
||||||
|
hotspots = []
|
||||||
|
for row in rows:
|
||||||
|
amount = float(row.budget_amount_usd or 0.0)
|
||||||
|
usage = float(row.current_usage_usd or 0.0)
|
||||||
|
ratio = (usage / amount) if amount > 0 else 0.0
|
||||||
|
if ratio >= 0.8:
|
||||||
|
hotspots.append(
|
||||||
|
{
|
||||||
|
"budget_id": int(row.id),
|
||||||
|
"project_id": row.project_id,
|
||||||
|
"usage_ratio": round(ratio, 4),
|
||||||
|
"hard_limit_enabled": bool(row.hard_limit_enabled),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return sorted(hotspots, key=lambda item: item["usage_ratio"], reverse=True)
|
||||||
|
|
||||||
|
async def _list_capacity_hotspots(self, *, project_id: Optional[int]) -> list[dict[str, Any]]:
|
||||||
|
stmt = select(UserWorkload).where(
|
||||||
|
and_(
|
||||||
|
UserWorkload.tenant_id == self.tenant_id,
|
||||||
|
UserWorkload.workload_ratio >= 0.85,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if project_id is not None:
|
||||||
|
stmt = stmt.where(
|
||||||
|
UserWorkload.user_id.in_(
|
||||||
|
select(UserProject.user_id).where(
|
||||||
|
and_(
|
||||||
|
UserProject.tenant_id == self.tenant_id,
|
||||||
|
UserProject.project_id == int(project_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows = (await self.db.execute(stmt.limit(50))).scalars().all()
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"user_id": int(row.user_id),
|
||||||
|
"workload_ratio": float(row.workload_ratio or 0.0),
|
||||||
|
"current_assigned_hours": float(row.current_assigned_hours or 0.0),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
return items
|
||||||
|
|
||||||
|
async def _list_pending_signal_sources(self, *, project_id: Optional[int]) -> list[dict[str, Any]]:
|
||||||
|
stmt = select(SignalSourceRegistry).where(
|
||||||
|
and_(
|
||||||
|
SignalSourceRegistry.tenant_id == self.tenant_id,
|
||||||
|
SignalSourceRegistry.onboarding_status == "pending_manual",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if project_id is not None:
|
||||||
|
stmt = stmt.where(SignalSourceRegistry.project_id == int(project_id))
|
||||||
|
rows = (await self.db.execute(stmt.order_by(SignalSourceRegistry.updated_at.desc()).limit(50))).scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"registry_id": int(row.id),
|
||||||
|
"project_id": int(row.project_id),
|
||||||
|
"source_kind": str(row.source_kind or ""),
|
||||||
|
"source_name": str(row.source_name or ""),
|
||||||
|
"status": str(row.status or ""),
|
||||||
|
"onboarding_status": str(row.onboarding_status or ""),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _list_unread_risk_notifications(self, *, project_id: Optional[int]) -> list[dict[str, Any]]:
|
||||||
|
stmt = select(Notification).where(
|
||||||
|
and_(
|
||||||
|
Notification.tenant_id == self.tenant_id,
|
||||||
|
Notification.is_read.is_(False),
|
||||||
|
Notification.kind.in_(["risk", "alert", "action_required"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if project_id is not None:
|
||||||
|
stmt = stmt.where(
|
||||||
|
and_(
|
||||||
|
Notification.entity_type == "project",
|
||||||
|
Notification.entity_id == int(project_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows = (await self.db.execute(stmt.order_by(Notification.updated_at.desc()).limit(50))).scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": int(row.id),
|
||||||
|
"title": str(row.title or ""),
|
||||||
|
"status": str(row.status or ""),
|
||||||
|
"priority": str(row.priority or ""),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_focus_actions(*, pending_manual_count: int, blocked_task_count: int) -> list[str]:
|
||||||
|
actions: list[str] = []
|
||||||
|
if pending_manual_count > 0:
|
||||||
|
actions.append("优先清理待人工确认队列,避免自动执行链路阻塞。")
|
||||||
|
if blocked_task_count > 0:
|
||||||
|
actions.append("优先处理 blocked tasks,降低关键依赖压力。")
|
||||||
|
if not actions:
|
||||||
|
actions.append("当前节奏平稳,继续按计划推进。")
|
||||||
|
return actions
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
"""AI PMO domain workflow adapters。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from .signal_source_orchestration_service import SignalSourceOrchestrationService
|
||||||
|
|
||||||
|
|
||||||
|
class SignalSourceDomainWorkflow:
|
||||||
|
"""把 signal source onboarding 归一成 domain workflow 结果。"""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession) -> None:
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def run(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
project_id: int,
|
||||||
|
signal_id: int,
|
||||||
|
initiating_text: str,
|
||||||
|
requested_via: str,
|
||||||
|
operator_user_id: Optional[int],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
raw_result = await SignalSourceOrchestrationService(self.db).orchestrate_from_signal(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
project_id=project_id,
|
||||||
|
signal_id=signal_id,
|
||||||
|
initiating_text=initiating_text,
|
||||||
|
requested_via=requested_via,
|
||||||
|
operator_user_id=operator_user_id,
|
||||||
|
dry_run=False,
|
||||||
|
response_mode="fast",
|
||||||
|
)
|
||||||
|
normalized = self.normalize(raw_result)
|
||||||
|
normalized["raw_result"] = raw_result
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def normalize(cls, raw_result: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = raw_result if isinstance(raw_result, dict) else {}
|
||||||
|
signal_source = payload.get("signal_source") if isinstance(payload.get("signal_source"), dict) else {}
|
||||||
|
worker_executions = payload.get("worker_executions") if isinstance(payload.get("worker_executions"), list) else []
|
||||||
|
typed_actions = cls._extract_typed_actions(payload.get("openclaw"))
|
||||||
|
material_actions = [
|
||||||
|
row
|
||||||
|
for row in typed_actions
|
||||||
|
if str(row.get("operation_code") or "").strip() != "notification.create"
|
||||||
|
and str(row.get("status") or "").strip() in {"applied", "planned"}
|
||||||
|
]
|
||||||
|
fallback_used = any(bool(item.get("used_fallback")) for item in worker_executions if isinstance(item, dict))
|
||||||
|
|
||||||
|
raw_status = str(payload.get("status") or "").strip().lower()
|
||||||
|
registry_status = str(signal_source.get("status") or "").strip().lower()
|
||||||
|
onboarding_status = str(signal_source.get("onboarding_status") or "").strip().lower()
|
||||||
|
openclaw_status = str((payload.get("openclaw") or {}).get("status") or "").strip().lower()
|
||||||
|
|
||||||
|
if raw_status == "failed" or openclaw_status == "failed":
|
||||||
|
final_status = "failed_safe"
|
||||||
|
elif onboarding_status != "completed":
|
||||||
|
final_status = "pending_manual"
|
||||||
|
elif not material_actions:
|
||||||
|
final_status = "pending_manual"
|
||||||
|
elif fallback_used:
|
||||||
|
final_status = "pending_manual"
|
||||||
|
elif raw_status in {"completed", "partial", "planned"}:
|
||||||
|
final_status = "completed"
|
||||||
|
else:
|
||||||
|
final_status = "pending_manual"
|
||||||
|
|
||||||
|
next_step = "等待人工确认" if final_status != "completed" else "继续自动执行后续任务"
|
||||||
|
return {
|
||||||
|
"status": final_status,
|
||||||
|
"registry_id": signal_source.get("registry_id"),
|
||||||
|
"registry_status": registry_status,
|
||||||
|
"onboarding_status": onboarding_status,
|
||||||
|
"openclaw_status": openclaw_status,
|
||||||
|
"typed_actions": typed_actions,
|
||||||
|
"material_action_count": len(material_actions),
|
||||||
|
"worker_executions": worker_executions,
|
||||||
|
"pending_workers": payload.get("pending_workers") if isinstance(payload.get("pending_workers"), list) else [],
|
||||||
|
"next_step": next_step,
|
||||||
|
"fallback_used": fallback_used,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_typed_actions(openclaw_payload: Any) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(openclaw_payload, dict):
|
||||||
|
return []
|
||||||
|
result = openclaw_payload.get("result") if isinstance(openclaw_payload.get("result"), dict) else openclaw_payload
|
||||||
|
execution = result.get("execution") if isinstance(result, dict) and isinstance(result.get("execution"), dict) else {}
|
||||||
|
rows = execution.get("results") if isinstance(execution.get("results"), list) else []
|
||||||
|
actions: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
actions.append(
|
||||||
|
{
|
||||||
|
"operation_code": str(row.get("operation_code") or "").strip(),
|
||||||
|
"status": str(row.get("status") or "").strip(),
|
||||||
|
"entity_id": row.get("entity_id"),
|
||||||
|
"payload": row.get("payload") if isinstance(row.get("payload"), dict) else {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return actions
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue