chore: snapshot current code before refactor
This commit is contained in:
parent
16f30bc2c6
commit
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"
|
||||
}
|
||||
|
|
@ -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_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:
|
||||
# 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
|
||||
# - ollama: 本地运行 (免费,但占用 CPU/内存)
|
||||
# - deepseek: 云端 API (便宜,¥1/百万 tokens)
|
||||
# - openai: 云端 API (GPT-4,较贵)
|
||||
# - qwen: 阿里云通义千问 (便宜,国内稳定)
|
||||
LLM_PROVIDER=deepseek
|
||||
# 单一控制面:PMFlow 仅调用 OpenClaw 网关(OpenAI-Compatible)
|
||||
LLM_PROVIDER=openai
|
||||
OPENAI_BASE_URL=http://openclaw:18789/v1
|
||||
OPENAI_API_KEY=<OPENCLAW_GATEWAY_TOKEN>
|
||||
|
||||
# --- DeepSeek 配置 (推荐,便宜好用) ---
|
||||
DEEPSEEK_API_KEY=your_deepseek_api_key_here
|
||||
# OpenClaw 函数路由
|
||||
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
|
||||
|
||||
# --- OpenAI 配置 ---
|
||||
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 本地配置(应急本地兜底) ---
|
||||
OLLAMA_HOST=http://127.0.0.1:11434
|
||||
OLLAMA_CHAT_MODEL=llama3:8b
|
||||
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_SECRET=
|
||||
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 pool
|
||||
from sqlalchemy import text
|
||||
|
||||
from alembic import context
|
||||
from app.core.config import settings
|
||||
|
|
@ -24,6 +25,7 @@ if config.config_file_name is not None:
|
|||
# Set the database URL from settings
|
||||
# Replace async driver with sync driver for migrations
|
||||
db_url = settings.database_url.replace("sqlite+aiosqlite", "sqlite")
|
||||
db_url = db_url.replace("postgresql+asyncpg", "postgresql+psycopg")
|
||||
config.set_main_option("sqlalchemy.url", db_url)
|
||||
|
||||
# add your model's MetaData object here
|
||||
|
|
@ -76,6 +78,11 @@ def run_migrations_online() -> None:
|
|||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
if str(connection.dialect.name).lower() == "postgresql":
|
||||
# 确保 pgvector 类型在迁移执行前可用,避免 VECTOR 列建表失败。
|
||||
connection.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
|
||||
connection.commit()
|
||||
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 .budget import router as budget_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__ = [
|
||||
"signals_router",
|
||||
|
|
@ -38,4 +44,10 @@ __all__ = [
|
|||
"model_capability_router",
|
||||
"budget_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. 个性化进度简报
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
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
|
||||
import json
|
||||
|
||||
from ..core.response import success_response
|
||||
from ..database import get_db
|
||||
from ..dependencies.auth import get_current_user
|
||||
from ..dependencies.permissions import require_manager_user
|
||||
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.signal_capture import ProactiveSignalCaptureService
|
||||
from ..services.cognitive_conflict import get_cognitive_conflict_service
|
||||
from ..services.blocker_detection import get_blocker_detection_service
|
||||
from ..services.daily_briefing import get_daily_briefing_service
|
||||
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"])
|
||||
|
||||
|
||||
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 ====================
|
||||
|
||||
class ProjectFromTextRequest(BaseModel):
|
||||
|
|
@ -77,6 +121,7 @@ class FeishuCaptureRequest(BaseModel):
|
|||
group_chat_id: str
|
||||
message_content: str
|
||||
sender: str
|
||||
feishu_message_id: Optional[str] = None
|
||||
|
||||
|
||||
class BrowserCaptureRequest(BaseModel):
|
||||
|
|
@ -100,8 +145,201 @@ class ConflictDetectRequest(BaseModel):
|
|||
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 ====================
|
||||
|
||||
@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)
|
||||
async def create_project_from_text(
|
||||
request: ProjectFromTextRequest,
|
||||
|
|
@ -163,7 +401,8 @@ async def capture_feishu_signal(
|
|||
group_chat_id=request.group_chat_id,
|
||||
message_content=request.message_content,
|
||||
sender=request.sender,
|
||||
tenant_id=tenant_id
|
||||
tenant_id=tenant_id,
|
||||
feishu_message_id=request.feishu_message_id,
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
|
|
@ -285,8 +524,8 @@ async def get_cognitive_conflicts(
|
|||
id=str(c.id),
|
||||
type=c.conflict_type,
|
||||
description=c.description,
|
||||
severity="high" if "critical" in c.description.lower() else "medium",
|
||||
related_users=json.loads(c.involved_user_ids) if c.involved_user_ids else []
|
||||
severity="high" if "critical" in (c.description or "").lower() else "medium",
|
||||
related_users=_parse_related_users(c.involved_user_ids),
|
||||
)
|
||||
for c in conflicts
|
||||
]
|
||||
|
|
@ -424,7 +663,7 @@ async def resolve_cognitive_conflict(
|
|||
raise HTTPException(status_code=404, detail="冲突记录不存在")
|
||||
|
||||
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}"
|
||||
|
||||
await db.commit()
|
||||
|
|
@ -790,10 +1029,13 @@ async def get_ai_steward_stats(
|
|||
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(
|
||||
select(func.count(Signal.id)).where(
|
||||
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
|
||||
|
|
@ -803,7 +1045,7 @@ async def get_ai_steward_stats(
|
|||
select(func.count(Signal.id)).where(
|
||||
Signal.tenant_id == tenant_id,
|
||||
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
|
||||
|
|
@ -822,7 +1064,7 @@ async def get_ai_steward_stats(
|
|||
select(func.count(CognitiveConflict.id)).where(
|
||||
CognitiveConflict.tenant_id == tenant_id,
|
||||
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
|
||||
|
|
@ -1105,6 +1347,7 @@ async def get_ai_config_suggestion(
|
|||
"""
|
||||
from ..models import Integration, Project, Signal
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy import func
|
||||
|
||||
# 检查已配置的集成
|
||||
configured_sources = []
|
||||
|
|
@ -1113,7 +1356,7 @@ async def get_ai_config_suggestion(
|
|||
feishu_result = await db.execute(
|
||||
select(Integration).where(
|
||||
Integration.tenant_id == tenant_id,
|
||||
Integration.provider == 'feishu',
|
||||
Integration.type == 'feishu',
|
||||
Integration.is_active == True
|
||||
)
|
||||
)
|
||||
|
|
@ -1133,7 +1376,7 @@ async def get_ai_config_suggestion(
|
|||
signal_result = await db.execute(
|
||||
select(func.count(Signal.id)).where(
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,38 +1,57 @@
|
|||
"""
|
||||
Feishu Integration API - 飞书集成接口
|
||||
|
||||
功能:
|
||||
1. 配置飞书应用凭证
|
||||
2. 验证 URL 和处理事件
|
||||
3. 发送消息到飞书群
|
||||
4. 查询集成状态
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, BackgroundTasks
|
||||
"""Feishu Integration API - 飞书集成接口。"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
|
||||
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.feishu_integration import get_feishu_service
|
||||
from ..services.feishu_long_connection import get_feishu_long_connection_manager
|
||||
|
||||
|
||||
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 ====================
|
||||
|
||||
class FeishuConfigRequest(BaseModel):
|
||||
"""飞书配置请求"""
|
||||
app_id: str = Field(..., description="飞书 App ID")
|
||||
app_secret: str = Field(..., description="飞书 App Secret")
|
||||
encrypt_key: str = Field(..., description="事件订阅加密密钥")
|
||||
verification_token: str = Field(..., description="事件订阅验证令牌")
|
||||
encrypt_key: Optional[str] = Field(None, description="已废弃字段,保留仅为兼容旧客户端")
|
||||
verification_token: Optional[str] = Field(None, description="已废弃字段,保留仅为兼容旧客户端")
|
||||
event_mode: str = Field("long_connection", description="事件接收模式,现仅支持 long_connection")
|
||||
|
||||
|
||||
class FeishuSendMessageRequest(BaseModel):
|
||||
|
|
@ -63,40 +82,47 @@ async def save_feishu_config(
|
|||
配置信息说明:
|
||||
- app_id: 飞书应用的 App ID,从飞书开放平台获取
|
||||
- app_secret: 飞书应用的 App Secret
|
||||
- encrypt_key: 事件订阅的加密 Key
|
||||
- verification_token: 事件订阅的验证 Token
|
||||
|
||||
获取步骤:
|
||||
1. 访问 https://open.feishu.cn/
|
||||
2. 创建企业自建应用
|
||||
3. 在「凭证与基础信息」页面获取 App ID 和 App Secret
|
||||
4. 在「事件订阅」配置 Encrypt Key 和 Verification Token
|
||||
5. 添加权限:im:message, im:message:group_at_msg, im:chat
|
||||
6. 订阅事件:接收消息 v1 (message.receive_v1)
|
||||
4. 添加权限:im:message, im:message:group_at_msg, im:chat
|
||||
5. 订阅事件:接收消息 v1 (message.receive_v1)
|
||||
"""
|
||||
# 检查用户权限
|
||||
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
|
||||
raise HTTPException(status_code=403, detail="只有管理员可以配置飞书集成")
|
||||
|
||||
try:
|
||||
event_mode = "long_connection"
|
||||
|
||||
service = get_feishu_service(db)
|
||||
await service.save_config(
|
||||
app_id=request.app_id,
|
||||
app_secret=request.app_secret,
|
||||
encrypt_key=request.encrypt_key,
|
||||
verification_token=request.verification_token,
|
||||
encrypt_key="",
|
||||
verification_token="",
|
||||
event_mode=event_mode,
|
||||
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 {
|
||||
'status': 'success',
|
||||
'message': '飞书配置已保存',
|
||||
'next_steps': [
|
||||
'1. 在飞书开放平台配置事件订阅 URL',
|
||||
f'2. URL: https://your-domain.com/api/feishu/webhook',
|
||||
'3. 点击验证按钮完成 URL 验证'
|
||||
]
|
||||
'message': '飞书配置已保存,并已切换到长连接模式',
|
||||
'next_steps': next_steps
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
|
|
@ -110,16 +136,19 @@ async def get_feishu_config(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id)
|
||||
):
|
||||
"""获取飞书配置状态(不返回敏感信息)"""
|
||||
"""获取飞书配置状态。管理员可获取完整配置用于回填修改表单。"""
|
||||
try:
|
||||
service = get_feishu_service(db)
|
||||
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 {
|
||||
'is_configured': service.is_configured(),
|
||||
'app_id': service.app_id if service.is_configured() else None,
|
||||
'has_encrypt_key': bool(service.encrypt_key),
|
||||
'has_verification_token': bool(service.verification_token)
|
||||
'app_id': service.app_id,
|
||||
'app_secret': service.app_secret if can_view_secrets else None,
|
||||
'event_mode': "long_connection",
|
||||
'long_connection': manager.status() if manager else None,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
|
|
@ -145,7 +174,7 @@ async def delete_feishu_config(
|
|||
result = await db.execute(
|
||||
select(Integration).where(
|
||||
Integration.tenant_id == tenant_id,
|
||||
Integration.provider == 'feishu'
|
||||
Integration.type == 'feishu'
|
||||
)
|
||||
)
|
||||
integration = result.scalar_one_or_none()
|
||||
|
|
@ -153,6 +182,9 @@ async def delete_feishu_config(
|
|||
if integration:
|
||||
integration.is_active = False
|
||||
await db.commit()
|
||||
manager = get_feishu_long_connection_manager()
|
||||
if manager:
|
||||
await manager.restart()
|
||||
|
||||
return {'status': 'success', 'message': '飞书配置已删除'}
|
||||
except Exception as e:
|
||||
|
|
@ -162,93 +194,45 @@ async def delete_feishu_config(
|
|||
)
|
||||
|
||||
|
||||
# ==================== Webhook Endpoints ====================
|
||||
|
||||
@router.get("/webhook")
|
||||
async def verify_webhook_url(
|
||||
challenge: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
@router.get("/long-connection/status")
|
||||
async def feishu_long_connection_status(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
验证飞书事件订阅 URL
|
||||
|
||||
飞书会发送 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)
|
||||
|
||||
"""查询飞书长连接状态。"""
|
||||
manager = get_feishu_long_connection_manager()
|
||||
if not manager:
|
||||
return {
|
||||
"challenge": challenge_response
|
||||
"status": "error",
|
||||
"message": "Feishu 长连接管理器未初始化",
|
||||
"mode": "long_connection",
|
||||
"data": None,
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
return {
|
||||
"status": "success",
|
||||
"mode": "long_connection",
|
||||
"data": manager.status(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/webhook")
|
||||
async def handle_feishu_event(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
@router.post("/long-connection/restart")
|
||||
async def restart_feishu_long_connection(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
处理飞书事件推送
|
||||
"""手动重启飞书长连接。"""
|
||||
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
|
||||
raise HTTPException(status_code=403, detail="只有管理员可以重启飞书长连接")
|
||||
|
||||
飞书会发送 POST 请求推送事件
|
||||
需要验证签名确保请求来自飞书
|
||||
"""
|
||||
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'}
|
||||
manager = get_feishu_long_connection_manager()
|
||||
if not manager:
|
||||
raise HTTPException(status_code=500, detail="Feishu 长连接管理器未初始化")
|
||||
|
||||
await manager.restart()
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Feishu 长连接已重启",
|
||||
"mode": "long_connection",
|
||||
"data": manager.status(),
|
||||
}
|
||||
|
||||
# ==================== Message Endpoints ====================
|
||||
|
||||
|
|
@ -333,6 +317,7 @@ async def test_feishu_connection(
|
|||
try:
|
||||
service = get_feishu_service(db)
|
||||
await service.load_config(tenant_id)
|
||||
manager = get_feishu_long_connection_manager()
|
||||
|
||||
if not service.is_configured():
|
||||
return {
|
||||
|
|
@ -343,10 +328,39 @@ async def test_feishu_connection(
|
|||
# 尝试获取 tenant_access_token 测试连接
|
||||
try:
|
||||
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 {
|
||||
'status': 'success',
|
||||
'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:
|
||||
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 typing import Optional
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
|
@ -12,6 +13,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from ..database import get_db
|
||||
from ..dependencies import get_current_user, get_tenant_id, require_roles
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
|
|
@ -19,6 +23,18 @@ router = APIRouter(prefix="/projects", tags=["projects"])
|
|||
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):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
|
|
@ -187,6 +203,7 @@ async def create_project(
|
|||
title = compact_title(body.title, max_len=24)
|
||||
if not title:
|
||||
raise HTTPException(status_code=400, detail="title required")
|
||||
await ensure_project_id_sequence(db)
|
||||
project = Project(
|
||||
tenant_id=tenant_id,
|
||||
name=title,
|
||||
|
|
@ -199,8 +216,17 @@ async def create_project(
|
|||
signal_count=0,
|
||||
)
|
||||
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.refresh(project)
|
||||
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
||||
return _serialize_project_row(project)
|
||||
|
||||
|
||||
|
|
@ -229,6 +255,7 @@ async def update_project(
|
|||
project.assigned_role = body.assigned_role
|
||||
await db.commit()
|
||||
await db.refresh(project)
|
||||
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
||||
return _serialize_project_row(project)
|
||||
|
||||
|
||||
|
|
@ -266,46 +293,10 @@ async def delete_project(
|
|||
)
|
||||
)
|
||||
await db.commit()
|
||||
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
||||
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}")
|
||||
async def link_signal_to_project(
|
||||
project_id: int,
|
||||
|
|
@ -361,6 +352,7 @@ async def link_signal_to_project(
|
|||
db.add(link)
|
||||
await db.commit()
|
||||
await db.refresh(link)
|
||||
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
||||
return {
|
||||
"id": str(link.id),
|
||||
"project_id": str(link.project_id),
|
||||
|
|
@ -390,4 +382,5 @@ async def unlink_signal_from_project(
|
|||
)
|
||||
)
|
||||
await db.commit()
|
||||
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
||||
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 .auth import router as auth_router
|
||||
from .users import router as users_router
|
||||
from .projects import router as projects_router
|
||||
from .endpoints.skill import router as skill_router
|
||||
from .workloads import router as workloads_router
|
||||
from .skill_tags import router as skill_tags_router
|
||||
from .sstc import router as sstc_router
|
||||
from .projects import router as projects_router
|
||||
from .scheduler import router as scheduler_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_router = APIRouter()
|
||||
|
|
@ -34,10 +46,22 @@ api_router.include_router(tasks_router)
|
|||
api_router.include_router(webhooks_router)
|
||||
api_router.include_router(auth_router)
|
||||
api_router.include_router(users_router)
|
||||
api_router.include_router(projects_router)
|
||||
api_router.include_router(skill_router)
|
||||
api_router.include_router(workloads_router)
|
||||
api_router.include_router(skill_tags_router)
|
||||
api_router.include_router(sstc_router)
|
||||
api_router.include_router(projects_router)
|
||||
api_router.include_router(scheduler_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 路由。"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..database import get_db
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/scheduler", tags=["scheduler"])
|
||||
|
|
@ -35,3 +42,132 @@ async def trigger_workload_recalc(
|
|||
"message": "success",
|
||||
"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 pydantic import BaseModel
|
||||
from sqlalchemy import and_, delete, func, or_, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..database import get_db
|
||||
|
|
@ -125,6 +126,15 @@ def _parse_int_list(raw: Optional[str]) -> list[int]:
|
|||
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(
|
||||
db: AsyncSession,
|
||||
tenant_id: int,
|
||||
|
|
@ -213,6 +223,22 @@ async def _replace_user_projects(
|
|||
for pid in unique_project_ids:
|
||||
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(
|
||||
db: AsyncSession,
|
||||
|
|
@ -393,12 +419,12 @@ async def list_users(
|
|||
query = query.where(func.coalesce(UserWorkload.workload_ratio, 0.0) > 100.0)
|
||||
|
||||
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)
|
||||
|
||||
result = await db.execute(
|
||||
query.distinct(User.id)
|
||||
query.distinct()
|
||||
.order_by(User.updated_at.desc(), User.id.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
|
|
@ -490,6 +516,9 @@ async def create_user(
|
|||
except ValueError as exc:
|
||||
await db.rollback()
|
||||
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:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
|
@ -571,8 +600,12 @@ async def update_user(
|
|||
before_data=before_data,
|
||||
after_data=user.to_dict(),
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
try:
|
||||
await db.commit()
|
||||
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])
|
||||
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:
|
||||
raise HTTPException(status_code=403, detail="No permission")
|
||||
|
||||
if len(body.new_password or "") < 6:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
||||
|
||||
user.password_hash = AuthService.hash_password(body.new_password)
|
||||
try:
|
||||
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()
|
||||
await log_operation(
|
||||
db,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
"""Webhook 接收 API"""
|
||||
"""Webhook 接收 API。"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
||||
|
|
@ -12,10 +12,8 @@ from ..database import get_db
|
|||
from ..models import Signal
|
||||
from ..services.llm import llm_service
|
||||
from ..services.plane import plane_service
|
||||
from ..services.lark import LarkService
|
||||
|
||||
router = APIRouter(prefix="/webhooks", tags=["webhooks"])
|
||||
lark_service = LarkService()
|
||||
|
||||
|
||||
@router.post("/git")
|
||||
|
|
@ -114,58 +112,3 @@ async def handle_git_webhook(
|
|||
)
|
||||
|
||||
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),
|
||||
tenant_id: int = Depends(get_tenant_id)
|
||||
):
|
||||
"""获取企业微信配置状态(不返回敏感信息)"""
|
||||
"""获取企业微信配置状态。管理员可获取完整配置用于回填修改表单。"""
|
||||
try:
|
||||
service = get_wecom_service(db)
|
||||
await service.load_config(tenant_id)
|
||||
can_view_secrets = has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"])
|
||||
|
||||
return {
|
||||
'is_configured': service.is_configured(),
|
||||
'corp_id': service.corp_id if service.is_configured() else None,
|
||||
'agent_id': service.agent_id if service.is_configured() else None,
|
||||
'corp_id': service.corp_id,
|
||||
'agent_id': service.agent_id,
|
||||
'app_secret': service.app_secret if can_view_secrets else None,
|
||||
'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:
|
||||
raise HTTPException(
|
||||
|
|
@ -137,7 +143,7 @@ async def delete_wecom_config(
|
|||
result = await db.execute(
|
||||
select(Integration).where(
|
||||
Integration.tenant_id == tenant_id,
|
||||
Integration.provider == 'wecom'
|
||||
Integration.type == 'wecom'
|
||||
)
|
||||
)
|
||||
integration = result.scalar_one_or_none()
|
||||
|
|
@ -157,6 +163,7 @@ async def delete_wecom_config(
|
|||
# ==================== Webhook Endpoints ====================
|
||||
|
||||
@router.get("/webhook")
|
||||
@router.get("/webhook/", include_in_schema=False)
|
||||
async def verify_webhook_url(
|
||||
msg_signature: str,
|
||||
timestamp: str,
|
||||
|
|
@ -193,6 +200,7 @@ async def verify_webhook_url(
|
|||
|
||||
|
||||
@router.post("/webhook")
|
||||
@router.post("/webhook/", include_in_schema=False)
|
||||
async def handle_wecom_event(
|
||||
request: Request,
|
||||
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 = """
|
||||
你是一位资深产品经理。根据以下市场/技术信号,生成一份结构化的需求宪章草案。
|
||||
|
||||
|
|
@ -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
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
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:
|
||||
|
|
@ -19,7 +28,11 @@ class Settings:
|
|||
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
|
||||
secret_key: str = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
import logging
|
||||
import sys
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
# 尝试使用 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):
|
||||
"""获取 logger 实例"""
|
||||
if USE_STRUCTLOG:
|
||||
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,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from .core.config import settings
|
||||
|
||||
|
|
@ -20,13 +22,43 @@ def _is_sqlite() -> bool:
|
|||
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(
|
||||
DATABASE_URL,
|
||||
echo=settings.debug,
|
||||
future=True,
|
||||
pool_pre_ping=not _is_sqlite(),
|
||||
**engine_kwargs,
|
||||
)
|
||||
|
||||
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(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from sqlalchemy.future import select
|
||||
|
||||
from ..database import get_db
|
||||
from ..dependencies.tenant import get_tenant_id
|
||||
from ..models import User, UserStatus # noqa: E402
|
||||
from ..services.auth import AuthService, has_any_role
|
||||
|
||||
|
|
@ -71,3 +72,17 @@ def require_roles(*roles: str):
|
|||
return current_user
|
||||
|
||||
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
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import sqlalchemy as sa
|
||||
|
||||
from .api.router import api_router
|
||||
from .core.config import settings
|
||||
|
|
@ -15,10 +17,310 @@ from .core.response import success_response
|
|||
from .database import AsyncSessionLocal, engine
|
||||
from .models import Base
|
||||
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.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)
|
||||
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
|
||||
|
|
@ -29,9 +331,12 @@ async def lifespan(_: FastAPI):
|
|||
"true",
|
||||
"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(_sqlite_backfill_columns)
|
||||
await conn.run_sync(_ensure_feishu_integrity_guards)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
|
|
@ -40,11 +345,39 @@ async def lifespan(_: FastAPI):
|
|||
logger.info("system_skill_tags_seeded", created=created)
|
||||
except Exception as exc: # pragma: no cover
|
||||
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.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
|
||||
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 engine.dispose()
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@ from .skill import SkillTag
|
|||
from .user_skill_binding import UserSkillBinding
|
||||
from .user_workload import UserWorkload
|
||||
from .signal import Signal
|
||||
from .signal_edit import SignalEdit
|
||||
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 .experiment import Experiment
|
||||
from .project_domain import ProjectDomain
|
||||
|
|
@ -29,8 +33,34 @@ from .model_capability_matrix import (
|
|||
BudgetAlert,
|
||||
)
|
||||
from .integration import Integration
|
||||
from .notification import Notification
|
||||
from .ai_call_log import AICallLog
|
||||
from .ai_processing_log import AIProcessingLog
|
||||
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__ = [
|
||||
"Base",
|
||||
|
|
@ -45,7 +75,11 @@ __all__ = [
|
|||
"UserSkillBinding",
|
||||
"UserWorkload",
|
||||
"Signal",
|
||||
"SignalEdit",
|
||||
"SignalFeedback",
|
||||
"FeishuItem",
|
||||
"FeishuReplyOutbox",
|
||||
"ProjectSuggestion",
|
||||
"ContextNode",
|
||||
"Experiment",
|
||||
"ProjectDomain",
|
||||
|
|
@ -64,6 +98,32 @@ __all__ = [
|
|||
"Budget",
|
||||
"BudgetAlert",
|
||||
"Integration",
|
||||
"Notification",
|
||||
"AICallLog",
|
||||
"AIProcessingLog",
|
||||
"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 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
|
||||
|
||||
|
||||
|
|
@ -16,5 +16,16 @@ class Signal(Base):
|
|||
processed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
strategic_analysis: 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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
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(
|
||||
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,
|
||||
ReviewArchiveRequest,
|
||||
)
|
||||
from .recipe import (
|
||||
RecipeCreate,
|
||||
RecipeUpdate,
|
||||
RecipeResponse,
|
||||
RecipeImageUploadRequest,
|
||||
RecipeAnalyzeCaloriesResponse,
|
||||
)
|
||||
from . import ai as ai_schemas
|
||||
|
||||
__all__ = [
|
||||
|
|
@ -87,4 +94,9 @@ __all__ = [
|
|||
"ReleaseStatusPatchRequest",
|
||||
"ReviewCreateRequest",
|
||||
"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 = "已完成"
|
||||
# release
|
||||
RELEASE_LOCKED = "未解锁"
|
||||
RELEASE_PREP = "可预发布"
|
||||
RELEASE_PENDING = "待发布"
|
||||
RELEASE_RELEASING = "发布中"
|
||||
RELEASE_LIVE = "已上线"
|
||||
RELEASE_VERIFIED = "已验证"
|
||||
# review
|
||||
REVIEW_LOCKED = "未解锁"
|
||||
REVIEW_PREP = "可阶段复盘"
|
||||
REVIEW_DRAFT = "草稿"
|
||||
REVIEW_ARCHIVED = "已归档"
|
||||
|
||||
|
|
@ -91,6 +94,11 @@ class TaskStatusPatchRequest(BaseModel):
|
|||
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):
|
||||
title: str
|
||||
charter_id: int
|
||||
|
|
@ -112,3 +120,22 @@ class ReviewCreateRequest(BaseModel):
|
|||
class ReviewArchiveRequest(BaseModel):
|
||||
archive: bool = True
|
||||
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
|
|
@ -0,0 +1,189 @@
|
|||
"""AI PMO review engine。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import PortfolioSnapshot, ProjectSignalLink, ReplyReceipt, SignalSourceRegistry, WorkItem
|
||||
|
||||
|
||||
def _safe_int(value: Any) -> int | None:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class AIPMOReviewService:
|
||||
"""复盘层:结果核验、偏差检测、风险再评分、规则建议。"""
|
||||
|
||||
def __init__(self, db: AsyncSession, tenant_id: int) -> None:
|
||||
self.db = db
|
||||
self.tenant_id = int(tenant_id)
|
||||
|
||||
async def review_work_item(
|
||||
self,
|
||||
*,
|
||||
work_item: WorkItem,
|
||||
intent_name: str,
|
||||
execution_result: dict[str, Any],
|
||||
world_model: dict[str, Any],
|
||||
verification: dict[str, Any],
|
||||
simulation: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
divergences: list[dict[str, Any]] = []
|
||||
rule_updates: list[dict[str, Any]] = []
|
||||
|
||||
signal_id = _safe_int(execution_result.get("signal_id")) or _safe_int(work_item.signal_id)
|
||||
project_id = _safe_int(work_item.project_id)
|
||||
if signal_id is not None and project_id is not None:
|
||||
link_exists = (
|
||||
await self.db.execute(
|
||||
select(ProjectSignalLink.id).where(
|
||||
and_(
|
||||
ProjectSignalLink.tenant_id == self.tenant_id,
|
||||
ProjectSignalLink.project_id == int(project_id),
|
||||
ProjectSignalLink.signal_id == int(signal_id),
|
||||
)
|
||||
).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if link_exists is None:
|
||||
divergences.append(
|
||||
{
|
||||
"code": "project_signal_link_missing",
|
||||
"severity": "high",
|
||||
"detail": f"signal#{signal_id} 未关联 project#{project_id}",
|
||||
}
|
||||
)
|
||||
rule_updates.append(
|
||||
{
|
||||
"rule_key": "signal_link_persistence_guard",
|
||||
"reason": "信号已写入但 link 缺失,后续查询会出现空白板。",
|
||||
}
|
||||
)
|
||||
|
||||
workflow = execution_result.get("signal_source_workflow") if isinstance(execution_result.get("signal_source_workflow"), dict) else {}
|
||||
raw_orchestration = execution_result.get("signal_source_orchestration") if isinstance(execution_result.get("signal_source_orchestration"), dict) else {}
|
||||
registry_id = _safe_int(workflow.get("registry_id"))
|
||||
if registry_id is not None:
|
||||
registry = (
|
||||
await self.db.execute(
|
||||
select(SignalSourceRegistry).where(
|
||||
and_(
|
||||
SignalSourceRegistry.tenant_id == self.tenant_id,
|
||||
SignalSourceRegistry.id == int(registry_id),
|
||||
)
|
||||
).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if registry is not None:
|
||||
normalized_status = str(workflow.get("status") or "").strip().lower()
|
||||
registry_onboarding = str(registry.onboarding_status or "").strip().lower()
|
||||
if normalized_status == "pending_manual" and registry_onboarding == "completed":
|
||||
divergences.append(
|
||||
{
|
||||
"code": "registry_workflow_divergence",
|
||||
"severity": "high",
|
||||
"detail": f"registry#{registry_id} 已 completed,但 domain workflow 为 pending_manual",
|
||||
}
|
||||
)
|
||||
rule_updates.append(
|
||||
{
|
||||
"rule_key": "signal_source_finalize_single_source",
|
||||
"reason": "registry 与 workflow 终态不一致,必须收口到 domain workflow。",
|
||||
}
|
||||
)
|
||||
if workflow and str(workflow.get("status") or "").strip().lower() != "completed":
|
||||
rule_updates.append(
|
||||
{
|
||||
"rule_key": "onboarding_requires_material_actions",
|
||||
"reason": "signal source onboarding 只有 material actions + 非 fallback 才能标记 completed。",
|
||||
}
|
||||
)
|
||||
|
||||
duplicate_reply_groups = (
|
||||
await self.db.execute(
|
||||
select(ReplyReceipt.receipt_phase, func.count(ReplyReceipt.id))
|
||||
.where(
|
||||
and_(
|
||||
ReplyReceipt.tenant_id == self.tenant_id,
|
||||
ReplyReceipt.work_item_id == int(work_item.id),
|
||||
)
|
||||
)
|
||||
.group_by(ReplyReceipt.receipt_phase)
|
||||
)
|
||||
).all()
|
||||
duplicate_phases = [str(phase) for phase, count in duplicate_reply_groups if int(count or 0) > 1]
|
||||
if duplicate_phases:
|
||||
divergences.append(
|
||||
{
|
||||
"code": "duplicate_reply_receipts",
|
||||
"severity": "medium",
|
||||
"detail": ",".join(duplicate_phases),
|
||||
}
|
||||
)
|
||||
rule_updates.append(
|
||||
{
|
||||
"rule_key": "reply_receipt_single_phase",
|
||||
"reason": "同一 source_message 不得生成多条同 phase 回执。",
|
||||
}
|
||||
)
|
||||
|
||||
issues = verification.get("issues") if isinstance(verification.get("issues"), list) else []
|
||||
manual_issue_codes = {"pending_manual", "signal_source_pending_manual", "waiting_manual"}
|
||||
base_score = 0.92 if verification.get("verified") else (0.56 if any(issue in manual_issue_codes for issue in issues) else 0.32)
|
||||
fallback_penalty = 0.15 if bool(workflow.get("fallback_used")) else 0.0
|
||||
divergence_penalty = min(0.45, len(divergences) * 0.12)
|
||||
agent_quality_score = max(0.0, round(base_score - fallback_penalty - divergence_penalty, 4))
|
||||
|
||||
risk_before = float((world_model.get("risk_summary") or {}).get("open_risk_count") or 0.0)
|
||||
projected_risk = float(simulation.get("projected_risk_score") or 0.0)
|
||||
risk_after = min(1.0, projected_risk + len(divergences) * 0.08 + (0.1 if not verification.get("verified") else 0.0))
|
||||
|
||||
next_actions: list[str] = []
|
||||
if divergences:
|
||||
next_actions.append("优先修正 projection / registry / reply 收口偏差。")
|
||||
if not verification.get("verified"):
|
||||
next_actions.append("当前工作项未形成可验证终态,进入风险例会或人工确认。")
|
||||
if risk_after >= 0.7:
|
||||
next_actions.append("风险重评分偏高,建议纳入风险例会升级。")
|
||||
if not next_actions:
|
||||
next_actions.append("复盘通过,进入下一轮节奏调度。")
|
||||
|
||||
quality_grade = "A" if agent_quality_score >= 0.85 else ("B" if agent_quality_score >= 0.7 else ("C" if agent_quality_score >= 0.5 else "D"))
|
||||
review_payload = {
|
||||
"verified": bool(verification.get("verified")),
|
||||
"issues": issues,
|
||||
"divergences": divergences,
|
||||
"risk_score_before": round(risk_before, 4),
|
||||
"risk_score_after": round(risk_after, 4),
|
||||
"agent_quality_score": agent_quality_score,
|
||||
"agent_quality_grade": quality_grade,
|
||||
"rule_updates": rule_updates,
|
||||
"next_actions": next_actions,
|
||||
"simulation_summary": simulation,
|
||||
"workflow_summary": workflow or raw_orchestration,
|
||||
}
|
||||
self.db.add(
|
||||
PortfolioSnapshot(
|
||||
tenant_id=self.tenant_id,
|
||||
scope_type="review_cycle",
|
||||
scope_ref=f"work_item:{int(work_item.id)}",
|
||||
summary={
|
||||
"work_item_id": int(work_item.id),
|
||||
"intent": intent_name,
|
||||
"verified": bool(verification.get("verified")),
|
||||
"agent_quality_score": agent_quality_score,
|
||||
"risk_score_after": round(risk_after, 4),
|
||||
},
|
||||
payload=review_payload,
|
||||
)
|
||||
)
|
||||
await self.db.flush()
|
||||
return review_payload
|
||||
|
|
@ -15,11 +15,12 @@ import os
|
|||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, AsyncGenerator, Optional, Union
|
||||
from sqlalchemy import and_, select
|
||||
|
||||
# LangChain & OpenAI
|
||||
try:
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_core.messages import HumanMessage, AIMessage
|
||||
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
|
||||
from langchain_core.language_models.chat_models import BaseChatModel
|
||||
LANGCHAIN_AVAILABLE = True
|
||||
except ImportError:
|
||||
|
|
@ -37,6 +38,11 @@ except ImportError:
|
|||
INPUT_PRICE_PER_1K = 0.00015
|
||||
OUTPUT_PRICE_PER_1K = 0.0006
|
||||
CACHE_TTL_SECONDS = 3600 # 1 小时
|
||||
PROMPT_PROFILE_CACHE_TTL_SECONDS = 60
|
||||
|
||||
from ..config.ai_prompts import build_default_role_prompt_bundle
|
||||
from ..database import AsyncSessionLocal
|
||||
from ..models import Integration
|
||||
|
||||
|
||||
def _t2s(v: Union[str, int]) -> str:
|
||||
|
|
@ -77,6 +83,7 @@ class AIService:
|
|||
self.max_retries = max_retries
|
||||
self._llm: Optional[BaseChatModel] = None
|
||||
self._redis: Optional[Any] = None
|
||||
self._prompt_profile_cache: dict[int, tuple[float, tuple[str, str]]] = {}
|
||||
|
||||
def _get_llm(self) -> BaseChatModel:
|
||||
if not LANGCHAIN_AVAILABLE:
|
||||
|
|
@ -116,6 +123,84 @@ class AIService:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
async def _resolve_active_system_prompt(
|
||||
self,
|
||||
*,
|
||||
tenant_id: Union[str, int],
|
||||
db=None,
|
||||
) -> tuple[str, str]:
|
||||
tenant_int = _tenant_to_int(tenant_id)
|
||||
now = time.time()
|
||||
cached = self._prompt_profile_cache.get(tenant_int)
|
||||
if cached and cached[0] > now:
|
||||
return cached[1]
|
||||
|
||||
role_name = ""
|
||||
system_prompt = ""
|
||||
try:
|
||||
async def _load_profile(session) -> tuple[str, str]:
|
||||
row = (
|
||||
await session.execute(
|
||||
select(Integration).where(
|
||||
and_(
|
||||
Integration.tenant_id == int(tenant_int),
|
||||
Integration.type == "ai_prompt_profiles",
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
bundle = row.config if row is not None and isinstance(row.config, dict) else {}
|
||||
active_key = str(bundle.get("active_role_key") or "").strip()
|
||||
roles = bundle.get("roles") if isinstance(bundle.get("roles"), list) else []
|
||||
selected = None
|
||||
for item in roles:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if str(item.get("role_key") or "").strip() == active_key:
|
||||
selected = item
|
||||
break
|
||||
if selected is None:
|
||||
for item in roles:
|
||||
if isinstance(item, dict) and bool(item.get("enabled", True)):
|
||||
selected = item
|
||||
break
|
||||
if not isinstance(selected, dict):
|
||||
return ("", "")
|
||||
return (
|
||||
str(selected.get("role_name") or "").strip(),
|
||||
str(selected.get("system_prompt") or "").strip(),
|
||||
)
|
||||
|
||||
if db is not None:
|
||||
role_name, system_prompt = await _load_profile(db)
|
||||
else:
|
||||
async with AsyncSessionLocal() as session:
|
||||
role_name, system_prompt = await _load_profile(session)
|
||||
except Exception:
|
||||
role_name = ""
|
||||
system_prompt = ""
|
||||
|
||||
if len(system_prompt) < 20:
|
||||
fallback = build_default_role_prompt_bundle()
|
||||
active_key = str(fallback.get("active_role_key") or "").strip()
|
||||
roles = fallback.get("roles") if isinstance(fallback.get("roles"), list) else []
|
||||
selected = None
|
||||
for row in roles:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
if str(row.get("role_key") or "").strip() == active_key:
|
||||
selected = row
|
||||
break
|
||||
if selected is None and roles and isinstance(roles[0], dict):
|
||||
selected = roles[0]
|
||||
if isinstance(selected, dict):
|
||||
role_name = str(selected.get("role_name") or role_name or "默认角色").strip()
|
||||
system_prompt = str(selected.get("system_prompt") or "").strip()
|
||||
|
||||
result = (role_name, system_prompt)
|
||||
self._prompt_profile_cache[tenant_int] = (now + PROMPT_PROFILE_CACHE_TTL_SECONDS, result)
|
||||
return result
|
||||
|
||||
async def _log_call(
|
||||
self,
|
||||
db,
|
||||
|
|
@ -194,11 +279,18 @@ class AIService:
|
|||
pass
|
||||
|
||||
llm = self._get_llm()
|
||||
_, system_prompt = await self._resolve_active_system_prompt(
|
||||
tenant_id=tenant_id,
|
||||
db=db,
|
||||
)
|
||||
last_err = None
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
t0 = time.perf_counter()
|
||||
resp = await llm.ainvoke([HumanMessage(content=prompt)])
|
||||
messages = [HumanMessage(content=prompt)]
|
||||
if system_prompt:
|
||||
messages = [SystemMessage(content=system_prompt), *messages]
|
||||
resp = await llm.ainvoke(messages)
|
||||
latency_ms = int((time.perf_counter() - t0) * 1000)
|
||||
text = resp.content if hasattr(resp, "content") else str(resp)
|
||||
pt = getattr(resp, "response_metadata", None) or {}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue