Compare commits

..

No commits in common. "93185369b99c39b2c569b020e40ad0c5988aeb2c" and "16f30bc2c6fd648d3bb4307b2003c9229eb9a6ac" have entirely different histories.

261 changed files with 3188 additions and 68958 deletions

View File

@ -1,42 +0,0 @@
# 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

View File

@ -1,82 +0,0 @@
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

View File

@ -1,91 +0,0 @@
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

View File

@ -1,74 +0,0 @@
{
"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
}
]
}

View File

@ -1,4 +0,0 @@
{
"version": 1,
"onboardingCompletedAt": "2026-03-04T07:50:38.457Z"
}

View File

@ -70,45 +70,3 @@ frontend/ # React 前端
## 执行要求
根据用户的具体需求,生成高质量、可直接运行的代码。
---
## 8. 服务边界约束(禁止越界)
- 数据库写操作必须通过 Alembic migration,禁止直接 `ALTER TABLE`
- 新增接口必须遵循响应包络:`{code, message, data}`,不得裸返回
- 禁止在 API 层写业务逻辑,业务逻辑归属 `services/`
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState`
## 9. 新增代码的参考模式
- 新增后端接口:参考 `backend/app/api/sstc.py` 的结构
- 新增前端 API 调用:参考 `frontend/src/api/sstc.ts`
- 新增数据库表:参考 `alembic/versions/` 最新 migration
- 新增飞书事件处理:参考 `backend/app/services/feishu_integration.py`
## 10. 幂等与事务约束
- 所有入站事件必须经过 `ingest_events` 幂等去重
- 飞书回复必须经过 `feishu_reply_outbox`,禁止同步直接发送
- 跨表写操作必须在同一个数据库事务内完成
## 11. 已知 BOUNDARY(不要幻构行为)
- 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用
- `agents.defaults.timeoutSeconds` 在 OpenClaw 网关行为未验证,当前以 `LLM_REQUEST_TIMEOUT` 环境变量为准
- World Model 每次全量构建,高负载场景下存在性能风险,暂未缓存,改动前需评估影响
## 12. 前端组件约束
- `ui/` 目录下组件禁止修改,仅使用
- `sstc/` 目录下组件已模块化拆分,修改前必须执行 `FIND_USAGE`
- 新增业务组件放入对应功能目录,禁止在 `pages/` 内写内联组件
- 样式只用 Tailwind utility class,禁止内联 `style`
## 13. 测试与验证
- 后端改动:`python -m py_compile` 通过 + 相关单测通过
- 前端改动:`npm run build` 通过 + `tsc --noEmit` 零错误
- 新增接口:必须在 `backend/tests/` 补充对应单测

283
CLAUDE.md
View File

@ -1,89 +1,226 @@
# PMFlow 项目结构说明(CLAUDE.md)
# CLAUDE.md
## 1. Agent 入口文件(你关心的“进项目先读什么”)
- `AGENTS.md`:Codex/Coding Agent 的主规则入口(优先级最高的项目级约束)
- `CLAUDE.md`:面向 Claude 风格的项目说明文档(补充背景)
- 建议:规则放 `AGENTS.md`,架构/目录说明放 `CLAUDE.md`
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 2. 项目定位
- 项目名:`PMFlow / Neural Sync`
- 形态:AI 原生项目管理系统(多租户 + 角色权限 + SSTC 工作流)
- 技术栈:
- 后端:`FastAPI + SQLAlchemy + Alembic`
- 前端:`React + TypeScript + Vite + Tailwind + Radix + React Query`
## Project Overview
## 3. 顶层目录(精简版)
```text
AIProjectManger/
├── AGENTS.md # Agent 执行规则(建议优先维护)
├── CLAUDE.md # 现有 Claude 说明文档
├── start.sh / stop.sh # 一键启动/停止入口
├── docker-compose*.yml # 容器编排
├── backend/ # FastAPI 后端
├── frontend/ # React 前端
├── scripts/ # 根目录运维脚本
├── tests/ # 根级测试(集成/脚本)
└── docs/ # 额外文档
```
**PMFlow / Neural Sync** is an AI-native project management platform designed for small (10-50 person) AI/ML technical teams. The system has evolved into a multi-tenant, role-based PM operating system with:
## 4. 后端结构(`backend/`)
```text
backend/
├── app/
│ ├── api/ # 路由层(含 endpoints)
│ ├── core/ # 配置、日志、核心能力
│ ├── dependencies/ # 鉴权/租户等依赖注入
│ ├── models/ # SQLAlchemy 模型
│ ├── schemas/ # Pydantic 模型
│ ├── services/ # 业务服务(AI/SSTC/集成)
│ ├── config/ # 配置与 prompt
│ └── utils/ # 工具函数
├── alembic/ # 数据库迁移
├── requirements.txt # Python 依赖
├── Dockerfile
└── tests/
```
- **Multi-tenant Architecture**: Complete data isolation with tenant_id filtering
- **RBAC**: Role-based access control with JWT authentication
- **Event-Driven Architecture**: Event bus with WebSocket real-time updates
- **AI-Native Features**: LangChain integration, skill management, intelligent task routing
## 5. 前端结构(`frontend/`)
```text
frontend/
├── src/
│ ├── api/ # 接口封装
│ ├── pages/ # 页面级组件
│ ├── components/
│ │ ├── ui/ # 基础 UI 组件
│ │ └── sstc/ # SSTC 工作流组件(已模块化拆分)
│ ├── hooks/ # 自定义 hooks
│ ├── lib/ # 通用工具
│ ├── stores/ # 状态管理
│ ├── router/ # 路由
│ └── types/ # TS 类型定义
├── package.json
└── vite.config.ts
```
### Dual Frontend Architecture
The project maintains two frontends:
- **Vue 3** (`frontend/`): Legacy Signal Dashboard with Naive UI
- **React 18** (`frontend-react/`): New PMFlow interface with shadcn/ui (recommended for new features)
## Common Development Commands
### Quick Start / Stop
## 6. 运行命令(高频)
### 根目录
```bash
./start.sh dev # 推荐:docker-dev 热更新模式
./stop.sh dev
# Start all services (backend + React frontend)
./start.sh
# Or use Python launcher (cross-platform)
python launcher.py
# Stop all services
./stop.sh
```
### 前端
### Backend Development
```bash
cd backend
# Install dependencies (use project venv)
source ../venv/bin/activate # On Windows: ../venv/Scripts/activate
pip install -r requirements.txt
# Run database migrations
alembic upgrade head
# Seed initial data (development)
python -m seed_projects.py
# Start backend server (dev mode with hot reload)
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
# Run specific file
python -m app.main
```
### Frontend Development
**React (PMFlow - Recommended)**:
```bash
cd frontend-react
npm install
npm run dev # Port 5174
npm run build
npm run preview
```
**Vue 3 (Legacy)**:
```bash
cd frontend
npm run dev
npm install
npm run dev -- --host # Port 5174
npm run build
```
### 后端(本地直跑)
```bash
cd backend
pip install -r requirements.txt
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
## Architecture
### Backend Structure ([`backend/app/`](backend/app/))
**Core Architecture**:
- [`api/`](backend/app/api/) - API routes organized by module (auth, signals, users, sstc, etc.)
- [`core/`](backend/app/core/) - Configuration, logging, response formatting
- [`models/`](backend/app/models/) - SQLAlchemy ORM with multi-tenant base classes
- [`schemas/`](backend/app/schemas/) - Pydantic request/response models
- [`services/`](backend/app/services/) - Business logic layer
- [`dependencies/`](backend/app/dependencies/) - FastAPI dependency injection (auth, tenant filtering)
- [`config/`](backend/app/config/) - Configuration and AI prompts
- [`main.py`](backend/app/main.py) - FastAPI application entry point
**Key Systems**:
1. **Multi-Tenancy** ([`models/base.py`](backend/app/models/base.py)):
- All models inherit from `Base` (includes `tenant_id`, `created_at`, `updated_at`)
- `Tenant` model uses `BaseNoTenant` (no tenant_id)
- All queries must filter by tenant_id via dependency injection
2. **Authentication** ([`services/auth.py`](backend/app/services/auth.py), [`dependencies/auth.py`](backend/app/dependencies/auth.py)):
- JWT-based authentication with 24-hour expiration
- RBAC with roles: BOSS, ADMIN, AI, FE, BE
- Permission matrix for fine-grained access control
- Use `require_roles()` and `get_current_user` dependencies
3. **Event System** ([`events.py`](backend/app/events.py), [`websocket.py`](backend/app/websocket.py)):
- Event bus for pub/sub messaging
- WebSocket manager with real-time broadcasting
- Event types: signals, tasks, code reviews, audits, integrations
4. **Role Dispatcher** ([`dispatcher.py`](backend/app/dispatcher.py)):
- Routes signals to appropriate roles (Boss, AI Engineer, Backend, Frontend)
- Keyword and pattern-based routing with confidence scoring
- Escalation logic for high-impact decisions
5. **AI Services**:
- [`ai_service.py`](backend/app/services/ai_service.py) - LangChain integration
- [`llm.py`](backend/app/services/llm.py) - Ollama/OpenAI client wrapper
- [`skill_service.py`](backend/app/services/skill_service.py) - User skill management
- [`config/ai_prompts.py`](backend/app/config/ai_prompts.py) - Centralized prompt templates
6. **Integration Services**:
- [`plane.py`](backend/app/services/plane.py) - Plane PM tool integration
- [`lark.py`](backend/app/services/lark.py) - Lark/Feishu messaging
- [`auditor.py`](backend/app/services/auditor.py) - Code audit workflow
- [`auto_merger.py`](backend/app/services/auto_merger.py) - Auto-merge logic
### Frontend Structure
**React** ([`frontend-react/src/`](frontend-react/src/)):
- [`pages/`](frontend-react/src/pages/) - Page components (Login, Dashboard, Projects, Users, Skills)
- [`components/`](frontend-react/src/components/) - Reusable UI components
- [`api/`](frontend-react/src/api/) - API client wrappers
- [`hooks/`](frontend-react/src/hooks/) - Custom React hooks (useUserRole)
- Uses React Router with HashRouter, Tailwind CSS, Zustand for state
**Vue 3** ([`frontend/src/`](frontend/src/)):
- [`pages/`](frontend/src/pages/) - Vue pages
- [`components/`](frontend/src/components/) - Vue components with Naive UI
- [`api/`](frontend/src/api/) - API client
- [`composables/`](frontend/src/composables/) - Vue composables
- [`stores/`](frontend/src/stores/) - Pinia stores
### Database Schema
**Multi-Tenant Models**:
- `User` - User accounts with roles (BOSS, ADMIN, AI, FE, BE)
- `Tenant` - Tenant isolation
- `Project` - Projects with tenant_id
- `Signal` - External signals with strategic analysis
- `Task` - Tasks linked to signals and projects
- `Skill` - User skills with proficiency levels
- `UserSkillBinding` - Many-to-many user-skill relationships
- `UserWorkload` - Real-time workload tracking
**Type Compatibility** ([`models/types.py`](backend/app/models/types.py)):
- PostgreSQL: JSONB, ARRAY, Vector (pgvector)
- SQLite: Fallback to JSON/text storage for development
## Key Integration Points
### Authentication Flow
1. Login via `/auth/login` returns JWT token
2. Include token in Authorization header: `Bearer <token>`
3. Use `get_current_user` dependency for protected routes
4. Use `require_roles("BOSS", "ADMIN")` for role-gated endpoints
### Multi-Tenant Queries
```python
from app.dependencies import get_tenant_id
@router.get("/items")
async def list_items(
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db)
):
result = await db.execute(
select(Item).where(Item.tenant_id == tenant_id)
)
```
## 7. 团队维护建议(简版)
- 规则变更:只改 `AGENTS.md`
- 结构变更:同步改 `CLAUDE.md`
- API/契约变更:后端 `schemas` + 前端 `types` 同步更新
### Event Broadcasting
```python
from app.events import get_event_bus, EventType
event_bus = get_event_bus()
await event_bus.publish(
EventType.TASK_ASSIGNED,
{"task_id": 123, "user_id": 456}
)
```
### WebSocket Events
- Events are automatically bridged to WebSocket clients
- Clients subscribe to event types for filtering
- Message format: `{"type": "event", "event_type": "...", "data": {...}, "timestamp": ...}`
## Development Notes
- **Database**: PostgreSQL with pgvector recommended; SQLite fallback for development
- **CORS**: Currently allows all origins (`*`) - restrict for production
- **Logging**: Structured logging via [`core/logging.py`](backend/app/core/logging.py)
- **API Response**: Standardized format via [`core/response.py`](backend/app/core/response.py)
- **AI Caching**: Redis-based caching for AI call results
- **AI Logging**: All AI calls logged to `ai_call_logs` table
## Ports and Endpoints
- **Backend API**: http://127.0.0.1:8000
- **API Docs**: http://127.0.0.1:8000/docs (Swagger UI)
- **React Frontend**: http://127.0.0.1:5174
- **Vue Frontend**: http://127.0.0.1:5173
## Environment Variables
Key environment variables (see [`.env.example`](frontend/.env.example)):
- `DATABASE_URL` - PostgreSQL/SQLite connection string
- `SECRET_KEY` - JWT signing key
- `OPENAI_API_KEY` - OpenAI API key for LangChain
- `REDIS_URL` - Redis connection for caching
- `PLANE_API_*` - Plane PM tool integration
## File Conventions
- **Pydantic Schemas**: Request/Response models in [`schemas/`](backend/app/schemas/)
- **SQLAlchemy Models**: Database models in [`models/`](backend/app/models/)
- **API Routes**: Route handlers in [`api/`](backend/app/api/)
- **Business Logic**: Services in [`services/`](backend/app/services/)
- **AI Prompts**: Centralized in [`config/ai_prompts.py`](backend/app/config/ai_prompts.py)

View File

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

View File

@ -1,23 +0,0 @@
# 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`.

36
SOUL.md
View File

@ -1,36 +0,0 @@
# 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._

View File

@ -1,40 +0,0 @@
# 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.

17
USER.md
View File

@ -1,17 +0,0 @@
# 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.

View File

@ -1,72 +1,34 @@
# Database Configuration
DATABASE_URL=postgresql+asyncpg://pmflow:pmflow_pass@db:5432/pmflow
# 可选:启动方言守卫(postgresql / sqlite)。配置后若不一致将拒绝启动。
PMFLOW_EXPECTED_DB_DIALECT=postgresql
DATABASE_URL=sqlite+aiosqlite:///./neural_sync.db
# 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 / OpenClaw Control Plane
# LLM Provider Configuration
# ========================================
# 单一控制面:PMFlow 仅调用 OpenClaw 网关(OpenAI-Compatible)
LLM_PROVIDER=openai
OPENAI_BASE_URL=http://openclaw:18789/v1
OPENAI_API_KEY=<OPENCLAW_GATEWAY_TOKEN>
# 选择 LLM 提供商: ollama, deepseek, openai, qwen
# - ollama: 本地运行 (免费,但占用 CPU/内存)
# - deepseek: 云端 API (便宜,¥1/百万 tokens)
# - openai: 云端 API (GPT-4,较贵)
# - qwen: 阿里云通义千问 (便宜,国内稳定)
LLM_PROVIDER=deepseek
# 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 配置 (推荐,便宜好用) ---
DEEPSEEK_API_KEY=your_deepseek_api_key_here
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
# --- Ollama 本地配置(应急本地兜底) ---
# --- 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_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
# ========================================
@ -91,6 +53,3 @@ 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

View File

@ -1,74 +0,0 @@
{
"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
}
]
}

View File

@ -1,19 +0,0 @@
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"]

View File

@ -7,7 +7,6 @@ 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
@ -25,7 +24,6 @@ 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
@ -78,11 +76,6 @@ 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
)

View File

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

View File

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

View File

@ -1,91 +0,0 @@
"""add kg sync runs table
Revision ID: add_kg_sync_runs
Revises: add_qa_session_state_table
Create Date: 2026-03-02
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
try:
from sqlalchemy.dialects.postgresql import JSONB
except Exception: # pragma: no cover
JSONB = sa.JSON
# revision identifiers, used by Alembic.
revision = "add_kg_sync_runs"
down_revision = "add_qa_session_state_table"
branch_labels = None
depends_on = None
def _json_type(bind):
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
def _table_exists(bind, table_name: str) -> bool:
inspector = sa.inspect(bind)
return inspector.has_table(table_name)
def _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
indexes = inspector.get_indexes(table_name)
return any(idx.get("name") == index_name for idx in indexes)
def _create_index_if_not_exists(bind, index_name: str, table_name: str, columns: list[str]) -> None:
if not _index_exists(bind, table_name, index_name):
op.create_index(index_name, table_name, columns)
def _drop_index_if_exists(bind, index_name: str, table_name: str) -> None:
if _index_exists(bind, table_name, index_name):
op.drop_index(index_name, table_name=table_name)
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "kg_sync_runs"):
op.create_table(
"kg_sync_runs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
sa.Column("run_type", sa.String(length=40), nullable=False),
sa.Column("trigger_source", sa.String(length=40), nullable=False, server_default="system"),
sa.Column("status", sa.String(length=20), nullable=False, server_default="running"),
sa.Column("started_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
sa.Column("ended_at", sa.TIMESTAMP(), nullable=True),
sa.Column("duration_ms", sa.Integer(), nullable=True),
sa.Column("input_payload", _json_type(bind), nullable=True),
sa.Column("result_payload", _json_type(bind), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
)
_create_index_if_not_exists(bind, "ix_kg_sync_runs_tenant_id", "kg_sync_runs", ["tenant_id"])
_create_index_if_not_exists(bind, "ix_kg_sync_runs_run_type", "kg_sync_runs", ["run_type"])
_create_index_if_not_exists(bind, "ix_kg_sync_runs_trigger_source", "kg_sync_runs", ["trigger_source"])
_create_index_if_not_exists(bind, "ix_kg_sync_runs_status", "kg_sync_runs", ["status"])
_create_index_if_not_exists(bind, "ix_kg_sync_runs_started_at", "kg_sync_runs", ["started_at"])
_create_index_if_not_exists(bind, "ix_kg_sync_runs_ended_at", "kg_sync_runs", ["ended_at"])
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "kg_sync_runs"):
return
_drop_index_if_exists(bind, "ix_kg_sync_runs_ended_at", "kg_sync_runs")
_drop_index_if_exists(bind, "ix_kg_sync_runs_started_at", "kg_sync_runs")
_drop_index_if_exists(bind, "ix_kg_sync_runs_status", "kg_sync_runs")
_drop_index_if_exists(bind, "ix_kg_sync_runs_trigger_source", "kg_sync_runs")
_drop_index_if_exists(bind, "ix_kg_sync_runs_run_type", "kg_sync_runs")
_drop_index_if_exists(bind, "ix_kg_sync_runs_tenant_id", "kg_sync_runs")
op.drop_table("kg_sync_runs")

View File

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

View File

@ -1,88 +0,0 @@
"""add qa session state table
Revision ID: add_qa_session_state_table
Revises: add_project_knowledge_graph_tables
Create Date: 2026-03-02
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
try:
from sqlalchemy.dialects.postgresql import JSONB
except Exception: # pragma: no cover
JSONB = sa.JSON
# revision identifiers, used by Alembic.
revision = "add_qa_session_state_table"
down_revision = "add_project_knowledge_graph_tables"
branch_labels = None
depends_on = None
def _json_type(bind):
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
def _table_exists(bind, table_name: str) -> bool:
inspector = sa.inspect(bind)
return inspector.has_table(table_name)
def _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
indexes = inspector.get_indexes(table_name)
return any(idx.get("name") == index_name for idx in indexes)
def _create_index_if_not_exists(bind, index_name: str, table_name: str, columns: list[str]) -> None:
if not _index_exists(bind, table_name, index_name):
op.create_index(index_name, table_name, columns)
def _drop_index_if_exists(bind, index_name: str, table_name: str) -> None:
if _index_exists(bind, table_name, index_name):
op.drop_index(index_name, table_name=table_name)
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "qa_session_states"):
op.create_table(
"qa_session_states",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
sa.Column("session_id", sa.Integer(), sa.ForeignKey("qa_sessions.id", ondelete="CASCADE"), nullable=False, unique=True),
sa.Column("last_subject", sa.String(length=255), nullable=True),
sa.Column("last_entity_ids", _json_type(bind), nullable=True),
sa.Column("last_intent", sa.String(length=20), nullable=True),
sa.Column("ambiguity", sa.String(length=20), nullable=True),
sa.Column("rewrite_history", _json_type(bind), nullable=True),
)
_create_index_if_not_exists(bind, "ix_qa_session_states_tenant_id", "qa_session_states", ["tenant_id"])
_create_index_if_not_exists(bind, "ix_qa_session_states_created_at", "qa_session_states", ["created_at"])
_create_index_if_not_exists(bind, "ix_qa_session_states_session_id", "qa_session_states", ["session_id"])
_create_index_if_not_exists(bind, "ix_qa_session_states_last_subject", "qa_session_states", ["last_subject"])
_create_index_if_not_exists(bind, "ix_qa_session_states_last_intent", "qa_session_states", ["last_intent"])
_create_index_if_not_exists(bind, "ix_qa_session_states_ambiguity", "qa_session_states", ["ambiguity"])
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "qa_session_states"):
return
_drop_index_if_exists(bind, "ix_qa_session_states_ambiguity", "qa_session_states")
_drop_index_if_exists(bind, "ix_qa_session_states_last_intent", "qa_session_states")
_drop_index_if_exists(bind, "ix_qa_session_states_last_subject", "qa_session_states")
_drop_index_if_exists(bind, "ix_qa_session_states_session_id", "qa_session_states")
_drop_index_if_exists(bind, "ix_qa_session_states_created_at", "qa_session_states")
_drop_index_if_exists(bind, "ix_qa_session_states_tenant_id", "qa_session_states")
op.drop_table("qa_session_states")

View File

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

View File

@ -1,155 +0,0 @@
"""add symbol index tables
Revision ID: add_symbol_index_tables
Revises: add_kg_sync_runs
Create Date: 2026-03-04
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
try:
from sqlalchemy.dialects.postgresql import JSONB
except Exception: # pragma: no cover
JSONB = sa.JSON
# revision identifiers, used by Alembic.
revision = "add_symbol_index_tables"
down_revision = "add_kg_sync_runs"
branch_labels = None
depends_on = None
def _json_type(bind):
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
def _table_exists(bind, table_name: str) -> bool:
inspector = sa.inspect(bind)
return inspector.has_table(table_name)
def _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
indexes = inspector.get_indexes(table_name)
return any(idx.get("name") == index_name for idx in indexes)
def _create_index_if_not_exists(bind, index_name: str, table_name: str, columns: list[str]) -> None:
if not _index_exists(bind, table_name, index_name):
op.create_index(index_name, table_name, columns)
def _drop_index_if_exists(bind, index_name: str, table_name: str) -> None:
if _index_exists(bind, table_name, index_name):
op.drop_index(index_name, table_name=table_name)
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "symbol_definitions"):
op.create_table(
"symbol_definitions",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
sa.Column("file_path", sa.String(length=600), nullable=False),
sa.Column("language", sa.String(length=20), nullable=False),
sa.Column("symbol_type", sa.String(length=32), nullable=False),
sa.Column("symbol_name", sa.String(length=255), nullable=False),
sa.Column("qualified_name", sa.String(length=700), nullable=True),
sa.Column("start_line", sa.Integer(), nullable=True),
sa.Column("end_line", sa.Integer(), nullable=True),
sa.Column("signature", sa.String(length=1000), nullable=True),
sa.Column("docstring", sa.String(length=2000), nullable=True),
sa.Column("file_hash", sa.String(length=64), nullable=True),
sa.Column("source_mtime", sa.TIMESTAMP(), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default="active"),
sa.Column("payload", _json_type(bind), nullable=True),
sa.Column("last_indexed_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint(
"tenant_id",
"file_path",
"symbol_type",
"symbol_name",
"start_line",
name="uq_symbol_definitions_tenant_file_type_name_line",
),
)
_create_index_if_not_exists(bind, "ix_symbol_definitions_tenant_id", "symbol_definitions", ["tenant_id"])
_create_index_if_not_exists(bind, "ix_symbol_definitions_file_path", "symbol_definitions", ["file_path"])
_create_index_if_not_exists(bind, "ix_symbol_definitions_language", "symbol_definitions", ["language"])
_create_index_if_not_exists(bind, "ix_symbol_definitions_symbol_type", "symbol_definitions", ["symbol_type"])
_create_index_if_not_exists(bind, "ix_symbol_definitions_symbol_name", "symbol_definitions", ["symbol_name"])
_create_index_if_not_exists(bind, "ix_symbol_definitions_qualified_name", "symbol_definitions", ["qualified_name"])
_create_index_if_not_exists(bind, "ix_symbol_definitions_start_line", "symbol_definitions", ["start_line"])
_create_index_if_not_exists(bind, "ix_symbol_definitions_file_hash", "symbol_definitions", ["file_hash"])
_create_index_if_not_exists(bind, "ix_symbol_definitions_status", "symbol_definitions", ["status"])
_create_index_if_not_exists(bind, "ix_symbol_definitions_last_indexed_at", "symbol_definitions", ["last_indexed_at"])
if not _table_exists(bind, "symbol_references"):
op.create_table(
"symbol_references",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
sa.Column("source_symbol_id", sa.Integer(), sa.ForeignKey("symbol_definitions.id", ondelete="CASCADE"), nullable=True),
sa.Column("file_path", sa.String(length=600), nullable=False),
sa.Column("relation_type", sa.String(length=32), nullable=False),
sa.Column("target_symbol_name", sa.String(length=255), nullable=False),
sa.Column("target_qualified_name", sa.String(length=700), nullable=True),
sa.Column("start_line", sa.Integer(), nullable=True),
sa.Column("end_line", sa.Integer(), nullable=True),
sa.Column("payload", _json_type(bind), nullable=True),
sa.UniqueConstraint(
"tenant_id",
"file_path",
"relation_type",
"target_symbol_name",
"start_line",
"source_symbol_id",
name="uq_symbol_references_tenant_file_relation_target_line_source",
),
)
_create_index_if_not_exists(bind, "ix_symbol_references_tenant_id", "symbol_references", ["tenant_id"])
_create_index_if_not_exists(bind, "ix_symbol_references_source_symbol_id", "symbol_references", ["source_symbol_id"])
_create_index_if_not_exists(bind, "ix_symbol_references_file_path", "symbol_references", ["file_path"])
_create_index_if_not_exists(bind, "ix_symbol_references_relation_type", "symbol_references", ["relation_type"])
_create_index_if_not_exists(bind, "ix_symbol_references_target_symbol_name", "symbol_references", ["target_symbol_name"])
_create_index_if_not_exists(bind, "ix_symbol_references_target_qualified_name", "symbol_references", ["target_qualified_name"])
_create_index_if_not_exists(bind, "ix_symbol_references_start_line", "symbol_references", ["start_line"])
def downgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "symbol_references"):
_drop_index_if_exists(bind, "ix_symbol_references_start_line", "symbol_references")
_drop_index_if_exists(bind, "ix_symbol_references_target_qualified_name", "symbol_references")
_drop_index_if_exists(bind, "ix_symbol_references_target_symbol_name", "symbol_references")
_drop_index_if_exists(bind, "ix_symbol_references_relation_type", "symbol_references")
_drop_index_if_exists(bind, "ix_symbol_references_file_path", "symbol_references")
_drop_index_if_exists(bind, "ix_symbol_references_source_symbol_id", "symbol_references")
_drop_index_if_exists(bind, "ix_symbol_references_tenant_id", "symbol_references")
op.drop_table("symbol_references")
if _table_exists(bind, "symbol_definitions"):
_drop_index_if_exists(bind, "ix_symbol_definitions_last_indexed_at", "symbol_definitions")
_drop_index_if_exists(bind, "ix_symbol_definitions_status", "symbol_definitions")
_drop_index_if_exists(bind, "ix_symbol_definitions_file_hash", "symbol_definitions")
_drop_index_if_exists(bind, "ix_symbol_definitions_start_line", "symbol_definitions")
_drop_index_if_exists(bind, "ix_symbol_definitions_qualified_name", "symbol_definitions")
_drop_index_if_exists(bind, "ix_symbol_definitions_symbol_name", "symbol_definitions")
_drop_index_if_exists(bind, "ix_symbol_definitions_symbol_type", "symbol_definitions")
_drop_index_if_exists(bind, "ix_symbol_definitions_language", "symbol_definitions")
_drop_index_if_exists(bind, "ix_symbol_definitions_file_path", "symbol_definitions")
_drop_index_if_exists(bind, "ix_symbol_definitions_tenant_id", "symbol_definitions")
op.drop_table("symbol_definitions")

View File

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

View File

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

View File

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

View File

@ -1,114 +0,0 @@
"""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")

View File

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

View File

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

View File

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

View File

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

View File

@ -20,12 +20,6 @@ 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",
@ -44,10 +38,4 @@ __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",
]

View File

@ -1,341 +0,0 @@
"""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))

View File

@ -1,417 +0,0 @@
"""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,
}

View File

@ -9,71 +9,27 @@ 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, Literal
from typing import List, Optional, Dict, Any
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 ProjectSignalLink, Signal, User
from ..models import 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):
@ -121,7 +77,6 @@ class FeishuCaptureRequest(BaseModel):
group_chat_id: str
message_content: str
sender: str
feishu_message_id: Optional[str] = None
class BrowserCaptureRequest(BaseModel):
@ -145,201 +100,8 @@ 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,
@ -401,8 +163,7 @@ async def capture_feishu_signal(
group_chat_id=request.group_chat_id,
message_content=request.message_content,
sender=request.sender,
tenant_id=tenant_id,
feishu_message_id=request.feishu_message_id,
tenant_id=tenant_id
)
return result
except Exception as e:
@ -524,8 +285,8 @@ async def get_cognitive_conflicts(
id=str(c.id),
type=c.conflict_type,
description=c.description,
severity="high" if "critical" in (c.description or "").lower() else "medium",
related_users=_parse_related_users(c.involved_user_ids),
severity="high" if "critical" in c.description.lower() else "medium",
related_users=json.loads(c.involved_user_ids) if c.involved_user_ids else []
)
for c in conflicts
]
@ -663,7 +424,7 @@ async def resolve_cognitive_conflict(
raise HTTPException(status_code=404, detail="冲突记录不存在")
conflict.status = 'resolved'
conflict.resolved_at = _utc_now_naive()
conflict.resolved_at = datetime.now(timezone.utc)
conflict.description += f"\n\n解决方案({current_user.username}): {resolution_note}"
await db.commit()
@ -1029,13 +790,10 @@ 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 >= week_ago
Signal.created_at >= datetime.now(timezone.utc) - timedelta(days=7)
)
)
signals_captured = signal_result.scalar() or 0
@ -1045,7 +803,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 >= week_ago
Signal.created_at >= datetime.now(timezone.utc) - timedelta(days=7)
)
)
high_impact_count = high_impact_result.scalar() or 0
@ -1064,7 +822,7 @@ async def get_ai_steward_stats(
select(func.count(CognitiveConflict.id)).where(
CognitiveConflict.tenant_id == tenant_id,
CognitiveConflict.status == 'resolved',
CognitiveConflict.resolved_at >= month_ago
CognitiveConflict.resolved_at >= datetime.now(timezone.utc) - timedelta(days=30)
)
)
conflicts_resolved = resolved_conflict_result.scalar() or 0
@ -1347,7 +1105,6 @@ async def get_ai_config_suggestion(
"""
from ..models import Integration, Project, Signal
from sqlalchemy.future import select
from sqlalchemy import func
# 检查已配置的集成
configured_sources = []
@ -1356,7 +1113,7 @@ async def get_ai_config_suggestion(
feishu_result = await db.execute(
select(Integration).where(
Integration.tenant_id == tenant_id,
Integration.type == 'feishu',
Integration.provider == 'feishu',
Integration.is_active == True
)
)
@ -1376,7 +1133,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 >= (_utc_now_naive() - timedelta(days=7))
Signal.created_at >= datetime.now(timezone.utc) - timedelta(days=7)
)
)
recent_signals = signal_result.scalar() or 0

View File

@ -1,57 +1,38 @@
"""Feishu Integration API - 飞书集成接口。"""
from fastapi import APIRouter, Depends, HTTPException
"""
Feishu Integration API - 飞书集成接口
功能:
1. 配置飞书应用凭证
2. 验证 URL 和处理事件
3. 发送消息到飞书群
4. 查询集成状态
"""
from fastapi import APIRouter, Depends, HTTPException, Request, BackgroundTasks
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: Optional[str] = Field(None, description="已废弃字段,保留仅为兼容旧客户端")
verification_token: Optional[str] = Field(None, description="已废弃字段,保留仅为兼容旧客户端")
event_mode: str = Field("long_connection", description="事件接收模式,现仅支持 long_connection")
encrypt_key: str = Field(..., description="事件订阅加密密钥")
verification_token: str = Field(..., description="事件订阅验证令牌")
class FeishuSendMessageRequest(BaseModel):
@ -82,47 +63,40 @@ 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. 添加权限:im:message, im:message:group_at_msg, im:chat
5. 订阅事件:接收消息 v1 (message.receive_v1)
4. 在「事件订阅」配置 Encrypt Key 和 Verification Token
5. 添加权限:im:message, im:message:group_at_msg, im:chat
6. 订阅事件:接收消息 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="",
verification_token="",
event_mode=event_mode,
encrypt_key=request.encrypt_key,
verification_token=request.verification_token,
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': next_steps
'message': '飞书配置已保存',
'next_steps': [
'1. 在飞书开放平台配置事件订阅 URL',
f'2. URL: https://your-domain.com/api/feishu/webhook',
'3. 点击验证按钮完成 URL 验证'
]
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500,
@ -136,19 +110,16 @@ 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,
'app_secret': service.app_secret if can_view_secrets else None,
'event_mode': "long_connection",
'long_connection': manager.status() if manager else None,
'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)
}
except Exception as e:
raise HTTPException(
@ -174,7 +145,7 @@ async def delete_feishu_config(
result = await db.execute(
select(Integration).where(
Integration.tenant_id == tenant_id,
Integration.type == 'feishu'
Integration.provider == 'feishu'
)
)
integration = result.scalar_one_or_none()
@ -182,9 +153,6 @@ 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:
@ -194,45 +162,93 @@ async def delete_feishu_config(
)
@router.get("/long-connection/status")
async def feishu_long_connection_status(
current_user: User = Depends(get_current_user),
# ==================== Webhook Endpoints ====================
@router.get("/webhook")
async def verify_webhook_url(
challenge: str,
db: AsyncSession = Depends(get_db)
):
"""查询飞书长连接状态。"""
manager = get_feishu_long_connection_manager()
if not manager:
"""
验证飞书事件订阅 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)
return {
"status": "error",
"message": "Feishu 长连接管理器未初始化",
"mode": "long_connection",
"data": None,
"challenge": challenge_response
}
return {
"status": "success",
"mode": "long_connection",
"data": manager.status(),
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/long-connection/restart")
async def restart_feishu_long_connection(
current_user: User = Depends(get_current_user),
@router.post("/webhook")
async def handle_feishu_event(
request: Request,
db: AsyncSession = Depends(get_db)
):
"""手动重启飞书长连接。"""
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
raise HTTPException(status_code=403, detail="只有管理员可以重启飞书长连接")
"""
处理飞书事件推送
manager = get_feishu_long_connection_manager()
if not manager:
raise HTTPException(status_code=500, detail="Feishu 长连接管理器未初始化")
飞书会发送 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'}
await manager.restart()
return {
"status": "success",
"message": "Feishu 长连接已重启",
"mode": "long_connection",
"data": manager.status(),
}
# ==================== Message Endpoints ====================
@ -317,7 +333,6 @@ 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 {
@ -328,39 +343,10 @@ 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),
'mode': 'long_connection',
'event_mode': event_mode,
'long_connection': long_status,
'has_token': bool(access_token)
}
except Exception as token_error:
return {

View File

@ -1,183 +0,0 @@
"""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)

View File

@ -1,188 +0,0 @@
"""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

View File

@ -1,171 +0,0 @@
"""站内通知 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)})

View File

@ -1,57 +0,0 @@
"""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)

View File

@ -2,8 +2,7 @@
from __future__ import annotations
import json
from typing import Any, Optional
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
@ -13,9 +12,6 @@ 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"])
@ -23,18 +19,6 @@ 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
@ -203,7 +187,6 @@ 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,
@ -216,17 +199,8 @@ 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)
@ -255,7 +229,6 @@ 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)
@ -293,10 +266,46 @@ 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,
@ -352,7 +361,6 @@ 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),
@ -382,5 +390,4 @@ async def unlink_signal_from_project(
)
)
await db.commit()
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
return {"message": "Signal unlinked"}

View File

@ -1,79 +0,0 @@
"""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

View File

@ -1,570 +0,0 @@
"""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")

View File

@ -1,165 +0,0 @@
"""食谱 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}

View File

@ -14,25 +14,13 @@ 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()
@ -46,22 +34,10 @@ 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)

View File

@ -1,18 +1,11 @@
"""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 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 ..models import User
from ..services import workload_service
router = APIRouter(prefix="/scheduler", tags=["scheduler"])
@ -42,132 +35,3 @@ 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}}

View File

@ -1,401 +0,0 @@
"""食谱系统信号源 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

View File

@ -10,7 +10,6 @@ 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
@ -126,15 +125,6 @@ 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,
@ -223,22 +213,6 @@ 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,
@ -419,12 +393,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().subquery())
select(func.count()).select_from(query.distinct(User.id).subquery())
)
total = int(total_result.scalar() or 0)
result = await db.execute(
query.distinct()
query.distinct(User.id)
.order_by(User.updated_at.desc(), User.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
@ -516,9 +490,6 @@ 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
@ -600,12 +571,8 @@ async def update_user(
before_data=before_data,
after_data=user.to_dict(),
)
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))
await db.commit()
await db.refresh(user)
workloads, skills, projects = await _collect_user_enrichment(db, tenant_id, [user])
return _serialize_user(user, workloads, skills, projects)
@ -632,10 +599,10 @@ async def reset_password(
if not is_manager and int(current_user.id) != user_id:
raise HTTPException(status_code=403, detail="No permission")
try:
user.password_hash = AuthService.hash_password(body.new_password)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
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)
user.updated_at = datetime.utcnow()
await log_operation(
db,

View File

@ -1,10 +1,10 @@
"""Webhook 接收 API。"""
"""Webhook 接收 API"""
import json
import re
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
@ -12,8 +12,10 @@ 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")
@ -112,3 +114,58 @@ 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"}

View File

@ -101,23 +101,17 @@ 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,
'agent_id': service.agent_id,
'app_secret': service.app_secret if can_view_secrets else None,
'corp_id': service.corp_id if service.is_configured() else None,
'agent_id': service.agent_id if service.is_configured() else None,
'has_token': bool(service.token),
'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,
'has_encoding_key': bool(service.encoding_aes_key)
}
except Exception as e:
raise HTTPException(
@ -143,7 +137,7 @@ async def delete_wecom_config(
result = await db.execute(
select(Integration).where(
Integration.tenant_id == tenant_id,
Integration.type == 'wecom'
Integration.provider == 'wecom'
)
)
integration = result.scalar_one_or_none()
@ -163,7 +157,6 @@ 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,
@ -200,7 +193,6 @@ 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)

View File

@ -169,393 +169,6 @@ 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 = """
你是一位资深产品经理。根据以下市场/技术信号,生成一份结构化的需求宪章草案。
@ -884,117 +497,3 @@ 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(),
},
],
}

View File

@ -1,455 +0,0 @@
"""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",
],
),
)

View File

@ -5,19 +5,10 @@
"""
import os
from pathlib import Path
from typing import Optional
from dotenv import 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()}"
load_dotenv()
class Settings:
@ -28,11 +19,7 @@ class Settings:
debug: bool = os.getenv("DEBUG", "false").lower() in ("true", "1", "yes")
# 数据库
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"))
database_url: str = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./neural_sync.db")
# JWT
secret_key: str = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")

View File

@ -6,7 +6,6 @@
import logging
import sys
import json
from typing import Any
# 尝试使用 structlog,若未安装则回退到标准 logging
@ -53,63 +52,8 @@ 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 _StdLoggerCompat(logging.getLogger(name))
return logging.getLogger(name)

View File

@ -10,8 +10,6 @@ from sqlalchemy.ext.asyncio import (
async_sessionmaker,
create_async_engine,
)
from sqlalchemy import event
from sqlalchemy.pool import NullPool
from .core.config import settings
@ -22,43 +20,13 @@ 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,
**engine_kwargs,
echo=settings.debug,
future=True,
pool_pre_ping=not _is_sqlite(),
)
if _is_sqlite():
@event.listens_for(engine.sync_engine, "connect")
def _configure_sqlite_pragmas(dbapi_connection, _connection_record) -> None: # pragma: no cover
cursor = dbapi_connection.cursor()
try:
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.execute("PRAGMA busy_timeout=30000")
finally:
cursor.close()
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,

View File

@ -12,7 +12,6 @@ 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
@ -72,17 +71,3 @@ 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)

View File

@ -2,13 +2,11 @@
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
@ -17,310 +15,10 @@ 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
@ -331,12 +29,9 @@ async def lifespan(_: FastAPI):
"true",
"yes",
}
async with engine.begin() as conn:
await conn.run_sync(_check_database_dialect_guard)
if auto_create_tables:
if auto_create_tables:
async with engine.begin() as conn:
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:
@ -345,39 +40,11 @@ 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()

View File

@ -8,11 +8,7 @@ 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
@ -33,34 +29,8 @@ 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",
@ -75,11 +45,7 @@ __all__ = [
"UserSkillBinding",
"UserWorkload",
"Signal",
"SignalEdit",
"SignalFeedback",
"FeishuItem",
"FeishuReplyOutbox",
"ProjectSuggestion",
"ContextNode",
"Experiment",
"ProjectDomain",
@ -98,32 +64,6 @@ __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",
]

View File

@ -1,42 +0,0 @@
"""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)

View File

@ -1,38 +0,0 @@
"""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)

View File

@ -1,49 +0,0 @@
"""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)

View File

@ -1,39 +0,0 @@
"""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)

View File

@ -1,78 +0,0 @@
"""飞书分流队列项模型"""
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)

View File

@ -1,24 +0,0 @@
"""飞书回执 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)

View File

@ -1,27 +0,0 @@
"""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)

View File

@ -1,24 +0,0 @@
"""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)

View File

@ -1,32 +0,0 @@
"""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,
)

View File

@ -1,41 +0,0 @@
"""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,
)

View File

@ -1,43 +0,0 @@
"""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)

View File

@ -1,28 +0,0 @@
"""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)

View File

@ -1,29 +0,0 @@
"""知识库分块向量模型。"""
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)

View File

@ -1,25 +0,0 @@
"""知识库文档元数据模型。"""
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)

View File

@ -1,37 +0,0 @@
"""站内通知模型"""
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)

View File

@ -1,37 +0,0 @@
"""操作执行幂等账本。"""
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)

View File

@ -1,27 +0,0 @@
"""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)

View File

@ -1,21 +0,0 @@
"""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)

View File

@ -1,25 +0,0 @@
"""飞书项目建议模型"""
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)

View File

@ -1,28 +0,0 @@
"""问答反馈模型。"""
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)

View File

@ -1,33 +0,0 @@
"""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)

View File

@ -1,22 +0,0 @@
"""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)

View File

@ -1,29 +0,0 @@
"""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)

View File

@ -1,37 +0,0 @@
"""食谱模型 - 支持热量识别与营养分析"""
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="是否收藏")

View File

@ -1,39 +0,0 @@
"""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)

View File

@ -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, ForeignKey
from sqlalchemy import BigInteger, Integer, VARCHAR, Text, Float, Boolean
from typing import Optional
@ -16,16 +16,5 @@ 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")

View File

@ -1,24 +0,0 @@
"""信号编辑历史模型"""
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)

View File

@ -1,46 +0,0 @@
"""信号源注册表。"""
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)

View File

@ -1,46 +0,0 @@
"""代码符号定义模型。"""
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,
)

View File

@ -1,41 +0,0 @@
"""代码符号引用关系模型。"""
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)

View File

@ -15,9 +15,6 @@ 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
)

View File

@ -1,57 +0,0 @@
"""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)

View File

@ -42,13 +42,6 @@ from .sstc import (
ReviewCreateRequest,
ReviewArchiveRequest,
)
from .recipe import (
RecipeCreate,
RecipeUpdate,
RecipeResponse,
RecipeImageUploadRequest,
RecipeAnalyzeCaloriesResponse,
)
from . import ai as ai_schemas
__all__ = [
@ -94,9 +87,4 @@ __all__ = [
"ReleaseStatusPatchRequest",
"ReviewCreateRequest",
"ReviewArchiveRequest",
"RecipeCreate",
"RecipeUpdate",
"RecipeResponse",
"RecipeImageUploadRequest",
"RecipeAnalyzeCaloriesResponse",
]

View File

@ -1,96 +0,0 @@
"""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)

View File

@ -1,71 +0,0 @@
"""食谱相关 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

View File

@ -36,14 +36,11 @@ 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 = "已归档"
@ -94,11 +91,6 @@ 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
@ -120,22 +112,3 @@ 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

View File

@ -1,179 +0,0 @@
"""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,
}

View File

@ -1,483 +0,0 @@
"""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

View File

@ -1,108 +0,0 @@
"""AI PMO domain workflow adapters。"""
from __future__ import annotations
from typing import Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from .signal_source_orchestration_service import SignalSourceOrchestrationService
class SignalSourceDomainWorkflow:
"""把 signal source onboarding 归一成 domain workflow 结果。"""
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def run(
self,
*,
tenant_id: int,
project_id: int,
signal_id: int,
initiating_text: str,
requested_via: str,
operator_user_id: Optional[int],
) -> dict[str, Any]:
raw_result = await SignalSourceOrchestrationService(self.db).orchestrate_from_signal(
tenant_id=tenant_id,
project_id=project_id,
signal_id=signal_id,
initiating_text=initiating_text,
requested_via=requested_via,
operator_user_id=operator_user_id,
dry_run=False,
response_mode="fast",
)
normalized = self.normalize(raw_result)
normalized["raw_result"] = raw_result
return normalized
@classmethod
def normalize(cls, raw_result: dict[str, Any]) -> dict[str, Any]:
payload = raw_result if isinstance(raw_result, dict) else {}
signal_source = payload.get("signal_source") if isinstance(payload.get("signal_source"), dict) else {}
worker_executions = payload.get("worker_executions") if isinstance(payload.get("worker_executions"), list) else []
typed_actions = cls._extract_typed_actions(payload.get("openclaw"))
material_actions = [
row
for row in typed_actions
if str(row.get("operation_code") or "").strip() != "notification.create"
and str(row.get("status") or "").strip() in {"applied", "planned"}
]
fallback_used = any(bool(item.get("used_fallback")) for item in worker_executions if isinstance(item, dict))
raw_status = str(payload.get("status") or "").strip().lower()
registry_status = str(signal_source.get("status") or "").strip().lower()
onboarding_status = str(signal_source.get("onboarding_status") or "").strip().lower()
openclaw_status = str((payload.get("openclaw") or {}).get("status") or "").strip().lower()
if raw_status == "failed" or openclaw_status == "failed":
final_status = "failed_safe"
elif onboarding_status != "completed":
final_status = "pending_manual"
elif not material_actions:
final_status = "pending_manual"
elif fallback_used:
final_status = "pending_manual"
elif raw_status in {"completed", "partial", "planned"}:
final_status = "completed"
else:
final_status = "pending_manual"
next_step = "等待人工确认" if final_status != "completed" else "继续自动执行后续任务"
return {
"status": final_status,
"registry_id": signal_source.get("registry_id"),
"registry_status": registry_status,
"onboarding_status": onboarding_status,
"openclaw_status": openclaw_status,
"typed_actions": typed_actions,
"material_action_count": len(material_actions),
"worker_executions": worker_executions,
"pending_workers": payload.get("pending_workers") if isinstance(payload.get("pending_workers"), list) else [],
"next_step": next_step,
"fallback_used": fallback_used,
}
@staticmethod
def _extract_typed_actions(openclaw_payload: Any) -> list[dict[str, Any]]:
if not isinstance(openclaw_payload, dict):
return []
result = openclaw_payload.get("result") if isinstance(openclaw_payload.get("result"), dict) else openclaw_payload
execution = result.get("execution") if isinstance(result, dict) and isinstance(result.get("execution"), dict) else {}
rows = execution.get("results") if isinstance(execution.get("results"), list) else []
actions: list[dict[str, Any]] = []
for row in rows:
if not isinstance(row, dict):
continue
actions.append(
{
"operation_code": str(row.get("operation_code") or "").strip(),
"status": str(row.get("status") or "").strip(),
"entity_id": row.get("entity_id"),
"payload": row.get("payload") if isinstance(row.get("payload"), dict) else {},
}
)
return actions

File diff suppressed because it is too large Load Diff

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