feat: complete PMFlow implementation
This commit is contained in:
parent
b0a8c6edb9
commit
16f30bc2c6
|
|
@ -3,34 +3,67 @@ __pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
*.so
|
*.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/
|
venv/
|
||||||
env/
|
env/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
.venv/
|
||||||
|
|
||||||
# Node
|
# Node.js
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# Database
|
# IDEs
|
||||||
*.db
|
.idea/
|
||||||
*.sqlite
|
.vscode/
|
||||||
*.sqlite3
|
*.swp
|
||||||
|
*.swo
|
||||||
# Environment
|
|
||||||
.env
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Ollama
|
|
||||||
ollama_bin/
|
|
||||||
.ollama/
|
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs/
|
logs/
|
||||||
*.log
|
*.log
|
||||||
nohup.out
|
|
||||||
|
|
||||||
# Coverage
|
# Databases
|
||||||
coverage/
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
# Temporary
|
# Large Files & Binaries
|
||||||
tmp/
|
.ollama/
|
||||||
temp/
|
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
|
## 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:
|
### Dual Frontend Architecture
|
||||||
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
|
|
||||||
|
|
||||||
## Technology Stack
|
The project maintains two frontends:
|
||||||
|
- **Vue 3** (`frontend/`): Legacy Signal Dashboard with Naive UI
|
||||||
### Backend
|
- **React 18** (`frontend-react/`): New PMFlow interface with shadcn/ui (recommended for new features)
|
||||||
- **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
|
|
||||||
|
|
||||||
## Common Development Commands
|
## 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
|
```bash
|
||||||
# Navigate to backend
|
|
||||||
cd backend
|
cd backend
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies (use project venv)
|
||||||
|
source ../venv/bin/activate # On Windows: ../venv/Scripts/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
# Run database migrations
|
# Run database migrations
|
||||||
alembic upgrade head
|
alembic upgrade head
|
||||||
|
|
||||||
# Seed initial data (development only)
|
# Seed initial data (development)
|
||||||
python -m seed
|
python -m seed_projects.py
|
||||||
|
|
||||||
# Start backend server (dev mode with hot reload)
|
# 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
|
```bash
|
||||||
# Navigate to frontend
|
cd frontend-react
|
||||||
cd frontend
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
npm install
|
npm install
|
||||||
|
npm run dev # Port 5174
|
||||||
# Start dev server (accessible on network with --host flag)
|
|
||||||
npm run dev -- --host
|
|
||||||
|
|
||||||
# Production build
|
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
# Preview production build
|
|
||||||
npm run preview
|
npm run preview
|
||||||
```
|
```
|
||||||
|
|
||||||
### Database Setup
|
**Vue 3 (Legacy)**:
|
||||||
|
|
||||||
**PostgreSQL (production)**:
|
|
||||||
```bash
|
```bash
|
||||||
# Create database with pgvector extension
|
cd frontend
|
||||||
createdb neural_sync
|
npm install
|
||||||
psql neural_sync -c "CREATE EXTENSION vector;"
|
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
|
## Architecture
|
||||||
|
|
||||||
### Backend Structure ([`backend/app/`](backend/app/))
|
### Backend Structure ([`backend/app/`](backend/app/))
|
||||||
|
|
||||||
- **[`main.py`](backend/app/main.py)**: FastAPI application with all endpoints
|
**Core Architecture**:
|
||||||
- Webhook handlers for Git and Lark events
|
- [`api/`](backend/app/api/) - API routes organized by module (auth, signals, users, sstc, etc.)
|
||||||
- Signal ingestion with strategic impact analysis
|
- [`core/`](backend/app/core/) - Configuration, logging, response formatting
|
||||||
- CRUD operations for experiments and context nodes
|
- [`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
|
**Key Systems**:
|
||||||
- `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
|
|
||||||
|
|
||||||
- **[`services/`](backend/app/services/)**: External integrations
|
1. **Multi-Tenancy** ([`models/base.py`](backend/app/models/base.py)):
|
||||||
- **[`llm.py`](backend/app/services/llm.py)**: Ollama client for embeddings, task breakdown, strategic evaluation, code audit
|
- All models inherit from `Base` (includes `tenant_id`, `created_at`, `updated_at`)
|
||||||
- **[`plane.py`](backend/app/services/plane.py)**: Plane PM tool integration (simulation mode without API key)
|
- `Tenant` model uses `BaseNoTenant` (no tenant_id)
|
||||||
- **[`lark.py`](backend/app/services/lark.py)**: Lark/Feishu bot messaging with rich cards
|
- All queries must filter by tenant_id via dependency injection
|
||||||
- **[`fetcher.py`](backend/app/services/fetcher.py)**: HackerNews signal fetching
|
|
||||||
- **[`analyzer.py`](backend/app/services/analyzer.py)**: Fallback keyword-based impact scoring
|
|
||||||
|
|
||||||
### 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
|
3. **Event System** ([`events.py`](backend/app/events.py), [`websocket.py`](backend/app/websocket.py)):
|
||||||
- **[`components/SignalDashboard.vue`](frontend/src/components/SignalDashboard.vue)**: Complex command center with:
|
- Event bus for pub/sub messaging
|
||||||
- Signal aggregation by source
|
- WebSocket manager with real-time broadcasting
|
||||||
- Competitive moat visualization with progress comparison
|
- Event types: signals, tasks, code reviews, audits, integrations
|
||||||
- ROI scorecard and radar charts
|
|
||||||
- Typewriter effect for AI analysis
|
4. **Role Dispatcher** ([`dispatcher.py`](backend/app/dispatcher.py)):
|
||||||
- Polling-based status updates
|
- Routes signals to appropriate roles (Boss, AI Engineer, Backend, Frontend)
|
||||||
- **[`components/ExperimentBoard.vue`](frontend/src/components/ExperimentBoard.vue)**: Experiment grid with auto-status based on scores
|
- Keyword and pattern-based routing with confidence scoring
|
||||||
- **[`components/ContextManager.vue`](frontend/src/components/ContextManager.vue)**: Knowledge nodes with AI insight display (moat, tech_specs)
|
- 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
|
## Key Integration Points
|
||||||
|
|
||||||
### Plane Integration
|
### Authentication Flow
|
||||||
- Environment variables: `PLANE_API_URL`, `PLANE_API_KEY`, `PLANE_WORKSPACE_SLUG`, `PLANE_PROJECT_ID`
|
1. Login via `/auth/login` returns JWT token
|
||||||
- Without API key, runs in simulation mode (logs to console)
|
2. Include token in Authorization header: `Bearer <token>`
|
||||||
- Supports creating issues with labels, priority, and time estimates
|
3. Use `get_current_user` dependency for protected routes
|
||||||
- Status updates require State UUID lookup (not fully implemented in MVP)
|
4. Use `require_roles("BOSS", "ADMIN")` for role-gated endpoints
|
||||||
|
|
||||||
### Ollama Integration
|
### Multi-Tenant Queries
|
||||||
- Default host: `http://127.0.0.1:11434`
|
```python
|
||||||
- Models used: `llama3` (general), `nomic-embed-text` (embeddings)
|
from app.dependencies import get_tenant_id
|
||||||
- All LLM calls are synchronous but wrapped in async endpoints
|
|
||||||
- Includes mock fallback responses for "Pixelle-Video" signals
|
|
||||||
|
|
||||||
### Git Webhook Flow
|
@router.get("/items")
|
||||||
1. Commit message contains task ID (e.g., `#PROJ-123`)
|
async def list_items(
|
||||||
2. Webhook extracts diff and message
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
3. [`llm.audit_code_change()`](backend/app/services/llm.py) evaluates against task requirement
|
db: AsyncSession = Depends(get_db)
|
||||||
4. If passed: updates Plane status, marks task Done in Signal
|
):
|
||||||
|
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
|
event_bus = get_event_bus()
|
||||||
- PostgreSQL: Uses `Vector(768)` from pgvector
|
await event_bus.publish(
|
||||||
- SQLite: Fallback to JSON storage for development
|
EventType.TASK_ASSIGNED,
|
||||||
- Embeddings generated via Ollama's `nomic-embed-text` model
|
{"task_id": 123, "user_id": 456}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
### Migration Files
|
### WebSocket Events
|
||||||
- Located in [`backend/alembic/versions/`](backend/alembic/versions/)
|
- Events are automatically bridged to WebSocket clients
|
||||||
- Key migrations: initial tables, pgvector support, signal model updates
|
- Clients subscribe to event types for filtering
|
||||||
|
- Message format: `{"type": "event", "event_type": "...", "data": {...}, "timestamp": ...}`
|
||||||
|
|
||||||
## Development Notes
|
## Development Notes
|
||||||
|
|
||||||
|
- **Database**: PostgreSQL with pgvector recommended; SQLite fallback for development
|
||||||
- **CORS**: Currently allows all origins (`*`) - restrict for production
|
- **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
|
- **Logging**: Structured logging via [`core/logging.py`](backend/app/core/logging.py)
|
||||||
- **Frontend API**: Hardcoded to `http://127.0.0.1:8000` - configure via env var for deployment
|
- **API Response**: Standardized format via [`core/response.py`](backend/app/core/response.py)
|
||||||
- **Polling**: Signal dashboard polls every 5 seconds with smart diff detection to minimize re-renders
|
- **AI Caching**: Redis-based caching for AI call results
|
||||||
- **AI Analysis**: All LLM prompts are in [`llm.py`](backend/app/services/llm.py) with fallback mock data for specific keywords
|
- **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]
|
# A generic, single database configuration.
|
||||||
# path to migration scripts
|
|
||||||
script_location = alembic
|
|
||||||
|
|
||||||
# template used to generate migration files
|
[alembic]
|
||||||
# file_template = %%(rev)s_%%(slug)s
|
# 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.
|
# 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 = .
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
|
||||||
# timezone to use when rendering the date within the migration file
|
# timezone to use when rendering the date within the migration file
|
||||||
# as well as the filename.
|
# as well as the filename.
|
||||||
# If specified, requires the python-dateutil library that can be
|
# If specified, requires the tzdata library which can be installed by adding
|
||||||
# installed by adding `alembic[timezone]` to the pip requirements
|
# `alembic[tz]` to the pip requirements.
|
||||||
# string value is passed to dateutil.tz.gettz()
|
# string value is passed to ZoneInfo()
|
||||||
# leave blank for default
|
# leave blank for localtime
|
||||||
# timezone =
|
# timezone =
|
||||||
|
|
||||||
# max length of characters to apply to the
|
# max length of characters to apply to the "slug" field
|
||||||
# "slug" field
|
|
||||||
# truncate_slug_length = 40
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
# set to 'true' to run the environment during
|
# set to 'true' to run the environment during
|
||||||
|
|
@ -30,27 +41,52 @@ prepend_sys_path = .
|
||||||
# versions/ directory
|
# versions/ directory
|
||||||
# sourceless = false
|
# sourceless = false
|
||||||
|
|
||||||
# version location specification; this defaults
|
# version location specification; This defaults
|
||||||
# to alembic/versions. When using multiple version
|
# to <script_location>/versions. When using multiple version
|
||||||
# directories, initial revisions must be specified with --version-path.
|
# directories, initial revisions must be specified with --version-path.
|
||||||
# The path separator used here should be the separator specified by "version_path_separator"
|
# The path separator used here should be the separator specified by "path_separator"
|
||||||
# version_locations = %(here)s/alembic/versions
|
# 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
|
# path_separator; This indicates what character is used to split lists of file
|
||||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
# paths, including version_locations and prepend_sys_path within configparser
|
||||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
# files such as alembic.ini.
|
||||||
# Valid values for version_path_separator are:
|
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||||
|
# to provide os-dependent path splitting.
|
||||||
#
|
#
|
||||||
# version_path_separator = :
|
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||||
# version_path_separator = ;
|
# take place if path_separator is not present in alembic.ini. If this
|
||||||
# version_path_separator = space
|
# option is omitted entirely, fallback logic is as follows:
|
||||||
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
#
|
||||||
|
# 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
|
# the output encoding used when revision files
|
||||||
# are written from script.py.mako
|
# are written from script.py.mako
|
||||||
# output_encoding = utf-8
|
# 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]
|
[post_write_hooks]
|
||||||
|
|
@ -64,7 +100,20 @@ sqlalchemy.url = postgresql+asyncpg://postgres:postgres@localhost:5435/neural_sy
|
||||||
# black.entrypoint = black
|
# black.entrypoint = black
|
||||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
# 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]
|
[loggers]
|
||||||
keys = root,sqlalchemy,alembic
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
|
@ -75,12 +124,12 @@ keys = console
|
||||||
keys = generic
|
keys = generic
|
||||||
|
|
||||||
[logger_root]
|
[logger_root]
|
||||||
level = WARN
|
level = WARNING
|
||||||
handlers = console
|
handlers = console
|
||||||
qualname =
|
qualname =
|
||||||
|
|
||||||
[logger_sqlalchemy]
|
[logger_sqlalchemy]
|
||||||
level = WARN
|
level = WARNING
|
||||||
handlers =
|
handlers =
|
||||||
qualname = sqlalchemy.engine
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
import asyncio
|
|
||||||
from logging.config import fileConfig
|
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 import pool
|
||||||
from sqlalchemy.engine import Connection
|
|
||||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
|
||||||
|
|
||||||
from alembic import context
|
from alembic import context
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models import Base
|
||||||
|
|
||||||
# this is the Alembic Config object, which provides
|
# this is the Alembic Config object, which provides
|
||||||
# access to the values within the .ini file in use.
|
# 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:
|
if config.config_file_name is not None:
|
||||||
fileConfig(config.config_file_name)
|
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
|
# add your model's MetaData object here
|
||||||
# for 'autogenerate' support
|
# for 'autogenerate' support
|
||||||
# from myapp import mymodel
|
# from myapp import mymodel
|
||||||
# target_metadata = mymodel.Base.metadata
|
# target_metadata = mymodel.Base.metadata
|
||||||
from app.models import Base
|
|
||||||
target_metadata = Base.metadata
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
# other values from the config, defined by the needs of env.py,
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
|
@ -53,33 +62,29 @@ def run_migrations_offline() -> None:
|
||||||
context.run_migrations()
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
def do_run_migrations(connection: Connection) -> None:
|
def run_migrations_online() -> None:
|
||||||
context.configure(connection=connection, target_metadata=target_metadata)
|
|
||||||
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
async def run_migrations_online() -> None:
|
|
||||||
"""Run migrations in 'online' mode.
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
In this scenario we need to create an Engine
|
In this scenario we need to create an Engine
|
||||||
and associate a connection with the context.
|
and associate a connection with the context.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
connectable = async_engine_from_config(
|
connectable = engine_from_config(
|
||||||
config.get_section(config.config_ini_section, {}),
|
config.get_section(config.config_ini_section, {}),
|
||||||
prefix="sqlalchemy.",
|
prefix="sqlalchemy.",
|
||||||
poolclass=pool.NullPool,
|
poolclass=pool.NullPool,
|
||||||
)
|
)
|
||||||
|
|
||||||
async with connectable.connect() as connection:
|
with connectable.connect() as connection:
|
||||||
await connection.run_sync(do_run_migrations)
|
context.configure(
|
||||||
|
connection=connection, target_metadata=target_metadata
|
||||||
|
)
|
||||||
|
|
||||||
await connectable.dispose()
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
if context.is_offline_mode():
|
if context.is_offline_mode():
|
||||||
run_migrations_offline()
|
run_migrations_offline()
|
||||||
else:
|
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
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
import pgvector
|
import pgvector.sqlalchemy
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = '380f4e61b41c'
|
revision: str = '380f4e61b41c'
|
||||||
|
|
@ -20,30 +20,32 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
"""Upgrade schema."""
|
"""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! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.create_table('context_nodes',
|
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('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('version', sa.Integer(), nullable=True),
|
||||||
sa.Column('status', sa.String(length=20), 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')
|
sa.PrimaryKeyConstraint('id')
|
||||||
)
|
)
|
||||||
op.create_table('experiments',
|
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('title', sa.String(length=255), nullable=True),
|
||||||
sa.Column('hypothesis', sa.Text(), nullable=True),
|
sa.Column('hypothesis', sa.Text(), nullable=True),
|
||||||
sa.Column('status', sa.String(length=50), nullable=True),
|
sa.Column('status', sa.String(length=50), nullable=True),
|
||||||
sa.Column('owner_id', 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.ARRAY(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('target_metric', sa.String(length=50), nullable=True),
|
||||||
sa.Column('current_score', sa.Float(), nullable=True),
|
sa.Column('current_score', sa.Float(), nullable=True),
|
||||||
sa.PrimaryKeyConstraint('id')
|
sa.PrimaryKeyConstraint('id')
|
||||||
)
|
)
|
||||||
op.create_table('signals',
|
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('source', sa.String(length=50), nullable=True),
|
||||||
sa.Column('content', sa.Text(), nullable=True),
|
sa.Column('content', sa.Text(), nullable=True),
|
||||||
sa.Column('impact_score', sa.Float(), 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,19 +9,23 @@ from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
import pgvector
|
import pgvector.sqlalchemy
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = 'd3c63f669b0a'
|
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
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
"""Upgrade schema."""
|
"""Upgrade schema."""
|
||||||
|
bind = op.get_bind()
|
||||||
|
is_sqlite = bind.dialect.name == 'sqlite'
|
||||||
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
if not is_sqlite:
|
||||||
op.alter_column('context_nodes', 'embedding',
|
op.alter_column('context_nodes', 'embedding',
|
||||||
existing_type=pgvector.sqlalchemy.vector.VECTOR(dim=1536),
|
existing_type=pgvector.sqlalchemy.vector.VECTOR(dim=1536),
|
||||||
type_=pgvector.sqlalchemy.vector.VECTOR(dim=768),
|
type_=pgvector.sqlalchemy.vector.VECTOR(dim=768),
|
||||||
|
|
@ -31,7 +35,11 @@ def upgrade() -> None:
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
"""Downgrade schema."""
|
"""Downgrade schema."""
|
||||||
|
bind = op.get_bind()
|
||||||
|
is_sqlite = bind.dialect.name == 'sqlite'
|
||||||
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
if not is_sqlite:
|
||||||
op.alter_column('context_nodes', 'embedding',
|
op.alter_column('context_nodes', 'embedding',
|
||||||
existing_type=pgvector.sqlalchemy.vector.VECTOR(dim=768),
|
existing_type=pgvector.sqlalchemy.vector.VECTOR(dim=768),
|
||||||
type_=pgvector.sqlalchemy.vector.VECTOR(dim=1536),
|
type_=pgvector.sqlalchemy.vector.VECTOR(dim=1536),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
from sqlalchemy.ext.asyncio import (
|
||||||
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
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:
|
async with AsyncSessionLocal() as session:
|
||||||
yield 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