feat: complete PMFlow implementation
This commit is contained in:
parent
b0a8c6edb9
commit
16f30bc2c6
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
271
CLAUDE.md
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,120 @@
|
|||
# Scheduler 执行清单 V1(前后端一体交付)
|
||||
|
||||
> 目标:交付“可见、可触发、可追踪”的调度中心,避免仅后端不可见改动。
|
||||
> 原则:每步必须包含 Backend + Frontend + 可见验收。
|
||||
|
||||
---
|
||||
|
||||
## 0. 执行规则(强制)
|
||||
|
||||
- [ ] 每步包含 `Backend任务` + `Frontend任务` + `可见验收`。
|
||||
- [ ] 每步完成即改 `[x]` 并补验收证据。
|
||||
- [ ] 无页面可见结果,不进入下一步。
|
||||
- [ ] 失败需记录回滚点与阻塞原因。
|
||||
|
||||
---
|
||||
|
||||
## 1. 范围与阶段
|
||||
|
||||
- [x] S0 稳定性前置(401 熔断、防轮询风暴)
|
||||
- [x] S1 调度基础能力(Job Registry + JobRun 持久化 + 手动触发)
|
||||
- [x] S2 调度中心页面(任务列表、触发、运行记录)
|
||||
- [x] S3 认知链路接入(对齐评分任务接入调度)
|
||||
- [x] S4 联调验收(失败重试、权限、可视化证据)
|
||||
|
||||
---
|
||||
|
||||
## 2. 分步清单
|
||||
|
||||
### [x] S1 调度基础能力
|
||||
|
||||
**Backend任务**
|
||||
- 新增 `scheduler_center` 服务:注册任务、执行任务、记录 `job_runs`。
|
||||
- 新增 `/scheduler` API:
|
||||
- `GET /scheduler/jobs`
|
||||
- `GET /scheduler/runs`
|
||||
- `POST /scheduler/jobs/{job_name}/trigger`
|
||||
|
||||
**Frontend任务**
|
||||
- 新增 `scheduler` API client,支持查询与触发。
|
||||
|
||||
**可见验收**
|
||||
- 调用 `GET /scheduler/jobs` 可见可触发任务列表。
|
||||
- 触发任务后 `GET /scheduler/runs` 出现新纪录(running/success/failed)。
|
||||
|
||||
**验收证据**
|
||||
- API 已新增:`GET /scheduler/jobs`、`GET /scheduler/runs`、`POST /scheduler/jobs/{job_name}/trigger`
|
||||
- 后端文件:`backend/app/services/scheduler_center.py`、`backend/app/api/scheduler.py`
|
||||
- 路由接入:`backend/app/api/__init__.py`、`backend/app/main.py`
|
||||
|
||||
---
|
||||
|
||||
### [x] S2 调度中心页面
|
||||
|
||||
**Backend任务**
|
||||
- 补充运行记录字段(耗时、错误信息、payload 摘要)并排序输出。
|
||||
|
||||
**Frontend任务**
|
||||
- 新增 `SchedulerCenterPage`:
|
||||
- 任务列表
|
||||
- 手动触发按钮
|
||||
- 最近运行记录
|
||||
- 错误可读展示
|
||||
- 路由接入,并在项目看板增加入口按钮。
|
||||
|
||||
**可见验收**
|
||||
- 页面可手动触发任务并实时看到运行结果。
|
||||
|
||||
**验收证据**
|
||||
- 页面已新增:`frontend/src/pages/SchedulerCenterPage.tsx`
|
||||
- 前端 API 已新增:`frontend/src/api/scheduler.ts`
|
||||
- 路由已接入:`/scheduler-center`(`frontend/src/router/index.tsx`)
|
||||
- 项目看板入口已接入:`frontend/src/pages/ProjectBoardPage.tsx`
|
||||
- 构建通过:`cd frontend && npm run build`
|
||||
|
||||
---
|
||||
|
||||
### [x] S3 认知链路接入(O4-4 首版)
|
||||
|
||||
**Backend任务**
|
||||
- 将“项目认知评分重算”封装为调度任务 `alignment_rescore_project`。
|
||||
- `ai_steward` 对应入口优先通过调度执行并写 `job_runs`。
|
||||
|
||||
**Frontend任务**
|
||||
- 调度中心支持传入 `project_id` 触发重算任务。
|
||||
|
||||
**可见验收**
|
||||
- 触发重算后,`job_runs` 成功记录可见;评分接口数据可更新。
|
||||
|
||||
**验收证据**
|
||||
- 调度任务真接线:`backend/app/services/scheduler_center.py` -> `alignment_rescore_project`
|
||||
- 评分内核抽离:`backend/app/services/alignment_orchestrator.py`
|
||||
- `ai_steward` 入口改为优先通过调度执行并落 `job_runs`:`backend/app/api/ai_steward.py`
|
||||
|
||||
---
|
||||
|
||||
### [x] S4 联调与收尾
|
||||
|
||||
**Backend任务**
|
||||
- 权限控制:仅 BOSS/ADMIN 可触发关键任务。
|
||||
- 失败重试策略:同名任务支持手动重跑。
|
||||
|
||||
**Frontend任务**
|
||||
- 失败任务标红 + 一键重试。
|
||||
|
||||
**可见验收**
|
||||
- 失败任务可重试并形成新的运行记录链。
|
||||
|
||||
**验收证据**
|
||||
- 关键任务触发权限:`backend/app/api/scheduler.py`(BOSS/ADMIN)与 `backend/app/api/ai_steward.py`(BOSS/ADMIN)
|
||||
- 后端重试接口:`POST /scheduler/runs/{run_id}/retry`(BOSS/ADMIN),`scheduler_center.retry_run()`
|
||||
- 前端失败一键重试:`schedulerApi.retryRun(runId)` + 失败任务红底/红边样式,`frontend/src/pages/SchedulerCenterPage.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 3. DoD(完成定义)
|
||||
|
||||
- [x] 调度中心页面可访问、可触发、可追踪。
|
||||
- [x] 关键调度任务有持久化记录(成功/失败可区分)。
|
||||
- [x] O4-4 首版实现“可持续更新认知状态”的调度入口。
|
||||
- [x] 前端构建通过:`cd frontend && npm run build`
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
# SSTC 看板执行清单 V3(前后端联动可见版)
|
||||
|
||||
更新时间:2026-02-25
|
||||
适用范围:`/projects/:id/board` 的 SSTC 看板全链路改造(React + FastAPI)
|
||||
|
||||
## 当前基线(基于现有代码)
|
||||
|
||||
- [x] 已有最小链路:`信号 -> 宪章 -> 任务`(`frontend/src/components/sstc/SSTCChainView.tsx` + `backend/app/api/sstc.py`)。
|
||||
- [x] 已有宪章详情页 + 任务创建:`frontend/src/pages/CharterDetailPage.tsx`。
|
||||
- [ ] 缺口:`frontend/src/components/sstc/SSTCGlobalHeader.tsx`、`SSTCStageNav.tsx`、`SSTCWorkspace.tsx`、`SSTCRiskFloatPanel.tsx` 当前为空文件。
|
||||
- [ ] 缺口:SSTC 仅有少量接口,缺少发布验证、复盘沉淀、链路健康聚合、阶段锁定校验。
|
||||
- [ ] 缺口:缺少统一操作日志表(全链路追溯要求)。
|
||||
- [ ] ⚠️ 待核实:`@dnd-kit/core`、`@tiptap/*` 不在 `frontend/package.json`,与方案约束不一致。
|
||||
|
||||
## 执行规则
|
||||
|
||||
- [ ] 每完成 1 步,将该步由 `[ ]` 改为 `[x]`。
|
||||
- [ ] 所有接口统一返回:`{ code, message, data }`(仅 SSTC 新增/改造接口先落地)。
|
||||
- [ ] 所有写操作记录:操作人、操作时间、变更摘要(写入 `operation_logs`)。
|
||||
- [ ] 所有 AI 调用记录:输入/输出、token、成本(复用 `ai_call_logs`)。
|
||||
|
||||
## P0 基础可运行(先打通框架)
|
||||
|
||||
- [x] **P0-1 建立 SSTC 统一数据契约**
|
||||
- 后端实现:新增 `backend/app/schemas/sstc.py`;标准化 stage/status 枚举、DTO、响应结构。
|
||||
- 前端实现:更新 `frontend/src/api/sstc.ts` 类型定义,全部接口适配 `data` 字段。
|
||||
- 可见验收:项目看板可正常加载,不再依赖“隐式字段”。
|
||||
|
||||
- [x] **P0-2 建立 SSTC 聚合服务层**
|
||||
- 后端实现:新增 `backend/app/services/sstc_service.py`,聚合项目头部指标、阶段状态、风险摘要。
|
||||
- 前端实现:新增 `getBoardOverview(projectId)` 调用。
|
||||
- 可见验收:看板顶部能显示统一概览数据。
|
||||
|
||||
- [x] **P0-3 填充 4 个空组件骨架**
|
||||
- 前端实现:补全 `SSTCGlobalHeader.tsx`、`SSTCStageNav.tsx`、`SSTCWorkspace.tsx`、`SSTCRiskFloatPanel.tsx` 基础布局。
|
||||
- 后端实现:提供骨架所需最小接口(overview、stage health、risk summary)。
|
||||
- 可见验收:`/projects/:id/board` 出现四段式布局,不再只有单卡片链路。
|
||||
|
||||
- [x] **P0-4 阶段锁定规则后端校验**
|
||||
- 后端实现:在任务创建/编辑、发布创建、复盘归档接口中增加上游状态校验(禁止前端绕过)。
|
||||
- 前端实现:灰态展示 + 按钮禁用 + 原因提示。
|
||||
- 可见验收:宪章未对齐时无法创建/确认任务。
|
||||
|
||||
## P1 核心链路(第一优先级:导航 + 宪章 + 任务 + 执行)
|
||||
|
||||
- [x] **P1-1 模块1:项目全局信息栏**
|
||||
- 后端实现:`GET /projects/{id}/sstc/overview`(基础信息、对齐度、进度、负载健康)。
|
||||
- 前端实现:`SSTCGlobalHeader` 渲染 5 区块;recharts 展示对齐度/进度/负载。
|
||||
- 可见验收:顶部 120px 信息栏完整显示指标和操作按钮。
|
||||
|
||||
- [x] **P1-2 模块2:六阶段链路导航栏**
|
||||
- 后端实现:`GET /projects/{id}/sstc/stages` 返回 6 节点状态、风险点、可编辑性。
|
||||
- 前端实现:`SSTCStageNav` 横向 6 节点 + 箭头 + Badge + 红点 + 选中高亮。
|
||||
- 可见验收:点击节点切换工作区;未解锁节点只读。
|
||||
|
||||
- [x] **P1-3 模块3.2:需求宪章工作区**
|
||||
- 后端实现:补齐宪章版本化、发起对齐、确认对齐、冲突查询与处理接口。
|
||||
- 前端实现:宪章编辑器(先 Textarea,后续可替换 Tiptap)、对齐状态卡、冲突卡、模型绑定卡。
|
||||
- 可见验收:可发起对齐;成员确认后状态从 `对齐中` 到 `已对齐`。
|
||||
|
||||
- [x] **P1-4 模块3.3:任务拆解工作区**
|
||||
- 后端实现:AI 拆解任务接口、负责人推荐接口、负载优化接口、任务确认接口。
|
||||
- 前端实现:任务树表格(父子折叠)、右侧负载监控与拆解设置卡。
|
||||
- 可见验收:点击“AI 重新拆解任务”后生成任务,显示推荐负责人和匹配度。
|
||||
|
||||
- [x] **P1-5 模块3.4:执行落地工作区**
|
||||
- 后端实现:任务状态迁移接口、风险预警聚合接口、阻塞处理接口。
|
||||
- 前端实现:Kanban 5 泳道、任务卡片详情弹窗、右侧风险栏。
|
||||
- 可见验收:拖拽任务卡更新状态并持久化。
|
||||
- ⚠️ 待核实:需先安装 `@dnd-kit/core`(当前未安装)。
|
||||
|
||||
## P2 扩展链路(第二优先级:信号源 + 发布验证 + 复盘)
|
||||
|
||||
- [x] **P2-1 模块3.1:信号源工作区**
|
||||
- 后端实现:信号列表筛选、归档、AI 影响评估、宪章关联接口。
|
||||
- 前端实现:左侧信号表格 + 右侧统计/AI/集成卡片。
|
||||
- 可见验收:可从信号直接跳转并创建宪章关联。
|
||||
|
||||
- [x] **P2-2 模块3.5:发布验证工作区**
|
||||
- 后端实现:发布计划 CRUD、发布状态流转、验证清单生成与提交。
|
||||
- 前端实现:发布计划表 + 验证标签页 + 右侧 AI 辅助栏。
|
||||
- 可见验收:仅可选择“已完成任务”创建发布计划。
|
||||
|
||||
- [x] **P2-3 模块3.6:复盘沉淀工作区**
|
||||
- 后端实现:复盘节点创建/版本/归档、改进项落地接口。
|
||||
- 前端实现:6 模块复盘编辑区 + 右侧知识沉淀/改进项/AI 卡片。
|
||||
- 可见验收:确认归档后链路锁定,进入只读状态。
|
||||
|
||||
## P3 全局能力(第三优先级:预警 + 权限 + 可追溯)
|
||||
|
||||
- [x] **P3-1 模块4:风险/认知冲突悬浮栏**
|
||||
- 后端实现:统一预警接口(高/中风险 + 未解决冲突)。
|
||||
- 前端实现:右下角悬浮窗(可最小化、角标、Tab 切换)。
|
||||
- 可见验收:新增高风险时角标数量实时变化。
|
||||
|
||||
- [x] **P3-2 操作日志全链路追溯**
|
||||
- 后端实现:新增 `operation_logs` 模型、迁移、写入中间件/服务封装。
|
||||
- 前端实现:关键操作写 `x-request-id`,便于追踪。
|
||||
- 可见验收:任一状态变更可查到操作人/时间/变更前后。
|
||||
|
||||
- [x] **P3-3 角色权限闭环**
|
||||
- 后端实现:项目负责人/成员/管理员权限矩阵校验。
|
||||
- 前端实现:按钮级权限渲染与禁用提示。
|
||||
- 可见验收:成员无法执行负责人专属操作。
|
||||
|
||||
- [x] **P3-4 AI 调用成本可观测**
|
||||
- 后端实现:SSTC AI 接口统一落 `ai_call_logs`,补齐 token/cost 字段。
|
||||
- 前端实现:在 AI 操作结果区展示“本次 token 与成本”。
|
||||
- 可见验收:每次 AI 操作后可在日志中检索到调用记录。
|
||||
|
||||
## 联调与验收清单(发布前必须全勾)
|
||||
|
||||
- [ ] E2E-1 项目进入看板后,四大区块均可见且无空白组件。
|
||||
- [ ] E2E-2 六阶段状态与锁定规则生效(前后端双校验)。
|
||||
- [ ] E2E-3 宪章未对齐无法进入任务确认与执行编辑。
|
||||
- [ ] E2E-4 任务拖拽状态迁移成功,刷新后状态保持。
|
||||
- [ ] E2E-5 发布必须绑定已完成任务,复盘必须在发布验证后。
|
||||
- [ ] E2E-6 全部 SSTC 新接口返回统一结构 `{code,message,data}`。
|
||||
- [ ] E2E-7 关键写操作可在 `operation_logs` 检索。
|
||||
- [ ] E2E-8 AI 操作可在 `ai_call_logs` 检索到 token/cost。
|
||||
|
||||
## 实施顺序建议(最短路径看到效果)
|
||||
|
||||
- [ ] 第 1 周:完成 P0 + P1-1 + P1-2(先把“看起来像完整看板”做出来)。
|
||||
- [ ] 第 2 周:完成 P1-3 + P1-4 + P1-5(核心闭环可操作)。
|
||||
- [ ] 第 3 周:完成 P2(补齐发布与复盘闭环)。
|
||||
- [ ] 第 4 周:完成 P3 + E2E(治理与可追溯收口)。
|
||||
|
|
@ -0,0 +1,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": {} }
|
||||
```
|
||||
78
README.md
78
README.md
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
@ -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')
|
||||
|
|
@ -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')
|
||||
|
|
@ -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)
|
||||
|
|
@ -9,20 +9,24 @@ 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! ###
|
||||
op.alter_column('context_nodes', 'embedding',
|
||||
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),
|
||||
existing_nullable=True)
|
||||
|
|
@ -31,8 +35,12 @@ 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! ###
|
||||
op.alter_column('context_nodes', 'embedding',
|
||||
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),
|
||||
existing_nullable=True)
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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(),
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
"""
|
||||
API 端点模块
|
||||
|
||||
按业务域拆分的具体端点实现。
|
||||
"""
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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)}"
|
||||
)
|
||||
|
|
@ -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})
|
||||
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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},
|
||||
}
|
||||
|
|
@ -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
|
||||
]
|
||||
|
|
@ -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"}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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),
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"}
|
||||
|
|
@ -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)}"
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
"""
|
||||
配置模块
|
||||
|
||||
- ai_prompts: AI 调用使用的 Prompt 模板,统一管理,禁止在业务代码中硬编码
|
||||
"""
|
||||
|
|
@ -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"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
"""
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
1189
backend/app/main.py
1189
backend/app/main.py
File diff suppressed because it is too large
Load Diff
|
|
@ -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")
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
"""知识节点相关 Pydantic 模型"""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ContextNodeCreate(BaseModel):
|
||||
"""创建知识节点请求"""
|
||||
|
||||
content: str
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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 # 未提供时自动计算
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
@ -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
Loading…
Reference in New Issue