feat: complete PMFlow implementation

This commit is contained in:
Yuuuu 2026-02-27 08:56:49 +08:00
parent b0a8c6edb9
commit 16f30bc2c6
272 changed files with 48470 additions and 8072 deletions

71
.gitignore vendored
View File

@ -3,34 +3,67 @@ __pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual Environments
venv/
env/
ENV/
env.bak/
venv.bak/
.venv/
# Node
# Node.js
node_modules/
dist/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.pnpm-debug.log*
# Database
*.db
*.sqlite
*.sqlite3
# Environment
.env
# IDEs
.idea/
.vscode/
*.swp
*.swo
.DS_Store
# Ollama
ollama_bin/
.ollama/
# Logs
logs/
*.log
nohup.out
# Coverage
coverage/
# Databases
*.db
*.sqlite3
# Temporary
tmp/
temp/
# Large Files & Binaries
.ollama/
ollama_bin/
Pixelle-Video/
*.dmg
*.zip
*.tar.gz
*.iso
# Local Env
.env
.env.*
!.env.example

72
AGENTS.md Normal file
View File

@ -0,0 +1,72 @@
# PMFlow 开发 Agent Prompt
你是一位资深的全栈工程师,精通 Python 3.11+、FastAPI、SQLAlchemy 2.0、LangChain、React 18、TypeScript、shadcn/ui、Tailwind CSS。
你正在为 **PMFlow** 开发代码:这是一款 AI 原生、人员能力驱动的项目管理操作系统,专为 10-50 人 AI 开发团队打造。
---
## 核心开发规范
1. **代码质量**:所有代码严格遵循 Python 与 TypeScript 最佳实践,代码清晰、注释完整、类型安全、可维护性强。
2. **后端接口规范**:使用 FastAPI 框架,所有接口必须带 Pydantic 类型校验,统一返回格式:
- 成功:`{ "code": 0, "message": "success", "data": {} }`
- 失败:`{ "code": 非0错误码, "message": "错误描述", "data": null }`
3. **数据库规范**:使用 SQLAlchemy 2.0,所有模型继承 Base 基类,必须包含 `tenant_id` 字段,所有查询必须自动过滤 `tenant_id`(使用依赖注入实现)。
4. **AI 能力规范**:所有 AI 能力必须封装在独立的 service 层,使用 LangChain 实现,Prompt 放在 `./backend/app/config/ai_prompts.py` 独立管理,禁止硬编码。
5. **AI 调用规范**:所有 AI 调用必须:
- 记录日志到 `ai_call_logs` 表
- 实现 Redis 结果缓存
- 完善的错误重试机制
- 降低成本
6. **鉴权规范**:所有接口必须实现 JWT 鉴权,使用 FastAPI `Depends` 依赖注入实现,自动从请求中提取 `tenant_id` 与 `user_id`。
7. **工程规范**:
- 完善的错误处理
- 结构化日志(structlog)
- 从第一天就支持多租户、可扩展性
- 模块化设计
8. **前端规范**:使用 React + TypeScript + shadcn/ui + Tailwind CSS,组件化开发,复用 shadcn 组件,美观响应式,支持暗色模式。
9. **核心约束**:人员能力图谱是 AI 智能任务分配的核心,所有任务拆解、分配逻辑必须优先调用该模块数据。
---
## 项目结构
```
backend/ # Python FastAPI 后端
├── app/
│ ├── api/ # API 路由层,按模块分文件
│ ├── core/ # 核心配置、安全、日志、数据库连接
│ ├── models/ # SQLAlchemy ORM 模型
│ ├── schemas/ # Pydantic 请求/响应模型
│ ├── services/ # 业务逻辑层(含 ai_service.py、skill_service.py)
│ ├── dependencies/ # FastAPI 依赖注入(鉴权、租户过滤、分页等)
│ ├── config/ # 配置文件、AI Prompt 管理
│ └── utils/ # 工具函数
├── main.py # 项目入口
├── pyproject.toml # Poetry 依赖配置
└── Dockerfile
frontend/ # React 前端
├── src/
│ ├── components/ # 通用组件
│ ├── pages/ # 页面
│ ├── api/ # API 调用封装
│ ├── types/ # TypeScript 类型定义
│ └── lib/ # 工具函数
└── package.json
```
---
## 执行要求
根据用户的具体需求,生成高质量、可直接运行的代码。

271
CLAUDE.md
View File

@ -4,156 +4,223 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
**Neural Sync** is an AI-native project management platform designed for small (10-person) AI/ML technical teams. Unlike traditional PM tools that manage static tasks, this system manages "Living Context" - external signals, AI experiments, and cognitive knowledge nodes with version tracking and semantic embedding.
**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:
### Core Philosophy
- **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
The system operates on three pillars:
1. **Signals (Pulse)**: External triggers (market news, model releases, competitor updates) captured from sources like HackerNews
2. **Context Nodes (Cognitive Core)**: Atomic knowledge units with vector embeddings for semantic search - when these change, dependent experiments auto-flag as "outdated"
3. **Experiments (Lab)**: Evaluation-driven tasks where success is measured by metrics (accuracy, latency) rather than just completion
### Dual Frontend Architecture
## Technology Stack
### Backend
- **FastAPI** with async/await patterns
- **SQLAlchemy 2.0** async with database-agnostic design (PostgreSQL with pgvector or SQLite fallback)
- **Alembic** for database migrations
- **Ollama** integration for local LLM operations (llama3, nomic-embed-text models)
### Frontend
- **Vue 3** with Composition API and `<script setup>` syntax
- **Naive UI** component library (chosen over Element Plus for data-dense applications)
- **TypeScript**
- **Vite** 8.x beta
- **ECharts** via vue-echarts for radar/competitiveness visualization
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
### Backend
### Quick Start / Stop
```bash
# Start all services (backend + React frontend)
./start.sh
# Or use Python launcher (cross-platform)
python launcher.py
# Stop all services
./stop.sh
```
### Backend Development
```bash
# Navigate to backend
cd backend
# Install dependencies
# 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 only)
python -m seed
# Seed initial data (development)
python -m seed_projects.py
# Start backend server (dev mode with hot reload)
uvicorn app.main:app --reload --port 8000
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
# Run specific file
python -m app.main
```
### Frontend
### Frontend Development
**React (PMFlow - Recommended)**:
```bash
# Navigate to frontend
cd frontend
# Install dependencies
cd frontend-react
npm install
# Start dev server (accessible on network with --host flag)
npm run dev -- --host
# Production build
npm run dev # Port 5174
npm run build
# Preview production build
npm run preview
```
### Database Setup
**PostgreSQL (production)**:
**Vue 3 (Legacy)**:
```bash
# Create database with pgvector extension
createdb neural_sync
psql neural_sync -c "CREATE EXTENSION vector;"
cd frontend
npm install
npm run dev -- --host # Port 5174
npm run build
```
**SQLite (development)**:
- Set `DATABASE_URL=sqlite+aiosqlite:///./neural_sync.db` in environment
- No external setup required
## Architecture
### Backend Structure ([`backend/app/`](backend/app/))
- **[`main.py`](backend/app/main.py)**: FastAPI application with all endpoints
- Webhook handlers for Git and Lark events
- Signal ingestion with strategic impact analysis
- CRUD operations for experiments and context nodes
**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
- **[`models.py`](backend/app/models.py)**: SQLAlchemy models with compatibility layer
- `Signal`: External signals with strategic analysis and generated tasks
- `ContextNode`: Knowledge units with vector embeddings and AI-extracted insights
- `Experiment`: Hypothesis-driven experiments with score tracking
- Note: Vector type and UUID have SQLite fallback for development
**Key Systems**:
- **[`services/`](backend/app/services/)**: External integrations
- **[`llm.py`](backend/app/services/llm.py)**: Ollama client for embeddings, task breakdown, strategic evaluation, code audit
- **[`plane.py`](backend/app/services/plane.py)**: Plane PM tool integration (simulation mode without API key)
- **[`lark.py`](backend/app/services/lark.py)**: Lark/Feishu bot messaging with rich cards
- **[`fetcher.py`](backend/app/services/fetcher.py)**: HackerNews signal fetching
- **[`analyzer.py`](backend/app/services/analyzer.py)**: Fallback keyword-based impact scoring
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
### Frontend Structure ([`frontend/src/`](frontend/src/))
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
- **[`App.vue`](frontend/src/App.vue)**: Main layout with dark theme and horizontal navigation
- **[`components/SignalDashboard.vue`](frontend/src/components/SignalDashboard.vue)**: Complex command center with:
- Signal aggregation by source
- Competitive moat visualization with progress comparison
- ROI scorecard and radar charts
- Typewriter effect for AI analysis
- Polling-based status updates
- **[`components/ExperimentBoard.vue`](frontend/src/components/ExperimentBoard.vue)**: Experiment grid with auto-status based on scores
- **[`components/ContextManager.vue`](frontend/src/components/ContextManager.vue)**: Knowledge nodes with AI insight display (moat, tech_specs)
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
### Plane Integration
- Environment variables: `PLANE_API_URL`, `PLANE_API_KEY`, `PLANE_WORKSPACE_SLUG`, `PLANE_PROJECT_ID`
- Without API key, runs in simulation mode (logs to console)
- Supports creating issues with labels, priority, and time estimates
- Status updates require State UUID lookup (not fully implemented in MVP)
### 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
### Ollama Integration
- Default host: `http://127.0.0.1:11434`
- Models used: `llama3` (general), `nomic-embed-text` (embeddings)
- All LLM calls are synchronous but wrapped in async endpoints
- Includes mock fallback responses for "Pixelle-Video" signals
### Multi-Tenant Queries
```python
from app.dependencies import get_tenant_id
### Git Webhook Flow
1. Commit message contains task ID (e.g., `#PROJ-123`)
2. Webhook extracts diff and message
3. [`llm.audit_code_change()`](backend/app/services/llm.py) evaluates against task requirement
4. If passed: updates Plane status, marks task Done in Signal
@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)
)
```
## Database Schema Notes
### Event Broadcasting
```python
from app.events import get_event_bus, EventType
### Vector Embeddings
- PostgreSQL: Uses `Vector(768)` from pgvector
- SQLite: Fallback to JSON storage for development
- Embeddings generated via Ollama's `nomic-embed-text` model
event_bus = get_event_bus()
await event_bus.publish(
EventType.TASK_ASSIGNED,
{"task_id": 123, "user_id": 456}
)
```
### Migration Files
- Located in [`backend/alembic/versions/`](backend/alembic/versions/)
- Key migrations: initial tables, pgvector support, signal model updates
### 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
- **Database echo**: Enabled in development - set `echo=False` in [`database.py`](backend/app/database.py) for production
- **Frontend API**: Hardcoded to `http://127.0.0.1:8000` - configure via env var for deployment
- **Polling**: Signal dashboard polls every 5 seconds with smart diff detection to minimize re-renders
- **AI Analysis**: All LLM prompts are in [`llm.py`](backend/app/services/llm.py) with fallback mock data for specific keywords
- **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
## Testing and Validation
## Ports and Endpoints
The system includes a mock fallback for certain signal types (e.g., "Pixelle-Video", "Civitai", "Sora") to demonstrate strategic analysis without requiring a live Ollama instance. These are defined in [`llm.py`](backend/app/services/llm.py) with hardcoded responses.
- **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

@ -0,0 +1,120 @@
# Scheduler 执行清单 V1(前后端一体交付)
> 目标:交付“可见、可触发、可追踪”的调度中心,避免仅后端不可见改动。
> 原则:每步必须包含 Backend + Frontend + 可见验收。
---
## 0. 执行规则(强制)
- [ ] 每步包含 `Backend任务` + `Frontend任务` + `可见验收`。
- [ ] 每步完成即改 `[x]` 并补验收证据。
- [ ] 无页面可见结果,不进入下一步。
- [ ] 失败需记录回滚点与阻塞原因。
---
## 1. 范围与阶段
- [x] S0 稳定性前置(401 熔断、防轮询风暴)
- [x] S1 调度基础能力(Job Registry + JobRun 持久化 + 手动触发)
- [x] S2 调度中心页面(任务列表、触发、运行记录)
- [x] S3 认知链路接入(对齐评分任务接入调度)
- [x] S4 联调验收(失败重试、权限、可视化证据)
---
## 2. 分步清单
### [x] S1 调度基础能力
**Backend任务**
- 新增 `scheduler_center` 服务:注册任务、执行任务、记录 `job_runs`。
- 新增 `/scheduler` API:
- `GET /scheduler/jobs`
- `GET /scheduler/runs`
- `POST /scheduler/jobs/{job_name}/trigger`
**Frontend任务**
- 新增 `scheduler` API client,支持查询与触发。
**可见验收**
- 调用 `GET /scheduler/jobs` 可见可触发任务列表。
- 触发任务后 `GET /scheduler/runs` 出现新纪录(running/success/failed)。
**验收证据**
- API 已新增:`GET /scheduler/jobs`、`GET /scheduler/runs`、`POST /scheduler/jobs/{job_name}/trigger`
- 后端文件:`backend/app/services/scheduler_center.py`、`backend/app/api/scheduler.py`
- 路由接入:`backend/app/api/__init__.py`、`backend/app/main.py`
---
### [x] S2 调度中心页面
**Backend任务**
- 补充运行记录字段(耗时、错误信息、payload 摘要)并排序输出。
**Frontend任务**
- 新增 `SchedulerCenterPage`:
- 任务列表
- 手动触发按钮
- 最近运行记录
- 错误可读展示
- 路由接入,并在项目看板增加入口按钮。
**可见验收**
- 页面可手动触发任务并实时看到运行结果。
**验收证据**
- 页面已新增:`frontend/src/pages/SchedulerCenterPage.tsx`
- 前端 API 已新增:`frontend/src/api/scheduler.ts`
- 路由已接入:`/scheduler-center`(`frontend/src/router/index.tsx`)
- 项目看板入口已接入:`frontend/src/pages/ProjectBoardPage.tsx`
- 构建通过:`cd frontend && npm run build`
---
### [x] S3 认知链路接入(O4-4 首版)
**Backend任务**
- 将“项目认知评分重算”封装为调度任务 `alignment_rescore_project`。
- `ai_steward` 对应入口优先通过调度执行并写 `job_runs`。
**Frontend任务**
- 调度中心支持传入 `project_id` 触发重算任务。
**可见验收**
- 触发重算后,`job_runs` 成功记录可见;评分接口数据可更新。
**验收证据**
- 调度任务真接线:`backend/app/services/scheduler_center.py` -> `alignment_rescore_project`
- 评分内核抽离:`backend/app/services/alignment_orchestrator.py`
- `ai_steward` 入口改为优先通过调度执行并落 `job_runs`:`backend/app/api/ai_steward.py`
---
### [x] S4 联调与收尾
**Backend任务**
- 权限控制:仅 BOSS/ADMIN 可触发关键任务。
- 失败重试策略:同名任务支持手动重跑。
**Frontend任务**
- 失败任务标红 + 一键重试。
**可见验收**
- 失败任务可重试并形成新的运行记录链。
**验收证据**
- 关键任务触发权限:`backend/app/api/scheduler.py`(BOSS/ADMIN)与 `backend/app/api/ai_steward.py`(BOSS/ADMIN)
- 后端重试接口:`POST /scheduler/runs/{run_id}/retry`(BOSS/ADMIN),`scheduler_center.retry_run()`
- 前端失败一键重试:`schedulerApi.retryRun(runId)` + 失败任务红底/红边样式,`frontend/src/pages/SchedulerCenterPage.tsx`
---
## 3. DoD(完成定义)
- [x] 调度中心页面可访问、可触发、可追踪。
- [x] 关键调度任务有持久化记录(成功/失败可区分)。
- [x] O4-4 首版实现“可持续更新认知状态”的调度入口。
- [x] 前端构建通过:`cd frontend && npm run build`

View File

@ -0,0 +1,127 @@
# SSTC 看板执行清单 V3(前后端联动可见版)
更新时间:2026-02-25
适用范围:`/projects/:id/board` 的 SSTC 看板全链路改造(React + FastAPI)
## 当前基线(基于现有代码)
- [x] 已有最小链路:`信号 -> 宪章 -> 任务`(`frontend/src/components/sstc/SSTCChainView.tsx` + `backend/app/api/sstc.py`)。
- [x] 已有宪章详情页 + 任务创建:`frontend/src/pages/CharterDetailPage.tsx`。
- [ ] 缺口:`frontend/src/components/sstc/SSTCGlobalHeader.tsx`、`SSTCStageNav.tsx`、`SSTCWorkspace.tsx`、`SSTCRiskFloatPanel.tsx` 当前为空文件。
- [ ] 缺口:SSTC 仅有少量接口,缺少发布验证、复盘沉淀、链路健康聚合、阶段锁定校验。
- [ ] 缺口:缺少统一操作日志表(全链路追溯要求)。
- [ ] ⚠️ 待核实:`@dnd-kit/core`、`@tiptap/*` 不在 `frontend/package.json`,与方案约束不一致。
## 执行规则
- [ ] 每完成 1 步,将该步由 `[ ]` 改为 `[x]`。
- [ ] 所有接口统一返回:`{ code, message, data }`(仅 SSTC 新增/改造接口先落地)。
- [ ] 所有写操作记录:操作人、操作时间、变更摘要(写入 `operation_logs`)。
- [ ] 所有 AI 调用记录:输入/输出、token、成本(复用 `ai_call_logs`)。
## P0 基础可运行(先打通框架)
- [x] **P0-1 建立 SSTC 统一数据契约**
- 后端实现:新增 `backend/app/schemas/sstc.py`;标准化 stage/status 枚举、DTO、响应结构。
- 前端实现:更新 `frontend/src/api/sstc.ts` 类型定义,全部接口适配 `data` 字段。
- 可见验收:项目看板可正常加载,不再依赖“隐式字段”。
- [x] **P0-2 建立 SSTC 聚合服务层**
- 后端实现:新增 `backend/app/services/sstc_service.py`,聚合项目头部指标、阶段状态、风险摘要。
- 前端实现:新增 `getBoardOverview(projectId)` 调用。
- 可见验收:看板顶部能显示统一概览数据。
- [x] **P0-3 填充 4 个空组件骨架**
- 前端实现:补全 `SSTCGlobalHeader.tsx`、`SSTCStageNav.tsx`、`SSTCWorkspace.tsx`、`SSTCRiskFloatPanel.tsx` 基础布局。
- 后端实现:提供骨架所需最小接口(overview、stage health、risk summary)。
- 可见验收:`/projects/:id/board` 出现四段式布局,不再只有单卡片链路。
- [x] **P0-4 阶段锁定规则后端校验**
- 后端实现:在任务创建/编辑、发布创建、复盘归档接口中增加上游状态校验(禁止前端绕过)。
- 前端实现:灰态展示 + 按钮禁用 + 原因提示。
- 可见验收:宪章未对齐时无法创建/确认任务。
## P1 核心链路(第一优先级:导航 + 宪章 + 任务 + 执行)
- [x] **P1-1 模块1:项目全局信息栏**
- 后端实现:`GET /projects/{id}/sstc/overview`(基础信息、对齐度、进度、负载健康)。
- 前端实现:`SSTCGlobalHeader` 渲染 5 区块;recharts 展示对齐度/进度/负载。
- 可见验收:顶部 120px 信息栏完整显示指标和操作按钮。
- [x] **P1-2 模块2:六阶段链路导航栏**
- 后端实现:`GET /projects/{id}/sstc/stages` 返回 6 节点状态、风险点、可编辑性。
- 前端实现:`SSTCStageNav` 横向 6 节点 + 箭头 + Badge + 红点 + 选中高亮。
- 可见验收:点击节点切换工作区;未解锁节点只读。
- [x] **P1-3 模块3.2:需求宪章工作区**
- 后端实现:补齐宪章版本化、发起对齐、确认对齐、冲突查询与处理接口。
- 前端实现:宪章编辑器(先 Textarea,后续可替换 Tiptap)、对齐状态卡、冲突卡、模型绑定卡。
- 可见验收:可发起对齐;成员确认后状态从 `对齐中` 到 `已对齐`。
- [x] **P1-4 模块3.3:任务拆解工作区**
- 后端实现:AI 拆解任务接口、负责人推荐接口、负载优化接口、任务确认接口。
- 前端实现:任务树表格(父子折叠)、右侧负载监控与拆解设置卡。
- 可见验收:点击“AI 重新拆解任务”后生成任务,显示推荐负责人和匹配度。
- [x] **P1-5 模块3.4:执行落地工作区**
- 后端实现:任务状态迁移接口、风险预警聚合接口、阻塞处理接口。
- 前端实现:Kanban 5 泳道、任务卡片详情弹窗、右侧风险栏。
- 可见验收:拖拽任务卡更新状态并持久化。
- ⚠️ 待核实:需先安装 `@dnd-kit/core`(当前未安装)。
## P2 扩展链路(第二优先级:信号源 + 发布验证 + 复盘)
- [x] **P2-1 模块3.1:信号源工作区**
- 后端实现:信号列表筛选、归档、AI 影响评估、宪章关联接口。
- 前端实现:左侧信号表格 + 右侧统计/AI/集成卡片。
- 可见验收:可从信号直接跳转并创建宪章关联。
- [x] **P2-2 模块3.5:发布验证工作区**
- 后端实现:发布计划 CRUD、发布状态流转、验证清单生成与提交。
- 前端实现:发布计划表 + 验证标签页 + 右侧 AI 辅助栏。
- 可见验收:仅可选择“已完成任务”创建发布计划。
- [x] **P2-3 模块3.6:复盘沉淀工作区**
- 后端实现:复盘节点创建/版本/归档、改进项落地接口。
- 前端实现:6 模块复盘编辑区 + 右侧知识沉淀/改进项/AI 卡片。
- 可见验收:确认归档后链路锁定,进入只读状态。
## P3 全局能力(第三优先级:预警 + 权限 + 可追溯)
- [x] **P3-1 模块4:风险/认知冲突悬浮栏**
- 后端实现:统一预警接口(高/中风险 + 未解决冲突)。
- 前端实现:右下角悬浮窗(可最小化、角标、Tab 切换)。
- 可见验收:新增高风险时角标数量实时变化。
- [x] **P3-2 操作日志全链路追溯**
- 后端实现:新增 `operation_logs` 模型、迁移、写入中间件/服务封装。
- 前端实现:关键操作写 `x-request-id`,便于追踪。
- 可见验收:任一状态变更可查到操作人/时间/变更前后。
- [x] **P3-3 角色权限闭环**
- 后端实现:项目负责人/成员/管理员权限矩阵校验。
- 前端实现:按钮级权限渲染与禁用提示。
- 可见验收:成员无法执行负责人专属操作。
- [x] **P3-4 AI 调用成本可观测**
- 后端实现:SSTC AI 接口统一落 `ai_call_logs`,补齐 token/cost 字段。
- 前端实现:在 AI 操作结果区展示“本次 token 与成本”。
- 可见验收:每次 AI 操作后可在日志中检索到调用记录。
## 联调与验收清单(发布前必须全勾)
- [ ] E2E-1 项目进入看板后,四大区块均可见且无空白组件。
- [ ] E2E-2 六阶段状态与锁定规则生效(前后端双校验)。
- [ ] E2E-3 宪章未对齐无法进入任务确认与执行编辑。
- [ ] E2E-4 任务拖拽状态迁移成功,刷新后状态保持。
- [ ] E2E-5 发布必须绑定已完成任务,复盘必须在发布验证后。
- [ ] E2E-6 全部 SSTC 新接口返回统一结构 `{code,message,data}`。
- [ ] E2E-7 关键写操作可在 `operation_logs` 检索。
- [ ] E2E-8 AI 操作可在 `ai_call_logs` 检索到 token/cost。
## 实施顺序建议(最短路径看到效果)
- [ ] 第 1 周:完成 P0 + P1-1 + P1-2(先把“看起来像完整看板”做出来)。
- [ ] 第 2 周:完成 P1-3 + P1-4 + P1-5(核心闭环可操作)。
- [ ] 第 3 周:完成 P2(补齐发布与复盘闭环)。
- [ ] 第 4 周:完成 P3 + E2E(治理与可追溯收口)。

0
EXECUTION_PLAN_V2.md Normal file
View File

203
PMFlow-Dev-Plan.md Normal file
View File

@ -0,0 +1,203 @@
# PMFlow Dev Plan — User / Skill / Workload Modules
**Stack:** FastAPI + React + shadcn/ui + Tailwind | **Timeline:** ~11 days, 1 dev
---
## Phase 1 — Core Foundation (Day 1–3)
### Day 1 — DB Migration + User CRUD
**DB**
```sql
ALTER TABLE users ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT TRUE;
CREATE TABLE user_projects (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL DEFAULT 1,
user_id BIGINT NOT NULL,
project_id BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (project_id) REFERENCES projects(id),
UNIQUE(tenant_id, user_id, project_id)
);
```
**Backend**
- [x] Update ORM: `User.is_active`, new `UserProject` model
- [x] `GET /api/users` — paginated list with filters (role/status/project/workload/skill)
- [x] `POST /api/users` — create user (bcrypt password, auto-init `user_workloads` row with 40h/week)
- [x] `PUT /api/users/{id}` — update user
- [x] `DELETE /api/users/{id}` — block if user has incomplete tasks
- [x] `POST /api/users/{id}/reset-password`
- [x] `PATCH /api/users/{id}/status` — disable: clear `tasks.assignee_id`, zero out workload
- [x] `POST /api/users/import` — bulk import via Excel (max 50 rows, return per-row result)
- [x] Transaction: delete user → cascade delete `user_skill_bindings` + `user_workloads`
- [x] Log all mutations (actor, time, diff)
**Frontend**
- [x] Routes: `/users`, `/skills`, `/workloads`, `/workloads/:id`
- [x] `/users` page: top bar (title + New/Import/Bulk-delete buttons + search)
- [x] Right filter panel (280px, collapsible): role / status / project / workload-state / skill multi-select
- [x] Table: avatar+name / email / role badge / linked projects / top-3 skill tags / workload% / status / actions
- [x] New/Edit user modal (form validation, edit pre-fills data)
- [x] Reset password modal
- [x] Bulk import modal (preview first 10 rows, highlight errors, show import result summary)
- [x] Workload% color: green <70%, yellow 70–100%, red >100%
---
### Day 2 — Skill & Workload APIs
**Backend — Skills**
- [x] Seed system skill tags on startup (Python, FastAPI, LangChain, React, Prompt Eng, etc.)
- [x] `GET /api/skill-tags` — list with category filter
- [x] `POST /api/skill-tags` — admin only
- [x] `PUT /api/skill-tags/{id}` — system tags: category only; custom: name + category
- [x] `DELETE /api/skill-tags/{id}` — block if `is_system=true`; cascade delete `user_skill_bindings`
- [x] `GET /api/users/{id}/skills` — user skill bindings
- [x] `POST /api/users/{id}/skills` — bind skill (default: beginner, scores 0.0)
- [x] `PUT /api/users/{id}/skills/{skill_id}` — update proficiency / scores
- [x] `DELETE /api/users/{id}/skills/{skill_id}` — unbind (block system-auto-bound skills)
**Backend — Workload**
- [x] Workload calc util: `ratio = assigned_hours / ((max_weekly_hours / 5) * remaining_workdays)`
- [x] `GET /api/workloads` — team list with project/date filters
- [x] `GET /api/workloads/{user_id}` — personal detail
- [x] `PUT /api/workloads/{user_id}` — update max weekly hours (triggers ratio recalc)
- [x] `GET /api/workloads/{user_id}/tasks` — assigned task list
- [x] `GET /api/workloads/{user_id}/trend` — daily ratio for last 7/30 days
- [x] Cron job at 00:00 daily: recalc ratio for all users based on updated remaining workdays
- [x] Auto-trigger ratio recalc on: task assign / estimate change / task complete / task delete
---
### Day 3 — Skill & Workload Frontend
**Frontend — Skill Tags (`/skills`)**
- [x] Table: name / category badge / linked-user count / actions
- [x] System tags: show "System" label, disable delete, disable name edit
- [x] New/Edit skill tag modal
- [x] Category badge colors: dev=blue / algorithm=purple / product=orange / pm=green / qa=gray
**Frontend — User Skill Detail (`/users/:id/skills`)**
- [x] Top bar: avatar / name / role / linked projects / "Batch Edit" + "Refresh Scores" buttons
- [x] Left 60%: skills grouped by category (collapsible), each row: name / proficiency dropdown / quality score / efficiency score / last used (read-only)
- [x] "Remove" button per row (disabled for system-bound skills)
- [x] "Add Skill" button → modal to select skill tags
- [x] Right 40%: recharts `RadarChart` — weighted score per skill, filter by category, hover shows detail
- [x] "Export Chart" button (save as image)
**Frontend — Team Workload (`/workloads`)**
- [x] Filter bar: project select + date range (this week / this month / custom)
- [x] 3 stat cards: avg team ratio / headcount by state (idle/normal/overloaded) / AI alert summary
- [x] Table: member info / projects / assigned hours / max weekly hours / ratio% / remaining hours / actions
- [x] Overloaded rows: red highlight + warning icon
**Frontend — Personal Workload (`/workloads/:id`)**
- [x] Header: ratio% / remaining hours / "Adjust Hours" button
- [x] recharts `LineChart`: daily ratio trend (7d/30d toggle), 70% + 100% reference lines, overloaded segments in red
- [x] Task list table: title / project / priority / est. hours / due date / status / view link
- [x] Task list filters: status / priority / due date
---
## Phase 2 — Sync Logic + AI (Day 4–8)
### Day 4–5 — Cross-module Sync
**Backend**
- [x] User create → init empty `user_skill_bindings`, init `user_workloads`
- [x] User delete → transaction: delete bindings + workload record
- [x] User disable → zero `current_assigned_hours` + `workload_ratio`, clear task assignments
- [x] Skill score update → recalc top-3 skills for user, sync to user list response
- [x] Workload ratio change → sync to user list `workload_ratio` field
- [x] Skill proficiency level → pass to task estimation: expert → lower est. hours → lower ratio
**Frontend**
- [x] User list table: workload% and top-3 skill tags update without page reload (TanStack Query invalidation)
- [x] Skill detail page: manual score edit shows warning "AI will overwrite on next update"
- [x] Workload team table: ratio column updates in real-time after task changes
---
### Day 6–7 — SSTC Kanban Integration
**Backend**
- [x] Task assign endpoint: filter candidates to current-project users only, exclude disabled users
- [x] `GET /api/tasks/recommend-assignees?task_id=` — rank users by skill match + proficiency + ratio (<70% preferred)
- [x] Task status → `done`: trigger async job to update `current_assigned_hours` (subtract est. hours) and recalc ratio
- [x] `GET /api/workloads/summary` — team health card data for kanban top bar
**Frontend**
- [x] SSTC kanban top bar: "Team Workload Health" card (avg ratio + overload count), click → `/workloads`
- [x] Task breakdown panel: recommended assignees list shows name / role / top skills / ratio badge
- [x] Assignee dropdown: disabled users grayed out and unselectable
- [x] Task status change → trigger workload refetch (TanStack Query)
---
### Day 8 — AI Features
**Backend**
- [x] New LangChain prompt: skill score update — input: task quality_score + (est_hours / actual_hours), output: quality/efficiency score delta per skill tag
- [x] Trigger on `tasks.status = done`: async call above prompt, apply weighted update (higher priority task = higher weight)
- [x] New LangChain prompt: workload optimization — input: all members' skills + ratios, output: suggested task reassignments
- [x] `POST /api/workloads/ai-optimize` — run optimization, return preview plan (no auto-apply)
- [x] `POST /api/workloads/ai-optimize/apply` — apply approved plan (batch update `tasks.assignee_id`)
- [x] Overload alert: ratio >100% → send notification (Feishu/email); if unresolved 24h → auto-generate transfer suggestion
**Frontend**
- [x] "AI Optimize Assignments" button → show optimization preview modal (old vs new assignee per task)
- [x] Confirm → apply; Cancel → discard
- [x] Overload alert banner on team workload page when any member >100%
---
## Phase 3 — Polish (Day 9–11)
### Day 9 — Filters / Export / Bulk Actions
- [x] Verify all filter combinations work correctly across all 3 pages
- [x] Export workload report (Excel/PDF)
- [x] Export skill radar chart (PNG)
- [x] Bulk delete users (with incomplete-task guard)
- [x] Bulk assign project / role to selected users
### Day 10 — Permissions
| Role | Users | Skills | Workload |
|------|-------|--------|----------|
| admin | Full | Full | Full |
| manager | Own project users (no delete) | Bind/edit scores for own project | View + optimize own project |
| member | View all + edit own profile | View own + edit own proficiency | View own + edit own weekly hours |
- [x] Backend: permission middleware on all mutation endpoints
- [x] Frontend: route guards + button-level permission checks
### Day 11 — Final QA
- [x] Dark mode audit across all new pages
- [x] Empty state / loading state / error state for all tables and cards
- [x] Toast notifications for all CRUD operations
- [x] End-to-end flow test: create user → bind skills → assign task → complete task → verify score + workload update
- [x] Fix bugs, merge
---
## Reference
**Workload ratio formula:**
```
ratio = current_assigned_hours / ((max_weekly_hours / 5) * remaining_workdays_this_week)
```
**Ratio recalc triggers:** task assign · est hours change · task complete · task delete · weekly hours update · daily cron
**API response format:**
```json
{ "code": 200, "message": "ok", "data": {} }
```

View File

@ -1,78 +0,0 @@
# Neural Sync - AI Project Manager
This is a custom internal AI Project Management platform designed to align cognitive processes and manage AI experiments efficiently. It focuses on speed and model adaptation, providing a centralized "AI Kitchen" for the team.
## Project Structure
- **Backend**: Python (FastAPI) with PostgreSQL (pgvector) for vector storage.
- **Frontend**: Vue 3 + TypeScript + Naive UI (Web-only, Desktop First).
## Prerequisites
- Python 3.10+
- Node.js 18+
- PostgreSQL 15+ (running on port 5435 by default)
- `pgvector` extension for PostgreSQL
## Setup Instructions
### 1. Database Setup
Ensure PostgreSQL is running and create the database:
```sql
CREATE DATABASE neural_sync;
\c neural_sync
CREATE EXTENSION vector;
```
### 2. Backend Setup
Navigate to the `backend` directory:
```bash
cd backend
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
pip install -r requirements.txt
pip install asyncpg alembic pgvector # Install key dependencies if not in requirements.txt
```
Run database migrations:
```bash
alembic upgrade head
```
Start the backend server:
```bash
uvicorn app.main:app --reload --port 8000
```
### 3. Frontend Setup
Navigate to the `frontend` directory:
```bash
cd frontend
npm install
```
Start the frontend development server:
```bash
npm run dev -- --host
```
Access the frontend at: `http://localhost:5173`
## Features
- **Signal Dashboard**: Monitor external signals and system status.
- **Experiment Board**: Manage AI experiments with hypothesis tracking and scoring.
- **Context Manager**: Define and manage cognitive nodes (RAG slices, rules).
## Notes
- The interface is currently optimized for **Desktop Web** usage only. Mobile support is not a priority.

View File

@ -1,24 +1,35 @@
[alembic]
# path to migration scripts
script_location = alembic
# A generic, single database configuration.
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[timezone]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for default
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
@ -30,27 +41,52 @@ prepend_sys_path = .
# versions/ directory
# sourceless = false
# version location specification; this defaults
# to alembic/versions. When using multiple version
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator"
# version_locations = %(here)s/alembic/versions
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = postgresql+asyncpg://postgres:postgres@localhost:5435/neural_sync
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
@ -64,7 +100,20 @@ sqlalchemy.url = postgresql+asyncpg://postgres:postgres@localhost:5435/neural_sy
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# Logging configuration
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
@ -75,12 +124,12 @@ keys = console
keys = generic
[logger_root]
level = WARN
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
level = WARNING
handlers =
qualname = sqlalchemy.engine

View File

@ -1,11 +1,16 @@
import asyncio
from logging.config import fileConfig
import sys
from pathlib import Path
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.core.config import settings
from app.models import Base
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
@ -16,11 +21,15 @@ config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Set the database URL from settings
# Replace async driver with sync driver for migrations
db_url = settings.database_url.replace("sqlite+aiosqlite", "sqlite")
config.set_main_option("sqlalchemy.url", db_url)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
from app.models import Base
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
@ -53,33 +62,29 @@ def run_migrations_offline() -> None:
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = async_engine_from_config(
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
await connectable.dispose()
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())
run_migrations_online()

View File

@ -0,0 +1,50 @@
"""add users.is_active and user_projects table
Revision ID: add_user_projects_is_active
Revises: add_experiment_project_id
Create Date: 2026-02-26
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "add_user_projects_is_active"
down_revision = "add_experiment_project_id"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"users",
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
)
op.create_table(
"user_projects",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column(
"updated_at",
sa.TIMESTAMP(),
server_default=sa.func.now(),
onupdate=sa.func.now(),
nullable=False,
),
sa.UniqueConstraint("tenant_id", "user_id", "project_id", name="uq_user_projects_tenant_user_project"),
)
op.create_index("ix_user_projects_tenant_id", "user_projects", ["tenant_id"])
op.create_index("ix_user_projects_user_id", "user_projects", ["user_id"])
op.create_index("ix_user_projects_project_id", "user_projects", ["project_id"])
def downgrade():
op.drop_index("ix_user_projects_project_id", table_name="user_projects")
op.drop_index("ix_user_projects_user_id", table_name="user_projects")
op.drop_index("ix_user_projects_tenant_id", table_name="user_projects")
op.drop_table("user_projects")
op.drop_column("users", "is_active")

View File

@ -9,7 +9,7 @@ from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import pgvector
import pgvector.sqlalchemy
# revision identifiers, used by Alembic.
revision: str = '380f4e61b41c'
@ -20,30 +20,32 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
bind = op.get_bind()
is_sqlite = bind.dialect.name == 'sqlite'
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('context_nodes',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('id', sa.String(36) if is_sqlite else sa.UUID(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('embedding', pgvector.sqlalchemy.vector.VECTOR(dim=1536), nullable=True),
sa.Column('embedding', sa.JSON() if is_sqlite else pgvector.sqlalchemy.vector.VECTOR(dim=1536), nullable=True),
sa.Column('version', sa.Integer(), nullable=True),
sa.Column('status', sa.String(length=20), nullable=True),
sa.Column('created_by', sa.UUID(), nullable=True),
sa.Column('created_by', sa.String(36) if is_sqlite else sa.UUID(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('experiments',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('id', sa.String(36) if is_sqlite else sa.UUID(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=True),
sa.Column('hypothesis', sa.Text(), nullable=True),
sa.Column('status', sa.String(length=50), nullable=True),
sa.Column('owner_id', sa.UUID(), nullable=True),
sa.Column('linked_context_nodes', sa.ARRAY(sa.UUID()), nullable=True),
sa.Column('owner_id', sa.String(36) if is_sqlite else sa.UUID(), nullable=True),
sa.Column('linked_context_nodes', sa.JSON() if is_sqlite else sa.ARRAY(sa.UUID()), nullable=True),
sa.Column('target_metric', sa.String(length=50), nullable=True),
sa.Column('current_score', sa.Float(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('signals',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('id', sa.String(36) if is_sqlite else sa.UUID(), nullable=False),
sa.Column('source', sa.String(length=50), nullable=True),
sa.Column('content', sa.Text(), nullable=True),
sa.Column('impact_score', sa.Float(), nullable=True),

View File

@ -0,0 +1,63 @@
"""add ai_call_logs, truth_nodes, user_skill quality/efficiency columns
Revision ID: add_ai_tables
Revises: add_skill_tables
Create Date: 2024-02-24
"""
from alembic import op
import sqlalchemy as sa
revision = 'add_ai_tables'
down_revision = 'add_skill_tables'
branch_labels = None
depends_on = None
def get_uuid():
import os
if "sqlite" in os.getenv("DATABASE_URL", ""):
return sa.CHAR(36)
from sqlalchemy.dialects.postgresql import UUID
return UUID(as_uuid=True)
def upgrade():
uuid_col = get_uuid()
op.create_table(
'ai_call_logs',
sa.Column('id', uuid_col, primary_key=True),
sa.Column('tenant_id', sa.String(50), nullable=False, index=True),
sa.Column('function_name', sa.String(100), nullable=False, index=True),
sa.Column('input_hash', sa.String(64), nullable=True, index=True),
sa.Column('status', sa.String(20), server_default='success'),
sa.Column('prompt_tokens', sa.Integer(), server_default='0'),
sa.Column('completion_tokens', sa.Integer(), server_default='0'),
sa.Column('total_tokens', sa.Integer(), server_default='0'),
sa.Column('cost_usd', sa.Float(), server_default='0'),
sa.Column('latency_ms', sa.Integer(), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
)
op.create_table(
'truth_nodes',
sa.Column('id', uuid_col, primary_key=True),
sa.Column('tenant_id', sa.String(50), nullable=False, index=True),
sa.Column('title', sa.String(255), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('category', sa.String(50), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
)
op.add_column('user_skill_bindings', sa.Column('quality_score', sa.Float(), server_default='0'))
op.add_column('user_skill_bindings', sa.Column('efficiency_score', sa.Float(), server_default='0'))
def downgrade():
op.drop_column('user_skill_bindings', 'efficiency_score')
op.drop_column('user_skill_bindings', 'quality_score')
op.drop_table('truth_nodes')
op.drop_table('ai_call_logs')

View File

@ -0,0 +1,82 @@
"""add budgets and budget alerts tables
Revision ID: add_budget_tables
Revises: add_model_capability_tables
Create Date: 2026-02-25
"""
from alembic import op
import sqlalchemy as sa
revision = "add_budget_tables"
down_revision = "add_model_capability_tables"
branch_labels = None
depends_on = None
def _table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = sa.inspect(bind)
return table_name in inspector.get_table_names()
def upgrade():
if not _table_exists("budgets"):
op.create_table(
"budgets",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("budget_type", sa.String(length=20), nullable=False),
sa.Column("project_id", sa.BigInteger(), nullable=True),
sa.Column("period", sa.String(length=20), nullable=False),
sa.Column("budget_amount_usd", sa.DECIMAL(12, 2), nullable=False, server_default="0"),
sa.Column("alert_threshold_percent", sa.Float(), nullable=False, server_default="80"),
sa.Column("hard_limit_enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("current_usage_usd", sa.DECIMAL(12, 2), nullable=False, server_default="0"),
sa.Column("current_period_start", sa.TIMESTAMP(), nullable=True),
sa.Column("current_period_end", sa.TIMESTAMP(), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default="active"),
sa.Column("override_history", sa.JSON(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"]),
)
op.create_index("ix_budgets_tenant_id", "budgets", ["tenant_id"])
op.create_index("ix_budgets_budget_type", "budgets", ["budget_type"])
op.create_index("ix_budgets_project_id", "budgets", ["project_id"])
if not _table_exists("budget_alerts"):
op.create_table(
"budget_alerts",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("budget_id", sa.BigInteger(), nullable=False),
sa.Column("alert_type", sa.String(length=50), nullable=False),
sa.Column("usage_usd", sa.DECIMAL(12, 2), nullable=False, server_default="0"),
sa.Column("usage_percent", sa.Float(), nullable=False, server_default="0"),
sa.Column("message", sa.Text(), nullable=False),
sa.Column("is_read", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("action_taken", sa.String(length=50), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default="active"),
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["budget_id"], ["budgets.id"]),
)
op.create_index("ix_budget_alerts_tenant_id", "budget_alerts", ["tenant_id"])
op.create_index("ix_budget_alerts_budget_id", "budget_alerts", ["budget_id"])
op.create_index("ix_budget_alerts_alert_type", "budget_alerts", ["alert_type"])
def downgrade():
if _table_exists("budget_alerts"):
op.drop_index("ix_budget_alerts_alert_type", table_name="budget_alerts")
op.drop_index("ix_budget_alerts_budget_id", table_name="budget_alerts")
op.drop_index("ix_budget_alerts_tenant_id", table_name="budget_alerts")
op.drop_table("budget_alerts")
if _table_exists("budgets"):
op.drop_index("ix_budgets_project_id", table_name="budgets")
op.drop_index("ix_budgets_budget_type", table_name="budgets")
op.drop_index("ix_budgets_tenant_id", table_name="budgets")
op.drop_table("budgets")

View File

@ -0,0 +1,67 @@
"""add experiment project_id column
Revision ID: add_experiment_project_id
Revises: add_budget_tables
Create Date: 2026-02-25
"""
from alembic import op
import sqlalchemy as sa
revision = "add_experiment_project_id"
down_revision = "add_budget_tables"
branch_labels = None
depends_on = None
def _has_column(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = inspector.get_columns(table_name)
return any(col["name"] == column_name for col in columns)
def _has_index(table_name: str, index_name: str) -> bool:
bind = op.get_bind()
inspector = sa.inspect(bind)
indexes = inspector.get_indexes(table_name)
return any(idx["name"] == index_name for idx in indexes)
def upgrade():
if not _has_column("experiments", "project_id"):
op.add_column("experiments", sa.Column("project_id", sa.BigInteger(), nullable=True))
bind = op.get_bind()
dialect = bind.dialect.name
# 尽量从 linked_context_nodes 推断 project_id(仅唯一归属时回填)
if dialect == "sqlite":
op.execute(
"""
UPDATE experiments
SET project_id = (
SELECT tn.project_id
FROM truth_nodes tn
WHERE instr(COALESCE(experiments.linked_context_nodes, ''), printf('%s', tn.id)) > 0
AND tn.project_id IS NOT NULL
GROUP BY tn.project_id
HAVING COUNT(*) >= 1
LIMIT 1
)
WHERE project_id IS NULL
"""
)
else:
# PostgreSQL JSON/数组形态不稳定,迁移阶段不做激进回填,留给服务层按需补写。
pass
if not _has_index("experiments", "ix_experiments_project_id"):
op.create_index("ix_experiments_project_id", "experiments", ["project_id"])
def downgrade():
if _has_index("experiments", "ix_experiments_project_id"):
op.drop_index("ix_experiments_project_id", table_name="experiments")
if _has_column("experiments", "project_id"):
op.drop_column("experiments", "project_id")

View File

@ -0,0 +1,94 @@
"""add model capability gap and cost tracking tables
Revision ID: add_model_capability_tables
Revises: add_ai_tables
Create Date: 2026-02-25
"""
from alembic import op
import sqlalchemy as sa
revision = "add_model_capability_tables"
down_revision = "add_ai_tables"
branch_labels = None
depends_on = None
def _table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = sa.inspect(bind)
return table_name in inspector.get_table_names()
def upgrade():
if not _table_exists("model_capability_gaps"):
op.create_table(
"model_capability_gaps",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("model_version_id", sa.BigInteger(), nullable=False),
sa.Column("truth_node_id", sa.BigInteger(), nullable=False),
sa.Column("gap_type", sa.String(length=50), nullable=False),
sa.Column("severity", sa.String(length=20), nullable=False),
sa.Column("description", sa.Text(), nullable=False),
sa.Column("ai_analysis", sa.JSON(), nullable=False),
sa.Column("status", sa.String(length=50), nullable=False, server_default="open"),
sa.Column("resolved_at", sa.TIMESTAMP(), nullable=True),
sa.Column("resolution_notes", sa.Text(), nullable=True),
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["model_version_id"], ["model_versions.id"]),
sa.ForeignKeyConstraint(["truth_node_id"], ["truth_nodes.id"]),
)
op.create_index("ix_model_capability_gaps_tenant_id", "model_capability_gaps", ["tenant_id"])
op.create_index("ix_model_capability_gaps_model_version_id", "model_capability_gaps", ["model_version_id"])
op.create_index("ix_model_capability_gaps_truth_node_id", "model_capability_gaps", ["truth_node_id"])
op.create_index("ix_model_capability_gaps_gap_type", "model_capability_gaps", ["gap_type"])
op.create_index("ix_model_capability_gaps_severity", "model_capability_gaps", ["severity"])
op.create_index("ix_model_capability_gaps_status", "model_capability_gaps", ["status"])
if not _table_exists("cost_trackings"):
op.create_table(
"cost_trackings",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("truth_node_id", sa.BigInteger(), nullable=True),
sa.Column("model_version_id", sa.BigInteger(), nullable=True),
sa.Column("task_id", sa.BigInteger(), nullable=True),
sa.Column("period", sa.String(length=20), nullable=False),
sa.Column("estimated_tokens", sa.Integer(), nullable=False, server_default="0"),
sa.Column("actual_tokens", sa.Integer(), nullable=False, server_default="0"),
sa.Column("estimated_cost_usd", sa.DECIMAL(10, 4), nullable=False, server_default="0"),
sa.Column("actual_cost_usd", sa.DECIMAL(10, 4), nullable=False, server_default="0"),
sa.Column("roi_score", sa.Float(), nullable=True),
sa.Column("roi_analysis", sa.JSON(), nullable=False),
sa.Column("optimization_suggestions", sa.JSON(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["truth_node_id"], ["truth_nodes.id"]),
sa.ForeignKeyConstraint(["model_version_id"], ["model_versions.id"]),
sa.ForeignKeyConstraint(["task_id"], ["tasks.id"]),
)
op.create_index("ix_cost_trackings_tenant_id", "cost_trackings", ["tenant_id"])
op.create_index("ix_cost_trackings_truth_node_id", "cost_trackings", ["truth_node_id"])
op.create_index("ix_cost_trackings_task_id", "cost_trackings", ["task_id"])
op.create_index("ix_cost_trackings_period", "cost_trackings", ["period"])
def downgrade():
if _table_exists("cost_trackings"):
op.drop_index("ix_cost_trackings_period", table_name="cost_trackings")
op.drop_index("ix_cost_trackings_task_id", table_name="cost_trackings")
op.drop_index("ix_cost_trackings_truth_node_id", table_name="cost_trackings")
op.drop_index("ix_cost_trackings_tenant_id", table_name="cost_trackings")
op.drop_table("cost_trackings")
if _table_exists("model_capability_gaps"):
op.drop_index("ix_model_capability_gaps_status", table_name="model_capability_gaps")
op.drop_index("ix_model_capability_gaps_severity", table_name="model_capability_gaps")
op.drop_index("ix_model_capability_gaps_gap_type", table_name="model_capability_gaps")
op.drop_index("ix_model_capability_gaps_truth_node_id", table_name="model_capability_gaps")
op.drop_index("ix_model_capability_gaps_model_version_id", table_name="model_capability_gaps")
op.drop_index("ix_model_capability_gaps_tenant_id", table_name="model_capability_gaps")
op.drop_table("model_capability_gaps")

View File

@ -0,0 +1,63 @@
"""add_projects_and_signal_links
Revision ID: add_projects_001
Revises: f38d00f6fee4
Create Date: 2026-02-21 10:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'add_projects_001'
down_revision: Union[str, Sequence[str], None] = 'f38d00f6fee4'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# Create projects table
op.create_table(
'projects',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('status', sa.String(length=50), server_default='active', nullable=False),
sa.Column('domain_id', sa.UUID(), nullable=True),
sa.Column('assigned_role', sa.String(length=50), nullable=True),
sa.Column('signal_count', sa.Integer(), server_default='0', nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['domain_id'], ['project_domains'], name=op.f('fk_projects_domain_id_project_domains')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_projects'))
)
op.create_index('projects', ['status'], ['created_at'], name='ix_projects_status_created_at')
# Create project_signal_links table
op.create_table(
'project_signal_links',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('project_id', sa.UUID(), nullable=False),
sa.Column('signal_id', sa.UUID(), nullable=False),
sa.Column('link_type', sa.String(length=50), server_default='related', nullable=False),
sa.Column('relevance_score', sa.Float(), server_default='1.0', nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['projects'], name=op.f('fk_project_signal_links_project_id_projects')),
sa.ForeignKeyConstraint(['signal_id'], ['signals'], name=op.f('fk_project_signal_links_signal_id_signals')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_project_signal_links'))
)
op.create_index('project_signal_links', ['project_id'], name='ix_project_signal_links_project_id')
op.create_index('project_signal_links', ['signal_id'], name='ix_project_signal_links_signal_id')
def downgrade() -> None:
"""Downgrade schema."""
op.drop_index('project_signal_links', 'ix_project_signal_links_signal_id', table_name='project_signal_links')
op.drop_index('project_signal_links', 'ix_project_signal_links_project_id', table_name='project_signal_links')
op.drop_table('project_signal_links')
op.drop_index('projects', 'ix_projects_status_created_at', table_name='projects')
op.drop_table('projects')

View File

@ -0,0 +1,80 @@
"""add skill tags, user_skill_bindings, user_workloads tables
Revision ID: add_skill_tables
Revises: add_projects_and_signal_links
Create Date: 2024-02-24
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.dialects import sqlite
# revision identifiers, used by Alembic.
revision = 'add_skill_tables'
down_revision = 'add_projects_001'
branch_labels = None
depends_on = None
def get_uuid_column():
"""Return UUID column appropriate for database."""
import os
if "sqlite" in os.getenv("DATABASE_URL", ""):
return sa.CHAR(36)
return UUID(as_uuid=True)
def upgrade():
uuid_col = get_uuid_column()
op.create_table(
'skill_tags',
sa.Column('id', uuid_col, primary_key=True),
sa.Column('tenant_id', sa.String(50), nullable=False, index=True),
sa.Column('name', sa.String(100), nullable=False, index=True),
sa.Column('slug', sa.String(80), nullable=False, index=True),
sa.Column('category', sa.String(50), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('is_system', sa.Boolean(), default=False),
sa.Column('sort_order', sa.Integer(), default=0),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
)
op.create_table(
'user_skill_bindings',
sa.Column('id', uuid_col, primary_key=True),
sa.Column('tenant_id', sa.String(50), nullable=False, index=True),
sa.Column('user_id', uuid_col, sa.ForeignKey('users.id'), nullable=False, index=True),
sa.Column('skill_tag_id', uuid_col, sa.ForeignKey('skill_tags.id'), nullable=False, index=True),
sa.Column('proficiency', sa.Integer(), default=50),
sa.Column('level', sa.String(20), default='intermediate'),
sa.Column('years_experience', sa.Float(), nullable=True),
sa.Column('last_used_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
)
op.create_table(
'user_workloads',
sa.Column('id', uuid_col, primary_key=True),
sa.Column('tenant_id', sa.String(50), nullable=False, index=True),
sa.Column('user_id', uuid_col, sa.ForeignKey('users.id'), nullable=False, index=True),
sa.Column('total_tasks', sa.Integer(), default=0),
sa.Column('completed_tasks', sa.Integer(), default=0),
sa.Column('in_progress_tasks', sa.Integer(), default=0),
sa.Column('estimated_hours', sa.Float(), default=0.0),
sa.Column('completed_hours', sa.Float(), default=0.0),
sa.Column('workload_rate', sa.Float(), default=0.0),
sa.Column('period_start', sa.DateTime(), nullable=False),
sa.Column('period_end', sa.DateTime(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
)
def downgrade():
op.drop_table('user_workloads')
op.drop_table('user_skill_bindings')
op.drop_table('skill_tags')

View File

@ -0,0 +1,83 @@
"""add users table RBAC
Revision ID: add_users_table
Revises:
Create Date: 2026-02-22
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, ENUM
import uuid
# revision identifiers, used by Alembic.
revision = 'add_users_table'
down_revision = '380f4e61b41c' # Changed from None to 380f4e61b41c
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
is_sqlite = bind.dialect.name == 'sqlite'
# Create enums (only for Postgres)
if not is_sqlite:
user_role_enum = ENUM('BOSS', 'ADMIN', 'AI', 'FE', 'BE', name='userrole', create_type=True)
user_status_enum = ENUM('active', 'away', 'offline', name='userstatus', create_type=True)
user_role_enum.create(bind, checkfirst=True)
user_status_enum.create(bind, checkfirst=True)
# Create users table
op.create_table(
'users',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True) if is_sqlite else sa.Column('id', UUID(as_uuid=True), primary_key=True, default=uuid.uuid4),
sa.Column('username', sa.String(50), unique=True, nullable=False, index=True),
sa.Column('password_hash', sa.String(255), nullable=False),
sa.Column('name', sa.String(100), nullable=False),
sa.Column('email', sa.String(255), unique=True, nullable=True),
sa.Column('role', sa.String(50) if is_sqlite else sa.Enum('BOSS', 'ADMIN', 'AI', 'FE', 'BE', name='userrole'), nullable=False, server_default='FE'),
sa.Column('assigned_domains', sa.JSON(), nullable=True, server_default='[]'),
sa.Column('workload', sa.Integer(), nullable=False, server_default='0'),
sa.Column('status', sa.String(50) if is_sqlite else sa.Enum('active', 'away', 'offline', name='userstatus'), nullable=False, server_default='active'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
# Create initial users (with simple password hashes for demo)
# In production, these should be created with proper password hashing
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto')
admin_hash = pwd_context.hash('admin123')
user_hash = pwd_context.hash('user123')
if is_sqlite:
op.execute(f"""
INSERT INTO users (username, password_hash, name, email, role, assigned_domains, workload, status, created_at, updated_at)
VALUES
('admin', '{admin_hash}', 'System Administrator', 'admin@neural-sync.ai', 'BOSS', '[]', 0, 'active', datetime('now'), datetime('now')),
('user', '{user_hash}', 'Test User', 'user@neural-sync.ai', 'FE', '[]', 0, 'active', datetime('now'), datetime('now'))
""")
else:
op.execute(f"""
INSERT INTO users (id, username, password_hash, name, email, role, assigned_domains, workload, status, created_at, updated_at)
VALUES
('{uuid.uuid4()}', 'admin', '{admin_hash}', 'System Administrator', 'admin@neural-sync.ai', 'BOSS', '[]', 0, 'active', NOW(), NOW()),
('{uuid.uuid4()}', 'user', '{user_hash}', 'Test User', 'user@neural-sync.ai', 'FE', '[]', 0, 'active', NOW(), NOW())
""")
def downgrade():
op.drop_table('users')
bind = op.get_bind()
is_sqlite = bind.dialect.name == 'sqlite'
# Drop enums (only for Postgres)
if not is_sqlite:
user_role_enum = sa.dialects.postgresql.ENUM(name='userrole')
user_status_enum = sa.dialects.postgresql.ENUM(name='userstatus')
user_role_enum.drop(bind, checkfirst=True)
user_status_enum.drop(bind, checkfirst=True)

View File

@ -9,19 +9,23 @@ from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import pgvector
import pgvector.sqlalchemy
# revision identifiers, used by Alembic.
revision: str = 'd3c63f669b0a'
down_revision: Union[str, Sequence[str], None] = '380f4e61b41c'
down_revision: Union[str, Sequence[str], None] = 'add_users_table'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
bind = op.get_bind()
is_sqlite = bind.dialect.name == 'sqlite'
# ### commands auto generated by Alembic - please adjust! ###
if not is_sqlite:
op.alter_column('context_nodes', 'embedding',
existing_type=pgvector.sqlalchemy.vector.VECTOR(dim=1536),
type_=pgvector.sqlalchemy.vector.VECTOR(dim=768),
@ -31,7 +35,11 @@ def upgrade() -> None:
def downgrade() -> None:
"""Downgrade schema."""
bind = op.get_bind()
is_sqlite = bind.dialect.name == 'sqlite'
# ### commands auto generated by Alembic - please adjust! ###
if not is_sqlite:
op.alter_column('context_nodes', 'embedding',
existing_type=pgvector.sqlalchemy.vector.VECTOR(dim=768),
type_=pgvector.sqlalchemy.vector.VECTOR(dim=1536),

View File

@ -0,0 +1,41 @@
"""
API 路由模块
按业务域拆分路由,便于维护与扩展。
"""
from .signals import router as signals_router
from .feedbacks import router as feedbacks_router
from .experiments import router as experiments_router
from .context import router as context_router
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 .endpoints.skill import router as skill_router
from .sstc import router as sstc_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 .model_capability import router as model_capability_router
from .budget import router as budget_router
from .scheduler import router as scheduler_router
__all__ = [
"signals_router",
"feedbacks_router",
"experiments_router",
"context_router",
"tasks_router",
"webhooks_router",
"auth_router",
"users_router",
"skill_router",
"sstc_router",
"ai_steward_router",
"feishu_router",
"wecom_router",
"model_capability_router",
"budget_router",
"scheduler_router",
]

File diff suppressed because it is too large Load Diff

48
backend/app/api/auth.py Normal file
View File

@ -0,0 +1,48 @@
"""认证 API"""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from ..database import get_db
from ..models import User, UserStatus
from ..schemas import UserLoginResponse
from ..services.auth import AuthService
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=UserLoginResponse)
async def login(
credentials: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db),
):
"""用户登录,返回 JWT"""
result = await db.execute(
select(User).where(User.username == credentials.username)
)
user = result.scalar_one_or_none()
if not user or not AuthService.verify_password(
credentials.password, user.password_hash
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
if not getattr(user, "is_active", True) or user.status != UserStatus.ACTIVE.value:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User is disabled",
)
access_token = AuthService.create_access_token(data={"sub": user.username})
return UserLoginResponse(
access_token=access_token,
token_type="bearer",
user=user.to_dict(),
)

236
backend/app/api/budget.py Normal file
View File

@ -0,0 +1,236 @@
"""预算管理 API"""
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, Query
from fastapi.responses import JSONResponse
from sqlalchemy import and_, desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..core.response import ErrorCode, error_response, success_response
from ..database import get_db
from ..dependencies import get_tenant_id, get_current_user, require_roles
from ..models import Budget, BudgetAlert, User
from ..schemas.budget import (
BudgetCheckRequest,
BudgetCreateRequest,
BudgetOverrideRequest,
BudgetUpdateRequest,
)
from ..services.budget_enforcement_service import BudgetEnforcementService
router = APIRouter(prefix="/budget", tags=["budget"])
def _period_window(now: datetime, period: str) -> tuple[datetime, datetime]:
p = (period or "monthly").lower()
if p == "yearly":
return datetime(now.year, 1, 1), datetime(now.year + 1, 1, 1)
if p == "quarterly":
q = (now.month - 1) // 3
start_month = q * 3 + 1
start = datetime(now.year, start_month, 1)
end = datetime(now.year + 1, 1, 1) if start_month == 10 else datetime(now.year, start_month + 3, 1)
return start, end
start = datetime(now.year, now.month, 1)
end = datetime(now.year + 1, 1, 1) if now.month == 12 else datetime(now.year, now.month + 1, 1)
return start, end
@router.get("/")
async def list_budgets(
budget_type: Optional[str] = Query(default=None, pattern="^(tenant|project)$"),
project_id: Optional[int] = Query(default=None),
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
_ = current_user
query = select(Budget).where(Budget.tenant_id == tenant_id)
if budget_type:
query = query.where(Budget.budget_type == budget_type)
if project_id is not None:
query = query.where(Budget.project_id == project_id)
query = query.order_by(desc(Budget.created_at))
result = await db.execute(query)
rows = result.scalars().all()
return success_response(
data=[
{
"id": b.id,
"budget_type": b.budget_type,
"project_id": b.project_id,
"period": b.period,
"budget_amount_usd": float(b.budget_amount_usd),
"alert_threshold_percent": float(b.alert_threshold_percent),
"hard_limit_enabled": bool(b.hard_limit_enabled),
"current_usage_usd": float(b.current_usage_usd),
"status": b.status,
"current_period_start": b.current_period_start.isoformat() if b.current_period_start else None,
"current_period_end": b.current_period_end.isoformat() if b.current_period_end else None,
}
for b in rows
]
)
@router.post("/")
async def create_budget(
request: BudgetCreateRequest,
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
_ = current_user
now = datetime.utcnow()
period_start, period_end = _period_window(now, request.period)
new_budget = Budget(
tenant_id=tenant_id,
budget_type=request.budget_type,
project_id=request.project_id,
period=request.period,
budget_amount_usd=request.budget_amount_usd,
alert_threshold_percent=request.alert_threshold_percent,
hard_limit_enabled=request.hard_limit_enabled,
current_usage_usd=0.0,
current_period_start=period_start,
current_period_end=period_end,
status="active",
override_history={"records": []},
)
db.add(new_budget)
await db.commit()
await db.refresh(new_budget)
return success_response(
data={
"id": new_budget.id,
"budget_type": new_budget.budget_type,
"project_id": new_budget.project_id,
"period": new_budget.period,
"budget_amount_usd": float(new_budget.budget_amount_usd),
"alert_threshold_percent": float(new_budget.alert_threshold_percent),
"hard_limit_enabled": bool(new_budget.hard_limit_enabled),
"status": new_budget.status,
}
)
@router.patch("/{budget_id}")
async def update_budget(
budget_id: int,
request: BudgetUpdateRequest,
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
_ = current_user
result = await db.execute(
select(Budget).where(and_(Budget.id == budget_id, Budget.tenant_id == tenant_id))
)
budget = result.scalar_one_or_none()
if not budget:
return JSONResponse(status_code=404, content=error_response(ErrorCode.NOT_FOUND, "预算不存在"))
if request.period is not None:
budget.period = request.period
if request.budget_amount_usd is not None:
budget.budget_amount_usd = request.budget_amount_usd
if request.alert_threshold_percent is not None:
budget.alert_threshold_percent = request.alert_threshold_percent
if request.hard_limit_enabled is not None:
budget.hard_limit_enabled = request.hard_limit_enabled
if request.status is not None:
budget.status = request.status
await db.commit()
await db.refresh(budget)
return success_response(
data={
"id": budget.id,
"period": budget.period,
"budget_amount_usd": float(budget.budget_amount_usd),
"alert_threshold_percent": float(budget.alert_threshold_percent),
"hard_limit_enabled": bool(budget.hard_limit_enabled),
"status": budget.status,
}
)
@router.post("/{budget_id}/override")
async def override_budget(
budget_id: int,
request: BudgetOverrideRequest,
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
service = BudgetEnforcementService(db)
try:
data = await service.override_budget_limit(
budget_id=budget_id,
tenant_id=tenant_id,
operator_user_id=int(current_user.id),
note=request.note,
duration_hours=request.duration_hours,
)
return success_response(data=data)
except ValueError as exc:
return JSONResponse(status_code=404, content=error_response(ErrorCode.NOT_FOUND, str(exc)))
@router.get("/alerts")
async def list_budget_alerts(
budget_id: Optional[int] = Query(default=None),
unread_only: bool = Query(default=False),
limit: int = Query(default=100, ge=1, le=500),
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
_ = current_user
query = select(BudgetAlert).where(BudgetAlert.tenant_id == tenant_id)
if budget_id is not None:
query = query.where(BudgetAlert.budget_id == budget_id)
if unread_only:
query = query.where(BudgetAlert.is_read.is_(False))
query = query.order_by(desc(BudgetAlert.created_at)).limit(limit)
result = await db.execute(query)
rows = result.scalars().all()
return success_response(
data=[
{
"id": a.id,
"budget_id": a.budget_id,
"alert_type": a.alert_type,
"usage_usd": float(a.usage_usd),
"usage_percent": float(a.usage_percent),
"message": a.message,
"is_read": bool(a.is_read),
"action_taken": a.action_taken,
"status": a.status,
"created_at": a.created_at.isoformat() if a.created_at else None,
}
for a in rows
]
)
@router.post("/check")
async def check_budget(
request: BudgetCheckRequest,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
_ = current_user
service = BudgetEnforcementService(db)
result = await service.check_budget_before_ai_call(
tenant_id=tenant_id,
estimated_cost_usd=request.estimated_cost_usd,
project_id=request.project_id,
)
return success_response(data=result)

View File

@ -0,0 +1,69 @@
"""知识节点相关 API"""
from fastapi import APIRouter, Depends, HTTPException
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 ContextNode
from ..schemas import ContextNodeCreate
from ..services.llm import llm_service
router = APIRouter(prefix="/context_nodes", tags=["context"])
@router.get("/")
async def list_context_nodes(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(ContextNode))
nodes = result.scalars().all()
return [
{
"id": node.id,
"content": node.content,
"status": node.status,
"version": node.version,
"has_embedding": node.embedding is not None,
"ai_insights": node.ai_insights,
}
for node in nodes
]
@router.post("/")
async def create_context_node(
node: ContextNodeCreate,
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
try:
embedding = await llm_service.get_embedding_async(
node.content,
db=db,
tenant_id=tenant_id,
)
insights = await llm_service.extract_context_insights_async(
node.content,
db=db,
tenant_id=tenant_id,
)
except RuntimeError:
raise HTTPException(status_code=403, detail="预算超限,AI 调用被阻止(hard limit)")
new_node = ContextNode(
content=node.content,
embedding=embedding if embedding else None,
ai_insights=insights,
)
db.add(new_node)
await db.commit()
await db.refresh(new_node)
return {
"id": new_node.id,
"content": new_node.content,
"status": new_node.status,
"version": new_node.version,
"has_embedding": new_node.embedding is not None,
"ai_insights": new_node.ai_insights,
}

View File

@ -0,0 +1,5 @@
"""
API 端点模块
按业务域拆分的具体端点实现。
"""

View File

@ -0,0 +1,381 @@
"""
PMFlow 人员能力图谱 API 端点
- 技能标签 CRUD
- 用户技能绑定
- 团队负载看板
- 用户个人负载查询
- 人员推荐
"""
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from ...database import get_db
from ...dependencies import get_current_user, require_roles, get_tenant_id
from ...models import User
from ...schemas.skill import (
SkillTagCreate,
SkillTagUpdate,
SkillTagResponse,
UserSkillBindingCreate,
UserSkillBindingUpdate,
UserSkillBindingBatchUpdate,
UserSkillBindingResponse,
UserWorkloadResponse,
PersonnelRecommendationResponse,
PersonnelRecommendationItem,
SkillTaskHistoryResponse,
SkillTaskHistoryItem,
)
from ...services import skill_service
from ...services import workload_service
from ...services.auth import has_any_role
router = APIRouter(prefix="/skills", tags=["人员能力图谱"])
def _skill_tag_to_response(tag) -> SkillTagResponse:
"""将 SkillTag ORM 转为响应模型"""
return SkillTagResponse(
id=str(tag.id),
tenant_id=tag.tenant_id,
name=tag.name,
slug=tag.slug,
category=tag.category,
description=tag.description,
is_system=tag.is_system,
sort_order=tag.sort_order,
created_at=tag.created_at.isoformat() if tag.created_at else None,
updated_at=tag.updated_at.isoformat() if tag.updated_at else None,
)
# ============================================================================
# 技能标签 CRUD
# ============================================================================
@router.get("/tags", response_model=List[SkillTagResponse])
async def list_skill_tags(
category: Optional[str] = Query(None, description="按分类过滤"),
include_system: bool = Query(True, description="是否包含系统标签"),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
列出技能标签
支持按分类过滤,系统标签可选包含。
"""
tags = await skill_service.list_skill_tags(
db, tenant_id=tenant_id, category=category, include_system=include_system
)
return [_skill_tag_to_response(t) for t in tags]
@router.post("/tags", response_model=SkillTagResponse)
async def create_skill_tag(
body: SkillTagCreate,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
db: AsyncSession = Depends(get_db),
):
"""
创建技能标签
仅 BOSS、ADMIN 可创建。
"""
try:
tag = await skill_service.create_skill_tag(
db,
tenant_id=tenant_id,
name=body.name,
slug=body.slug,
category=body.category,
description=body.description,
sort_order=body.sort_order,
)
return _skill_tag_to_response(tag)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.get("/tags/{tag_id}", response_model=SkillTagResponse)
async def get_skill_tag(
tag_id: UUID,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取单个技能标签"""
tag = await skill_service.get_skill_tag(db, tag_id, tenant_id)
if not tag:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="技能标签不存在")
return _skill_tag_to_response(tag)
@router.patch("/tags/{tag_id}", response_model=SkillTagResponse)
async def update_skill_tag(
tag_id: UUID,
body: SkillTagUpdate,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
db: AsyncSession = Depends(get_db),
):
"""
更新技能标签
系统预设标签不可修改。
"""
try:
tag = await skill_service.update_skill_tag(
db,
tag_id=tag_id,
tenant_id=tenant_id,
name=body.name,
category=body.category,
description=body.description,
sort_order=body.sort_order,
)
if not tag:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="技能标签不存在")
return _skill_tag_to_response(tag)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.delete("/tags/{tag_id}")
async def delete_skill_tag(
tag_id: UUID,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
db: AsyncSession = Depends(get_db),
):
"""
删除技能标签
系统预设标签不可删除。
"""
try:
ok = await skill_service.delete_skill_tag(db, tag_id, tenant_id)
if not ok:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="技能标签不存在")
return {"message": "已删除"}
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# ============================================================================
# 用户技能绑定
# ============================================================================
@router.get("/users/{user_id}/bindings", response_model=List[UserSkillBindingResponse])
async def get_user_skill_bindings(
user_id: int,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取用户的技能绑定列表"""
bindings = await skill_service.get_user_skill_bindings(db, tenant_id, user_id)
return [
UserSkillBindingResponse(
id=str(b.id),
user_id=str(b.user_id),
skill_tag_id=str(b.skill_tag_id),
skill_name=b.skill_tag.name if b.skill_tag else "",
proficiency=b.proficiency or 50,
level=b.level or b.proficiency_level or "intermediate",
years_experience=b.years_experience,
quality_score=getattr(b, "quality_score", None),
efficiency_score=getattr(b, "efficiency_score", None),
last_used_at=b.last_used_at.isoformat() if b.last_used_at else None,
created_at=b.created_at.isoformat() if b.created_at else None,
updated_at=b.updated_at.isoformat() if b.updated_at else None,
)
for b in bindings
]
@router.put("/users/{user_id}/bindings/batch")
async def batch_update_user_skill_bindings(
user_id: int,
body: UserSkillBindingBatchUpdate,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
批量更新用户技能绑定
请求体 bindings 每项格式: { "skill_tag_id": "uuid", "proficiency": 80, "level": "advanced", "years_experience": 3 }
使用路径中的 user_id,body.user_id 可省略。
"""
bindings = await skill_service.batch_update_user_skill_bindings(
db, tenant_id, user_id, body.bindings
)
return {"updated_count": len(bindings), "message": "已更新"}
@router.get("/tags/{tag_id}/users")
async def get_skill_tag_users(
tag_id: int,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""按技能标签查询拥有该技能的用户"""
bindings = await skill_service.get_skill_tag_user_bindings(db, tenant_id, tag_id)
return [
{
"user_id": str(b.user_id),
"user_name": b.user.name if b.user else "",
"proficiency": b.proficiency,
"level": b.level,
}
for b in bindings
]
@router.get("/users/{user_id}/tasks", response_model=SkillTaskHistoryResponse)
async def get_user_skill_task_history(
user_id: int,
skill_id: int = Query(..., description="技能标签 ID"),
limit: int = Query(50, ge=1, le=200, description="返回条数上限"),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
查询用户某技能下的历史任务。
权限:
- 用户可查询自己
- BOSS / ADMIN 可查询任意用户
"""
from enum import Enum
current_role = (
current_user.role.value
if isinstance(current_user.role, Enum)
else current_user.role
)
if current_user.id != user_id and not has_any_role(str(current_role), ["BOSS", "ADMIN"]):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="仅可查询自己的技能历史任务",
)
items = await skill_service.get_user_skill_task_history(
db=db,
tenant_id=tenant_id,
user_id=user_id,
skill_tag_id=skill_id,
limit=limit,
)
return SkillTaskHistoryResponse(
items=[SkillTaskHistoryItem(**item) for item in items],
total=len(items),
)
# ============================================================================
# 团队负载看板
# ============================================================================
@router.get("/workload/team")
async def get_team_workload_board(
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
获取团队负载看板数据
返回当前周期内所有用户的工作量汇总。
"""
data = await workload_service.get_team_workload_board(db, tenant_id)
return {"items": data, "total": len(data)}
# ============================================================================
# 用户个人负载
# ============================================================================
@router.get("/workload/users/{user_id}", response_model=dict)
async def get_user_personal_workload(
user_id: int,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
获取用户个人负载详情
用户只能查询自己的负载,BOSS/ADMIN 可查询任意用户。
"""
from enum import Enum
current_role = (
current_user.role.value
if isinstance(current_user.role, Enum)
else current_user.role
)
if current_user.id != user_id and not has_any_role(str(current_role), ["BOSS", "ADMIN"]):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="仅可查询自己的负载",
)
data = await workload_service.get_user_personal_workload(db, tenant_id, user_id)
if not data:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="无负载数据")
return data
# ============================================================================
# 人员推荐
# ============================================================================
@router.get("/recommend", response_model=PersonnelRecommendationResponse)
async def recommend_personnel(
skills: str = Query(..., description="所需技能,逗号分隔,如: python,react,fastapi"),
task_description: Optional[str] = Query(None),
task_id: Optional[str] = Query(None),
limit: int = Query(5, ge=1, le=20),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
根据所需技能推荐匹配人员
返回匹配度评分、推荐理由等。
"""
required_skills = [s.strip() for s in skills.split(",") if s.strip()]
result = await skill_service.recommend_personnel(
db,
tenant_id=tenant_id,
required_skills=required_skills,
task_description=task_description,
task_id=task_id,
limit=limit,
)
return PersonnelRecommendationResponse(
task_id=result.get("task_id"),
task_description=result.get("task_description"),
required_skills=result.get("required_skills", []),
recommendations=[
PersonnelRecommendationItem(**r) for r in result.get("recommendations", [])
],
total_count=result.get("total_count", 0),
)

View File

@ -0,0 +1,59 @@
"""实验相关 API"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from ..database import get_db
from ..models import Experiment
from ..schemas import ExperimentCreate, ExperimentUpdate
router = APIRouter(prefix="/experiments", tags=["experiments"])
@router.get("/")
async def list_experiments(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Experiment))
return result.scalars().all()
@router.post("/")
async def create_experiment(
experiment: ExperimentCreate,
db: AsyncSession = Depends(get_db),
):
new_experiment = Experiment(
title=experiment.title,
hypothesis=experiment.hypothesis,
target_metric=experiment.target_metric,
linked_context_nodes=experiment.linked_context_nodes,
status="running",
)
db.add(new_experiment)
await db.commit()
await db.refresh(new_experiment)
return new_experiment
@router.patch("/{experiment_id}")
async def update_experiment_score(
experiment_id: int,
update: ExperimentUpdate,
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Experiment).where(Experiment.id == experiment_id))
experiment = result.scalar_one_or_none()
if not experiment:
raise HTTPException(status_code=404, detail="Experiment not found")
experiment.current_score = update.current_score
if update.current_score >= 0.9:
experiment.status = "success"
elif update.current_score < 0.5:
experiment.status = "failed"
else:
experiment.status = "running"
await db.commit()
await db.refresh(experiment)
return experiment

View File

@ -0,0 +1,57 @@
"""反馈统计 API"""
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from ..database import get_db
from ..models import SignalFeedback
router = APIRouter(prefix="/feedbacks", tags=["feedbacks"])
@router.get("/stats")
async def get_feedback_stats(db: AsyncSession = Depends(get_db)):
"""获取反馈统计"""
result = await db.execute(select(SignalFeedback))
all_feedbacks = result.scalars().all()
total = len(all_feedbacks)
if total == 0:
return {
"total_feedbacks": 0,
"accuracy": 0.0,
"true_positives": 0,
"false_positives": 0,
"true_negatives": 0,
"false_negatives": 0,
}
correct = sum(
1 for f in all_feedbacks if f.ai_predicted_strategic == f.was_strategic
)
true_positives = sum(
1 for f in all_feedbacks if f.ai_predicted_strategic and f.was_strategic
)
false_positives = sum(
1 for f in all_feedbacks if f.ai_predicted_strategic and not f.was_strategic
)
true_negatives = sum(
1
for f in all_feedbacks
if not f.ai_predicted_strategic and not f.was_strategic
)
false_negatives = sum(
1
for f in all_feedbacks
if not f.ai_predicted_strategic and f.was_strategic
)
return {
"total_feedbacks": total,
"accuracy": correct / total,
"true_positives": true_positives,
"false_positives": false_positives,
"true_negatives": true_negatives,
"false_negatives": false_negatives,
}

362
backend/app/api/feishu.py Normal file
View File

@ -0,0 +1,362 @@
"""
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
router = APIRouter(prefix="/feishu", tags=["Feishu Integration"])
# ==================== Request/Response Models ====================
class FeishuConfigRequest(BaseModel):
"""飞书配置请求"""
app_id: str = Field(..., description="飞书 App ID")
app_secret: str = Field(..., description="飞书 App Secret")
encrypt_key: str = Field(..., description="事件订阅加密密钥")
verification_token: str = Field(..., description="事件订阅验证令牌")
class FeishuSendMessageRequest(BaseModel):
"""发送消息请求"""
chat_id: str = Field(..., description="群聊 ID")
message: str = Field(..., description="消息内容")
class FeishuSendCardRequest(BaseModel):
"""发送卡片请求"""
chat_id: str = Field(..., description="群聊 ID")
title: str = Field(..., description="卡片标题")
content: List[Dict[str, Any]] = Field(..., description="卡片内容")
# ==================== Configuration Endpoints ====================
@router.post("/config")
async def save_feishu_config(
request: FeishuConfigRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
保存飞书配置
配置信息说明:
- app_id: 飞书应用的 App ID,从飞书开放平台获取
- app_secret: 飞书应用的 App Secret
- encrypt_key: 事件订阅的加密 Key
- verification_token: 事件订阅的验证 Token
获取步骤:
1. 访问 https://open.feishu.cn/
2. 创建企业自建应用
3. 在「凭证与基础信息」页面获取 App ID 和 App Secret
4. 在「事件订阅」配置 Encrypt Key 和 Verification Token
5. 添加权限:im:message, im:message:group_at_msg, im:chat
6. 订阅事件:接收消息 v1 (message.receive_v1)
"""
# 检查用户权限
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
raise HTTPException(status_code=403, detail="只有管理员可以配置飞书集成")
try:
service = get_feishu_service(db)
await service.save_config(
app_id=request.app_id,
app_secret=request.app_secret,
encrypt_key=request.encrypt_key,
verification_token=request.verification_token,
tenant_id=tenant_id
)
return {
'status': 'success',
'message': '飞书配置已保存',
'next_steps': [
'1. 在飞书开放平台配置事件订阅 URL',
f'2. URL: https://your-domain.com/api/feishu/webhook',
'3. 点击验证按钮完成 URL 验证'
]
}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"保存配置失败: {str(e)}"
)
@router.get("/config")
async def get_feishu_config(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""获取飞书配置状态(不返回敏感信息)"""
try:
service = get_feishu_service(db)
await service.load_config(tenant_id)
return {
'is_configured': service.is_configured(),
'app_id': service.app_id if service.is_configured() else None,
'has_encrypt_key': bool(service.encrypt_key),
'has_verification_token': bool(service.verification_token)
}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"获取配置失败: {str(e)}"
)
@router.delete("/config")
async def delete_feishu_config(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""删除飞书配置"""
from ..models import Integration
from sqlalchemy.future import select
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
raise HTTPException(status_code=403, detail="只有管理员可以删除配置")
try:
result = await db.execute(
select(Integration).where(
Integration.tenant_id == tenant_id,
Integration.provider == 'feishu'
)
)
integration = result.scalar_one_or_none()
if integration:
integration.is_active = False
await db.commit()
return {'status': 'success', 'message': '飞书配置已删除'}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"删除配置失败: {str(e)}"
)
# ==================== Webhook Endpoints ====================
@router.get("/webhook")
async def verify_webhook_url(
challenge: str,
db: AsyncSession = Depends(get_db)
):
"""
验证飞书事件订阅 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 {
"challenge": challenge_response
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/webhook")
async def handle_feishu_event(
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
处理飞书事件推送
飞书会发送 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'}
# ==================== Message Endpoints ====================
@router.post("/send-message")
async def send_feishu_message(
request: FeishuSendMessageRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
发送文本消息到飞书群
用于发送简报、通知等
"""
try:
service = get_feishu_service(db)
await service.load_config(tenant_id)
result = await service.send_message_to_group(
chat_id=request.chat_id,
message=request.message
)
return {
'status': 'success',
'result': result
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"发送消息失败: {str(e)}"
)
@router.post("/send-card")
async def send_feishu_card(
request: FeishuSendCardRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
发送富文本卡片到飞书群
用于发送格式化的每日简报、风险预警等
"""
try:
service = get_feishu_service(db)
await service.load_config(tenant_id)
result = await service.send_rich_card(
chat_id=request.chat_id,
title=request.title,
content=request.content
)
return {
'status': 'success',
'result': result
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"发送卡片失败: {str(e)}"
)
# ==================== Test Endpoints ====================
@router.post("/test-connection")
async def test_feishu_connection(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""测试飞书连接是否正常"""
try:
service = get_feishu_service(db)
await service.load_config(tenant_id)
if not service.is_configured():
return {
'status': 'error',
'message': '飞书未配置'
}
# 尝试获取 tenant_access_token 测试连接
try:
access_token = await service._get_tenant_access_token()
return {
'status': 'success',
'message': '飞书连接正常',
'has_token': bool(access_token)
}
except Exception as token_error:
return {
'status': 'error',
'message': f'获取 Token 失败: {str(token_error)}',
'hint': '请检查 App ID 和 App Secret 是否正确'
}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"测试连接失败: {str(e)}"
)

View File

@ -0,0 +1,491 @@
"""模型能力矩阵 API"""
from typing import Optional
from fastapi import APIRouter, Depends, Query
from fastapi.responses import JSONResponse
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..core.response import ErrorCode, error_response, success_response
from ..database import get_db
from ..dependencies import get_current_user, get_tenant_id
from ..models import Experiment, TruthNode, User
from ..schemas.model_capability import (
CostOptimizationAnalyzeRequest,
ExperimentSyncRequest,
ExperimentSyncReviewRequest,
ExperimentSyncRollbackRequest,
GenerateMigrationTasksRequest,
LifecycleImpactRequest,
ValidateMatchRequest,
)
from ..services.ai_service import get_ai_service
from ..services.cost_optimization_service import CostOptimizationService
from ..services.experiment_sync_service import ExperimentSyncService
from ..services.model_capability_service import ModelCapabilityService
from ..services.model_lifecycle_service import ModelLifecycleService
router = APIRouter(prefix="/model-capability", tags=["model-capability"])
def _build_service(db: AsyncSession) -> ModelCapabilityService:
return ModelCapabilityService(db=db, ai_service=get_ai_service())
@router.post("/validate-match")
async def validate_model_requirement_match(
request: ValidateMatchRequest,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""需求-模型匹配校验"""
_ = current_user
service = _build_service(db)
try:
data = await service.validate_model_requirement_match(
truth_node_id=request.truth_node_id,
model_version_id=request.model_version_id,
tenant_id=tenant_id,
project_id=request.project_id,
)
return success_response(data=data)
except ValueError as exc:
return JSONResponse(
status_code=404,
content=error_response(ErrorCode.NOT_FOUND, str(exc)),
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"匹配校验失败: {exc}"),
)
@router.get("/capability-gaps")
async def list_capability_gaps(
status: Optional[str] = Query(default=None),
severity: Optional[str] = Query(default=None),
limit: int = Query(default=100, ge=1, le=500),
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""列出能力差距预警"""
_ = current_user
service = _build_service(db)
try:
data = await service.list_capability_gaps(
tenant_id=tenant_id,
status=status,
severity=severity,
limit=limit,
)
return success_response(data=data)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"查询能力差距失败: {exc}"),
)
@router.get("/cost-tracking/{truth_node_id}")
async def get_cost_tracking(
truth_node_id: int,
period: str = Query(default="weekly", pattern="^(daily|weekly|monthly|project)$"),
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""查询成本跟踪与 ROI"""
_ = current_user
service = _build_service(db)
try:
data = await service.get_cost_tracking(
truth_node_id=truth_node_id,
period=period,
tenant_id=tenant_id,
)
return success_response(data=data)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"查询成本跟踪失败: {exc}"),
)
@router.get("/cost-optimization/{truth_node_id}")
async def get_cost_optimization(
truth_node_id: int,
period: str = Query(default="weekly", pattern="^(daily|weekly|monthly|project)$"),
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""查询成本优化建议(必要时自动触发一次计算)"""
_ = current_user
service = _build_service(db)
try:
tracking = await service.get_cost_tracking(
truth_node_id=truth_node_id,
period=period,
tenant_id=tenant_id,
)
optimization = tracking.get("optimization_suggestions") or {}
if not optimization:
computed = await service.track_cost_and_roi(
truth_node_id=truth_node_id,
period=period,
tenant_id=tenant_id,
)
optimization = computed.get("optimization_suggestions", {})
tracking = await service.get_cost_tracking(
truth_node_id=truth_node_id,
period=period,
tenant_id=tenant_id,
)
return success_response(
data={
"truth_node_id": truth_node_id,
"period": period,
"optimization_suggestions": optimization,
"roi_score": tracking.get("roi_score"),
"actual_cost_usd": tracking.get("actual_cost_usd", 0.0),
}
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"查询成本优化失败: {exc}"),
)
@router.post("/lifecycle/assess-impact")
async def assess_model_update_impact(
request: LifecycleImpactRequest,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""评估模型升级对现有需求集合的影响。"""
_ = current_user
service = ModelLifecycleService(db)
try:
data = await service.assess_model_update_impact(
old_model_version_id=request.old_model_version_id,
new_model_version_id=request.new_model_version_id,
tenant_id=tenant_id,
)
return success_response(data=data)
except ValueError as exc:
return JSONResponse(
status_code=404,
content=error_response(ErrorCode.NOT_FOUND, str(exc)),
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"影响评估失败: {exc}"),
)
@router.get("/lifecycle/impacts")
async def list_lifecycle_impacts(
limit: int = Query(default=50, ge=1, le=200),
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""列出模型生命周期影响评估记录。"""
_ = current_user
service = ModelLifecycleService(db)
try:
data = await service.list_impacts(tenant_id=tenant_id, limit=limit)
return success_response(data=data)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"查询影响评估失败: {exc}"),
)
@router.post("/lifecycle/{impact_id}/migration-tasks")
async def create_migration_tasks(
impact_id: int,
request: GenerateMigrationTasksRequest,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""基于影响评估自动生成迁移任务建议。"""
_ = current_user
service = ModelLifecycleService(db)
try:
data = await service.generate_migration_tasks(
impact_id=impact_id,
tenant_id=tenant_id,
max_tasks=request.max_tasks,
)
return success_response(data=data)
except ValueError as exc:
return JSONResponse(
status_code=404,
content=error_response(ErrorCode.NOT_FOUND, str(exc)),
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"生成迁移任务失败: {exc}"),
)
@router.post("/experiment-sync")
async def create_experiment_sync(
request: ExperimentSyncRequest,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""创建实验结果同步记录,满足阈值时自动同步。"""
_ = current_user
service = ExperimentSyncService(db)
try:
data = await service.auto_sync_experiment_result(
experiment_id=request.experiment_id,
truth_node_id=request.truth_node_id,
tenant_id=tenant_id,
project_id=request.project_id,
sync_type=request.sync_type,
auto_apply_threshold=request.auto_apply_threshold,
)
return success_response(data=data)
except ValueError as exc:
return JSONResponse(
status_code=404,
content=error_response(ErrorCode.NOT_FOUND, str(exc)),
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"创建同步失败: {exc}"),
)
@router.get("/experiment-sync")
async def list_experiment_sync_records(
truth_node_id: Optional[int] = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""查询实验同步记录。"""
_ = current_user
service = ExperimentSyncService(db)
try:
data = await service.list_sync_records(
tenant_id=tenant_id,
truth_node_id=truth_node_id,
limit=limit,
)
return success_response(data=data)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"查询同步记录失败: {exc}"),
)
@router.post("/experiment-sync/{sync_id}/review")
async def review_experiment_sync(
sync_id: int,
request: ExperimentSyncReviewRequest,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""人工审核实验同步记录。"""
service = ExperimentSyncService(db)
try:
data = await service.review_sync(
sync_id=sync_id,
tenant_id=tenant_id,
reviewer_id=current_user.id,
action=request.action,
review_notes=request.review_notes,
)
return success_response(data=data)
except ValueError as exc:
return JSONResponse(
status_code=400,
content=error_response(ErrorCode.BAD_REQUEST, str(exc)),
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"审核同步失败: {exc}"),
)
@router.post("/experiment-sync/{sync_id}/rollback")
async def rollback_experiment_sync(
sync_id: int,
request: ExperimentSyncRollbackRequest,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""回滚已应用的实验同步。"""
service = ExperimentSyncService(db)
try:
data = await service.rollback_sync(
sync_id=sync_id,
tenant_id=tenant_id,
reviewer_id=current_user.id,
rollback_notes=request.rollback_notes,
)
return success_response(data=data)
except ValueError as exc:
return JSONResponse(
status_code=400,
content=error_response(ErrorCode.BAD_REQUEST, str(exc)),
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"回滚同步失败: {exc}"),
)
@router.post("/cost-optimization/analyze")
async def analyze_cost_optimization(
request: CostOptimizationAnalyzeRequest,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""生成并持久化成本优化建议。"""
_ = current_user
service = CostOptimizationService(db)
try:
data = await service.analyze_cost_optimization(
truth_node_id=request.truth_node_id,
tenant_id=tenant_id,
period=request.period,
project_id=request.project_id,
)
return success_response(data=data)
except ValueError as exc:
return JSONResponse(
status_code=400,
content=error_response(ErrorCode.BAD_REQUEST, str(exc)),
)
except Exception as exc:
return JSONResponse(
status_code=500,
content=error_response(ErrorCode.INTERNAL_ERROR, f"成本优化分析失败: {exc}"),
)
@router.get("/context/projects/{project_id}/truth-nodes")
async def list_project_truth_nodes(
project_id: int,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""获取项目上下文中的宪章列表,用于前端选择器。"""
_ = current_user
result = await db.execute(
select(TruthNode).where(
and_(
TruthNode.tenant_id == tenant_id,
TruthNode.project_id == project_id,
)
).order_by(TruthNode.created_at.desc())
)
nodes = result.scalars().all()
return success_response(
data={
"items": [
{
"id": n.id,
"title": n.title,
"status": n.status,
"node_type": n.node_type,
}
for n in nodes
]
}
)
@router.get("/context/projects/{project_id}/experiments")
async def list_project_experiments(
project_id: int,
current_user: User = Depends(get_current_user),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
"""获取项目上下文中的实验列表(基于 linked_context_nodes 关联)。"""
_ = current_user
truth_ids_result = await db.execute(
select(TruthNode.id).where(
and_(
TruthNode.tenant_id == tenant_id,
TruthNode.project_id == project_id,
)
)
)
truth_ids = {int(row[0]) for row in truth_ids_result.all()}
if not truth_ids:
return success_response(data={"items": []})
exp_result = await db.execute(
select(Experiment)
.where(Experiment.tenant_id == tenant_id)
.order_by(Experiment.created_at.desc())
)
experiments = exp_result.scalars().all()
def _linked_ids(raw) -> set[int]:
ids = set()
if isinstance(raw, list):
for x in raw:
try:
ids.add(int(x))
except (TypeError, ValueError):
continue
return ids
items = []
for e in experiments:
if e.project_id is not None:
if int(e.project_id) != int(project_id):
continue
items.append(
{
"id": e.id,
"title": e.title or f"Experiment {e.id}",
"status": e.status,
"target_metric": e.target_metric,
}
)
continue
# 迁移兼容:project_id 为空时,回退到 linked_context_nodes 交叉判断。
if _linked_ids(e.linked_context_nodes).isdisjoint(truth_ids):
continue
items.append(
{
"id": e.id,
"title": e.title or f"Experiment {e.id}",
"status": e.status,
"target_metric": e.target_metric,
}
)
return success_response(data={"items": items})

393
backend/app/api/projects.py Normal file
View File

@ -0,0 +1,393 @@
"""项目管理 API。"""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import and_, delete, func, select
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 ..utils.title import compact_title
router = APIRouter(prefix="/projects", tags=["projects"])
DONE_STATUSES = {"done", "completed", "closed", "resolved", "archived"}
class ProjectCreateRequest(BaseModel):
title: str
description: Optional[str] = None
status: str = "active"
domain_id: Optional[int] = None
assigned_role: Optional[str] = None
class ProjectUpdateRequest(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = None
domain_id: Optional[int] = None
assigned_role: Optional[str] = None
class LinkSignalRequest(BaseModel):
link_type: Optional[str] = "related"
relevance_score: Optional[float] = 1.0
def _serialize_project_row(project: Project) -> dict:
return {
"id": str(project.id),
"title": project.title or project.name,
"description": project.description,
"status": project.status,
"domain_id": str(project.domain_id) if project.domain_id is not None else None,
"assigned_role": project.assigned_role,
"signal_count": int(project.signal_count or 0),
"created_at": project.created_at.isoformat() if project.created_at else None,
"updated_at": project.updated_at.isoformat() if project.updated_at else None,
}
async def _build_project_summary(
db: AsyncSession,
*,
tenant_id: int,
project: Project,
) -> dict:
base = _serialize_project_row(project)
signal_count_result = await db.execute(
select(func.count(ProjectSignalLink.id)).where(
and_(
ProjectSignalLink.tenant_id == tenant_id,
ProjectSignalLink.project_id == int(project.id),
)
)
)
signal_count = int(signal_count_result.scalar() or 0)
base["signal_count"] = signal_count
task_rows = (
await db.execute(
select(Task).where(
and_(
Task.tenant_id == tenant_id,
Task.project_id == int(project.id),
)
)
)
).scalars().all()
total_tasks = len(task_rows)
completed_tasks = 0
in_progress_tasks = 0
pending_tasks = 0
task_items: list[dict] = []
for task in task_rows:
status_value = (task.status or "").lower()
if status_value in DONE_STATUSES:
completed_tasks += 1
elif status_value in {"in_progress", "review", "blocked"}:
in_progress_tasks += 1
else:
pending_tasks += 1
task_items.append(
{
"task_name": task.title,
"type": "task",
"status": task.status,
"signal_id": str(task.truth_node_id) if task.truth_node_id is not None else "",
}
)
base["progress"] = round((completed_tasks / total_tasks) * 100, 2) if total_tasks else 0.0
base["total_tasks"] = total_tasks
base["completed_tasks"] = completed_tasks
base["in_progress_tasks"] = in_progress_tasks
base["pending_tasks"] = pending_tasks
base["tasks"] = task_items
return base
async def _get_project_or_404(
db: AsyncSession,
*,
tenant_id: int,
project_id: int,
) -> Project:
project = (
await db.execute(
select(Project).where(
and_(
Project.tenant_id == tenant_id,
Project.id == project_id,
)
)
)
).scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project
@router.get("/")
async def list_projects(
summary: Optional[int] = Query(default=0),
status: Optional[str] = Query(default=None),
assigned_role: Optional[str] = Query(default=None),
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
query = select(Project).where(Project.tenant_id == tenant_id)
if status:
query = query.where(Project.status == status)
if assigned_role:
query = query.where(Project.assigned_role == assigned_role)
query = query.order_by(Project.updated_at.desc(), Project.id.desc())
projects = (await db.execute(query)).scalars().all()
if int(summary or 0) == 1:
rows = [
await _build_project_summary(
db,
tenant_id=tenant_id,
project=project,
)
for project in projects
]
return {"projects": rows}
return [_serialize_project_row(project) for project in projects]
@router.get("/{project_id}")
async def get_project(
project_id: int,
tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
return _serialize_project_row(project)
@router.post("/")
async def create_project(
body: ProjectCreateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
):
tenant_id = int(current_user.tenant_id)
title = compact_title(body.title, max_len=24)
if not title:
raise HTTPException(status_code=400, detail="title required")
project = Project(
tenant_id=tenant_id,
name=title,
title=title,
description=body.description,
status=body.status or "active",
domain_id=body.domain_id,
assigned_role=body.assigned_role,
owner_id=int(current_user.id),
signal_count=0,
)
db.add(project)
await db.commit()
await db.refresh(project)
return _serialize_project_row(project)
@router.patch("/{project_id}")
async def update_project(
project_id: int,
body: ProjectUpdateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
):
tenant_id = int(current_user.tenant_id)
project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
if body.title is not None:
title = compact_title(body.title, max_len=24)
if not title:
raise HTTPException(status_code=400, detail="title cannot be empty")
project.title = title
project.name = title
if body.description is not None:
project.description = body.description
if body.status is not None:
project.status = body.status
if body.domain_id is not None:
project.domain_id = body.domain_id
if body.assigned_role is not None:
project.assigned_role = body.assigned_role
await db.commit()
await db.refresh(project)
return _serialize_project_row(project)
@router.delete("/{project_id}")
async def delete_project(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
):
tenant_id = int(current_user.tenant_id)
_ = current_user
await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
await db.execute(
delete(ProjectSignalLink).where(
and_(
ProjectSignalLink.tenant_id == tenant_id,
ProjectSignalLink.project_id == project_id,
)
)
)
await db.execute(
delete(Task).where(
and_(
Task.tenant_id == tenant_id,
Task.project_id == project_id,
)
)
)
await db.execute(
delete(Project).where(
and_(
Project.tenant_id == tenant_id,
Project.id == project_id,
)
)
)
await db.commit()
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,
signal_id: int,
body: LinkSignalRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
):
tenant_id = int(current_user.tenant_id)
await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
signal = (
await db.execute(
select(Signal).where(
and_(
Signal.tenant_id == tenant_id,
Signal.id == signal_id,
)
)
)
).scalar_one_or_none()
if not signal:
raise HTTPException(status_code=404, detail="Signal not found")
existing = (
await db.execute(
select(ProjectSignalLink).where(
and_(
ProjectSignalLink.tenant_id == tenant_id,
ProjectSignalLink.project_id == project_id,
ProjectSignalLink.signal_id == signal_id,
)
)
)
).scalar_one_or_none()
if existing:
return {
"id": str(existing.id),
"project_id": str(existing.project_id),
"signal_id": str(existing.signal_id),
"link_type": existing.link_type,
"relevance_score": float(existing.relevance_score or 0.0),
"created_at": existing.created_at.isoformat() if existing.created_at else None,
}
link = ProjectSignalLink(
tenant_id=tenant_id,
project_id=project_id,
signal_id=signal_id,
link_type=body.link_type or "related",
relevance_score=float(body.relevance_score or 1.0),
)
db.add(link)
await db.commit()
await db.refresh(link)
return {
"id": str(link.id),
"project_id": str(link.project_id),
"signal_id": str(link.signal_id),
"link_type": link.link_type,
"relevance_score": float(link.relevance_score or 0.0),
"created_at": link.created_at.isoformat() if link.created_at else None,
}
@router.delete("/{project_id}/signals/{signal_id}")
async def unlink_signal_from_project(
project_id: int,
signal_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
):
tenant_id = int(current_user.tenant_id)
_ = current_user
await db.execute(
delete(ProjectSignalLink).where(
and_(
ProjectSignalLink.tenant_id == tenant_id,
ProjectSignalLink.project_id == project_id,
ProjectSignalLink.signal_id == signal_id,
)
)
)
await db.commit()
return {"message": "Signal unlinked"}

43
backend/app/api/router.py Normal file
View File

@ -0,0 +1,43 @@
"""
API 路由汇总
统一注册所有 API 路由,供 main.py 引入。
"""
from fastapi import APIRouter
from .signals import router as signals_router
from .feedbacks import router as feedbacks_router
from .experiments import router as experiments_router
from .context import router as context_router
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 .scheduler import router as scheduler_router
from .ai_steward import router as ai_steward_router
# 创建主 API 路由器
api_router = APIRouter()
# 注册各模块路由
api_router.include_router(signals_router)
api_router.include_router(feedbacks_router)
api_router.include_router(experiments_router)
api_router.include_router(context_router)
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(scheduler_router)
api_router.include_router(ai_steward_router)

View File

@ -0,0 +1,37 @@
"""Scheduler API 路由。"""
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from ..database import get_db
from ..dependencies import require_manager_user
from ..models import User
from ..services import workload_service
router = APIRouter(prefix="/scheduler", tags=["scheduler"])
@router.get("/health")
async def scheduler_health() -> dict:
"""调度模块健康检查。"""
return {"code": 0, "message": "success", "data": {"status": "ok"}}
@router.post("/workloads/recalc")
async def trigger_workload_recalc(
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
) -> dict:
"""手动触发一次负载重算。"""
_ = current_user
tenant_ids = await workload_service.list_tenant_ids(db)
if not tenant_ids:
tenant_ids = [1]
total = 0
for tenant_id in tenant_ids:
total += await workload_service.recalculate_all_users_workload_ratio(db, tenant_id=tenant_id)
return {
"code": 0,
"message": "success",
"data": {"tenant_count": len(tenant_ids), "user_count": total},
}

462
backend/app/api/signals.py Normal file
View File

@ -0,0 +1,462 @@
"""信号相关 API 路由"""
import json
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Query
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 Signal, SignalFeedback, Project, ProjectSignalLink
from ..schemas import (
SignalCreate,
SignalPriority,
classify_signal_priority,
SignalFeedbackCreate,
SignalFeedbackResponse,
)
from ..services.fetcher import fetch_hn_stories
from ..services.analyzer import analyze_impact
from ..services.llm import llm_service
from ..services.plane import plane_service
from ..services.lark import LarkService
router = APIRouter(prefix="/signals", tags=["signals"])
lark_service = LarkService()
@router.get("/")
async def list_signals(
roles: Optional[str] = Query(None, description="逗号分隔的角色列表,如 'frontend,backend'"),
db: AsyncSession = Depends(get_db),
):
"""获取信号列表,支持按角色过滤"""
result = await db.execute(select(Signal).order_by(Signal.impact_score.desc()))
signals = result.scalars().all()
if roles:
role_list = [r.strip() for r in roles.split(",")]
filtered_signals = []
for signal in signals:
tasks = []
if signal.tasks_generated:
try:
tasks = json.loads(signal.tasks_generated)
except Exception:
tasks = []
filtered_tasks = [t for t in tasks if t.get("type") in role_list]
if filtered_tasks:
signal_dict = {
"id": str(signal.id),
"source": signal.source,
"content": signal.content,
"impact_score": signal.impact_score,
"priority": signal.priority,
"processed": signal.processed,
"strategic_analysis": signal.strategic_analysis,
"tasks_generated": signal.tasks_generated,
"tasks_filtered": filtered_tasks,
"created_at": signal.created_at.isoformat() if signal.created_at else None,
}
filtered_signals.append(signal_dict)
return filtered_signals
return signals
@router.post("/", response_model=dict)
async def create_signal(signal: SignalCreate, db: AsyncSession = Depends(get_db)):
new_signal = Signal(
source=signal.source,
content=signal.content,
impact_score=signal.impact_score,
)
db.add(new_signal)
await db.commit()
await db.refresh(new_signal)
return {"id": str(new_signal.id), "status": "received"}
@router.post("/ingest", response_model=dict)
async def ingest_signal(
signal: SignalCreate,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
"""接入外部信号,按优先级处理"""
initial_priority = signal.priority or classify_signal_priority(signal.impact_score)
new_signal = Signal(
source=signal.source,
content=signal.content,
impact_score=signal.impact_score,
priority=initial_priority.value,
)
db.add(new_signal)
await db.commit()
await db.refresh(new_signal)
if initial_priority == SignalPriority.LOW:
return {
"id": str(new_signal.id),
"priority": initial_priority.value,
"status": "recorded",
"message": "Low priority signal recorded without processing",
}
async def process_signal_internal():
try:
relevant_context = await llm_service.find_relevant_context(
signal_content=signal.content,
db_session=db,
top_k=3,
min_similarity=0.3,
tenant_id=tenant_id,
)
strategy_result = await llm_service.evaluate_with_voting_async(
signal_content=signal.content,
relevant_context=relevant_context,
impact_score=signal.impact_score,
db=db,
tenant_id=tenant_id,
)
except RuntimeError:
if initial_priority == SignalPriority.CRITICAL:
raise HTTPException(status_code=403, detail="预算超限,AI 调用被阻止(hard limit)")
return {
"id": str(new_signal.id),
"priority": initial_priority.value,
"status": "blocked",
"processing_mode": "background",
"message": "预算超限,AI 调用被阻止(hard limit)",
}
is_strategic = strategy_result.get("is_strategic", False)
decision_proposal = strategy_result.get("decision_proposal", {})
tasks = strategy_result.get("tasks", [])
confidence = strategy_result.get("confidence", 0.0)
final_priority = classify_signal_priority(signal.impact_score, confidence)
new_signal.priority = final_priority.value
sync_result = None
notification_sent = False
if final_priority in [SignalPriority.CRITICAL, SignalPriority.HIGH]:
if is_strategic and tasks:
sync_result = await plane_service.sync_tasks(
tasks, is_urgent=(final_priority == SignalPriority.CRITICAL)
)
default_chat_id = "oc_1234567890"
await lark_service.send_strategic_alert(
default_chat_id, signal.content, decision_proposal, tasks
)
notification_sent = True
new_signal.impact_score = 1.0
analysis_data = {
"decision_proposal": decision_proposal,
"competitiveness_radar": strategy_result.get("competitiveness_radar"),
"detected_tech_stacks": strategy_result.get("detected_tech_stacks", []),
"related_knowledge_nodes": strategy_result.get("related_knowledge_nodes", []),
"rag_context_used": len(relevant_context) if relevant_context else 0,
"rag_context_sources": [ctx["id"] for ctx in (relevant_context or [])],
}
if "_voting" in strategy_result:
voting_info = strategy_result["_voting"]
analysis_data["voting"] = {
"enabled": voting_info.get("enabled"),
"models_used": voting_info.get("models_used"),
"strategic_votes": voting_info.get("strategic_votes"),
"total_votes": voting_info.get("total_votes"),
"unanimous": voting_info.get("unanimous"),
}
new_signal.strategic_analysis = json.dumps(analysis_data)
new_signal.tasks_generated = json.dumps(tasks)
new_signal.processed = True
await db.commit()
await db.refresh(new_signal)
return {
"id": str(new_signal.id),
"priority": final_priority.value,
"is_strategic": is_strategic,
"confidence": confidence,
"decision_proposal": decision_proposal,
"rag_context_used": len(relevant_context) if relevant_context else 0,
"voting": strategy_result.get("_voting"),
"tasks_generated": len(tasks),
"sync_result": sync_result,
"notification_sent": notification_sent,
}
if initial_priority == SignalPriority.CRITICAL:
result = await process_signal_internal()
result["processing_mode"] = "synchronous"
return result
else:
background_tasks.add_task(process_signal_internal)
return {
"id": str(new_signal.id),
"priority": initial_priority.value,
"status": "queued",
"processing_mode": "background",
"message": f"{initial_priority.value.upper()} priority signal queued for processing",
}
@router.post("/fetch", response_model=dict)
async def trigger_signal_fetch(
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
"""手动触发抓取并分析信号"""
def _stop_ollama():
import subprocess
import signal
import os
killed = False
try:
result = subprocess.run(
["pgrep", "-f", "ollama"],
capture_output=True,
text=True,
)
if result.returncode == 0:
for pid in result.stdout.strip().split("\n"):
if pid:
try:
os.kill(int(pid), signal.SIGTERM)
killed = True
except ProcessLookupError:
pass
except Exception as e:
print(f"[AI] pgrep failed: {e}")
if not killed:
try:
result = subprocess.run(
["pkill", "-9", "-f", "ollama"],
capture_output=True,
timeout=5,
)
killed = result.returncode == 0
except Exception as e:
print(f"[AI] pkill failed: {e}")
if killed:
print("[AI] Ollama stopped - analysis complete")
raw_stories = await fetch_hn_stories(limit=30)
new_signals_count = 0
processed_count = 0
for story in raw_stories:
result = await db.execute(select(Signal).where(Signal.content == story["content"]))
if result.scalars().first():
continue
impact = analyze_impact(story["content"])
if impact > 0.4:
initial_priority = classify_signal_priority(impact)
new_signal = Signal(
source=story["source"],
content=story["content"],
impact_score=impact,
priority=initial_priority.value,
)
db.add(new_signal)
await db.commit()
await db.refresh(new_signal)
new_signals_count += 1
if initial_priority == SignalPriority.LOW:
continue
async def process_fetched():
nonlocal processed_count
try:
relevant_context = await llm_service.find_relevant_context(
signal_content=story["content"],
db_session=db,
top_k=3,
min_similarity=0.3,
tenant_id=tenant_id,
)
strategy_result = await llm_service.evaluate_with_voting_async(
signal_content=story["content"],
relevant_context=relevant_context,
impact_score=impact,
db=db,
tenant_id=tenant_id,
)
except RuntimeError:
return
is_strategic = strategy_result.get("is_strategic", False)
decision_proposal = strategy_result.get("decision_proposal", {})
confidence = strategy_result.get("confidence", 0.0)
final_priority = classify_signal_priority(impact, confidence)
new_signal.priority = final_priority.value
analysis_data = {
"decision_proposal": decision_proposal,
"competitiveness_radar": strategy_result.get("competitiveness_radar"),
"detected_tech_stacks": strategy_result.get("detected_tech_stacks", []),
"related_knowledge_nodes": strategy_result.get(
"related_knowledge_nodes", []
),
"rag_context_used": len(relevant_context) if relevant_context else 0,
"rag_context_sources": [ctx["id"] for ctx in (relevant_context or [])],
"confidence": confidence,
"is_strategic": is_strategic,
}
if "_voting" in strategy_result:
vi = strategy_result["_voting"]
analysis_data["voting"] = {
"enabled": vi.get("enabled"),
"models_used": vi.get("models_used"),
"strategic_votes": vi.get("strategic_votes"),
"total_votes": vi.get("total_votes"),
"unanimous": vi.get("unanimous"),
}
new_signal.strategic_analysis = json.dumps(analysis_data)
new_signal.tasks_generated = json.dumps(strategy_result.get("tasks", []))
new_signal.processed = True
await db.commit()
processed_count += 1
if initial_priority in [SignalPriority.MEDIUM, SignalPriority.HIGH]:
background_tasks.add_task(process_fetched)
else:
await process_fetched()
_stop_ollama()
return {
"status": "success",
"new_signals_count": new_signals_count,
"processed_count": processed_count,
"message": f"Fetched {new_signals_count} signals, {processed_count} analyzed",
}
@router.get("/{signal_id}/projects")
async def get_signal_projects(
signal_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取信号关联的项目"""
try:
sid = int(signal_id)
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail="Invalid signal_id")
signal_result = await db.execute(select(Signal).where(Signal.id == sid))
if not signal_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Signal not found")
result = await db.execute(
select(Project, ProjectSignalLink)
.join(ProjectSignalLink, Project.id == ProjectSignalLink.project_id)
.where(ProjectSignalLink.signal_id == sid)
)
rows = result.all()
return [
{
"id": str(p.id),
"title": p.title,
"status": p.status,
"assigned_role": p.assigned_role,
"link_type": link.link_type,
"relevance_score": link.relevance_score,
}
for p, link in rows
]
@router.post("/{signal_id}/feedback", response_model=SignalFeedbackResponse)
async def submit_signal_feedback(
signal_id: int,
feedback: SignalFeedbackCreate,
db: AsyncSession = Depends(get_db),
):
"""提交信号反馈"""
result = await db.execute(select(Signal).where(Signal.id == signal_id))
signal = result.scalar_one_or_none()
if not signal:
raise HTTPException(status_code=404, detail="Signal not found")
ai_predicted_strategic = False
ai_confidence = 0.0
if signal.strategic_analysis:
try:
analysis = json.loads(signal.strategic_analysis)
ai_predicted_strategic = analysis.get("is_strategic", signal.impact_score >= 0.9)
ai_confidence = analysis.get("confidence", 0.0)
except Exception:
ai_predicted_strategic = signal.impact_score >= 0.9
new_feedback = SignalFeedback(
signal_id=signal_id,
was_strategic=feedback.was_strategic,
reason=feedback.reason,
user_id=feedback.user_id,
ai_predicted_strategic=ai_predicted_strategic,
ai_confidence=ai_confidence,
)
db.add(new_feedback)
await db.commit()
await db.refresh(new_feedback)
is_correct = ai_predicted_strategic == feedback.was_strategic
return SignalFeedbackResponse(
id=new_feedback.id,
signal_id=new_feedback.signal_id,
was_strategic=new_feedback.was_strategic,
ai_predicted_strategic=new_feedback.ai_predicted_strategic,
ai_confidence=new_feedback.ai_confidence,
is_correct=is_correct,
)
@router.get("/{signal_id}/feedbacks", response_model=List[SignalFeedbackResponse])
async def get_signal_feedbacks(
signal_id: int,
db: AsyncSession = Depends(get_db),
):
"""获取某信号的全部反馈"""
result = await db.execute(
select(SignalFeedback)
.where(SignalFeedback.signal_id == signal_id)
.order_by(SignalFeedback.created_at.desc())
)
feedbacks = result.scalars().all()
return [
SignalFeedbackResponse(
id=f.id,
signal_id=f.signal_id,
was_strategic=f.was_strategic,
ai_predicted_strategic=f.ai_predicted_strategic,
ai_confidence=f.ai_confidence or 0.0,
is_correct=f.ai_predicted_strategic == f.was_strategic,
)
for f in feedbacks
]

View File

@ -0,0 +1,320 @@
"""技能标签与用户技能绑定标准路由。"""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import and_, delete, func, select
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 SkillTag, User, UserSkillBinding
from ..services import skill_service
from ..services.auth import has_any_role
router = APIRouter(tags=["skill-tags"])
class SkillTagCreateRequest(BaseModel):
name: str
slug: str
category: Optional[str] = None
description: Optional[str] = None
sort_order: int = 0
class SkillTagUpdateRequest(BaseModel):
name: Optional[str] = None
category: Optional[str] = None
description: Optional[str] = None
sort_order: Optional[int] = None
class UserSkillCreateRequest(BaseModel):
skill_tag_id: int
level: str = "beginner"
proficiency: int = Field(default=50, ge=0, le=100)
quality_score: float = 0.0
efficiency_score: float = 0.0
class UserSkillUpdateRequest(BaseModel):
level: Optional[str] = None
proficiency: Optional[int] = Field(default=None, ge=0, le=100)
quality_score: Optional[float] = None
efficiency_score: Optional[float] = None
def _to_tag_payload(tag: SkillTag) -> dict:
return {
"id": int(tag.id),
"tenant_id": int(tag.tenant_id),
"name": tag.name,
"slug": tag.slug,
"category": tag.category,
"description": tag.description,
"is_system": bool(tag.is_system),
"sort_order": int(tag.sort_order or 0),
"created_at": tag.created_at.isoformat() if tag.created_at else None,
"updated_at": tag.updated_at.isoformat() if tag.updated_at else None,
}
@router.get("/skill-tags")
async def list_skill_tags(
category: Optional[str] = Query(None),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_ = current_user
tags = await skill_service.list_skill_tags(
db,
tenant_id=tenant_id,
category=category,
include_system=True,
)
count_rows = await db.execute(
select(UserSkillBinding.skill_tag_id, func.count(UserSkillBinding.id))
.where(UserSkillBinding.tenant_id == tenant_id)
.group_by(UserSkillBinding.skill_tag_id)
)
count_map = {int(skill_id): int(total) for skill_id, total in count_rows.all()}
items = []
for t in tags:
payload = _to_tag_payload(t)
payload["linked_user_count"] = count_map.get(int(t.id), 0)
items.append(payload)
return {"items": items, "total": len(items)}
@router.post("/skill-tags")
async def create_skill_tag(
body: SkillTagCreateRequest,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
db: AsyncSession = Depends(get_db),
):
_ = current_user
try:
tag = await skill_service.create_skill_tag(
db,
tenant_id=tenant_id,
name=body.name,
slug=body.slug,
category=body.category,
description=body.description,
sort_order=body.sort_order,
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
return _to_tag_payload(tag)
@router.put("/skill-tags/{tag_id}")
async def update_skill_tag(
tag_id: int,
body: SkillTagUpdateRequest,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
db: AsyncSession = Depends(get_db),
):
_ = current_user
tag = await skill_service.get_skill_tag(db, tag_id, tenant_id)
if not tag:
raise HTTPException(status_code=404, detail="Skill tag not found")
# 系统标签仅允许改分类,禁止改名。
if tag.is_system and body.name is not None and body.name != tag.name:
raise HTTPException(status_code=400, detail="System tag name cannot be changed")
if body.name is not None and not tag.is_system:
tag.name = body.name
if body.category is not None:
tag.category = body.category
if body.description is not None and not tag.is_system:
tag.description = body.description
if body.sort_order is not None:
tag.sort_order = body.sort_order
tag.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(tag)
return _to_tag_payload(tag)
@router.delete("/skill-tags/{tag_id}")
async def delete_skill_tag(
tag_id: int,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
db: AsyncSession = Depends(get_db),
):
_ = current_user
tag = await skill_service.get_skill_tag(db, tag_id, tenant_id)
if not tag:
raise HTTPException(status_code=404, detail="Skill tag not found")
if tag.is_system:
raise HTTPException(status_code=400, detail="System tag cannot be deleted")
await db.execute(
delete(UserSkillBinding).where(
and_(
UserSkillBinding.tenant_id == tenant_id,
UserSkillBinding.skill_tag_id == tag_id,
)
)
)
await db.delete(tag)
await db.commit()
return {"message": "Skill tag deleted"}
@router.get("/users/{user_id}/skills")
async def get_user_skills(
user_id: int,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_ = current_user
bindings = await skill_service.get_user_skill_bindings(db, tenant_id=tenant_id, user_id=user_id)
items = []
for row in bindings:
items.append(
{
"skill_tag_id": int(row.skill_tag_id),
"skill_name": row.skill_tag.name if row.skill_tag else "",
"category": row.skill_tag.category if row.skill_tag else None,
"level": row.level or row.proficiency_level,
"proficiency": int(row.proficiency or 0),
"quality_score": float(row.quality_score or 0.0),
"efficiency_score": float(row.efficiency_score or 0.0),
"last_used_at": row.last_used_at.isoformat() if row.last_used_at else None,
"is_system": bool(row.skill_tag.is_system) if row.skill_tag else False,
}
)
return {"items": items, "total": len(items)}
@router.post("/users/{user_id}/skills")
async def bind_user_skill(
user_id: int,
body: UserSkillCreateRequest,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
db: AsyncSession = Depends(get_db),
):
_ = current_user
exists = (
await db.execute(
select(UserSkillBinding).where(
and_(
UserSkillBinding.tenant_id == tenant_id,
UserSkillBinding.user_id == user_id,
UserSkillBinding.skill_tag_id == body.skill_tag_id,
)
)
)
).scalar_one_or_none()
if exists:
raise HTTPException(status_code=400, detail="Skill already bound")
row = UserSkillBinding(
tenant_id=tenant_id,
user_id=user_id,
skill_tag_id=body.skill_tag_id,
level=body.level,
proficiency_level=body.level,
proficiency=body.proficiency,
quality_score=body.quality_score,
efficiency_score=body.efficiency_score,
)
db.add(row)
await db.commit()
return {"message": "Skill bound"}
@router.put("/users/{user_id}/skills/{skill_id}")
async def update_user_skill(
user_id: int,
skill_id: int,
body: UserSkillUpdateRequest,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
current_role = str(current_user.role or "")
is_manager = has_any_role(current_role, ["BOSS", "ADMIN"])
is_self = int(current_user.id) == user_id
if not is_manager and not is_self:
raise HTTPException(status_code=403, detail="No permission")
if is_self and not is_manager:
if body.quality_score is not None or body.efficiency_score is not None:
raise HTTPException(
status_code=403,
detail="Members can only update own proficiency",
)
row = (
await db.execute(
select(UserSkillBinding).where(
and_(
UserSkillBinding.tenant_id == tenant_id,
UserSkillBinding.user_id == user_id,
UserSkillBinding.skill_tag_id == skill_id,
)
)
)
).scalar_one_or_none()
if not row:
raise HTTPException(status_code=404, detail="Binding not found")
if body.level is not None:
row.level = body.level
row.proficiency_level = body.level
if body.proficiency is not None:
row.proficiency = body.proficiency
if body.quality_score is not None:
row.quality_score = body.quality_score
if body.efficiency_score is not None:
row.efficiency_score = body.efficiency_score
row.updated_at = datetime.utcnow()
await db.commit()
return {"message": "Skill updated"}
@router.delete("/users/{user_id}/skills/{skill_id}")
async def unbind_user_skill(
user_id: int,
skill_id: int,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
db: AsyncSession = Depends(get_db),
):
_ = current_user
row = (
await db.execute(
select(UserSkillBinding)
.join(SkillTag, SkillTag.id == UserSkillBinding.skill_tag_id)
.where(
and_(
UserSkillBinding.tenant_id == tenant_id,
UserSkillBinding.user_id == user_id,
UserSkillBinding.skill_tag_id == skill_id,
)
)
)
).scalar_one_or_none()
if not row:
raise HTTPException(status_code=404, detail="Binding not found")
if row.skill_tag and row.skill_tag.is_system:
raise HTTPException(status_code=400, detail="System bound skill cannot be removed")
await db.delete(row)
await db.commit()
return {"message": "Skill unbound"}

1639
backend/app/api/sstc.py Normal file

File diff suppressed because it is too large Load Diff

123
backend/app/api/tasks.py Normal file
View File

@ -0,0 +1,123 @@
"""任务相关 API。"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..database import get_db
from ..dependencies import get_current_user, require_manager_user
from ..models import Task, User, UserProject
from ..schemas import TaskBreakdownRequest, TaskBreakdownResponse
from ..services.llm import llm_service
from ..services.plane import plane_service
from ..services.skill_service import recommend_personnel
router = APIRouter(prefix="/tasks", tags=["tasks"])
@router.post("/breakdown", response_model=TaskBreakdownResponse)
async def breakdown_tasks(
request: TaskBreakdownRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_manager_user),
):
"""AI 任务拆解"""
tenant_id = int(current_user.tenant_id)
try:
tasks = await llm_service.generate_task_breakdown_async(
request.description,
db=db,
tenant_id=tenant_id,
)
except RuntimeError:
raise HTTPException(status_code=403, detail="预算超限,AI 调用被阻止(hard limit)")
if not tasks:
raise HTTPException(status_code=500, detail="Failed to generate task breakdown")
sync_result = None
if request.sync_to_plane:
sync_result = await plane_service.sync_tasks(tasks)
return {"tasks": tasks, "sync_result": sync_result}
@router.get("/recommend-assignees")
async def recommend_assignees(
task_id: int = Query(..., description="任务 ID"),
limit: int = Query(5, ge=1, le=20),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
推荐任务负责人(按技能匹配 + 熟练度 + 负载)。
仅在任务所属项目成员中选择,且排除禁用用户。
"""
tenant_id = int(current_user.tenant_id)
task = (
await db.execute(
select(Task).where(
and_(
Task.id == task_id,
Task.tenant_id == tenant_id,
)
)
)
).scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
required_skills = [str(x) for x in (task.required_skill_tag_ids or [])]
recommendations = await recommend_personnel(
db=db,
tenant_id=tenant_id,
required_skills=required_skills,
task_description=task.description or task.title,
task_id=str(task.id),
limit=limit * 2,
)
project_user_ids_result = await db.execute(
select(UserProject.user_id).where(
and_(
UserProject.tenant_id == tenant_id,
UserProject.project_id == int(task.project_id),
)
)
)
project_user_ids = {int(uid) for uid in project_user_ids_result.scalars().all()}
active_users_result = await db.execute(
select(User.id).where(
and_(
User.tenant_id == tenant_id,
User.status == "active",
User.is_active.is_(True),
)
)
)
active_user_ids = {int(uid) for uid in active_users_result.scalars().all()}
filtered = []
for item in recommendations.get("recommendations", []):
try:
uid = int(item.get("user_id"))
except (TypeError, ValueError):
continue
if uid in project_user_ids and uid in active_user_ids:
filtered.append(item)
filtered.sort(
key=lambda x: (
float(x.get("workload_rate", 9999)) >= 70.0,
float(x.get("workload_rate", 9999)),
-float(x.get("match_score", 0)),
)
)
final_items = filtered[:limit]
return {
"task_id": str(task.id),
"required_skills": required_skills,
"recommendations": final_items,
"total_count": len(final_items),
}

1052
backend/app/api/users.py Normal file

File diff suppressed because it is too large Load Diff

171
backend/app/api/webhooks.py Normal file
View File

@ -0,0 +1,171 @@
"""Webhook 接收 API"""
import json
import re
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
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")
async def handle_git_webhook(
payload: dict,
db: AsyncSession = Depends(get_db),
):
"""接收 Git Webhook,审计代码并更新状态"""
commits = payload.get("commits", [])
if not commits:
return {"status": "ignored", "reason": "No commits found"}
updated_tasks = []
for commit in commits:
message = commit.get("message", "")
code_diff = commit.get("patch") or str(commit.get("modified", []))
task_matches = re.findall(
r"#([A-Za-z0-9-]+-\d+|[A-Za-z0-9]+-\d+)", message
)
for task_id in task_matches:
result = await db.execute(
select(Signal).order_by(Signal.created_at.desc()).limit(20)
)
signals = result.scalars().all()
target_task = None
target_signal = None
for sig in signals:
if not sig.tasks_generated:
continue
try:
tasks = json.loads(sig.tasks_generated)
for t in tasks:
if t.get("plane_key") == task_id or t.get(
"plane_id"
) == task_id:
target_task = t
target_signal = sig
break
except Exception:
continue
if target_task:
break
task_name = (
target_task.get("task_name") if target_task else f"Task {task_id}"
)
# 尽量从目标信号推断租户,避免跨租户预算串账。
tenant_id = int(getattr(target_signal, "tenant_id", 1) or 1)
try:
audit_result = await llm_service.audit_code_change_async(
task_name,
message,
code_diff,
db=db,
tenant_id=tenant_id,
)
except RuntimeError:
updated_tasks.append(
{
"task_id": task_id,
"status": "Blocked",
"audit": {"passed": False, "reason": "预算超限,AI 审计被阻止"},
}
)
continue
if audit_result.get("passed"):
await plane_service.update_issue_status(task_id, status="Done")
if target_signal and target_task:
tasks = json.loads(target_signal.tasks_generated)
for t in tasks:
if t.get("plane_key") == task_id or t.get(
"plane_id"
) == task_id:
t["status"] = "Done"
t["audit_feedback"] = audit_result.get("reason")
t["completed_at"] = (
datetime.now(timezone.utc).isoformat()
)
target_signal.tasks_generated = json.dumps(tasks)
await db.commit()
updated_tasks.append(
{"task_id": task_id, "status": "Done", "audit": audit_result}
)
else:
updated_tasks.append(
{
"task_id": task_id,
"status": "Failed",
"audit": audit_result,
}
)
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"}

319
backend/app/api/wecom.py Normal file
View File

@ -0,0 +1,319 @@
"""
WeChat Work (企业微信) 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 ..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.wecom_integration import get_wecom_service
router = APIRouter(prefix="/wecom", tags=["WeChat Work Integration"])
# ==================== Request/Response Models ====================
class WeComConfigRequest(BaseModel):
"""企业微信配置请求"""
corp_id: str = Field(..., description="企业 ID")
agent_id: str = Field(..., description="应用 ID")
app_secret: str = Field(..., description="应用 Secret")
token: str = Field(..., description="回调验证 Token")
encoding_aes_key: str = Field(..., description="消息加密密钥")
class WeComSendMessageRequest(BaseModel):
"""发送消息请求"""
chat_id: str = Field(..., description="群聊 ID")
message: str = Field(..., description="消息内容")
# ==================== Configuration Endpoints ====================
@router.post("/config")
async def save_wecom_config(
request: WeComConfigRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
保存企业微信配置
配置信息说明:
- corp_id: 企业 ID,从企业微信管理后台获取
- agent_id: 应用 ID
- app_secret: 应用 Secret
- token: 事件订阅验证 Token
- encoding_aes_key: 消息加密密钥
获取步骤:
1. 访问 https://work.weixin.qq.com/
2. 进入应用管理 -> 自建应用
3. 在「开发配置」获取 Agent ID 和 Secret
4. 在「接收消息」配置 Token 和 EncodingAESKey
"""
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
raise HTTPException(status_code=403, detail="只有管理员可以配置企业微信集成")
try:
service = get_wecom_service(db)
await service.save_config(
corp_id=request.corp_id,
agent_id=request.agent_id,
app_secret=request.app_secret,
token=request.token,
encoding_aes_key=request.encoding_aes_key,
tenant_id=tenant_id
)
return {
'status': 'success',
'message': '企业微信配置已保存',
'next_steps': [
'1. 在企业微信管理平台配置接收消息 URL',
f'2. URL: https://your-domain.com/api/wecom/webhook',
'3. 点击验证按钮完成 URL 验证'
]
}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"保存配置失败: {str(e)}"
)
@router.get("/config")
async def get_wecom_config(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""获取企业微信配置状态(不返回敏感信息)"""
try:
service = get_wecom_service(db)
await service.load_config(tenant_id)
return {
'is_configured': service.is_configured(),
'corp_id': service.corp_id if service.is_configured() else None,
'agent_id': service.agent_id if service.is_configured() else None,
'has_token': bool(service.token),
'has_encoding_key': bool(service.encoding_aes_key)
}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"获取配置失败: {str(e)}"
)
@router.delete("/config")
async def delete_wecom_config(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""删除企业微信配置"""
from ..models import Integration
from sqlalchemy.future import select
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
raise HTTPException(status_code=403, detail="只有管理员可以删除配置")
try:
result = await db.execute(
select(Integration).where(
Integration.tenant_id == tenant_id,
Integration.provider == 'wecom'
)
)
integration = result.scalar_one_or_none()
if integration:
integration.is_active = False
await db.commit()
return {'status': 'success', 'message': '企业微信配置已删除'}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"删除配置失败: {str(e)}"
)
# ==================== Webhook Endpoints ====================
@router.get("/webhook")
async def verify_webhook_url(
msg_signature: str,
timestamp: str,
nonce: str,
echostr: str,
db: AsyncSession = Depends(get_db)
):
"""
验证企业微信事件订阅 URL
企业微信会发送 GET 请求来验证 URL 有效性
"""
try:
service = get_wecom_service(db)
await service.load_config(tenant_id=1)
if not service.is_configured():
raise HTTPException(
status_code=400,
detail="请先配置企业微信"
)
decrypted_echostr = service.verify_url(msg_signature, timestamp, nonce, echostr)
return {
"errcode": 0,
"errmsg": "ok",
"echostr": decrypted_echostr
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/webhook")
async def handle_wecom_event(
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
处理企业微信事件推送
企业微信会发送 POST 请求推送事件
需要验证签名确保请求来自企业微信
"""
try:
# 获取请求头
msg_signature = request.headers.get('msg_signature', '')
timestamp = request.headers.get('timestamp', '')
nonce = request.headers.get('nonce', '')
# 读取请求体
body_bytes = await request.body()
body = body_bytes.decode('utf-8')
# 加载企业微信配置
service = get_wecom_service(db)
await service.load_config(tenant_id=1)
# 验证签名
if not service.verify_callback(msg_signature, timestamp, nonce, body):
raise HTTPException(status_code=401, detail="签名验证失败")
# 解析事件
from ..services.wecom_integration import WeChatWorkIntegrationService
decrypted = service._decrypt(body)
event_data = json.loads(decrypted) if decrypted else {}
# 处理事件
result = await service.handle_message_event(event_data)
return {
"errcode": 0,
"errmsg": "ok"
}
except HTTPException:
raise
except Exception as e:
print(f"[WeCom] Webhook error: {e}")
# 企业微信要求返回成功状态,避免重复推送
return {'errcode': 0, 'errmsg': 'ok'}
# ==================== Message Endpoints ====================
@router.post("/send-message")
async def send_wecom_message(
request: WeComSendMessageRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""
发送文本消息到企业微信群
用于发送简报、通知等
"""
try:
service = get_wecom_service(db)
await service.load_config(tenant_id)
result = await service.send_message_to_group(
chat_id=request.chat_id,
message=request.message
)
return {
'status': 'success',
'result': result
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"发送消息失败: {str(e)}"
)
# ==================== Test Endpoints ====================
@router.post("/test-connection")
async def test_wecom_connection(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id)
):
"""测试企业微信连接是否正常"""
try:
service = get_wecom_service(db)
await service.load_config(tenant_id)
if not service.is_configured():
return {
'status': 'error',
'message': '企业微信未配置'
}
# 尝试获取 access_token 测试连接
try:
access_token = await service._get_access_token()
return {
'status': 'success',
'message': '企业微信连接正常',
'has_token': bool(access_token)
}
except Exception as token_error:
return {
'status': 'error',
'message': f'获取 Token 失败: {str(token_error)}',
'hint': '请检查 Corp ID、Agent ID 和 App Secret 是否正确'
}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"测试连接失败: {str(e)}"
)

View File

@ -0,0 +1,196 @@
"""工作负载 API。"""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from ..database import get_db
from ..dependencies import (
get_current_user,
get_tenant_id,
require_manager_user,
require_roles,
require_self_or_manager,
)
from ..models import User
from ..services import workload_service
router = APIRouter(prefix="/workloads", tags=["workloads"])
class WorkloadHoursUpdateRequest(BaseModel):
max_weekly_hours: float = Field(..., gt=0, le=120)
class AIOptimizePreviewRequest(BaseModel):
project_id: Optional[int] = None
max_moves: int = Field(default=20, ge=1, le=100)
class AIOptimizeApplyRequest(BaseModel):
proposals: list[dict]
@router.get("/")
async def get_team_workloads(
project_id: Optional[int] = Query(None),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
db: AsyncSession = Depends(get_db),
):
_ = current_user
rows = await workload_service.get_team_workload_board(
db,
tenant_id=tenant_id,
project_id=project_id,
)
total = len(rows)
avg_ratio = round(sum(float(r.get("workload_ratio", 0)) for r in rows) / total, 2) if total else 0.0
overloaded = sum(1 for r in rows if float(r.get("workload_ratio", 0)) > 100)
idle = sum(1 for r in rows if float(r.get("workload_ratio", 0)) < 70)
return {
"items": rows,
"total": total,
"summary": {
"avg_ratio": avg_ratio,
"overloaded_count": overloaded,
"idle_count": idle,
"normal_count": max(0, total - overloaded - idle),
},
}
@router.get("/summary")
async def get_workload_summary(
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_ = current_user
rows = await workload_service.get_team_workload_board(db, tenant_id=tenant_id)
total = len(rows)
avg_ratio = round(sum(float(r.get("workload_ratio", 0)) for r in rows) / total, 2) if total else 0.0
return {
"team_size": total,
"avg_ratio": avg_ratio,
"overloaded_count": sum(1 for r in rows if float(r.get("workload_ratio", 0)) > 100),
"normal_count": sum(
1
for r in rows
if 70 <= float(r.get("workload_ratio", 0)) <= 100
),
"idle_count": sum(1 for r in rows if float(r.get("workload_ratio", 0)) < 70),
}
@router.get("/{user_id}")
async def get_user_workload(
user_id: int,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_self_or_manager),
db: AsyncSession = Depends(get_db),
):
_ = current_user
item = await workload_service.get_user_personal_workload(db, tenant_id, user_id)
if not item:
raise HTTPException(status_code=404, detail="User workload not found")
return item
@router.put("/{user_id}")
async def update_weekly_hours(
user_id: int,
body: WorkloadHoursUpdateRequest,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_self_or_manager),
db: AsyncSession = Depends(get_db),
):
_ = current_user
result = await workload_service.update_user_max_weekly_hours(
db,
tenant_id=tenant_id,
user_id=user_id,
max_weekly_hours=body.max_weekly_hours,
)
if not result:
raise HTTPException(status_code=404, detail="User not found")
return result
@router.get("/{user_id}/tasks")
async def get_user_workload_tasks(
user_id: int,
status_value: Optional[str] = Query(None, alias="status"),
priority: Optional[str] = Query(None),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_self_or_manager),
db: AsyncSession = Depends(get_db),
):
_ = current_user
items = await workload_service.list_user_assigned_tasks(
db,
tenant_id=tenant_id,
user_id=user_id,
status=status_value,
priority=priority,
)
return {"items": items, "total": len(items)}
@router.get("/{user_id}/trend")
async def get_user_workload_trend(
user_id: int,
days: int = Query(7, ge=1, le=30),
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_self_or_manager),
db: AsyncSession = Depends(get_db),
):
_ = current_user
items = await workload_service.get_user_workload_trend(
db,
tenant_id=tenant_id,
user_id=user_id,
days=days,
)
return {"items": items, "total": len(items)}
@router.post("/ai-optimize")
async def ai_optimize_preview(
body: AIOptimizePreviewRequest,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
):
_ = current_user
proposals = await workload_service.build_ai_optimize_preview(
db,
tenant_id=tenant_id,
project_id=body.project_id,
max_moves=body.max_moves,
)
return {"proposals": proposals, "total": len(proposals)}
@router.post("/ai-optimize/apply")
async def apply_ai_optimize(
body: AIOptimizeApplyRequest,
tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
):
_ = current_user
result = await workload_service.apply_ai_optimize_plan(
db,
tenant_id=tenant_id,
proposals=body.proposals,
)
return result

View File

@ -0,0 +1,5 @@
"""
配置模块
- ai_prompts: AI 调用使用的 Prompt 模板,统一管理,禁止在业务代码中硬编码
"""

View File

@ -0,0 +1,499 @@
"""
AI Prompt 集中管理
所有 AI 相关 prompt 模板放在此处,避免在 services 中硬编码。
使用 .format() 或 f-string 时需保证占位符与调用处一致。
"""
# ============================================================================
# 影响分析
# ============================================================================
PROMPT_IMPACT_ANALYSIS = """
Analyze the following news/signal for an AI engineering team.
Determine its "Impact Score" (0.0 to 1.0) based on how critical it is for our RAG/LLM development.
Signal: "{signal_content}"
Rules:
- 0.9-1.0: Major SOTA model release (e.g., GPT-5, DeepSeek-V4), critical vulnerability, or massive breakthrough.
- 0.7-0.8: Important update, new useful library, or relevant research paper.
- 0.4-0.6: Interesting but not critical news.
- 0.0-0.3: Irrelevant noise.
Output ONLY the numeric score (e.g., 0.85). Do not output any text.
"""
# ============================================================================
# 任务拆解
# ============================================================================
PROMPT_TASK_BREAKDOWN = """
You are an expert AI Technical Project Manager.
Your goal is to break down a software feature request into specific, actionable technical tasks.
Feature Request: "{description}"
Required Output Format: JSON Array only.
Each item must have:
- "task_name": Concise technical task description.
- "type": One of ["frontend", "backend", "database"].
- "estimated_hours": Realistic hourly estimate (float).
Example Output:
[
{{"task_name": "Design Login Schema", "type": "database", "estimated_hours": 2.0}},
{{"task_name": "Implement Auth API", "type": "backend", "estimated_hours": 4.0}}
]
Ensure the breakdown is comprehensive (Frontend, Backend, Database) and strictly follows the JSON format.
Do not include any markdown formatting or explanation outside the JSON.
"""
# ============================================================================
# 策略分析(CTO 视角)
# ============================================================================
PROMPT_STRATEGIC_ANALYSIS_HEADER = """
You are a Chief Technology Officer (CTO) AI Assistant for our team.
=== Our Product/Team ===
{business_context}
{rag_context}
=== External Technology Signal ===
Signal: "{signal_content}"
=== Your Task ===
1. Analyze: How does this signal impact OUR product's competitiveness?
2. Decide: Is it necessary to follow up immediately? (is_strategic)
3. Proposal: If YES, generate a "Strategic Decision Proposal".
4. Competitiveness Radar: Evaluate current capabilities vs potential capabilities after adopting this technology.
- Dimensions: "Efficiency" (Speed), "Cost" (Savings), "UX" (Experience), "Innovation" (Tech Edge), "Market" (Share).
- Score 1-10.
5. Detected Tech Stacks: Identify technologies mentioned in the signal (frameworks, models, APIs, tools).
- Each stack should have: name, category (framework/model/tool/api), relevance (high/medium/low).
6. Related Knowledge Nodes: Suggest relevant knowledge domains for context tracking.
- Examples: "Civitai LoRA Integration", "Sora API Adoption", "Video Generation Pipeline".
7. Plan: Break down the strategy into specific technical tasks.
Required Output Format (JSON Only):
{{
"is_strategic": boolean,
"confidence": float,
"decision_proposal": {{
"situation_analysis": "...",
"recommendation": "...",
"expected_benefits": "..."
}},
"competitiveness_radar": {{
"current": [5, 4, 6, 5, 5],
"projected": [8, 7, 9, 9, 7]
}},
"detected_tech_stacks": [
{{"name": "Technology Name", "category": "framework|model|tool|api", "relevance": "high|medium|low"}}
],
"related_knowledge_nodes": [
{{"title": "Knowledge Node Title", "category": "tech_stack|competitor|market", "summary": "Brief summary"}}
],
"tasks": [
{{
"task_name": "Task description",
"type": "backend/frontend/database",
"estimated_hours": float
}}
]
}}
If is_strategic is false, tasks should be empty, but decision_proposal is still required to explain why.
Do not output any markdown formatting or text outside the JSON.
"""
# ============================================================================
# RAG 上下文片段
# ============================================================================
PROMPT_RAG_CONTEXT_HEADER = """
=== Related Knowledge from Our Database ===
The following relevant information was found in our knowledge base:
"""
PROMPT_RAG_CONTEXT_ITEM = "[Relevance: {similarity:.1%}]\n{content}\n\n"
# ============================================================================
# 代码审计
# ============================================================================
PROMPT_CODE_AUDIT = """
You are a Senior Tech Lead performing an automated Code Audit.
Task: "{task_name}"
Commit Message: "{commit_message}"
Code Diff:
{code_diff}
Evaluate the code change against the task requirements. Check for:
1. Security vulnerabilities
2. Performance issues
3. Code style and best practices
4. Functional correctness
Output JSON with: score (0-100), passed (boolean), issues (list of {{severity, message, suggestion}}), summary.
"""
# ============================================================================
# 知识节点洞察提取
# ============================================================================
PROMPT_CONTEXT_INSIGHTS = """
Analyze the following technical documentation/context and extract key insights.
Context: "{content}"
Output JSON:
{{
"moat": "Core competitive advantage described",
"tech_specs": "Key technical specifications"
}}
"""
# ============================================================================
# PMFlow 专属 Prompts
# ============================================================================
PROMPT_GENERATE_CHARTER = """
你是一位资深产品经理。根据以下市场/技术信号,生成一份结构化的需求宪章草案。
信号内容:
{signal_content}
请输出 JSON 格式,包含以下字段:
{{
"title": "需求宪章标题",
"summary": "一句话摘要",
"background": "背景与动机",
"objectives": ["目标1", "目标2"],
"scope": "范围说明",
"success_metrics": ["指标1", "指标2"],
"risks": ["风险1", "风险2"]
}}
只输出 JSON,不要其他文字。
"""
PROMPT_DECOMPOSE_TASKS = """
你是一位 AI 技术项目经理。根据以下需求宪章内容,拆解为可执行的任务列表。
项目 ID:{project_id}。
宪章内容:
{charter_content}
请识别每个任务所需的 AI 开发技能标签(从系统技能中匹配:Python, TypeScript, FastAPI, React, SQLAlchemy, LangChain, LLM调优, RAG构建, 向量数据库, Ollama, Docker, Git 等)。
输出 JSON 格式:
{{
"tasks": [
{{
"task_name": "任务名称",
"description": "任务描述",
"type": "frontend|backend|database|ai|devops",
"estimated_hours": 0.0,
"required_skill_names": ["技能1", "技能2"],
"priority": "high|medium|low"
}}
],
"detected_skills": ["检测到的所有技能"]
}}
只输出 JSON,不要其他文字。
"""
PROMPT_RECOMMEND_ASSIGNEES = """
你是一位项目协调员。根据以下任务内容、所需技能、以及候选人信息,推荐 Top3 最合适的负责人。
任务内容:{task_content}
所需技能标签 IDs:{required_skill_tag_ids}
候选人信息(JSON 数组,每项包含 user_id, user_name, workload, proficiency_avg, skill_match_count):
{candidates_json}
请考虑:当前负载、技能匹配度、历史表现。输出 JSON:
{{
"recommendations": [
{{
"user_id": "用户ID",
"user_name": "姓名",
"match_score": 0-100,
"reason": "推荐理由"
}}
]
}}
只输出 JSON。
"""
PROMPT_ALIGNMENT_SCORE = """
计算用户对真理节点的认知对齐度(0-100 分)。
用户 ID:{user_id}
真理节点 ID:{truth_node_id}
真理节点内容:
{truth_content}
用户相关行为/产出(如有):
{user_context}
请评估该用户对该真理节点的理解与践行程度。输出 JSON:
{{
"alignment_score": 0-100,
"explanation": "简要说明"
}}
只输出 JSON。
"""
PROMPT_COGNITIVE_CONFLICTS = """
识别真理节点下的隐性认知冲突。
真理节点 ID:{truth_node_id}
真理节点内容:
{truth_content}
相关用户的观点/产出:
{user_viewpoints}
请识别冲突类型(如 definition/定义分歧、priority/优先级冲突、scope/范围争议),输出 JSON:
{{
"conflicts": [
{{
"conflict_type": "类型",
"description": "冲突描述",
"involved_user_ids": ["用户ID"],
"involved_user_names": ["姓名"],
"severity": "low|medium|high"
}}
]
}}
只输出 JSON。
"""
PROMPT_EVALUATE_MODEL_REQUIREMENT = """
评估需求与 AI 模型的匹配度。
宪章内容:{charter_content}
模型版本 ID:{model_version_id}
模型信息(如有):{model_info}
请评估兼容性、成本、风险。输出 JSON:
{{
"compatibility_score": 0-100,
"cost_estimate_usd": 预估日成本美元,
"cost_estimate_tokens_per_day": 预估日 token 量,
"risk_hints": ["风险1", "风险2"],
"recommendations": ["建议1", "建议2"]
}}
只输出 JSON。
"""
# ============================================================================
# 模型能力矩阵专属 Prompts
# ============================================================================
PROMPT_ANALYZE_CAPABILITY_GAP = """
你是一位 AI 模型能力专家。分析以下需求与模型之间的能力差距。
需求宪章:
{charter_content}
模型信息:
{model_info}
请识别并分类能力差距,输出 JSON:
{{
"gaps": [
{{
"gap_type": "capability|cost|performance|risk",
"severity": "critical|high|medium|low",
"description": "差距描述",
"impact": "对项目的影响",
"suggestion": "改进建议"
}}
],
"overall_risk_level": "critical|high|medium|low",
"confidence": 0.0-1.0
}}
只输出 JSON。
"""
PROMPT_ANALYZE_COST_ROI = """
分析 AI 项目的成本效益。
总成本:{total_cost}
总 Token:{total_tokens}
任务数:{task_count}
项目内容:{truth_node_content}
请计算:
1. ROI 评分 (0-10)
2. 成本效率评估
3. 优化建议
输出 JSON:
{{
"roi_score": 0.0-10.0,
"cost_efficiency": "excellent|good|fair|poor",
"cost_per_task": 数值,
"optimization_suggestions": ["建议1", "建议2"],
"benchmark_comparison": "与行业标准比较"
}}
只输出 JSON。
"""
PROMPT_ANALYZE_COST_OPTIMIZATION = """
分析成本优化机会。
成本历史:{cost_history}
项目内容:{truth_node_content}
请识别优化机会,输出 JSON:
{{
"can_downgrade_model": true|false,
"downgrade_savings": 预计节省金额,
"suggested_model": "建议的替代模型",
"high_cache_potential": true|false,
"cache_savings": 预计节省金额,
"batchable_operations": true|false,
"batch_savings": 预计节省金额,
"monthly_cost": 数值,
"total_potential_savings": 数值,
"roi_after_optimization": 数值
}}
只输出 JSON。
"""
PROMPT_ASSESS_MODEL_UPDATE_IMPACT = """
评估模型升级影响。
旧模型信息:{old_model_info}
新模型信息:{new_model_info}
受影响宪章:{affected_truth_nodes}
输出 JSON:
{{
"cost_change_percentage": 数值,
"performance_change": {{
"context_window_change_percent": 数值,
"quality_change_percent": 数值
}},
"compatibility_risks": [{{"type": "风险类型", "severity": "low|medium|high"}}],
"recommendation": "immediate_upgrade|evaluate|defer"
}}
只输出 JSON。
"""
PROMPT_EXPERIMENT_SYNC_DECISION = """
评估实验结果是否可自动同步到宪章。
实验信息:{experiment_info}
宪章内容:{truth_content}
阈值:{threshold}
输出 JSON:
{{
"confidence_score": 0.0-1.0,
"auto_apply": true|false,
"reason": "原因"
}}
只输出 JSON。
"""
# ============================================================================
# Day8: 技能分更新 / 负载优化
# ============================================================================
PROMPT_SKILL_SCORE_UPDATE = """
你是 PMFlow 的技能评估引擎。
请根据任务完成结果,给每个技能标签输出质量分与效率分的增量(delta)。
输入:
- task_id: {task_id}
- priority: {priority}
- quality_score: {quality_score}
- estimated_hours: {estimated_hours}
- actual_hours: {actual_hours}
- required_skill_tag_ids: {required_skill_tag_ids}
规则:
1. quality_delta 与 efficiency_delta 范围均为 [-10, 10]。
2. quality_score 越高,quality_delta 越大。
3. estimated_hours / actual_hours > 1,表示效率高,efficiency_delta 应偏正;< 1 偏负。
4. 结果必须覆盖所有 required_skill_tag_ids。
5. 不输出解释文本,仅输出 JSON。
输出 JSON:
{{
"deltas": [
{{
"skill_tag_id": 1,
"quality_delta": 1.2,
"efficiency_delta": -0.5
}}
]
}}
"""
PROMPT_WORKLOAD_OPTIMIZATION = """
你是 PMFlow 的任务分配优化器。
请基于团队成员技能与当前负载,输出任务重分配建议。
输入:
- members_json: {members_json}
- tasks_json: {tasks_json}
约束:
1. 只对未完成任务(todo/in_progress/review/blocked)给建议。
2. 优先降低 >100% 过载成员负载。
3. 优先选择技能匹配更高且负载更低的成员。
4. 每条建议都要给 reason。
5. 不输出解释文本,仅输出 JSON。
输出 JSON:
{{
"proposals": [
{{
"task_id": 101,
"new_assignee_id": 2001,
"reason": "skill_match_high_and_lower_workload"
}}
]
}}
"""

View File

@ -0,0 +1,18 @@
"""
Core 模块:配置、安全、响应、日志
遵循 AGENTS.md 规范:
- 统一 API 返回格式
- 结构化日志
- 核心配置集中管理
"""
from .config import settings
from .response import success_response, error_response, ApiResponse
__all__ = [
"settings",
"success_response",
"error_response",
"ApiResponse",
]

View File

@ -0,0 +1,45 @@
"""
核心配置
集中管理环境变量和应用配置,遵循 12-factor 原则。
"""
import os
from typing import Optional
from dotenv import load_dotenv
load_dotenv()
class Settings:
"""应用配置(从环境变量读取)"""
# 应用
app_name: str = os.getenv("APP_NAME", "Neural Sync API")
debug: bool = os.getenv("DEBUG", "false").lower() in ("true", "1", "yes")
# 数据库
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")
algorithm: str = os.getenv("JWT_ALGORITHM", "HS256")
access_token_expire_minutes: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", str(60 * 24 * 7)))
# CORS
cors_origins: str = os.getenv("CORS_ORIGINS", "*")
# LLM
llm_provider: str = os.getenv("LLM_PROVIDER", "ollama").lower()
ollama_host: str = os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434")
business_context: Optional[str] = os.getenv("BUSINESS_CONTEXT")
# Redis(可选,用于 AI 结果缓存)
redis_url: Optional[str] = os.getenv("REDIS_URL")
# 多租户(预留)
default_tenant_id: str = os.getenv("DEFAULT_TENANT_ID", "default")
# 全局配置实例
settings = Settings()

View File

@ -0,0 +1,59 @@
"""
结构化日志配置
使用 structlog 实现结构化、可观测的日志,便于生产环境排查与聚合。
"""
import logging
import sys
from typing import Any
# 尝试使用 structlog,若未安装则回退到标准 logging
try:
import structlog
USE_STRUCTLOG = True
except ImportError:
USE_STRUCTLOG = False
def configure_logging(debug: bool = False) -> None:
"""
配置应用日志
Args:
debug: 是否为调试模式
"""
log_level = logging.DEBUG if debug else logging.INFO
if USE_STRUCTLOG:
structlog.configure(
processors=[
structlog.stdlib.filter_by_level,
structlog.stdlib.add_logger_name,
structlog.stdlib.add_log_level,
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.UnicodeDecoder(),
structlog.dev.ConsoleRenderer() if debug else structlog.processors.JSONRenderer(),
],
wrapper_class=structlog.stdlib.BoundLogger,
context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(),
cache_logger_on_first_use=True,
)
else:
logging.basicConfig(
level=log_level,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
stream=sys.stdout,
)
def get_logger(name: str):
"""获取 logger 实例"""
if USE_STRUCTLOG:
return structlog.get_logger(name)
return logging.getLogger(name)

View File

@ -0,0 +1,68 @@
"""
统一 API 响应格式
遵循 AGENTS.md 规范:
成功:{ "code": 0, "message": "success", "data": {} }
失败:{ "code": 非0错误码, "message": "错误描述", "data": null }
"""
from typing import Any, Generic, TypeVar
from pydantic import BaseModel, Field
T = TypeVar("T")
class ApiResponse(BaseModel, Generic[T]):
"""统一 API 响应模型"""
code: int = Field(description="状态码,0 表示成功")
message: str = Field(description="描述信息")
data: T | None = Field(default=None, description="响应数据,失败时为 null")
def success_response(data: Any = None, message: str = "success") -> dict:
"""
构建成功响应
Args:
data: 响应数据,可为任意 JSON 可序列化类型
message: 成功描述
Returns:
符合规范的响应字典
"""
return {
"code": 0,
"message": message,
"data": data,
}
def error_response(code: int, message: str) -> dict:
"""
构建失败响应
Args:
code: 非零错误码
message: 错误描述
Returns:
符合规范的响应字典
"""
return {
"code": code,
"message": message,
"data": None,
}
# 常用错误码
class ErrorCode:
"""错误码常量"""
UNAUTHORIZED = 401
FORBIDDEN = 403
NOT_FOUND = 404
VALIDATION_ERROR = 422
INTERNAL_ERROR = 500

View File

@ -1,17 +1,41 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker, declarative_base
import os
from dotenv import load_dotenv
"""数据库会话与引擎管理。"""
load_dotenv()
from __future__ import annotations
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./neural_sync.db")
from collections.abc import AsyncGenerator
engine = create_async_engine(DATABASE_URL, echo=True)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
Base = declarative_base()
from .core.config import settings
async def get_db():
DATABASE_URL = settings.database_url
def _is_sqlite() -> bool:
return DATABASE_URL.lower().startswith("sqlite")
engine: AsyncEngine = create_async_engine(
DATABASE_URL,
echo=settings.debug,
future=True,
pool_pre_ping=not _is_sqlite(),
)
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""FastAPI 依赖:提供数据库会话。"""
async with AsyncSessionLocal() as session:
yield session

View File

@ -0,0 +1,31 @@
"""
FastAPI 依赖注入
- 鉴权(JWT、当前用户、角色检查)
- 租户过滤(预留)
- 分页(预留)
"""
from .auth import (
oauth2_scheme,
get_current_user,
get_current_active_user,
require_roles,
)
from .permissions import (
require_manager_user,
require_project_member_or_manager,
require_self_or_manager,
)
from .tenant import get_tenant_id
__all__ = [
"oauth2_scheme",
"get_current_user",
"get_current_active_user",
"require_roles",
"require_manager_user",
"require_project_member_or_manager",
"require_self_or_manager",
"get_tenant_id",
]

View File

@ -0,0 +1,73 @@
"""
鉴权依赖注入
JWT 验证、当前用户、角色检查。
"""
from enum import Enum
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from ..database import get_db
from ..models import User, UserStatus # noqa: E402
from ..services.auth import AuthService, has_any_role
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db),
) -> User:
"""从 JWT 解析并返回当前认证用户"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
username = AuthService.verify_token(token)
if username is None:
raise credentials_exception
result = await db.execute(select(User).where(User.username == username))
user = result.scalar_one_or_none()
if user is None:
raise credentials_exception
return user
async def get_current_active_user(
current_user: User = Depends(get_current_user),
) -> User:
"""当前活跃用户(排除 AWAY / OFFLINE)"""
if (not getattr(current_user, "is_active", True)) or current_user.status != UserStatus.ACTIVE:
raise HTTPException(status_code=403, detail="User is not active")
return current_user
def require_roles(*roles: str):
"""依赖工厂:要求用户具备指定角色之一"""
async def role_checker(
current_user: User = Depends(get_current_user),
) -> User:
user_role = (
current_user.role.value
if isinstance(current_user.role, Enum)
else current_user.role
)
if not has_any_role(user_role, list(roles)):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Access denied. Required roles: {', '.join(roles)}",
)
return current_user
return role_checker

View File

@ -0,0 +1,74 @@
"""权限中间件依赖。"""
from __future__ import annotations
from enum import Enum
from fastapi import Depends, HTTPException
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..database import get_db
from ..models import User, UserProject
from ..services.auth import parse_role_values
from .auth import get_current_user
MANAGER_ROLES = {"BOSS", "ADMIN"}
def _role_value(role: object) -> str:
return role.value if isinstance(role, Enum) else str(role or "")
async def require_manager_user(
current_user: User = Depends(get_current_user),
) -> User:
"""要求当前用户是管理角色。"""
user_roles = set(parse_role_values(_role_value(current_user.role)))
if not user_roles.intersection(MANAGER_ROLES):
raise HTTPException(status_code=403, detail="Manager permission required")
return current_user
async def require_self_or_manager(
user_id: int,
current_user: User = Depends(get_current_user),
) -> User:
"""要求“本人或管理角色”。"""
user_roles = set(parse_role_values(_role_value(current_user.role)))
if int(current_user.id) == int(user_id) or user_roles.intersection(MANAGER_ROLES):
return current_user
raise HTTPException(status_code=403, detail="No permission")
async def require_project_member_or_manager(
project_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> User:
"""
要求为项目成员或管理角色。
适用于所有带 `project_id` 的 mutation 端点。
"""
user_roles = set(parse_role_values(_role_value(current_user.role)))
if user_roles.intersection(MANAGER_ROLES):
return current_user
try:
pid = int(project_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid project_id") from exc
membership = await db.execute(
select(UserProject.user_id).where(
and_(
UserProject.tenant_id == int(current_user.tenant_id),
UserProject.project_id == pid,
UserProject.user_id == int(current_user.id),
)
)
)
if membership.scalar_one_or_none() is None:
raise HTTPException(status_code=403, detail="Project membership required")
return current_user

View File

@ -0,0 +1,24 @@
"""
租户过滤依赖
从请求中提取 tenant_id,用于多租户数据隔离。
"""
from typing import Annotated
from fastapi import Header
async def get_tenant_id(
x_tenant_id: Annotated[str | None, Header(alias="X-Tenant-ID")] = None,
) -> int:
"""
从 Header X-Tenant-ID 或 JWT 解析 tenant_id。
未提供时返回默认租户 1。
"""
if x_tenant_id:
try:
return int(x_tenant_id)
except ValueError:
return 1
return 1

399
backend/app/dispatcher.py Normal file
View File

@ -0,0 +1,399 @@
"""
Neural Sync - Role Dispatcher
根据任务的技术指纹自动路由到对应角色的工作台。
"""
from enum import Enum
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Any, Tuple
from datetime import datetime
import re
import logging
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
class Role(str, Enum):
"""系统角色定义"""
BOSS = "boss" # 老板 - 战略决策
AI_ENGINEER = "ai_engineer" # AI 工程师 - Prompt/模型/RAG
BACKEND = "backend" # 后端开发 - API/数据库/爬虫
FRONTEND = "frontend" # 前端开发 - UI/组件/交互
@dataclass
class RoutingRule:
"""路由规则定义"""
role: Role
name: str
description: str
triggers: List[str] = field(default_factory=list) # 触发关键词
keywords: List[str] = field(default_factory=list) # 技术关键词
patterns: List[str] = field(default_factory=list) # 正则模式
threshold: Optional[Dict[str, Any]] = None # 阈值条件
priority: int = 0 # 优先级,数值越大越优先
class RoleRouter:
"""
角色路由引擎
根据信号的技术指纹自动路由到对应角色:
- Boss: 高成本、高风险、架构变更决策
- AI Engineer: LLM、Prompt、RAG、数据处理
- Backend: API、数据库、爬虫、性能优化
- Frontend: UI、可视化、交互、响应式
"""
# 角色路由规则定义
ROLE_RULES: List[RoutingRule] = [
RoutingRule(
role=Role.BOSS,
name="战略决策",
description="需要老板决策的高影响任务",
triggers=["cost_estimate", "architecture_change", "high_risk", "security"],
keywords=["预算", "架构", "安全", "风险", "审批", "决策"],
threshold={"dev_days": 5, "risk_level": ["CRITICAL", "HIGH"]},
priority=100,
),
RoutingRule(
role=Role.AI_ENGINEER,
name="AI 工程",
description="AI 模型和数据处理相关任务",
triggers=["prompt", "model", "embedding", "rag", "llm"],
keywords=[
"ollama",
"openai",
"claude",
"prompt",
"embedding",
"rag",
"llm",
"向量",
"模型",
"训练",
"数据清洗",
"token",
"context",
],
patterns=[r"(prompt|embedding|rag|llm|vector|model)", r"(ai|machine.?learning|ml)"],
priority=50,
),
RoutingRule(
role=Role.BACKEND,
name="后端开发",
description="API、数据库、爬虫等后端任务",
triggers=["api", "database", "crawler", "performance", "docker"],
keywords=[
"fastapi",
"sqlalchemy",
"postgres",
"sql",
"async",
"docker",
"kubernetes",
"redis",
"celery",
"爬虫",
"api",
"接口",
"数据库",
"后端",
"性能",
"缓存",
],
patterns=[
r"(api|endpoint|route)",
r"(sql|database|db)",
r"(docker|k8s|kubernetes)",
r"(async|await|celery)",
],
priority=30,
),
RoutingRule(
role=Role.FRONTEND,
name="前端开发",
description="UI、可视化、交互等前端任务",
triggers=["ui", "visualization", "interaction", "responsive", "chart"],
keywords=[
"vue",
"react",
"typescript",
"component",
"chart",
"echarts",
"d3",
"可视化",
"ui",
"ux",
"样式",
"动画",
"响应式",
"组件",
"交互",
"页面",
"布局",
],
patterns=[
r"(vue|react|tsx?|jsx?)",
r"(css|scss|tailwind)",
r"(component|props|emit)",
r"(chart|graph|visualization)",
],
priority=30,
),
]
# 技术栈关键词映射
TECH_KEYWORDS = {
"frontend": [
"vue",
"react",
"typescript",
"javascript",
"css",
"scss",
"tailwind",
"component",
"props",
"emit",
"template",
"style",
],
"backend": [
"fastapi",
"flask",
"django",
"sqlalchemy",
"postgres",
"mysql",
"redis",
"celery",
"async",
"await",
"docker",
"kubernetes",
"k8s",
"api",
"endpoint",
"route",
],
"ai": [
"llm",
"ollama",
"openai",
"claude",
"prompt",
"embedding",
"rag",
"vector",
"model",
"training",
"inference",
"token",
"context",
],
}
def __init__(self):
# 编译正则模式
self._compiled_patterns: Dict[Role, List[re.Pattern]] = {}
for rule in self.ROLE_RULES:
patterns = [re.compile(p, re.IGNORECASE) for p in rule.patterns]
self._compiled_patterns[rule.role] = patterns
def route(
self,
signal_content: str,
analysis: Optional[Dict[str, Any]] = None,
source: Optional[str] = None,
) -> Tuple[Role, float, Dict[str, Any]]:
"""
根据信号内容和分析结果决定路由角色
Args:
signal_content: 信号内容
analysis: AI 分析结果(可选)
source: 信号来源(可选)
Returns:
(角色, 置信度, 路由详情)
"""
analysis = analysis or {}
details = {
"matched_rules": [],
"scores": {},
"reasons": [],
}
# 1. 首先检查是否需要升级到老板决策
if self._should_escalate_to_boss(analysis):
details["reasons"].append("触发老板决策阈值")
details["matched_rules"].append("threshold_check")
return Role.BOSS, 1.0, details
# 2. 计算每个角色的匹配分数
scores: Dict[Role, float] = {rule.role: 0.0 for rule in self.ROLE_RULES}
# 基于关键词匹配
content_lower = signal_content.lower()
for rule in self.ROLE_RULES:
keyword_score = sum(
1 for kw in rule.keywords if kw.lower() in content_lower
)
if keyword_score > 0:
scores[rule.role] += keyword_score * 0.3
details["scores"][f"{rule.role}_keywords"] = keyword_score
# 基于正则模式匹配
for rule in self.ROLE_RULES:
if rule.role in self._compiled_patterns:
pattern_score = sum(
1 for pattern in self._compiled_patterns[rule.role]
if pattern.search(signal_content)
)
if pattern_score > 0:
scores[rule.role] += pattern_score * 0.5
details["scores"][f"{rule.role}_patterns"] = pattern_score
# 基于触发词匹配
for rule in self.ROLE_RULES:
trigger_score = sum(
1 for trigger in rule.triggers if trigger.lower() in content_lower
)
if trigger_score > 0:
scores[rule.role] += trigger_score * 0.7
details["scores"][f"{rule.role}_triggers"] = trigger_score
# 基于优先级加权
for rule in self.ROLE_RULES:
if scores[rule.role] > 0:
scores[rule.role] *= (1 + rule.priority * 0.01)
# 3. 找出最高分的角色
max_role = Role.AI_ENGINEER # 默认 AI 工程师
max_score = 0.0
for role, score in scores.items():
if score > max_score:
max_score = score
max_role = role
elif score == max_score and score > 0:
# 分数相同时,优先级高的胜出
for rule in self.ROLE_RULES:
if rule.role == role:
role_priority = rule.priority
if rule.role == max_role:
max_role_priority = rule.priority
if role_priority > max_role_priority:
max_role = role
# 归一化置信度
confidence = min(max_score / 3.0, 1.0) if max_score > 0 else 0.5
# 记录详情
details["matched_rules"] = [
rule.name for rule in self.ROLE_RULES if scores[rule.role] > 0
]
details["final_scores"] = {role.value: score for role, score in scores.items()}
details["reasons"].append(f"基于关键词和模式匹配")
return max_role, confidence, details
def _should_escalate_to_boss(self, analysis: Dict[str, Any]) -> bool:
"""检查是否需要升级到老板决策"""
# 检查开发成本
dev_days = analysis.get("resource_cost", {}).get("dev_days", 0)
if dev_days >= 5:
return True
# 检查风险等级
risk_level = analysis.get("risk_level", "")
if risk_level in ["CRITICAL", "HIGH"]:
return True
# 检查是否涉及架构变更
is_architecture = analysis.get("is_architecture_change", False)
if is_architecture:
return True
return False
def get_role_info(self, role: Role) -> Dict[str, Any]:
"""获取角色信息"""
for rule in self.ROLE_RULES:
if rule.role == role:
return {
"role": role.value,
"name": rule.name,
"description": rule.description,
"keywords": rule.keywords,
"priority": rule.priority,
}
return {}
def get_all_roles(self) -> List[Dict[str, Any]]:
"""获取所有角色信息"""
return [
{
"role": rule.role.value,
"name": rule.name,
"description": rule.description,
"priority": rule.priority,
}
for rule in sorted(self.ROLE_RULES, key=lambda r: -r.priority)
]
class RoutingResult(BaseModel):
"""路由结果模型"""
role: str
confidence: float
details: Dict[str, Any]
signal_id: Optional[str] = None
timestamp: float = Field(default_factory=lambda: datetime.utcnow().timestamp())
# 全局路由器实例
_global_router: Optional[RoleRouter] = None
def get_router() -> RoleRouter:
"""获取全局路由器实例"""
global _global_router
if _global_router is None:
_global_router = RoleRouter()
return _global_router
# 便捷函数
def route_signal(
signal_content: str,
analysis: Optional[Dict[str, Any]] = None,
signal_id: Optional[str] = None,
) -> RoutingResult:
"""
路由信号到对应角色
Args:
signal_content: 信号内容
analysis: AI 分析结果
signal_id: 信号 ID
Returns:
路由结果
"""
router = get_router()
role, confidence, details = router.route(signal_content, analysis)
return RoutingResult(
role=role.value,
confidence=confidence,
details=details,
signal_id=signal_id,
)

372
backend/app/events.py Normal file
View File

@ -0,0 +1,372 @@
"""
Neural Sync - Event Bus System
实现事件驱动架构的核心组件,支持异步事件发布/订阅模式。
"""
from enum import Enum
from dataclasses import dataclass, field
from datetime import datetime
from typing import Callable, Dict, List, Any, Optional
import asyncio
import logging
from contextlib import asynccontextmanager
logger = logging.getLogger(__name__)
class EventType(Enum):
"""系统事件类型定义"""
# 信号事件
SIGNAL_INGESTED = "signal.ingested"
SIGNAL_ANALYZED = "signal.analyzed"
SIGNAL_FEEDBACK = "signal.feedback"
SIGNAL_PRIORITY_CHANGED = "signal.priority_changed"
# 任务事件
TASK_ASSIGNED = "task.assigned"
TASK_STARTED = "task.started"
TASK_COMPLETED = "task.completed"
TASK_FAILED = "task.failed"
# 代码事件
CODE_COMMIT = "code.commit"
CODE_REVIEW_REQUESTED = "code.review_requested"
CODE_DRAFT_GENERATED = "code.draft_generated"
# 审计事件
AUDIT_STARTED = "audit.started"
AUDIT_COMPLETED = "audit.completed"
AUDIT_FAILED = "audit.failed"
# 合并事件
AUTO_MERGE_REQUESTED = "auto_merge.requested"
AUTO_MERGE_SUCCESS = "auto_merge.success"
AUTO_MERGE_FAILED = "auto_merge.failed"
AUTO_MERGE_COOLDOWN = "auto_merge.cooldown"
# 集成事件
PLANE_SYNC_SUCCESS = "integration.plane.success"
PLANE_SYNC_FAILED = "integration.plane.failed"
LARK_NOTIFY_SUCCESS = "integration.lark.success"
LARK_NOTIFY_FAILED = "integration.lark.failed"
# 项目域事件
DOMAIN_CREATED = "domain.created"
DOMAIN_UPDATED = "domain.updated"
DOMAIN_DELETED = "domain.deleted"
DOMAIN_SWITCHED = "domain.switched"
DOMAIN_SYNCING = "domain.syncing"
DOMAIN_SYNCED = "domain.synced"
# 项目事件
PROJECT_CREATED = "project.created"
PROJECT_UPDATED = "project.updated"
PROJECT_DELETED = "project.deleted"
SIGNAL_LINKED = "signal.linked"
SIGNAL_UNLINKED = "signal.unlinked"
@dataclass
class Event:
"""事件数据结构"""
type: EventType
data: Dict[str, Any]
timestamp: float = field(default_factory=lambda: datetime.utcnow().timestamp())
source: Optional[str] = None
correlation_id: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""转换为字典格式"""
return {
"type": self.type.value,
"data": self.data,
"timestamp": self.timestamp,
"source": self.source,
"correlation_id": self.correlation_id,
"metadata": self.metadata,
}
class EventBus:
"""
事件总线 - 核心事件分发系统
特性:
- 异步事件发布
- 多订阅者支持
- 事件过滤
- 错误处理
- 性能监控
"""
def __init__(self):
self._subscribers: Dict[EventType, List[Callable]] = {}
self._wildcard_subscribers: List[Callable] = []
self._event_history: List[Event] = []
self._max_history = 1000
self._metrics = {
"published": 0,
"delivered": 0,
"failed": 0,
}
self._lock = asyncio.Lock()
self._running = False
@property
def metrics(self) -> Dict[str, int]:
"""获取事件指标"""
return self._metrics.copy()
@property
def history(self) -> List[Dict[str, Any]]:
"""获取事件历史"""
return [e.to_dict() for e in self._event_history]
async def start(self):
"""启动事件总线"""
self._running = True
logger.info("EventBus started")
async def stop(self):
"""停止事件总线"""
self._running = False
logger.info("EventBus stopped")
def subscribe(
self,
event_type: EventType,
handler: Callable,
filter_func: Optional[Callable[[Event], bool]] = None,
) -> Callable:
"""
订阅事件
Args:
event_type: 事件类型
handler: 事件处理函数 (async def handler(event: Event) -> None)
filter_func: 事件过滤函数 (def filter(event: Event) -> bool)
Returns:
取消订阅的函数
"""
async def wrapped_handler(event: Event):
if filter_func and not filter_func(event):
return
try:
await handler(event)
self._metrics["delivered"] += 1
except Exception as e:
logger.error(
f"Error in event handler for {event_type.value}: {e}",
exc_info=True,
)
self._metrics["failed"] += 1
# 包装为原始 handler 用于取消订阅
wrapped_handler.original = handler # type: ignore
async def _subscribe():
async with self._lock:
if event_type not in self._subscribers:
self._subscribers[event_type] = []
self._subscribers[event_type].append(wrapped_handler)
asyncio.create_task(_subscribe())
def unsubscribe():
asyncio.create_task(self._unsubscribe(event_type, wrapped_handler))
return unsubscribe
def subscribe_wildcard(
self,
handler: Callable,
filter_func: Optional[Callable[[Event], bool]] = None,
) -> Callable:
"""订阅所有事件(通配符订阅)"""
async def wrapped_handler(event: Event):
if filter_func and not filter_func(event):
return
try:
await handler(event)
self._metrics["delivered"] += 1
except Exception as e:
logger.error(
f"Error in wildcard event handler: {e}",
exc_info=True,
)
self._metrics["failed"] += 1
wrapped_handler.original = handler # type: ignore
async def _subscribe():
async with self._lock:
self._wildcard_subscribers.append(wrapped_handler)
asyncio.create_task(_subscribe())
def unsubscribe():
asyncio.create_task(self._unsubscribe_wildcard(wrapped_handler))
return unsubscribe
async def _unsubscribe(self, event_type: EventType, handler: Callable):
"""取消订阅"""
async with self._lock:
if event_type in self._subscribers:
self._subscribers[event_type] = [
h for h in self._subscribers[event_type] if h != handler
]
async def _unsubscribe_wildcard(self, handler: Callable):
"""取消通配符订阅"""
async with self._lock:
self._wildcard_subscribers = [
h for h in self._wildcard_subscribers if h != handler
]
async def publish(self, event: Event) -> bool:
"""
发布事件
Args:
event: 事件对象
Returns:
是否成功发布
"""
if not self._running:
logger.warning(f"EventBus not running, discarding event: {event.type.value}")
return False
self._metrics["published"] += 1
# 记录历史
self._event_history.append(event)
if len(self._event_history) > self._max_history:
self._event_history.pop(0)
# 通知订阅者
tasks = []
# 特定事件订阅者
if event.type in self._subscribers:
for handler in self._subscribers[event.type]:
tasks.append(handler(event))
# 通配符订阅者
for handler in self._wildcard_subscribers:
tasks.append(handler(event))
if tasks:
results = await asyncio.gather(*tasks, return_exceptions=True)
failed = sum(1 for r in results if isinstance(r, Exception))
if failed > 0:
self._metrics["failed"] += failed
logger.debug(
f"Published event {event.type.value} to {len(tasks)} subscribers"
)
return True
async def publish_and_wait(
self, event: Event, timeout: float = 5.0
) -> List[Any]:
"""
发布事件并等待所有处理完成
Args:
event: 事件对象
timeout: 超时时间(秒)
Returns:
所有处理器的返回值列表
"""
if not self._running:
raise RuntimeError("EventBus not running")
self._metrics["published"] += 1
self._event_history.append(event)
tasks = []
if event.type in self._subscribers:
tasks.extend(self._subscribers[event.type])
for handler in self._wildcard_subscribers:
tasks.append(handler(event))
if not tasks:
return []
try:
results = await asyncio.wait_for(
asyncio.gather(*tasks, return_exceptions=True),
timeout=timeout,
)
return results
except asyncio.TimeoutError:
logger.warning(
f"Event {event.type.value} processing timed out after {timeout}s"
)
raise
def clear_history(self):
"""清空事件历史"""
self._event_history.clear()
def get_subscriber_count(self, event_type: Optional[EventType] = None) -> int:
"""获取订阅者数量"""
if event_type:
return len(self._subscribers.get(event_type, []))
return sum(len(subs) for subs in self._subscribers.values()) + len(
self._wildcard_subscribers
)
# 全局事件总线实例
_global_event_bus: Optional[EventBus] = None
def get_event_bus() -> EventBus:
"""获取全局事件总线实例"""
global _global_event_bus
if _global_event_bus is None:
_global_event_bus = EventBus()
return _global_event_bus
@asynccontextmanager
async def event_bus_context():
"""事件总线上下文管理器"""
bus = get_event_bus()
await bus.start()
try:
yield bus
finally:
await bus.stop()
# 便捷函数
async def publish_event(event_type: EventType, data: Dict[str, Any], **kwargs) -> bool:
"""发布事件的便捷函数"""
event = Event(type=event_type, data=data, **kwargs)
return await get_event_bus().publish(event)
def subscribe_event(
event_type: EventType, handler: Callable
) -> Callable:
"""订阅事件的便捷函数"""
return get_event_bus().subscribe(event_type, handler)
def subscribe_all_events(handler: Callable) -> Callable:
"""订阅所有事件的便捷函数"""
return get_event_bus().subscribe_wildcard(handler)

File diff suppressed because it is too large Load Diff

View File

@ -1,99 +0,0 @@
from sqlalchemy import Column, Integer, String, Boolean, Float, ForeignKey, Text, DateTime, JSON
from sqlalchemy.orm import relationship
import uuid
from datetime import datetime
from .database import Base
# Compatibility layer for SQLite
try:
from sqlalchemy.dialects.postgresql import UUID, ARRAY
from pgvector.sqlalchemy import Vector
# Check if we are using SQLite
import os
if "sqlite" in os.getenv("DATABASE_URL", ""):
raise ImportError("Using SQLite")
except ImportError:
from sqlalchemy.types import TypeDecorator, CHAR
import json
class UUID(TypeDecorator):
impl = CHAR(36)
def __init__(self, as_uuid=True):
super().__init__()
def process_bind_param(self, value, dialect):
if value is None: return None
return str(value)
def process_result_value(self, value, dialect):
if value is None: return None
return uuid.UUID(value)
class Vector(TypeDecorator):
impl = Text
def __init__(self, dim=None):
super().__init__()
def process_bind_param(self, value, dialect):
if value is None: return None
return json.dumps(value)
def process_result_value(self, value, dialect):
if value is None: return None
return json.loads(value)
# Use JSON for ARRAY in SQLite
ARRAY = JSON
class ContextNode(Base):
__tablename__ = "context_nodes"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
content = Column(Text, nullable=False)
embedding = Column(Vector(768))
version = Column(Integer, default=1)
status = Column(String(20), default='active')
created_by = Column(UUID(as_uuid=True), nullable=True) # Placeholder for user link
# New fields for AI Insights
ai_insights = Column(JSON, nullable=True) # Stores 'moat', 'tech_specs', etc.
class Experiment(Base):
__tablename__ = "experiments"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
title = Column(String(255))
hypothesis = Column(Text)
status = Column(String(50), default='draft')
owner_id = Column(UUID(as_uuid=True), nullable=True) # Placeholder
linked_context_nodes = Column(JSON, nullable=True) # Changed from ARRAY to JSON for compatibility
target_metric = Column(String(50))
current_score = Column(Float, nullable=True)
class Signal(Base):
__tablename__ = "signals"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
source = Column(String(50))
content = Column(Text)
impact_score = Column(Float)
priority = Column(String(20), default='low') # critical, high, medium, low
processed = Column(Boolean, default=False)
# Store strategic analysis result
strategic_analysis = Column(Text, nullable=True) # JSON string
tasks_generated = Column(Text, nullable=True) # JSON string
created_at = Column(DateTime, default=datetime.utcnow)
class SignalFeedback(Base):
"""User feedback on AI strategic analysis of signals"""
__tablename__ = "signal_feedbacks"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
signal_id = Column(UUID(as_uuid=True), ForeignKey('signals.id'), nullable=False)
# User's correction of the AI's judgment
was_strategic = Column(Boolean, nullable=False) # User's actual judgment
reason = Column(Text, nullable=True) # Why they agree/disagree
# Reference to original AI prediction for analysis
ai_predicted_strategic = Column(Boolean, nullable=False)
ai_confidence = Column(Float, nullable=True)
# Optional: user info for tracking
user_id = Column(String(100), nullable=True) # Simple identifier (email, name, etc.)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationship to signal
signal = relationship("Signal", backref="feedbacks")

View File

@ -0,0 +1,69 @@
"""统一模型包 - PMFlow + Neural Sync"""
from .base import Base, BaseNoTenant, BaseNoCreatedAt
from .enums import UserRole, UserStatus
from .tenant import Tenant
from .user import User
from .user_project import UserProject
from .skill import SkillTag
from .user_skill_binding import UserSkillBinding
from .user_workload import UserWorkload
from .signal import Signal
from .signal_feedback import SignalFeedback
from .context_node import ContextNode
from .experiment import Experiment
from .project_domain import ProjectDomain
from .project import Project
from .project_signal_link import ProjectSignalLink
from .truth_node import TruthNode
from .task import Task
from .alignment_record import AlignmentRecord
from .cognitive_conflict import CognitiveConflict
from .model_version import ModelVersion
from .model_requirement_binding import ModelRequirementBinding
from .model_capability_matrix import (
ModelCapabilityGap,
CostTracking,
ModelUpdateImpact,
ExperimentTruthNodeSync,
Budget,
BudgetAlert,
)
from .integration import Integration
from .ai_call_log import AICallLog
from .operation_log import OperationLog
__all__ = [
"Base",
"BaseNoTenant",
"BaseNoCreatedAt",
"UserRole",
"UserStatus",
"Tenant",
"User",
"UserProject",
"SkillTag",
"UserSkillBinding",
"UserWorkload",
"Signal",
"SignalFeedback",
"ContextNode",
"Experiment",
"ProjectDomain",
"Project",
"ProjectSignalLink",
"TruthNode",
"Task",
"AlignmentRecord",
"CognitiveConflict",
"ModelVersion",
"ModelRequirementBinding",
"ModelCapabilityGap",
"CostTracking",
"ModelUpdateImpact",
"ExperimentTruthNodeSync",
"Budget",
"BudgetAlert",
"Integration",
"AICallLog",
"OperationLog",
]

View File

@ -0,0 +1,34 @@
"""AI 调用日志"""
from datetime import datetime
from typing import Optional
from sqlalchemy import BigInteger, DECIMAL, ForeignKey, Integer, Text, TIMESTAMP, VARCHAR, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import BaseNoTenant
class BaseLog(BaseNoTenant):
"""日志表基类"""
__abstract__ = True
tenant_id: Mapped[int] = mapped_column(
BigInteger, nullable=False, default=1, index=True
)
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP, nullable=False, server_default=func.now()
)
class AICallLog(BaseLog):
__tablename__ = "ai_call_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[Optional[int]] = mapped_column(
BigInteger, ForeignKey("users.id"), nullable=True
)
function_name: Mapped[str] = mapped_column(VARCHAR(255), nullable=False)
prompt: Mapped[str] = mapped_column(Text, nullable=False)
response: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
tokens_used: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
cost: Mapped[Optional[float]] = mapped_column(DECIMAL(10, 6), nullable=True)
status: Mapped[str] = mapped_column(VARCHAR(50), nullable=False)

View File

@ -0,0 +1,20 @@
"""认知对齐记录模型"""
from .base import Base
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import BigInteger, Integer, Boolean, Text, ForeignKey
from typing import Optional
class AlignmentRecord(Base):
__tablename__ = "alignment_records"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
truth_node_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("truth_nodes.id"), nullable=False
)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id"), nullable=False
)
alignment_score: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
is_confirmed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
comments: Mapped[Optional[str]] = mapped_column(Text, nullable=True)

View File

@ -0,0 +1,44 @@
"""核心基类
Tenant 表无 tenant_id(自身即租户),使用 BaseNoTenant。
其他表继承 Base(含 tenant_id、created_at、updated_at)。
"""
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import TIMESTAMP, func, BigInteger
from typing import Optional
class BaseNoTenant(DeclarativeBase):
"""无 tenant_id 的基类,用于 Tenant 表"""
__abstract__ = True
created_at: Mapped[Optional[TIMESTAMP]] = mapped_column(
TIMESTAMP, nullable=False, server_default=func.now()
)
updated_at: Mapped[Optional[TIMESTAMP]] = mapped_column(
TIMESTAMP, nullable=False, server_default=func.now(), onupdate=func.now()
)
class Base(BaseNoTenant):
"""带 tenant_id 的基类,所有租户表继承此类"""
__abstract__ = True
tenant_id: Mapped[int] = mapped_column(
BigInteger, nullable=False, default=1, index=True
)
created_at: Mapped[Optional[TIMESTAMP]] = mapped_column(
TIMESTAMP, nullable=False, server_default=func.now()
)
updated_at: Mapped[Optional[TIMESTAMP]] = mapped_column(
TIMESTAMP, nullable=False, server_default=func.now(), onupdate=func.now()
)
class BaseNoCreatedAt(BaseNoTenant):
"""仅有 tenant_id 和 updated_at,无 created_at(如 user_workloads)"""
__abstract__ = True
tenant_id: Mapped[int] = mapped_column(
BigInteger, nullable=False, default=1, index=True
)
updated_at: Mapped[Optional[TIMESTAMP]] = mapped_column(
TIMESTAMP, nullable=False, server_default=func.now(), onupdate=func.now()
)

View File

@ -0,0 +1,34 @@
"""认知冲突模型"""
from .base import BaseNoTenant
from .types import ARRAY
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import BigInteger, Integer, VARCHAR, Text, TIMESTAMP, ForeignKey, func
from typing import Optional, List
from datetime import datetime
class BaseNoUpdatedAt(BaseNoTenant):
"""仅有 tenant_id 和 created_at,无 updated_at"""
__abstract__ = True
tenant_id: Mapped[int] = mapped_column(
BigInteger, nullable=False, default=1, index=True
)
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP, nullable=False, server_default=func.now()
)
class CognitiveConflict(BaseNoUpdatedAt):
__tablename__ = "cognitive_conflicts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
truth_node_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("truth_nodes.id"), nullable=False
)
conflict_type: Mapped[str] = mapped_column(VARCHAR(100), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False)
involved_user_ids: Mapped[List[int]] = mapped_column(
ARRAY(BigInteger), nullable=False
)
status: Mapped[str] = mapped_column(VARCHAR(50), nullable=False)
resolved_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)

View File

@ -0,0 +1,17 @@
"""上下文节点模型"""
from .base import Base
from .types import Vector, JSONB
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import BigInteger, Text, Integer, VARCHAR
from typing import Optional
class ContextNode(Base):
__tablename__ = "context_nodes"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
content: Mapped[str] = mapped_column(Text, nullable=False)
embedding: Mapped[Optional[object]] = mapped_column(Vector(768), nullable=True)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="active")
ai_insights: Mapped[Optional[object]] = mapped_column(JSONB, nullable=True)

View File

@ -0,0 +1,18 @@
"""枚举定义"""
from enum import Enum
class UserRole(str, Enum):
"""用户角色"""
BOSS = "BOSS"
ADMIN = "ADMIN"
AI = "AI"
FE = "FE"
BE = "BE"
class UserStatus(str, Enum):
"""用户状态"""
ACTIVE = "active"
AWAY = "away"
OFFLINE = "offline"

View File

@ -0,0 +1,21 @@
"""实验模型"""
from .base import Base
from .types import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import BigInteger, Integer, VARCHAR, Text, Float, ForeignKey
from typing import Optional
class Experiment(Base):
__tablename__ = "experiments"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
title: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True)
hypothesis: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(VARCHAR(50), nullable=False, default="draft")
target_metric: Mapped[Optional[str]] = mapped_column(VARCHAR(50), nullable=True)
current_score: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
linked_context_nodes: Mapped[Optional[object]] = mapped_column(JSONB, nullable=True)
project_id: Mapped[Optional[int]] = mapped_column(
BigInteger, ForeignKey("projects.id"), nullable=True, index=True
)

View File

@ -0,0 +1,14 @@
"""集成配置表"""
from .base import Base
from .types import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import BigInteger, Integer, VARCHAR, Boolean
class Integration(Base):
__tablename__ = "integrations"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
type: Mapped[str] = mapped_column(VARCHAR(100), nullable=False)
config: Mapped[dict] = mapped_column(JSONB, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)

View File

View File

@ -0,0 +1,288 @@
"""模型能力矩阵核心模型
AI 开发团队专属垂直能力矩阵的数据模型,包括:
- ModelCapabilityGap: 模型能力差距记录(认知差预警)
- CostTracking: 算力成本跟踪与 ROI 核算
- ModelUpdateImpact: 模型更新影响评估(阶段2)
- ExperimentTruthNodeSync: 实验与宪章同步记录(阶段2)
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import BigInteger, DECIMAL, Boolean, Float, ForeignKey, Integer, Text, VARCHAR, TIMESTAMP
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
from .types import JSONB
class ModelCapabilityGap(Base):
"""模型能力差距记录 - AI 预警认知差
当需求与绑定的模型能力不匹配时,自动生成差距记录,
提前预警产品经理和技术团队,规避返工风险。
"""
__tablename__ = "model_capability_gaps"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
model_version_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("model_versions.id"), nullable=False, index=True
)
truth_node_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("truth_nodes.id"), nullable=False, index=True
)
# 差距类型:capability(能力不足)、cost(成本过高)、performance(性能不符)、risk(兼容性风险)
gap_type: Mapped[str] = mapped_column(VARCHAR(50), nullable=False, index=True)
# 严重程度:critical(严重,必须解决)、high(高,建议解决)、medium(中)、low(低)
severity: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, index=True)
# 差距描述和 AI 分析
description: Mapped[str] = mapped_column(Text, nullable=False)
ai_analysis: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
# 状态:open(待处理)、acknowledged(已确认)、resolved(已解决)、ignored(已忽略)
status: Mapped[str] = mapped_column(
VARCHAR(50), nullable=False, default="open", index=True
)
# 解决时间
resolved_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)
# 解决方案备注
resolution_notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
class CostTracking(Base):
"""算力成本跟踪与 ROI 核算
跟踪每个需求(宪章)、每个任务的算力成本,
自动计算 ROI,实现成本透明化。
"""
__tablename__ = "cost_trackings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 关联的宪章(需求)
truth_node_id: Mapped[Optional[int]] = mapped_column(
BigInteger, ForeignKey("truth_nodes.id"), nullable=True, index=True
)
# 关联的模型版本
model_version_id: Mapped[Optional[int]] = mapped_column(
BigInteger, ForeignKey("model_versions.id"), nullable=True
)
# 关联的任务(可选,用于任务级成本追踪)
task_id: Mapped[Optional[int]] = mapped_column(
BigInteger, ForeignKey("tasks.id"), nullable=True, index=True
)
# 周期:daily、weekly、monthly、project(项目全周期)
period: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, index=True)
# Token 数量
estimated_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
actual_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# 成本(美元)
estimated_cost_usd: Mapped[float] = mapped_column(
DECIMAL(10, 4), nullable=False, default=0.0
)
actual_cost_usd: Mapped[float] = mapped_column(
DECIMAL(10, 4), nullable=False, default=0.0
)
# ROI 评分(0-10)
roi_score: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
# ROI 分析详情
roi_analysis: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
# 成本优化建议
optimization_suggestions: Mapped[dict] = mapped_column(
JSONB, nullable=False, default=dict
)
class ModelUpdateImpact(Base):
"""模型更新影响评估
当监控到新模型发布时,自动评估对现有项目的影响,
生成迁移任务和调整建议。
"""
__tablename__ = "model_update_impacts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 旧模型和新模型
old_model_version_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("model_versions.id"), nullable=False
)
new_model_version_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("model_versions.id"), nullable=False
)
# 受影响的宪章及其影响评分 {node_id: impact_score}
affected_truth_nodes: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
# 成本变化百分比
cost_change_percentage: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
# 性能变化 {speed_change_percent, quality_change_percent, etc.}
performance_change: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
# 兼容性风险列表
compatibility_risks: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
# 自动生成的迁移任务
migration_tasks: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
# 升级建议:immediate_upgrade(立即升级)、evaluate(评估后升级)、defer(暂缓升级)
recommendation: Mapped[str] = mapped_column(VARCHAR(50), nullable=False)
# 状态:pending_review(待审核)、approved(已批准)、in_progress(进行中)、completed(已完成)
status: Mapped[str] = mapped_column(
VARCHAR(50), nullable=False, default="pending_review"
)
class ExperimentTruthNodeSync(Base):
"""实验与宪章同步记录
算法工程师的实验结果自动同步到项目宪章,
确保实验与落地不脱节。
"""
__tablename__ = "experiment_truth_node_syncs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 关联的实验(需要 Experiment 表,如果不存在可以先创建外键约束)
experiment_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True)
# 实验标识(用于关联外部实验平台)
experiment_external_id: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True)
# 关联的宪章
truth_node_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("truth_nodes.id"), nullable=False, index=True
)
# 同步类型:result_sync(结果同步)、metric_update(指标更新)、conclusion_sync(结论同步)
sync_type: Mapped[str] = mapped_column(VARCHAR(50), nullable=False)
# 同步数据(包含实验结果、建议的变更等)
sync_data: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
# AI 置信度(0-1)
confidence_score: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
# 是否自动应用
auto_applied: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# 应用状态:pending(待应用)、applied(已应用)、rejected(已拒绝)、requires_review(需审核)
apply_status: Mapped[str] = mapped_column(
VARCHAR(50), nullable=False, default="pending"
)
# 审核意见
review_notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# 审核人
reviewed_by: Mapped[Optional[int]] = mapped_column(
BigInteger, ForeignKey("users.id"), nullable=True
)
# 审核时间
reviewed_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)
class Budget(Base):
"""预算设置
按租户、项目设置月度/季度预算,
实现硬性预算管控。
"""
__tablename__ = "budgets"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 预算类型:tenant(租户级)、project(项目级)
budget_type: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, index=True)
# 关联项目(project 类型时使用)
project_id: Mapped[Optional[int]] = mapped_column(
BigInteger, ForeignKey("projects.id"), nullable=True, index=True
)
# 周期:monthly、quarterly、yearly
period: Mapped[str] = mapped_column(VARCHAR(20), nullable=False)
# 预算金额(美元)
budget_amount_usd: Mapped[float] = mapped_column(
DECIMAL(12, 2), nullable=False, default=0.0
)
# 预警阈值(百分比,默认 80%)
alert_threshold_percent: Mapped[float] = mapped_column(
Float, nullable=False, default=80.0
)
# 硬性限制(是否启用硬性预算控制)
hard_limit_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# 当前已使用金额
current_usage_usd: Mapped[float] = mapped_column(
DECIMAL(12, 2), nullable=False, default=0.0
)
# 当前周期(用于跟踪每月/每季度的使用情况)
current_period_start: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)
current_period_end: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)
# 状态:active(激活)、suspended(暂停,超支后暂停)、overridden(被覆盖)
status: Mapped[str] = mapped_column(
VARCHAR(20), nullable=False, default="active"
)
# 超支解锁记录(BOSS/ADMIN 可以临时解锁)
override_history: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
class BudgetAlert(Base):
"""预算预警记录
当预算使用达到预警阈值或超支时,自动生成预警记录。
"""
__tablename__ = "budget_alerts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 关联预算
budget_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("budgets.id"), nullable=False, index=True
)
# 预警类型:threshold_reached(达到阈值)、exceeded(超支)、suspended(暂停服务)
alert_type: Mapped[str] = mapped_column(VARCHAR(50), nullable=False, index=True)
# 当前使用量
usage_usd: Mapped[float] = mapped_column(DECIMAL(12, 2), nullable=False)
# 使用百分比
usage_percent: Mapped[float] = mapped_column(Float, nullable=False)
# 预警消息
message: Mapped[str] = mapped_column(Text, nullable=False)
# 是否已读
is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# 预警操作(如暂停服务)
action_taken: Mapped[Optional[str]] = mapped_column(VARCHAR(50), nullable=True)
# 状态:active(激活)、dismissed(已忽略)、resolved(已解决)
status: Mapped[str] = mapped_column(
VARCHAR(20), nullable=False, default="active"
)

View File

@ -0,0 +1,36 @@
"""模型与需求绑定"""
from datetime import datetime
from typing import Optional
from sqlalchemy import BigInteger, DECIMAL, ForeignKey, Integer, Text, TIMESTAMP, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import BaseNoTenant
class BaseCreatedOnly(BaseNoTenant):
"""仅有 tenant_id 和 created_at,无 updated_at"""
__abstract__ = True
tenant_id: Mapped[int] = mapped_column(
BigInteger, nullable=False, default=1, index=True
)
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP, nullable=False, server_default=func.now()
)
class ModelRequirementBinding(BaseCreatedOnly):
__tablename__ = "model_requirement_bindings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
truth_node_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("truth_nodes.id"), nullable=False
)
model_version_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("model_versions.id"), nullable=False
)
estimated_cost: Mapped[Optional[float]] = mapped_column(
DECIMAL(10, 2), nullable=True
)
compatibility_score: Mapped[int] = mapped_column(Integer, nullable=False)
risk_notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)

View File

@ -0,0 +1,20 @@
"""模型版本表"""
from .base import Base
from .types import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import BigInteger, Integer, VARCHAR, DECIMAL, Boolean
from typing import Optional
class ModelVersion(Base):
__tablename__ = "model_versions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(VARCHAR(255), nullable=False)
provider: Mapped[str] = mapped_column(VARCHAR(100), nullable=False)
version: Mapped[str] = mapped_column(VARCHAR(100), nullable=False)
capability_boundaries: Mapped[dict] = mapped_column(JSONB, nullable=False)
cost_per_1k_tokens: Mapped[Optional[float]] = mapped_column(
DECIMAL(10, 6), nullable=True
)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)

View File

@ -0,0 +1,27 @@
"""操作日志模型(全链路可追溯)"""
from typing import Optional
from sqlalchemy import BigInteger, Integer, ForeignKey, Text, VARCHAR
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
from .types import JSONB
class OperationLog(Base):
__tablename__ = "operation_logs"
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
)
project_id: Mapped[Optional[int]] = mapped_column(
BigInteger, ForeignKey("projects.id"), nullable=True, index=True
)
entity_type: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=True)
entity_id: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, index=True)
action: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=True)
before_data: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
after_data: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
remark: Mapped[Optional[str]] = mapped_column(Text, nullable=True)

View File

@ -0,0 +1,25 @@
"""项目模型 - 含 PMFlow 与 Neural Sync 字段"""
from .base import Base
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import BigInteger, Integer, VARCHAR, Text, ForeignKey
from typing import Optional
class Project(Base):
__tablename__ = "projects"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(VARCHAR(255), nullable=False)
title: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
owner_id: Mapped[Optional[int]] = mapped_column(
BigInteger, ForeignKey("users.id"), nullable=True
)
status: Mapped[str] = mapped_column(VARCHAR(50), nullable=False)
domain_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True)
assigned_role: Mapped[Optional[str]] = mapped_column(VARCHAR(50), nullable=True)
signal_count: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
signal_links = relationship(
"ProjectSignalLink", back_populates="project", cascade="all, delete-orphan"
)

View File

@ -0,0 +1,22 @@
"""项目域模型"""
from .base import Base
from .types import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import BigInteger, VARCHAR, Text, Boolean, Integer
from typing import Optional
class ProjectDomain(Base):
__tablename__ = "project_domains"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(VARCHAR(100), nullable=False)
slug: Mapped[str] = mapped_column(VARCHAR(50), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
config: Mapped[Optional[object]] = mapped_column(JSONB, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
signal_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
context_node_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
experiment_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
owner_id: Mapped[Optional[str]] = mapped_column(VARCHAR(100), nullable=True)

View File

@ -0,0 +1,21 @@
"""项目-信号关联"""
from .base import Base
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import BigInteger, Integer, VARCHAR, Float, ForeignKey
class ProjectSignalLink(Base):
__tablename__ = "project_signal_links"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("projects.id"), nullable=False
)
signal_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("signals.id"), nullable=False
)
link_type: Mapped[str] = mapped_column(VARCHAR(50), nullable=False, default="related")
relevance_score: Mapped[float] = mapped_column(Float, nullable=False, default=1.0)
project = relationship("Project", back_populates="signal_links")
signal = relationship("Signal")

View File

@ -0,0 +1,20 @@
"""信号模型"""
from .base import Base
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import BigInteger, Integer, VARCHAR, Text, Float, Boolean
from typing import Optional
class Signal(Base):
__tablename__ = "signals"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
source: Mapped[Optional[str]] = mapped_column(VARCHAR(50), nullable=True)
content: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
impact_score: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
priority: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="low")
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)
feedbacks = relationship("SignalFeedback", back_populates="signal", cascade="all, delete-orphan")

View File

@ -0,0 +1,20 @@
"""信号反馈模型"""
from .base import Base
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import BigInteger, Integer, Float, Boolean, Text, VARCHAR, ForeignKey
class SignalFeedback(Base):
__tablename__ = "signal_feedbacks"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
signal_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("signals.id"), nullable=False
)
was_strategic: Mapped[bool] = mapped_column(Boolean, nullable=False)
reason: Mapped[str] = mapped_column(Text, nullable=True)
ai_predicted_strategic: Mapped[bool] = mapped_column(Boolean, nullable=False)
ai_confidence: Mapped[float] = mapped_column(Float, nullable=True)
user_id: Mapped[str] = mapped_column(VARCHAR(100), nullable=True)
signal = relationship("Signal", back_populates="feedbacks")

View File

@ -0,0 +1,21 @@
"""技能标签模型"""
from .base import Base
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import BigInteger, VARCHAR, Boolean, Integer, Text
from typing import List, Optional
class SkillTag(Base):
__tablename__ = "skill_tags"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(VARCHAR(255), nullable=False)
slug: Mapped[str] = mapped_column(VARCHAR(80), nullable=False, index=True)
category: Mapped[str] = mapped_column(VARCHAR(100), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
is_system: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
user_bindings: Mapped[List["UserSkillBinding"]] = relationship(
"UserSkillBinding", back_populates="skill_tag", cascade="all, delete-orphan"
)

View File

@ -0,0 +1,35 @@
"""任务模型"""
from .base import Base
from .types import JSONB, ARRAY
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import BigInteger, Integer, VARCHAR, Text, DECIMAL, TIMESTAMP, ForeignKey
from typing import Optional, List
from datetime import datetime
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
truth_node_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("truth_nodes.id"), nullable=False
)
project_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("projects.id"), nullable=False
)
assignee_id: Mapped[Optional[int]] = mapped_column(
BigInteger, ForeignKey("users.id"), nullable=True
)
ai_recommended_assignees: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
title: Mapped[str] = mapped_column(VARCHAR(500), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
required_skill_tag_ids: Mapped[List[int]] = mapped_column(
ARRAY(BigInteger), nullable=False
)
status: Mapped[str] = mapped_column(VARCHAR(50), nullable=False)
priority: Mapped[str] = mapped_column(VARCHAR(50), nullable=False)
estimated_hours: Mapped[Optional[float]] = mapped_column(DECIMAL(5, 2), nullable=True)
actual_hours: Mapped[Optional[float]] = mapped_column(DECIMAL(5, 2), nullable=True)
quality_score: Mapped[Optional[float]] = mapped_column(DECIMAL(3, 1), nullable=True)
due_date: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)
completed_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)

View File

@ -0,0 +1,13 @@
"""租户模型"""
from .base import BaseNoTenant
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import BigInteger, Integer, VARCHAR
from typing import Optional
class Tenant(BaseNoTenant):
__tablename__ = "tenants"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(VARCHAR(255), nullable=False)
domain: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True)

View File

@ -0,0 +1,24 @@
"""真理节点模型"""
from .base import Base
from .types import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import BigInteger, VARCHAR, Integer, ForeignKey
from typing import Optional
class TruthNode(Base):
__tablename__ = "truth_nodes"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
node_type: Mapped[str] = mapped_column(VARCHAR(50), nullable=False)
parent_id: Mapped[Optional[int]] = mapped_column(
BigInteger, ForeignKey("truth_nodes.id"), nullable=True
)
project_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True)
created_by: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id"), nullable=False
)
title: Mapped[str] = mapped_column(VARCHAR(500), nullable=False)
content: Mapped[dict] = mapped_column(JSONB, nullable=False)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
status: Mapped[str] = mapped_column(VARCHAR(50), nullable=False)

View File

@ -0,0 +1,76 @@
"""PostgreSQL / SQLite 类型兼容层
仅当 DATABASE_URL 明确为 PostgreSQL 时使用 JSONB/ARRAY/Vector,
否则使用 SQLite 兼容的 TypeDecorator(与 database.py 默认一致)。
"""
import os
import json
# 与 database.py 默认一致:未设置或 sqlite 时按 SQLite 处理
_database_url = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./neural_sync.db").lower()
_use_pg = "postgresql" in _database_url or "postgres" in _database_url
try:
if not _use_pg:
raise ImportError("SQLite in use")
from sqlalchemy.dialects.postgresql import JSONB, ARRAY
from pgvector.sqlalchemy import Vector
HAS_PG = True
except ImportError:
HAS_PG = False
from sqlalchemy.types import TypeDecorator, Text, JSON
class JSONB(TypeDecorator):
impl = Text
cache_ok = True
def process_bind_param(self, value, dialect):
if value is None:
return None
return json.dumps(value) if not isinstance(value, str) else value
def process_result_value(self, value, dialect):
if value is None:
return None
if isinstance(value, str):
try:
return json.loads(value)
except (TypeError, ValueError):
# 兼容历史脏数据:某些 JSONB 字段被写入了纯文本
return value
return value
class ARRAY(TypeDecorator):
impl = Text
cache_ok = True
def __init__(self, item_type=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.item_type = item_type
def process_bind_param(self, value, dialect):
if value is None:
return None
return json.dumps(list(value))
def process_result_value(self, value, dialect):
if value is None:
return None
return json.loads(value) if isinstance(value, str) else value
class Vector(TypeDecorator):
impl = Text
cache_ok = True
def __init__(self, dim=None, *args, **kwargs):
super().__init__(*args, **kwargs)
def process_bind_param(self, value, dialect):
if value is None:
return None
return json.dumps(value) if not isinstance(value, str) else value
def process_result_value(self, value, dialect):
if value is None:
return None
return json.loads(value) if isinstance(value, str) else value

View File

@ -0,0 +1,56 @@
"""用户模型"""
from __future__ import annotations
from .base import Base
from .enums import UserRole, UserStatus
from .types import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import BigInteger, VARCHAR, TEXT, Integer, Boolean
from typing import List, Optional, TYPE_CHECKING, Any
if TYPE_CHECKING:
from .user_skill_binding import UserSkillBinding
from .user_project import UserProject
from .user_workload import UserWorkload
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(VARCHAR(100), nullable=False, unique=True)
email: Mapped[str] = mapped_column(VARCHAR(255), nullable=False, unique=True)
password_hash: Mapped[str] = mapped_column(VARCHAR(255), nullable=False)
name: Mapped[str] = mapped_column(VARCHAR(100), nullable=False)
role: Mapped[str] = mapped_column(VARCHAR(50), nullable=False)
assigned_domains: Mapped[Any] = mapped_column(JSONB, nullable=True, default=list)
workload: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
status: Mapped[str] = mapped_column(VARCHAR(50), nullable=False, default="active")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
avatar_url: Mapped[Optional[str]] = mapped_column(VARCHAR(500), nullable=True)
bio: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
skill_bindings: Mapped[List["UserSkillBinding"]] = relationship(
"UserSkillBinding", back_populates="user", cascade="all, delete-orphan"
)
workload_rel: Mapped[Optional["UserWorkload"]] = relationship(
"UserWorkload", back_populates="user", uselist=False, cascade="all, delete-orphan"
)
project_links: Mapped[List["UserProject"]] = relationship(
"UserProject", back_populates="user", cascade="all, delete-orphan"
)
def to_dict(self) -> dict:
return {
"id": str(self.id),
"username": self.username,
"name": self.name,
"email": self.email,
"role": self.role,
"assigned_domains": self.assigned_domains or [],
"workload": self.workload,
"status": self.status,
"is_active": self.is_active,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}

View File

@ -0,0 +1,26 @@
"""用户-项目关联模型。"""
from __future__ import annotations
from sqlalchemy import BigInteger, Integer, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base
class UserProject(Base):
__tablename__ = "user_projects"
__table_args__ = (
UniqueConstraint("tenant_id", "user_id", "project_id", name="uq_user_projects_tenant_user_project"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
project_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
user = relationship("User", back_populates="project_links")
project = relationship("Project")

View File

@ -0,0 +1,28 @@
"""用户-技能绑定模型"""
from .base import Base
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import BigInteger, VARCHAR, DECIMAL, TIMESTAMP, ForeignKey, Integer, Float
from typing import Optional
from datetime import datetime
class UserSkillBinding(Base):
__tablename__ = "user_skill_bindings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id"), nullable=False
)
skill_tag_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("skill_tags.id"), nullable=False
)
proficiency_level: Mapped[str] = mapped_column(VARCHAR(50), nullable=False)
proficiency: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
level: Mapped[Optional[str]] = mapped_column(VARCHAR(20), nullable=True)
years_experience: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
last_used_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)
quality_score: Mapped[float] = mapped_column(DECIMAL(3, 1), nullable=False, default=0.0)
efficiency_score: Mapped[float] = mapped_column(DECIMAL(3, 1), nullable=False, default=0.0)
user = relationship("User", back_populates="skill_bindings")
skill_tag = relationship("SkillTag", back_populates="user_bindings")

View File

@ -0,0 +1,24 @@
"""用户工作量模型"""
from .base import BaseNoCreatedAt
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import BigInteger, Integer, DECIMAL, ForeignKey
class UserWorkload(BaseNoCreatedAt):
__tablename__ = "user_workloads"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id"), nullable=False
)
current_assigned_hours: Mapped[float] = mapped_column(
DECIMAL(5, 2), nullable=False, default=0.0
)
max_available_hours_per_week: Mapped[float] = mapped_column(
DECIMAL(5, 2), nullable=False, default=40.0
)
workload_ratio: Mapped[float] = mapped_column(
DECIMAL(4, 2), nullable=False, default=0.0
)
user = relationship("User", back_populates="workload_rel")

View File

@ -0,0 +1,90 @@
"""
Pydantic 请求/响应模型
将 API 的请求体、响应体模型集中管理,便于复用与维护。
"""
from .auth import (
UserCreate,
UserUpdate,
UserResponse,
UserLoginResponse,
UserResetPasswordRequest,
UserStatusPatchRequest,
)
from .signal import SignalCreate, SignalPriority, classify_signal_priority
from .experiment import ExperimentCreate, ExperimentUpdate
from .context import ContextNodeCreate
from .common import TaskBreakdownRequest, TaskBreakdownResponse, TaskItem
from .feedback import SignalFeedbackCreate, SignalFeedbackResponse
from .model_capability import (
ValidateMatchRequest,
LifecycleImpactRequest,
GenerateMigrationTasksRequest,
ExperimentSyncRequest,
ExperimentSyncReviewRequest,
ExperimentSyncRollbackRequest,
CostOptimizationAnalyzeRequest,
)
from .budget import BudgetCreateRequest, BudgetUpdateRequest, BudgetOverrideRequest, BudgetCheckRequest
from .sstc import (
SSTCStageKey,
StageStatus,
StageNode,
ProjectOverview,
BoardOverviewResponse,
CharterUpdateRequest,
AlignmentStartRequest,
AlignmentConfirmRequest,
TaskStatusPatchRequest,
ReleaseCreateRequest,
ReleaseStatusPatchRequest,
ReviewCreateRequest,
ReviewArchiveRequest,
)
from . import ai as ai_schemas
__all__ = [
"SignalPriority",
"classify_signal_priority",
"UserCreate",
"UserUpdate",
"UserResponse",
"UserLoginResponse",
"UserResetPasswordRequest",
"UserStatusPatchRequest",
"SignalCreate",
"SignalPriority",
"ExperimentCreate",
"ExperimentUpdate",
"ContextNodeCreate",
"TaskBreakdownRequest",
"TaskBreakdownResponse",
"TaskItem",
"SignalFeedbackCreate",
"SignalFeedbackResponse",
"ValidateMatchRequest",
"LifecycleImpactRequest",
"GenerateMigrationTasksRequest",
"ExperimentSyncRequest",
"ExperimentSyncReviewRequest",
"ExperimentSyncRollbackRequest",
"CostOptimizationAnalyzeRequest",
"BudgetCreateRequest",
"BudgetUpdateRequest",
"BudgetOverrideRequest",
"BudgetCheckRequest",
"SSTCStageKey",
"StageStatus",
"StageNode",
"ProjectOverview",
"BoardOverviewResponse",
"CharterUpdateRequest",
"AlignmentStartRequest",
"AlignmentConfirmRequest",
"TaskStatusPatchRequest",
"ReleaseCreateRequest",
"ReleaseStatusPatchRequest",
"ReviewCreateRequest",
"ReviewArchiveRequest",
]

150
backend/app/schemas/ai.py Normal file
View File

@ -0,0 +1,150 @@
"""
PMFlow AI 服务 - Pydantic 请求/响应模型
对应 ai_service 各核心函数的输入输出。
"""
from typing import List, Optional
from pydantic import BaseModel, Field
# ============================================================================
# 需求宪章
# ============================================================================
class CharterDraftResponse(BaseModel):
"""从信号生成的需求宪章草案"""
title: str
summary: str
background: str
objectives: List[str]
scope: str
success_metrics: List[str]
risks: List[str]
raw_content: Optional[str] = None
# ============================================================================
# 任务拆解
# ============================================================================
class TaskWithSkillRequirement(BaseModel):
"""带技能要求的任务项"""
task_name: str
description: str
type: str # frontend, backend, database, ai, devops
estimated_hours: float
required_skill_tag_ids: List[int] = Field(default_factory=list)
required_skill_names: List[str] = Field(default_factory=list)
priority: str = "medium"
class TaskDecompositionResponse(BaseModel):
"""宪章拆解任务响应"""
tasks: List[TaskWithSkillRequirement]
total_estimated_hours: float
detected_skills: List[str]
# ============================================================================
# 人员推荐
# ============================================================================
class AssigneeRecommendation(BaseModel):
"""被推荐人"""
user_id: str # UUID 或 int 的字符串形式
user_name: str
match_score: float = Field(..., ge=0, le=100)
reason: str
current_workload: float
proficiency_avg: float
historical_performance: Optional[float] = None
class AssigneeRecommendationResponse(BaseModel):
"""人员推荐响应"""
recommendations: List[AssigneeRecommendation]
total_count: int
# ============================================================================
# 技能分数更新
# ============================================================================
class UserSkillScoreUpdate(BaseModel):
"""用户技能分数更新结果"""
user_id: str
skill_tag_id: str
quality_score_delta: float
efficiency_score_delta: float
last_used_at: str
class UpdateSkillScoresResponse(BaseModel):
"""更新技能分数响应"""
updated_count: int
updates: List[UserSkillScoreUpdate]
# ============================================================================
# 认知对齐
# ============================================================================
class AlignmentScoreResponse(BaseModel):
"""认知对齐度评分响应"""
user_id: str
truth_node_id: str
alignment_score: float = Field(..., ge=0, le=100)
dimension_scores: Optional[dict] = None
explanation: Optional[str] = None
# ============================================================================
# 认知冲突
# ============================================================================
class CognitiveConflict(BaseModel):
"""认知冲突项"""
conflict_type: str # e.g. definition, priority, scope
description: str
involved_user_ids: List[str]
involved_user_names: List[str]
severity: str # low, medium, high
class CognitiveConflictsResponse(BaseModel):
"""认知冲突检测响应"""
truth_node_id: str
conflicts: List[CognitiveConflict]
total_count: int
# ============================================================================
# 模型需求评估
# ============================================================================
class ModelRequirementEvaluation(BaseModel):
"""需求与模型匹配度评估"""
compatibility_score: float = Field(..., ge=0, le=100)
cost_estimate_usd: Optional[float] = None
cost_estimate_tokens_per_day: Optional[int] = None
risk_hints: List[str] = Field(default_factory=list)
recommendations: List[str] = Field(default_factory=list)

View File

@ -0,0 +1,71 @@
"""认证与用户相关 Pydantic 模型"""
from typing import List, Optional, Union
from pydantic import BaseModel
# 从 models 导入枚举以保持一致性
from ..models import UserRole, UserStatus
class UserLoginResponse(BaseModel):
"""登录响应"""
access_token: str
token_type: str
user: dict
class UserCreate(BaseModel):
"""创建用户请求"""
username: str
password: str
name: str
email: Optional[str] = None
role: Union[UserRole, List[UserRole], str]
assigned_domains: Optional[List[str]] = []
project_ids: Optional[List[int]] = []
class UserUpdate(BaseModel):
"""更新用户请求"""
name: Optional[str] = None
email: Optional[str] = None
role: Optional[Union[UserRole, List[UserRole], str]] = None
assigned_domains: Optional[List[str]] = None
workload: Optional[int] = None
status: Optional[UserStatus] = None
is_active: Optional[bool] = None
project_ids: Optional[List[int]] = None
class UserResetPasswordRequest(BaseModel):
"""重置密码请求"""
new_password: str
class UserStatusPatchRequest(BaseModel):
"""启用/禁用状态切换请求"""
is_active: bool
class UserResponse(BaseModel):
"""用户响应"""
id: str
username: str
name: str
email: Optional[str] = None
role: str
assigned_domains: List[str]
workload: int
status: str
is_active: bool = True
workload_ratio: Optional[float] = None
top_skills: List[str] = []
project_ids: List[int] = []
created_at: str
updated_at: str

View File

@ -0,0 +1,33 @@
"""预算管理相关 Pydantic 模型"""
from typing import Optional
from pydantic import BaseModel, Field
class BudgetCreateRequest(BaseModel):
budget_type: str = Field(..., pattern="^(tenant|project)$")
project_id: Optional[int] = None
period: str = Field(default="monthly", pattern="^(monthly|quarterly|yearly)$")
budget_amount_usd: float = Field(..., gt=0)
alert_threshold_percent: float = Field(default=80.0, ge=1.0, le=100.0)
hard_limit_enabled: bool = True
class BudgetUpdateRequest(BaseModel):
period: Optional[str] = Field(default=None, pattern="^(monthly|quarterly|yearly)$")
budget_amount_usd: Optional[float] = Field(default=None, gt=0)
alert_threshold_percent: Optional[float] = Field(default=None, ge=1.0, le=100.0)
hard_limit_enabled: Optional[bool] = None
status: Optional[str] = Field(default=None, pattern="^(active|suspended|overridden)$")
class BudgetOverrideRequest(BaseModel):
note: str = ""
duration_hours: int = Field(default=24, ge=1, le=168)
class BudgetCheckRequest(BaseModel):
estimated_cost_usd: float = Field(..., ge=0)
project_id: Optional[int] = None

View File

@ -0,0 +1,26 @@
"""通用 Pydantic 模型"""
from typing import List, Optional
from pydantic import BaseModel
class TaskItem(BaseModel):
"""任务项"""
task_name: str
type: str
estimated_hours: float
class TaskBreakdownRequest(BaseModel):
"""任务拆解请求"""
description: str
sync_to_plane: bool = False
class TaskBreakdownResponse(BaseModel):
"""任务拆解响应"""
tasks: List[TaskItem]
sync_result: Optional[dict] = None

View File

@ -0,0 +1,9 @@
"""知识节点相关 Pydantic 模型"""
from pydantic import BaseModel
class ContextNodeCreate(BaseModel):
"""创建知识节点请求"""
content: str

View File

@ -0,0 +1,20 @@
"""实验相关 Pydantic 模型"""
import uuid
from typing import List
from pydantic import BaseModel
class ExperimentCreate(BaseModel):
"""创建实验请求"""
title: str
hypothesis: str
target_metric: str
linked_context_nodes: List[uuid.UUID] = []
class ExperimentUpdate(BaseModel):
"""更新实验请求"""
current_score: float

View File

@ -0,0 +1,23 @@
"""信号反馈相关 Pydantic 模型"""
from pydantic import BaseModel
class SignalFeedbackCreate(BaseModel):
"""创建信号反馈请求"""
signal_id: int
was_strategic: bool
reason: str = ""
user_id: str = ""
class SignalFeedbackResponse(BaseModel):
"""信号反馈响应"""
id: int
signal_id: int
was_strategic: bool
ai_predicted_strategic: bool
ai_confidence: float
is_correct: bool

View File

@ -0,0 +1,93 @@
"""模型能力矩阵相关 Pydantic 模型"""
from typing import Literal, Optional
from pydantic import BaseModel, Field
class ValidateMatchRequest(BaseModel):
"""模型-需求匹配校验请求"""
truth_node_id: int = Field(..., description="宪章节点 ID")
model_version_id: int = Field(..., description="模型版本 ID")
project_id: Optional[int] = Field(default=None, description="项目 ID(用于交叉校验)")
class CostTrackingQuery(BaseModel):
"""成本跟踪查询参数"""
period: str = Field(default="weekly", description="周期: daily/weekly/monthly/project")
class CostOptimizationQuery(BaseModel):
"""成本优化查询参数"""
period: str = Field(default="weekly", description="周期: daily/weekly/monthly/project")
class LifecycleImpactRequest(BaseModel):
"""模型生命周期影响评估请求"""
old_model_version_id: int = Field(..., description="旧模型版本 ID")
new_model_version_id: int = Field(..., description="新模型版本 ID")
class GenerateMigrationTasksRequest(BaseModel):
"""生成迁移任务请求"""
max_tasks: int = Field(default=5, ge=1, le=20, description="最多生成任务数")
class ExperimentSyncRequest(BaseModel):
"""实验结果同步请求"""
experiment_id: int = Field(..., description="实验 ID")
truth_node_id: int = Field(..., description="目标宪章 ID")
project_id: Optional[int] = Field(default=None, description="项目 ID(用于交叉校验)")
sync_type: Literal["result_sync", "metric_update", "conclusion_sync"] = Field(
default="result_sync",
description="同步类型",
)
auto_apply_threshold: float = Field(
default=0.8, ge=0.0, le=1.0, description="自动应用阈值"
)
class ExperimentSyncReviewRequest(BaseModel):
"""实验同步审核请求"""
action: Literal["approve", "reject"] = Field(..., description="审核动作")
review_notes: Optional[str] = Field(default=None, description="审核备注")
class ExperimentSyncRollbackRequest(BaseModel):
"""实验同步回滚请求"""
rollback_notes: Optional[str] = Field(default=None, description="回滚备注")
class CostOptimizationAnalyzeRequest(BaseModel):
"""成本优化分析请求"""
truth_node_id: int = Field(..., description="宪章 ID")
project_id: Optional[int] = Field(default=None, description="项目 ID(用于交叉校验)")
period: str = Field(default="weekly", description="周期: daily/weekly/monthly/project")
class ProjectTruthNodeContextItem(BaseModel):
"""项目上下文中的宪章项"""
id: int
title: str
status: str
node_type: str
class ProjectExperimentContextItem(BaseModel):
"""项目上下文中的实验项"""
id: int
title: str
status: str
target_metric: Optional[str] = None

View File

@ -0,0 +1,43 @@
"""信号相关 Pydantic 模型"""
from enum import Enum
from pydantic import BaseModel
class SignalPriority(str, Enum):
"""
信号优先级
- CRITICAL: impact >= 0.9, 立即处理 + 通知
- HIGH: 0.7 <= impact < 0.9, 异步处理 + 通知
- MEDIUM: 0.5 <= impact < 0.7, 仅异步处理
- LOW: impact < 0.5, 仅记录
"""
CRITICAL = "critical"
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
def classify_signal_priority(impact_score: float, confidence: float = 0.0) -> SignalPriority:
"""
根据 impact_score 和 confidence 分类信号优先级。
"""
if impact_score >= 0.9:
return SignalPriority.CRITICAL
elif impact_score >= 0.7 and confidence >= 0.6:
return SignalPriority.HIGH
elif impact_score >= 0.5 or (impact_score >= 0.7 and confidence < 0.6):
return SignalPriority.MEDIUM
else:
return SignalPriority.LOW
class SignalCreate(BaseModel):
"""创建信号请求"""
source: str
content: str
impact_score: float
priority: SignalPriority | None = None # 未提供时自动计算

View File

@ -0,0 +1,199 @@
"""
PMFlow 人员能力图谱 - Pydantic 请求/响应模型
包含 SkillTag、UserSkillBinding、UserWorkload 及人员推荐相关模型。
"""
from datetime import datetime
from typing import List, Optional
from uuid import UUID
from pydantic import BaseModel, Field
# ============================================================================
# SkillTag 模型
# ============================================================================
class SkillTagCreate(BaseModel):
"""创建技能标签请求"""
name: str = Field(..., min_length=1, max_length=100, description="技能名称")
slug: str = Field(..., min_length=1, max_length=80, description="URL 安全标识")
category: Optional[str] = Field(None, max_length=50, description="分类")
description: Optional[str] = Field(None, description="描述")
sort_order: int = Field(default=0, ge=0, description="排序序号")
class SkillTagUpdate(BaseModel):
"""更新技能标签请求(系统标签不可更新)"""
name: Optional[str] = Field(None, min_length=1, max_length=100)
category: Optional[str] = Field(None, max_length=50)
description: Optional[str] = None
sort_order: Optional[int] = Field(None, ge=0)
class SkillTagResponse(BaseModel):
"""技能标签响应"""
id: str
tenant_id: int | str
name: str
slug: str
category: Optional[str] = None
description: Optional[str] = None
is_system: bool
sort_order: int
created_at: str
updated_at: str
class Config:
from_attributes = True
# ============================================================================
# UserSkillBinding 模型
# ============================================================================
class UserSkillBindingCreate(BaseModel):
"""创建用户技能绑定请求"""
user_id: UUID
skill_tag_id: UUID
proficiency: int = Field(default=50, ge=0, le=100, description="掌握程度 0-100")
level: str = Field(
default="intermediate",
description="熟练等级: beginner, intermediate, advanced, expert",
)
years_experience: Optional[float] = Field(None, ge=0, description="从业年限")
class UserSkillBindingUpdate(BaseModel):
"""更新用户技能绑定请求"""
proficiency: Optional[int] = Field(None, ge=0, le=100)
level: Optional[str] = None
years_experience: Optional[float] = Field(None, ge=0)
class UserSkillBindingBatchUpdate(BaseModel):
"""批量更新用户技能绑定请求"""
user_id: Optional[UUID] = None # 可选,由路径提供时省略
bindings: List[dict] = Field(
...,
description="绑定列表,每项含 skill_tag_id, proficiency, level, years_experience",
)
class UserSkillBindingResponse(BaseModel):
"""用户技能绑定响应"""
id: str
user_id: str
skill_tag_id: str
skill_name: str
proficiency: int
level: str
years_experience: Optional[float] = None
quality_score: Optional[float] = None # 0-100 质量分
efficiency_score: Optional[float] = None # 0-100 效率分
last_used_at: Optional[str] = None
created_at: str
updated_at: str
class Config:
from_attributes = True
# ============================================================================
# UserWorkload 模型
# ============================================================================
class UserWorkloadUpdate(BaseModel):
"""更新用户工作量请求(通常由系统自动调用)"""
user_id: UUID
total_tasks: Optional[int] = Field(None, ge=0)
completed_tasks: Optional[int] = Field(None, ge=0)
in_progress_tasks: Optional[int] = Field(None, ge=0)
estimated_hours: Optional[float] = Field(None, ge=0)
completed_hours: Optional[float] = Field(None, ge=0)
class UserWorkloadResponse(BaseModel):
"""用户工作量响应"""
id: str
user_id: str
user_name: str
total_tasks: int
completed_tasks: int
in_progress_tasks: int
estimated_hours: float
completed_hours: float
workload_rate: float
period_start: str
period_end: str
updated_at: str
class Config:
from_attributes = True
# ============================================================================
# 人员推荐模型
# ============================================================================
class PersonnelRecommendationItem(BaseModel):
"""人员推荐单项"""
user_id: str
user_name: str
match_score: float = Field(..., ge=0, le=100, description="匹配度评分")
reason: str = Field(..., description="推荐理由")
skill_match_count: int = Field(..., ge=0)
avg_proficiency: float = Field(..., ge=0, le=100)
workload_rate: float = Field(..., ge=0, le=100)
recommended_role: Optional[str] = None
class PersonnelRecommendationResponse(BaseModel):
"""人员推荐响应"""
task_id: Optional[str] = None
task_description: Optional[str] = None
required_skills: List[str] = []
recommendations: List[PersonnelRecommendationItem] = []
total_count: int = 0
# ============================================================================
# 技能历史任务
# ============================================================================
class SkillTaskHistoryItem(BaseModel):
"""用户某技能下的历史任务项"""
id: str
title: str
description: Optional[str] = None
status: Optional[str] = None
priority: Optional[str] = None
estimated_hours: Optional[float] = None
project_id: Optional[str] = None
truth_node_id: Optional[str] = None
updated_at: Optional[str] = None
class SkillTaskHistoryResponse(BaseModel):
"""技能历史任务列表响应"""
items: List[SkillTaskHistoryItem] = []
total: int = 0

114
backend/app/schemas/sstc.py Normal file
View File

@ -0,0 +1,114 @@
"""SSTC 看板 schema"""
from __future__ import annotations
from enum import Enum
from typing import Any, Literal, Optional
from pydantic import BaseModel, Field
class SSTCStageKey(str, Enum):
SIGNAL = "signal"
CHARTER = "charter"
TASK = "task"
EXECUTION = "execution"
RELEASE = "release"
REVIEW = "review"
class StageStatus(str, Enum):
# signal
SIGNAL_PENDING = "待处理"
SIGNAL_SYNCED = "已同步"
# charter
CHARTER_DRAFT = "草稿"
CHARTER_ALIGNING = "对齐中"
CHARTER_ALIGNED = "已对齐"
CHARTER_ARCHIVED = "已归档"
# task
TASK_LOCKED = "未解锁"
TASK_PENDING = "待确认"
TASK_CONFIRMED = "已确认"
# execution
EXEC_NOT_STARTED = "未开始"
EXEC_IN_PROGRESS = "进行中"
EXEC_DONE = "已完成"
# release
RELEASE_LOCKED = "未解锁"
RELEASE_PENDING = "待发布"
RELEASE_LIVE = "已上线"
RELEASE_VERIFIED = "已验证"
# review
REVIEW_LOCKED = "未解锁"
REVIEW_DRAFT = "草稿"
REVIEW_ARCHIVED = "已归档"
class StageNode(BaseModel):
key: SSTCStageKey
label: str
status: str
summary: str = ""
locked: bool = False
editable: bool = True
risk_count: int = 0
class ProjectOverview(BaseModel):
project: dict[str, Any]
chain_id: Optional[str] = None
alignment_score: float = 0
pending_alignment_nodes: int = 0
unresolved_conflicts: int = 0
task_progress: dict[str, Any]
workload_health: dict[str, Any]
class BoardOverviewResponse(BaseModel):
overview: ProjectOverview
stages: list[StageNode]
risks: dict[str, Any]
class CharterUpdateRequest(BaseModel):
title: Optional[str] = None
content: Optional[dict[str, Any]] = None
force_new_version: bool = True
class AlignmentStartRequest(BaseModel):
note: Optional[str] = None
class AlignmentConfirmRequest(BaseModel):
score: int = Field(default=80, ge=0, le=100)
confirmed: bool = True
comments: Optional[str] = None
class TaskStatusPatchRequest(BaseModel):
status: Literal["todo", "in_progress", "blocked", "review", "done", "completed"]
class ReleaseCreateRequest(BaseModel):
title: str
charter_id: int
task_ids: list[int] = Field(default_factory=list)
planned_at: Optional[str] = None
class ReleaseStatusPatchRequest(BaseModel):
status: Literal["pending", "releasing", "online", "rolled_back", "verified"]
note: Optional[str] = None
class ReviewCreateRequest(BaseModel):
title: str
release_node_id: int
content: dict[str, Any] = Field(default_factory=dict)
class ReviewArchiveRequest(BaseModel):
archive: bool = True
note: Optional[str] = None

View File

@ -0,0 +1,401 @@
"""
Seed Projects and Signals
初始化示例项目和信号数据(新模型:BigInteger id)
含 SSTC 宪章:Pixelle-Video 项目关联宪章与任务
"""
import asyncio
from datetime import datetime
from typing import Optional
from sqlalchemy import func, select, or_, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from app.database import _is_sqlite
from app.models import Signal, Project, ProjectSignalLink, TruthNode, Task, User
from app.services.auth import AuthService
PIXELLE_NAME = "Pixelle-Video 集成"
async def seed_pixelle_project(db: Optional[AsyncSession] = None):
"""
创建或恢复 Pixelle-Video 示例项目:无项目则全量创建;有项目但无宪章/无信号链接则补全 SSTC 数据。
"""
from app.database import engine
if db is None:
async_session_maker = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session_maker() as session:
await _seed_projects(session)
else:
await _seed_projects(db)
async def _ensure_pixelle_sstc_data(session: AsyncSession, pixelle_project: Project) -> bool:
"""
若「Pixelle-Video 集成」已存在但无宪章或无项目-信号链接,则补全信号、链接、宪章与任务。返回是否做了变更。
"""
import json
pid = pixelle_project.id
# 是否已有项目-信号链接
link_count = await session.execute(
select(func.count()).select_from(ProjectSignalLink).where(ProjectSignalLink.project_id == pid)
)
has_links = (link_count.scalar() or 0) > 0
# 是否已有宪章
charter_count = await session.execute(
select(func.count()).select_from(TruthNode).where(
TruthNode.project_id == pid, TruthNode.node_type == "charter"
)
)
has_charters = (charter_count.scalar() or 0) > 0
if has_links and has_charters:
return False
changed = False
pixelle_signals = []
if not has_links:
pixelle_signals = [
Signal(
source="HackerNews",
content="Pixelle-Video: An AI-powered automated short video generation platform with script-to-video automation",
impact_score=9.2,
priority="critical",
processed=True,
),
Signal(
source="ProductHunt",
content="Pixelle-Video launches API for programmatic video content generation",
impact_score=8.5,
priority="high",
processed=True,
),
Signal(
source="TechCrunch",
content="Pixelle-Video raises Series A funding to scale AI video infrastructure",
impact_score=7.8,
priority="high",
processed=True,
),
]
for signal in pixelle_signals:
signal.strategic_analysis = json.dumps({
"decision_proposal": {
"is_strategic": True,
"threat_level": "HIGH",
"lead_time_days": 14,
"impact_score": getattr(signal, "impact_score", 0),
"recommendation": "立即集成 Pixelle-Video API 到主平台"
},
"competitiveness_radar": {
"innovation": 85,
"market_potential": 90,
"technical_complexity": 75,
"resource_intensity": 70
},
"detected_tech_stacks": ["AI/ML", "Video Processing", "REST API"],
"related_knowledge_nodes": [],
"rag_context_used": 0
})
signal.tasks_generated = json.dumps([
{"task_name": "Integrate Pixelle-Video API", "type": "backend", "estimated_hours": 8.0},
{"task_name": "Design Video Workflow UI", "type": "frontend", "estimated_hours": 12.0},
{"task_name": "Optimize Video Pipeline", "type": "ai_engineer", "estimated_hours": 16.0},
])
if _is_sqlite():
next_id = (await session.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM signals"))).scalar() or 1
for i, s in enumerate(pixelle_signals):
s.id = next_id + i
for signal in pixelle_signals:
session.add(signal)
await session.flush()
next_link_id = None
if _is_sqlite():
next_link_id = (await session.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM project_signal_links"))).scalar() or 1
for i, signal in enumerate(pixelle_signals):
link = ProjectSignalLink(
project_id=pid,
signal_id=signal.id,
link_type="related",
relevance_score=1.0,
)
if _is_sqlite() and next_link_id is not None:
link.id = next_link_id + i
session.add(link)
pixelle_project.signal_count = len(pixelle_signals)
changed = True
await session.flush()
if not has_charters:
user_result = await session.execute(select(User).limit(1))
seed_user = user_result.scalar_one_or_none()
if not seed_user:
seed_user = User(
username="boss",
email="boss@pmflow.local",
password_hash=AuthService.hash_password("boss123"),
name="负责人",
role="BOSS",
status="active",
)
session.add(seed_user)
await session.flush()
if pixelle_signals:
signal_ids = [s.id for s in pixelle_signals]
else:
link_rows = await session.execute(
select(ProjectSignalLink.signal_id).where(ProjectSignalLink.project_id == pid)
)
signal_ids = [r[0] for r in link_rows.fetchall()]
charter = TruthNode(
node_type="charter",
parent_id=None,
project_id=pid,
created_by=seed_user.id,
title="Pixelle-Video 集成需求宪章",
content={
"rationale": "基于 HackerNews/ProductHunt/TechCrunch 信号,决定集成 Pixelle-Video API 到主平台",
"signal_ids": signal_ids,
"decision": "立即集成 Pixelle-Video API,支持脚本到视频的自动化生成",
},
version=1,
status="aligned",
)
if _is_sqlite():
charter.id = (await session.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM truth_nodes"))).scalar() or 1
session.add(charter)
await session.flush()
next_task_id = None
if _is_sqlite():
next_task_id = (await session.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM tasks"))).scalar() or 1
for idx, task_item in enumerate([
{"title": "Integrate Pixelle-Video API", "priority": "high", "hours": 8.0},
{"title": "Design Video Workflow UI", "priority": "medium", "hours": 12.0},
{"title": "Optimize Video Pipeline", "priority": "medium", "hours": 16.0},
]):
task = Task(
truth_node_id=charter.id,
project_id=pid,
title=task_item["title"],
required_skill_tag_ids=[],
status="todo",
priority=task_item["priority"],
estimated_hours=task_item["hours"],
)
if _is_sqlite() and next_task_id is not None:
task.id = next_task_id + idx
session.add(task)
changed = True
print(f"[SSTC] 已为「{PIXELLE_NAME}」恢复宪章 1 个、任务 3 个")
return changed
async def _seed_projects(session: AsyncSession):
import json
result = await session.execute(
select(Project).where(
or_(Project.title == PIXELLE_NAME, Project.name == PIXELLE_NAME)
)
)
existing = result.scalar_one_or_none()
if existing:
restored = await _ensure_pixelle_sstc_data(session, existing)
if restored:
await session.commit()
else:
print("示例项目已存在且 SSTC 数据完整,跳过")
return
pixelle_signals = [
Signal(
source="HackerNews",
content="Pixelle-Video: An AI-powered automated short video generation platform with script-to-video automation",
impact_score=9.2,
priority="critical",
processed=True,
),
Signal(
source="ProductHunt",
content="Pixelle-Video launches API for programmatic video content generation",
impact_score=8.5,
priority="high",
processed=True,
),
Signal(
source="TechCrunch",
content="Pixelle-Video raises Series A funding to scale AI video infrastructure",
impact_score=7.8,
priority="high",
processed=True,
),
]
for signal in pixelle_signals:
signal.strategic_analysis = json.dumps({
"decision_proposal": {
"is_strategic": True,
"threat_level": "HIGH",
"lead_time_days": 14,
"impact_score": signal.impact_score,
"recommendation": "立即集成 Pixelle-Video API 到主平台"
},
"competitiveness_radar": {
"innovation": 85,
"market_potential": 90,
"technical_complexity": 75,
"resource_intensity": 70
},
"detected_tech_stacks": ["AI/ML", "Video Processing", "REST API"],
"related_knowledge_nodes": [],
"rag_context_used": 0
})
signal.tasks_generated = json.dumps([
{"task_name": "Integrate Pixelle-Video API", "type": "backend", "estimated_hours": 8.0},
{"task_name": "Design Video Workflow UI", "type": "frontend", "estimated_hours": 12.0},
{"task_name": "Optimize Video Pipeline", "type": "ai_engineer", "estimated_hours": 16.0},
])
session.add(signal)
await session.flush()
pixelle_project = Project(
name="Pixelle-Video 集成",
title="Pixelle-Video 集成",
description="集成 Pixelle-Video AI 视频生成平台到主系统",
status="active",
assigned_role="backend",
signal_count=len(pixelle_signals),
)
session.add(pixelle_project)
await session.flush()
for signal in pixelle_signals:
link = ProjectSignalLink(
project_id=pixelle_project.id,
signal_id=signal.id,
link_type="related",
relevance_score=1.0,
)
session.add(link)
ai_analyst_project = Project(
name="AI 分析引擎优化",
title="AI 分析引擎优化",
description="优化 LLM 服务性能,实现多模型投票机制和 RAG 检索增强",
status="active",
assigned_role="ai_engineer",
signal_count=1,
)
session.add(ai_analyst_project)
await session.flush()
ai_signal = Signal(
source="ArXiv",
content="New paper: Multi-Agent LLM Voting System improves accuracy by 23%",
impact_score=7.5,
priority="medium",
processed=True,
)
ai_signal.strategic_analysis = json.dumps({
"decision_proposal": {
"is_strategic": True,
"threat_level": "MEDIUM",
"lead_time_days": 30,
"impact_score": 7.5,
"recommendation": "实现多模型投票机制提升 AI 分析准确性"
},
"detected_tech_stacks": ["LLM", "Voting", "Multi-Agent"]
})
session.add(ai_signal)
await session.flush()
ai_link = ProjectSignalLink(
project_id=ai_analyst_project.id,
signal_id=ai_signal.id,
link_type="related",
relevance_score=0.9,
)
session.add(ai_link)
await session.flush()
# --- SSTC: 获取或创建默认用户,用于宪章 created_by ---
user_result = await session.execute(select(User).limit(1))
seed_user = user_result.scalar_one_or_none()
if not seed_user:
seed_user = User(
username="boss",
email="boss@pmflow.local",
password_hash=AuthService.hash_password("boss123"),
name="负责人",
role="BOSS",
status="active",
)
session.add(seed_user)
await session.flush()
# --- SSTC: 为 Pixelle-Video 创建宪章(关联信号)---
pixelle_signal_ids = [s.id for s in pixelle_signals]
charter = TruthNode(
node_type="charter",
parent_id=None,
project_id=pixelle_project.id,
created_by=seed_user.id,
title="Pixelle-Video 集成需求宪章",
content={
"rationale": "基于 HackerNews/ProductHunt/TechCrunch 信号,决定集成 Pixelle-Video API 到主平台",
"signal_ids": pixelle_signal_ids,
"decision": "立即集成 Pixelle-Video API,支持脚本到视频的自动化生成",
},
version=1,
status="aligned",
)
session.add(charter)
await session.flush()
# --- SSTC: 创建任务(绑定到宪章)---
for task_item in [
{"title": "Integrate Pixelle-Video API", "priority": "high", "hours": 8.0},
{"title": "Design Video Workflow UI", "priority": "medium", "hours": 12.0},
{"title": "Optimize Video Pipeline", "priority": "medium", "hours": 16.0},
]:
task = Task(
truth_node_id=charter.id,
project_id=pixelle_project.id,
title=task_item["title"],
required_skill_tag_ids=[],
status="todo",
priority=task_item["priority"],
estimated_hours=task_item["hours"],
)
session.add(task)
await session.commit()
print(f"创建示例项目:")
print(f" - {pixelle_project.title} ({pixelle_project.assigned_role})")
print(f" - {ai_analyst_project.title} ({ai_analyst_project.assigned_role})")
print(f"\n关联信号: {len(pixelle_signals) + 1} 条")
print(f"[SSTC] Pixelle-Video 宪章 1 个,任务 3 个")
async def main():
print("开始初始化示例数据...")
from app.database import engine
from app.models import Base
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await seed_pixelle_project()
print("\n初始化完成!")
if __name__ == "__main__":
asyncio.run(main())

View File

@ -0,0 +1,498 @@
"""
PMFlow AI 核心能力封装
技术栈:LangChain 0.3+、OpenAI SDK、Redis
- 封装 GPT-4o mini 调用(流式/非流式)
- Prompt 集中在 config/ai_prompts
- AI 调用日志写入 ai_call_logs
- Redis 结果缓存(1 小时 TTL)
- 指数退避重试
"""
import hashlib
import json
import os
import time
from datetime import datetime
from typing import Any, AsyncGenerator, Optional, Union
# LangChain & OpenAI
try:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, AIMessage
from langchain_core.language_models.chat_models import BaseChatModel
LANGCHAIN_AVAILABLE = True
except ImportError:
LANGCHAIN_AVAILABLE = False
BaseChatModel = object
# Redis
try:
import redis.asyncio as redis
REDIS_AVAILABLE = True
except ImportError:
REDIS_AVAILABLE = False
# Token 定价 (GPT-4o-mini, 2024)
INPUT_PRICE_PER_1K = 0.00015
OUTPUT_PRICE_PER_1K = 0.0006
CACHE_TTL_SECONDS = 3600 # 1 小时
def _t2s(v: Union[str, int]) -> str:
"""统一转为 str"""
return str(v) if v is not None else ""
def _hash_input(fn: str, *args, **kwargs) -> str:
"""生成缓存 key"""
payload = json.dumps({"fn": fn, "args": args, "kwargs": kwargs}, sort_keys=True, default=str)
return hashlib.sha256(payload.encode()).hexdigest()
def _estimate_cost(prompt_tokens: int, completion_tokens: int) -> float:
return (prompt_tokens * INPUT_PRICE_PER_1K + completion_tokens * OUTPUT_PRICE_PER_1K) / 1000
def _tenant_to_int(tenant_id: Union[str, int]) -> int:
try:
return int(tenant_id)
except (TypeError, ValueError):
return 1
class AIService:
"""PMFlow AI 服务"""
def __init__(
self,
api_key: Optional[str] = None,
model: str = "gpt-4o-mini",
redis_url: Optional[str] = None,
max_retries: int = 3,
):
self.api_key = api_key or os.getenv("OPENAI_API_KEY", "")
self.model = model
self.redis_url = redis_url or os.getenv("REDIS_URL")
self.max_retries = max_retries
self._llm: Optional[BaseChatModel] = None
self._redis: Optional[Any] = None
def _get_llm(self) -> BaseChatModel:
if not LANGCHAIN_AVAILABLE:
raise RuntimeError("请安装 langchain-openai: pip install langchain-openai langchain-core")
if not self.api_key:
raise RuntimeError("请设置 OPENAI_API_KEY 环境变量")
if self._llm is None:
self._llm = ChatOpenAI(
model=self.model,
api_key=self.api_key,
temperature=0.3,
)
return self._llm
async def _get_redis(self):
if not REDIS_AVAILABLE or not self.redis_url:
return None
if self._redis is None:
self._redis = redis.from_url(self.redis_url, decode_responses=True)
return self._redis
async def _cache_get(self, key: str) -> Optional[str]:
r = await self._get_redis()
if r is None:
return None
try:
return await r.get(key)
except Exception:
return None
async def _cache_set(self, key: str, value: str, ttl: int = CACHE_TTL_SECONDS):
r = await self._get_redis()
if r is None:
return
try:
await r.setex(key, ttl, value)
except Exception:
pass
async def _log_call(
self,
db,
tenant_id: str,
function_name: str,
input_hash: str,
status: str,
prompt_tokens: int = 0,
completion_tokens: int = 0,
latency_ms: Optional[int] = None,
error_message: Optional[str] = None,
):
try:
from ..models import AICallLog
total = prompt_tokens + completion_tokens
cost = _estimate_cost(prompt_tokens, completion_tokens)
log = AICallLog(
tenant_id=_t2s(tenant_id),
function_name=function_name,
input_hash=input_hash,
status=status,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=total,
cost_usd=cost,
latency_ms=latency_ms,
error_message=error_message,
)
db.add(log)
await db.commit()
except Exception:
pass
async def _invoke_with_retry(
self,
prompt: str,
db=None,
tenant_id: str = "default",
function_name: str = "unknown",
use_cache: bool = True,
cache_key: Optional[str] = None,
project_id: Optional[int] = None,
) -> str:
"""带缓存、重试、日志的 LLM 调用"""
input_hash = cache_key or _hash_input(function_name, prompt)
if use_cache and db is None:
cached = await self._cache_get(input_hash)
if cached:
return cached
# 预算前置检查(仅在有 db 会话时启用)
if db is not None:
try:
from .budget_enforcement_service import BudgetEnforcementService
tenant_int = _tenant_to_int(tenant_id)
# 预估:按 prompt 长度粗略估算,保守给定 completion 预算
estimated_prompt_tokens = max(len(prompt) // 4, 1)
estimated_completion_tokens = 1200
estimated_cost = _estimate_cost(estimated_prompt_tokens, estimated_completion_tokens)
budget_service = BudgetEnforcementService(db)
budget_check = await budget_service.check_budget_before_ai_call(
tenant_id=tenant_int,
estimated_cost_usd=estimated_cost,
project_id=project_id,
)
if not budget_check.get("allowed", True):
raise RuntimeError("预算超限,AI 调用被阻止(hard limit)")
except RuntimeError:
raise
except Exception:
# 预算系统异常不阻断主流程,避免影响现有可用性
pass
llm = self._get_llm()
last_err = None
for attempt in range(self.max_retries):
try:
t0 = time.perf_counter()
resp = await llm.ainvoke([HumanMessage(content=prompt)])
latency_ms = int((time.perf_counter() - t0) * 1000)
text = resp.content if hasattr(resp, "content") else str(resp)
pt = getattr(resp, "response_metadata", None) or {}
usage = (pt or {}).get("usage", {}) or (getattr(resp, "response_metadata", None) or {}).get("usage", {})
prompt_tokens = usage.get("input_tokens", 0) or usage.get("prompt_tokens", 0)
completion_tokens = usage.get("output_tokens", 0) or usage.get("completion_tokens", 0)
if db:
await self._log_call(
db, tenant_id, function_name, input_hash, "success",
prompt_tokens, completion_tokens, latency_ms,
)
# 按实际 token 成本累计预算
try:
from .budget_enforcement_service import BudgetEnforcementService
actual_cost = _estimate_cost(prompt_tokens, completion_tokens)
await BudgetEnforcementService(db).update_budget_usage_after_ai_call(
tenant_id=_tenant_to_int(tenant_id),
actual_cost_usd=actual_cost,
project_id=project_id,
)
except Exception:
pass
if use_cache:
await self._cache_set(input_hash, text)
return text
except Exception as e:
last_err = e
wait = 2 ** attempt
time.sleep(wait)
if db:
await self._log_call(
db, tenant_id, function_name, input_hash, "failed",
error_message=str(last_err),
)
raise last_err
def _parse_json(self, text: str) -> dict:
text = text.strip()
for p in ("```json", "```"):
if text.startswith(p):
text = text[len(p):]
if text.endswith("```"):
text = text[:-3]
return json.loads(text.strip())
# ========================================================================
# 核心函数
# ========================================================================
async def generate_charter_from_signal(
self,
signal_content: str,
tenant_id: Union[str, int] = "default",
db=None,
) -> dict:
"""从市场/模型信号生成需求宪章草案"""
from ..config.ai_prompts import PROMPT_GENERATE_CHARTER
tid = _t2s(tenant_id)
prompt = PROMPT_GENERATE_CHARTER.format(signal_content=signal_content)
text = await self._invoke_with_retry(
prompt, db=db, tenant_id=tid, function_name="generate_charter_from_signal",
)
return self._parse_json(text)
async def decompose_tasks_from_charter(
self,
charter_content: str,
project_id: Union[str, int],
tenant_id: Union[str, int] = "default",
db=None,
) -> dict:
"""从宪章拆解任务,自动识别所需技能标签,返回带技能要求的任务列表"""
from ..config.ai_prompts import PROMPT_DECOMPOSE_TASKS
tid = _t2s(tenant_id)
prompt = PROMPT_DECOMPOSE_TASKS.format(
charter_content=charter_content,
project_id=_t2s(project_id),
)
text = await self._invoke_with_retry(
prompt, db=db, tenant_id=tid, function_name="decompose_tasks_from_charter",
)
return self._parse_json(text)
async def recommend_assignees_for_task(
self,
task_content: str,
required_skill_tag_ids: list,
project_id: Union[str, int],
tenant_id: Union[str, int] = "default",
db=None,
candidates_json: Optional[str] = None,
) -> dict:
"""基于人员能力图谱、负载、熟练度,推荐 Top3 人员"""
from ..config.ai_prompts import PROMPT_RECOMMEND_ASSIGNEES
tid = _t2s(tenant_id)
candidates = candidates_json or "[]"
prompt = PROMPT_RECOMMEND_ASSIGNEES.format(
task_content=task_content,
required_skill_tag_ids=json.dumps(required_skill_tag_ids),
candidates_json=candidates,
)
text = await self._invoke_with_retry(
prompt, db=db, tenant_id=tid, function_name="recommend_assignees_for_task",
)
return self._parse_json(text)
async def update_user_skill_scores(
self,
task_id: Union[str, int],
tenant_id: Union[str, int] = "default",
db=None,
quality_score: float = 0.0,
efficiency_score: float = 0.0,
assignee_ids: Optional[list] = None,
skill_tag_ids: Optional[list] = None,
) -> dict:
"""任务完成后,根据质量/效率评分更新相关人员技能分"""
from sqlalchemy import select
from ..models import UserSkillBinding
from uuid import UUID
def to_uuid(v):
if v is None:
return None
if isinstance(v, UUID):
return v
s = str(v)
try:
return UUID(s) if len(s) == 36 else None
except ValueError:
return None
if db is None:
return {"updated_count": 0, "updates": []}
tid = _t2s(tenant_id)
updates = []
if assignee_ids and skill_tag_ids:
for uid in assignee_ids:
for sid in skill_tag_ids:
uid_u = to_uuid(uid)
sid_u = to_uuid(sid)
if not uid_u or not sid_u:
continue
try:
r = await db.execute(
select(UserSkillBinding).where(
UserSkillBinding.tenant_id == tid,
UserSkillBinding.user_id == uid_u,
UserSkillBinding.skill_tag_id == sid_u,
)
)
b = r.scalar_one_or_none()
if b:
b.quality_score = min(100, max(0, (b.quality_score or 0) + quality_score))
b.efficiency_score = min(100, max(0, (b.efficiency_score or 0) + efficiency_score))
b.last_used_at = datetime.utcnow()
await db.commit()
updates.append({
"user_id": str(uid),
"skill_tag_id": str(sid),
"quality_score_delta": quality_score,
"efficiency_score_delta": efficiency_score,
"last_used_at": b.last_used_at.isoformat(),
})
except Exception:
pass
return {"updated_count": len(updates), "updates": updates}
async def calculate_alignment_score(
self,
user_id: Union[str, int],
truth_node_id: Union[str, int],
tenant_id: Union[str, int] = "default",
db=None,
truth_content: str = "",
user_context: str = "",
) -> dict:
"""计算用户对真理节点的认知对齐度(0-100)"""
from ..config.ai_prompts import PROMPT_ALIGNMENT_SCORE
from sqlalchemy import select
from ..models import TruthNode
tid = _t2s(tenant_id)
if db and not truth_content:
r = await db.execute(
select(TruthNode).where(
TruthNode.id == truth_node_id,
TruthNode.tenant_id == tid,
)
)
tn = r.scalar_one_or_none()
if tn:
truth_content = tn.content or tn.title or ""
prompt = PROMPT_ALIGNMENT_SCORE.format(
user_id=_t2s(user_id),
truth_node_id=_t2s(truth_node_id),
truth_content=truth_content or "(无内容)",
user_context=user_context or "(无)",
)
text = await self._invoke_with_retry(
prompt, db=db, tenant_id=tid, function_name="calculate_alignment_score",
)
data = self._parse_json(text)
data["user_id"] = _t2s(user_id)
data["truth_node_id"] = _t2s(truth_node_id)
return data
async def detect_cognitive_conflicts(
self,
truth_node_id: Union[str, int],
tenant_id: Union[str, int] = "default",
db=None,
truth_content: str = "",
user_viewpoints: str = "",
) -> dict:
"""识别真理节点下的隐性认知冲突"""
from ..config.ai_prompts import PROMPT_COGNITIVE_CONFLICTS
from sqlalchemy import select
from ..models import TruthNode
tid = _t2s(tenant_id)
if db and not truth_content:
r = await db.execute(
select(TruthNode).where(
TruthNode.id == truth_node_id,
TruthNode.tenant_id == tid,
)
)
tn = r.scalar_one_or_none()
if tn:
truth_content = tn.content or tn.title or ""
prompt = PROMPT_COGNITIVE_CONFLICTS.format(
truth_node_id=_t2s(truth_node_id),
truth_content=truth_content or "(无内容)",
user_viewpoints=user_viewpoints or "(无)",
)
text = await self._invoke_with_retry(
prompt, db=db, tenant_id=tid, function_name="detect_cognitive_conflicts",
)
data = self._parse_json(text)
data["truth_node_id"] = _t2s(truth_node_id)
data["total_count"] = len(data.get("conflicts", []))
return data
async def evaluate_model_requirement(
self,
charter_content: str,
model_version_id: Union[str, int],
tenant_id: Union[str, int] = "default",
db=None,
model_info: str = "",
) -> dict:
"""评估需求与模型的匹配度"""
from ..config.ai_prompts import PROMPT_EVALUATE_MODEL_REQUIREMENT
tid = _t2s(tenant_id)
prompt = PROMPT_EVALUATE_MODEL_REQUIREMENT.format(
charter_content=charter_content,
model_version_id=_t2s(model_version_id),
model_info=model_info or "(未提供)",
)
text = await self._invoke_with_retry(
prompt, db=db, tenant_id=tid, function_name="evaluate_model_requirement",
)
return self._parse_json(text)
# ========================================================================
# 流式调用
# ========================================================================
async def stream_chat(self, prompt: str) -> AsyncGenerator[str, None]:
"""流式生成"""
llm = self._get_llm()
async for chunk in llm.astream([HumanMessage(content=prompt)]):
if hasattr(chunk, "content") and chunk.content:
yield chunk.content
# 单例
_ai_service: Optional[AIService] = None
def get_ai_service() -> AIService:
global _ai_service
if _ai_service is None:
_ai_service = AIService()
return _ai_service

View File

@ -0,0 +1,508 @@
"""
AI Steward Service - 主动式 AI 管家核心服务
核心功能:
1. 零配置项目创建 - 从一句话生成完整 SSTC 链路
2. 主动信号捕获 - 从多个源自动抓取并评估
3. 认知冲突检测 - 自动识别并消解团队认知差异
4. 阻塞自动解决 - 主动识别和解决开发阻塞
5. 个性化进度简报 - 为每个角色生成定制化简报
"""
import asyncio
import json
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy import text
from ..database import get_db
from ..models import (
User, Project, Signal, TruthNode, Task,
ProjectDomain, CognitiveConflict, UserSkillBinding
)
from .llm import LLMService
class AIStewardService:
"""主动式 AI 管家服务"""
def __init__(self, db: AsyncSession):
self.db = db
self.llm = LLMService()
async def create_project_from_natural_language(
self,
input_text: str,
creator_id: int,
tenant_id: int = 1
) -> Dict[str, Any]:
"""
零配置项目创建 - 从一句话生成完整 SSTC 链路
输入示例:
"我们要做一个 AI 客服工具,基于 GPT-4o mini,对标 XX 竞品,3 周上线"
输出:完整的 SSTC 链路
- 竞品信号分析
- 宪章(TruthNode)
- 任务拆解(Task)
- 依赖关系
- 风险预警
"""
# Step 0: 获取下一个可用的项目 ID(SQLite 兼容)
from sqlalchemy import text
next_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM projects"))).scalar() or 1
# Step 1: AI 解析用户输入,提取关键信息
analysis = await self._analyze_project_input(input_text, tenant_id)
# Step 2: 自动生成项目
project = Project(
id=next_id,
name=analysis.get('project_name', '未命名项目')[:50],
title=analysis.get('project_title', input_text[:50]),
description=input_text,
owner_id=creator_id,
status='active',
domain_id=analysis.get('domain_id'),
assigned_role=analysis.get('primary_role', 'backend'),
signal_count=0,
tenant_id=tenant_id
)
self.db.add(project)
await self.db.flush()
# Step 3: 生成信号(竞品分析)
signal = await self._generate_competitive_signal(
project.id,
analysis,
tenant_id
)
# Step 4: 生成宪章(核心真理节点)
charter = await self._generate_charter(
project.id,
analysis,
creator_id,
tenant_id
)
charter_id = int(charter['id']) # Convert string ID to int
# Step 5: 生成任务链
tasks = await self._generate_task_chain(
project.id,
charter_id,
analysis,
tenant_id
)
# Step 6: 生成风险预警规则
risks = await self._generate_risk_alert_rules(
project.id,
charter_id,
analysis,
tenant_id
)
await self.db.commit()
return {
'project': {
'id': str(project.id),
'name': project.name,
'title': project.title,
'description': project.description,
'status': project.status
},
'signal': signal,
'charter': charter,
'tasks': tasks,
'risks': risks,
'analysis': analysis
}
async def _analyze_project_input(self, input_text: str, tenant_id: int) -> Dict[str, Any]:
"""使用 AI 分析项目输入,提取关键信息"""
prompt = f"""分析以下项目需求,提取关键信息并以 JSON 格式返回:
用户输入:{input_text}
请提取以下信息:
{{
"project_name": "项目名称(简短)",
"project_title": "项目标题",
"tech_stack": ["技术栈列表"],
"target_audience": "目标用户",
"timeline": "时间周期",
"primary_role": "主要角色类型(ai_engineer/backend/frontend)",
"domain_id": 1,
"key_features": ["核心功能列表"],
"competitors": ["竞品列表"],
"success_metrics": ["成功指标"]
}}
只返回 JSON,不要其他内容。"""
response = await self.llm.generate(
prompt,
db=self.db,
tenant_id=tenant_id,
)
try:
# 清理可能的 markdown 代码块标记
response = response.strip()
if response.startswith('```'):
response = response.split('```')[1]
if response.startswith('json'):
response = response[4:]
analysis = json.loads(response.strip())
except:
# 降级处理
analysis = {
'project_name': input_text[:20],
'project_title': input_text[:50],
'tech_stack': ['GPT-4o-mini'],
'target_audience': '通用',
'timeline': '3周',
'primary_role': 'ai_engineer',
'domain_id': 1,
'key_features': [],
'competitors': [],
'success_metrics': []
}
return analysis
async def _generate_competitive_signal(
self,
project_id: int,
analysis: Dict[str, Any],
tenant_id: int
) -> Dict[str, Any]:
"""生成竞品分析信号"""
from sqlalchemy import text
# 获取下一个可用的 signal ID
next_signal_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM signals"))).scalar() or 1
competitors = analysis.get('competitors', [])
if not competitors:
competitors = ['行业同类产品']
prompt = f"""基于以下项目信息,生成竞品分析信号:
项目:{analysis.get('project_title')}
技术栈:{', '.join(analysis.get('tech_stack', []))}
竞品:{', '.join(competitors)}
请生成:
1. 竞品现状分析
2. 差异化机会
3. 技术风险评估
以 JSON 格式返回:
{{
"content": "信号内容(200字以内)",
"impact_score": 0.9,
"priority": "high",
"strategic_analysis": {{"is_strategic": true, "threat_level": "MEDIUM"}}
}}"""
response = await self.llm.generate(
prompt,
db=self.db,
tenant_id=tenant_id,
project_id=project_id,
)
try:
response = response.strip()
if response.startswith('```'):
response = response.split('```')[1]
if response.startswith('json'):
response = response[4:]
signal_data = json.loads(response.strip())
except:
signal_data = {
'content': f'竞品分析:{", ".join(competitors)} 的技术现状和差异化机会',
'impact_score': 0.8,
'priority': 'high',
'strategic_analysis': {'is_strategic': True}
}
signal = Signal(
id=next_signal_id,
source='AI-Steward',
content=signal_data['content'],
impact_score=signal_data.get('impact_score', 0.8),
priority=signal_data.get('priority', 'high'),
processed=True,
strategic_analysis=json.dumps(signal_data.get('strategic_analysis', {})),
tasks_generated=json.dumps([]),
tenant_id=tenant_id
)
self.db.add(signal)
await self.db.flush()
# 创建信号与项目的关联
from ..models import ProjectSignalLink
next_link_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM project_signal_links"))).scalar() or 1
link = ProjectSignalLink(
id=next_link_id,
project_id=project_id,
signal_id=signal.id,
link_type='competitive_analysis',
relevance_score=0.9,
tenant_id=tenant_id
)
self.db.add(link)
await self.db.flush()
return {
'id': str(signal.id),
'content': signal.content,
'impact_score': signal.impact_score
}
async def _generate_charter(
self,
project_id: int,
analysis: Dict[str, Any],
creator_id: int,
tenant_id: int
) -> Dict[str, Any]:
"""生成项目宪章(核心真理节点)"""
prompt = f"""基于以下项目信息,生成项目宪章:
项目:{analysis.get('project_title')}
描述:{analysis.get('project_title')}
技术栈:{', '.join(analysis.get('tech_stack', []))}
核心功能:{', '.join(analysis.get('key_features', []))}
成功指标:{', '.join(analysis.get('success_metrics', []))}
请生成包含以下内容的宪章:
1. 项目目标(明确、可衡量)
2. 成功标准(具体指标)
3. 技术约束
4. 时间节点
以 JSON 格式返回:
{{
"title": "宪章标题",
"content": "宪章完整内容(包含上述4个方面)",
"success_criteria": ["标准1", "标准2"],
"constraints": ["约束1", "约束2"]
}}"""
response = await self.llm.generate(
prompt,
db=self.db,
tenant_id=tenant_id,
project_id=project_id,
)
try:
response = response.strip()
if response.startswith('```'):
response = response.split('```')[1]
if response.startswith('json'):
response = response[4:]
charter_data = json.loads(response.strip())
except:
charter_data = {
'title': f'{analysis.get("project_name")}项目宪章',
'content': f'项目目标:{analysis.get("project_title")}\n成功标准:按时上线',
'success_criteria': ['按时交付', '功能完整'],
'constraints': ['时间限制']
}
raw_content = charter_data.get('content', '')
if isinstance(raw_content, dict):
charter_content = dict(raw_content)
else:
charter_content = {
'rationale': str(raw_content),
'success_criteria': charter_data.get('success_criteria', []),
'constraints': charter_data.get('constraints', []),
'signal_ids': [],
}
# 获取下一个可用的 truth_node ID
from sqlalchemy import text
next_charter_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM truth_nodes"))).scalar() or 1
charter = TruthNode(
id=next_charter_id,
node_type='charter',
project_id=project_id,
created_by=creator_id,
title=charter_data.get('title', '项目宪章'),
content=charter_content,
version=1,
status='active',
tenant_id=tenant_id
)
self.db.add(charter)
await self.db.flush()
return {
'id': str(charter.id),
'title': charter.title,
'content': charter_content
}
async def _generate_task_chain(
self,
project_id: int,
charter_id: int,
analysis: Dict[str, Any],
tenant_id: int
) -> List[Dict[str, Any]]:
"""生成任务链"""
prompt = f"""基于以下信息,生成完整的任务链:
项目:{analysis.get('project_title')}
技术栈:{', '.join(analysis.get('tech_stack', []))}
时间周期:{analysis.get('timeline', '3周')}
请生成:
1. 任务列表(按优先级排序)
2. 每个任务包含:名称、类型、预估工时、依赖关系、技能要求
以 JSON 格式返回:
{{
"tasks": [
{{
"title": "任务名称",
"type": "backend/frontend/ai_engineer",
"estimated_hours": 8,
"description": "任务描述",
"priority": "high/medium/low",
"required_skills": ["技能1", "技能2"]
}}
]
}}"""
response = await self.llm.generate(
prompt,
db=self.db,
tenant_id=tenant_id,
project_id=project_id,
)
try:
response = response.strip()
if response.startswith('```'):
response = response.split('```')[1]
if response.startswith('json'):
response = response[4:]
tasks_data = json.loads(response.strip())
tasks_list = tasks_data.get('tasks', [])
except:
# 降级生成基础任务
tasks_list = [
{
'title': '技术方案设计',
'type': analysis.get('primary_role', 'backend'),
'estimated_hours': 16,
'description': '完成项目技术方案设计',
'priority': 'high',
'required_skills': ['系统设计']
},
{
'title': '核心功能开发',
'type': analysis.get('primary_role', 'backend'),
'estimated_hours': 40,
'description': '完成核心功能开发',
'priority': 'high',
'required_skills': ['开发']
}
]
tasks = []
from sqlalchemy import text
next_task_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM tasks"))).scalar() or 1
for i, task_data in enumerate(tasks_list):
task = Task(
id=next_task_id,
truth_node_id=charter_id,
project_id=project_id,
assignee_id=None, # AI 推荐稍后分配
title=task_data.get('title'),
description=task_data.get('description', ''),
required_skill_tag_ids=task_data.get('required_skills', []), # TypeDecorator 会自动处理
status='pending',
priority=task_data.get('priority', 'medium').upper(),
estimated_hours=task_data.get('estimated_hours', 8),
actual_hours=0,
quality_score=0,
tenant_id=tenant_id
)
self.db.add(task)
await self.db.flush()
next_task_id += 1
tasks.append({
'id': str(task.id),
'title': task.title,
'type': task_data.get('type'),
'estimated_hours': task.estimated_hours,
'priority': task.priority
})
return tasks
async def _generate_risk_alert_rules(
self,
project_id: int,
charter_id: int,
analysis: Dict[str, Any],
tenant_id: int
) -> List[Dict[str, Any]]:
"""生成风险预警规则"""
risks = [
{
'type': 'schedule_delay',
'description': '任务超时预警',
'threshold_hours': analysis.get('estimated_hours', 40) * 1.2,
'severity': 'medium'
},
{
'type': 'quality_drop',
'description': '质量下降预警',
'threshold': 0.85,
'severity': 'high'
},
{
'type': 'skill_mismatch',
'description': '技能匹配度不足',
'threshold': 0.6,
'severity': 'medium'
}
]
# 获取下一个可用的 cognitive_conflict ID
from sqlalchemy import text
next_conflict_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM cognitive_conflicts"))).scalar() or 1
# 创建认知冲突记录作为风险预警
for risk in risks:
conflict = CognitiveConflict(
id=next_conflict_id,
truth_node_id=charter_id, # 关联到宪章
conflict_type=risk['type'],
description=risk['description'],
involved_user_ids=json.dumps([]),
status='active',
tenant_id=tenant_id
)
self.db.add(conflict)
next_conflict_id += 1
return risks
# 全局服务实例
def get_ai_steward_service(db: AsyncSession) -> AIStewardService:
"""获取 AI Steward 服务实例"""
return AIStewardService(db)

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