Compare commits
43 Commits
aea0d51b44
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c38e37b23 | ||
|
|
a5fbe5130a | ||
| b27c5f2cd6 | |||
| c9058a27b7 | |||
|
|
1e438164c1 | ||
|
|
f4864fafd0 | ||
|
|
9798b34717 | ||
|
|
284995d79c | ||
|
|
e18a367abb | ||
|
|
817d13c8f7 | ||
|
|
a72b8f1e4b | ||
|
|
bccd3bf448 | ||
|
|
a67ca2c19c | ||
|
|
2c1e08a271 | ||
| 1468834116 | |||
|
|
ba4059fe3b | ||
|
|
4050c120d5 | ||
|
|
7d5b345779 | ||
| 156a952b47 | |||
|
|
4173b53b1b | ||
|
|
ab9e87f948 | ||
|
|
5a040366da | ||
| 8789019db2 | |||
|
|
3cb20a4a28 | ||
|
|
a6085a2612 | ||
|
|
4899bc8779 | ||
|
|
e70538e64d | ||
| 39a5390ecd | |||
|
|
746d1118df | ||
|
|
6e1197d321 | ||
|
|
a70cf6addd | ||
|
|
8b83b8facf | ||
|
|
e09c6e81df | ||
|
|
2bceb686c6 | ||
| cd354f52e6 | |||
|
|
acbd6d91b3 | ||
|
|
762d48d090 | ||
|
|
42f2efb449 | ||
|
|
8c1a0d785c | ||
|
|
66eac77c96 | ||
|
|
d8ba048c6d | ||
|
|
c9ddc3b652 | ||
|
|
0917206191 |
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
node_modules
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/dist
|
||||||
|
frontend/.vite
|
||||||
|
npm-debug.log*
|
||||||
|
docker-compose*.yml
|
||||||
|
README.md
|
||||||
|
design-qa.md
|
||||||
|
docs
|
||||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -12,6 +12,10 @@ __pycache__/
|
|||||||
build/
|
build/
|
||||||
develop-eggs/
|
develop-eggs/
|
||||||
dist/
|
dist/
|
||||||
|
!frontend/dist/
|
||||||
|
!frontend/dist/**
|
||||||
|
node_modules/
|
||||||
|
*.tsbuildinfo
|
||||||
downloads/
|
downloads/
|
||||||
eggs/
|
eggs/
|
||||||
.eggs/
|
.eggs/
|
||||||
@@ -37,6 +41,15 @@ MANIFEST
|
|||||||
pip-log.txt
|
pip-log.txt
|
||||||
pip-delete-this-directory.txt
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Runtime data and logs
|
||||||
|
runtime/
|
||||||
|
backend/runtime/
|
||||||
|
logs/
|
||||||
|
backend/logs/
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
# Unit test / coverage reports
|
# Unit test / coverage reports
|
||||||
htmlcov/
|
htmlcov/
|
||||||
.tox/
|
.tox/
|
||||||
@@ -174,3 +187,5 @@ cython_debug/
|
|||||||
# PyPI configuration file
|
# PyPI configuration file
|
||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
|
# Local backend configuration may contain database credentials.
|
||||||
|
backend/config.yaml
|
||||||
|
|||||||
270
README.md
270
README.md
@@ -1,133 +1,197 @@
|
|||||||
# YG_FT
|
# YG_FT 模型微调平台
|
||||||
|
|
||||||
远光微调平台 - 面向大语言模型的微调、评测、推理与对比一体化前端。
|
YG_FT 是一个面向企业治理场景的模型微调平台,覆盖用户中心、多租户、项目隔离、数据集管理、模型管理、训练任务、评测、推理、审批流、审计留存、算力调度和训练引擎适配。
|
||||||
|
|
||||||
## 技术栈
|
当前前端已有基础页面,后端与算力平台已按多人协作开发方式建立工程骨架,并开始实现正式系统主链路能力。当前代码和 SQL 均作为后续生产演进基线维护,不再以一次性演示或静态 Mock 为开发准则。
|
||||||
|
|
||||||
| 类别 | 技术 | 版本 |
|
## 总体架构
|
||||||
|------|------|------|
|
|
||||||
| 框架 | Vue 3 | ^3.5.13 |
|
|
||||||
| 语言 | TypeScript | ~5.7.2 |
|
|
||||||
| 构建工具 | Vite | ^6.0.7 |
|
|
||||||
| 路由 | Vue Router | ^4.5.0 |
|
|
||||||
| 状态管理 | Pinia | ^2.3.0 |
|
|
||||||
| UI 组件库 | Element Plus | ^2.9.1 |
|
|
||||||
| HTTP 客户端 | axios | ^1.7.9 |
|
|
||||||
| 图表 | ECharts / vue-echarts | ^6.1.0 / ^8.0.1 |
|
|
||||||
| Markdown | marked + DOMPurify | ^15.0.5 / ^3.2.3 |
|
|
||||||
| 编辑器 | md-editor-v3 | ^5.1.4 |
|
|
||||||
| 工具集 | @vueuse/core | ^11.3.0 |
|
|
||||||
| 样式 | Sass | ^1.83.0 |
|
|
||||||
|
|
||||||
**项目版本**:1.0.0
|
```text
|
||||||
|
YG_FT/
|
||||||
|
frontend/ # 前端控制台
|
||||||
|
backend/ # FastAPI 应用平台后端
|
||||||
|
app/
|
||||||
|
api/v1/ # 对前端暴露的 REST API
|
||||||
|
core/ # 配置、日志、中间件、权限等基础能力
|
||||||
|
db/ # 数据库连接、迁移、事务工具
|
||||||
|
modules/ # 业务模块目录
|
||||||
|
schemas/ # Pydantic 入参/出参模型
|
||||||
|
services/ # 跨模块应用服务
|
||||||
|
workers/ # 后台任务入口
|
||||||
|
requirements.txt # 后端 Python 第三方依赖
|
||||||
|
compute/ # 算力平台与训练框架适配层
|
||||||
|
api/ # 内部 Compute API
|
||||||
|
agent/ # 单机多 GPU 调度与进程管理
|
||||||
|
engines/llama_factory/ # LLaMA-Factory 适配器
|
||||||
|
file_gateway/ # 本地文件上传、下载、导入、产物管理
|
||||||
|
docs/ # 需求、接口、数据库、开发计划和部署文档
|
||||||
|
docker/ # 容器化配置
|
||||||
|
```
|
||||||
|
|
||||||
## 环境要求
|
## 平台分层
|
||||||
|
|
||||||
- **Node.js** >= 18(推荐 20 LTS)
|
| 层级 | 职责 | 主要目录 |
|
||||||
- **npm** >= 9
|
| --- | --- | --- |
|
||||||
- 后端服务运行于 `http://localhost:7861`(前端通过代理转发,见下文)
|
| 前端控制台 | 用户操作入口、任务看板、项目/模型/数据集/训练/审批/审计页面 | `frontend/` |
|
||||||
|
| 应用平台后端 | 用户中心、多租户、RBAC/ABAC、项目隔离、元数据、审批流、审计、API 编排 | `backend/` |
|
||||||
|
| 算力平台 | GPU 发现、资源锁定、训练进程管理、日志采集、产物归档、任务状态同步 | `compute/` |
|
||||||
|
| 训练引擎 | 当前固定接入 LLaMA-Factory,预留其他训练平台适配标准 | `compute/engines/` |
|
||||||
|
| 数据层 | PostgreSQL、Redis、本地文件存储、日志归档 | `docs/postgres-schema.sql` |
|
||||||
|
|
||||||
## 快速开始
|
## 当前开发基线
|
||||||
|
|
||||||
### 1. 安装依赖
|
- 使用 FastAPI 提供统一 API 响应结构 `{ code, message, data }`。
|
||||||
|
- 本地运行阶段统一使用 PostgreSQL,后端启动时会在 PG 中初始化当前运行表和系统内置账号;模型、数据集、算力节点、GPU、微调任务等业务数据必须通过页面、接口或正式导入流程产生。
|
||||||
|
- 支持登录、模型管理、数据集管理、微调任务创建/启动/停止/进度轮询。
|
||||||
|
- 支持训练日志、loss 指标、checkpoint 和训练产物接口;真实训练执行器接入前,联调状态机必须通过显式环境变量开启。
|
||||||
|
- 支持多算力节点、GPU、任务队列、资源副本和资源同步状态接口。
|
||||||
|
- 前端新增 `/compute` 算力节点页面,展示节点地址、权重、标签、启用状态、GPU、队列和资源副本。
|
||||||
|
- `compute/engines/llama_factory/adapter.py` 提供 LLaMA-Factory 参数校验、命令生成和日志解析基础能力。
|
||||||
|
|
||||||
|
## 一键启动前端和后端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/start-dev.sh --setup # 首次运行,初始化本地依赖
|
||||||
|
./scripts/start-dev.sh # 后续直接启动
|
||||||
|
./scripts/start-dev.sh --check # 仅检查依赖和 PostgreSQL 连接
|
||||||
|
```
|
||||||
|
|
||||||
|
首次运行前可将 `backend/config.example.yaml` 复制为 `backend/config.yaml` 并填写
|
||||||
|
本地数据库凭据;`config.yaml` 已加入 Git 忽略。脚本会检查 PostgreSQL 和两个服务
|
||||||
|
的健康状态,并在退出时同时停止前端和后端。也可以按下面步骤分别启动服务。
|
||||||
|
|
||||||
|
一键启动使用的前后端端口也统一配置在同一个 YAML 中:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
server:
|
||||||
|
frontend_port: 16801
|
||||||
|
backend_port: 17861
|
||||||
|
```
|
||||||
|
|
||||||
|
端口被占用时修改这两项后重新启动即可。未显式配置 `cors_allow_origins` 时,后端会
|
||||||
|
根据 `frontend_port` 自动允许本机前端来源。环境变量 `FRONTEND_PORT` 和
|
||||||
|
`BACKEND_PORT` 可以临时覆盖 YAML。
|
||||||
|
|
||||||
|
## 后端启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
/opt/miniconda3/bin/python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
uvicorn app.main:app --reload --port 17861
|
||||||
|
```
|
||||||
|
|
||||||
|
默认接口前缀为 `/modelTF`,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /modelTF/health
|
||||||
|
POST /modelTF/login
|
||||||
|
GET /modelTF/model-manage
|
||||||
|
GET /modelTF/dataset-manage
|
||||||
|
GET /modelTF/fine-tune
|
||||||
|
GET /modelTF/compute/nodes
|
||||||
|
```
|
||||||
|
|
||||||
|
本地运行时默认通过 `backend/config.yaml` 连接 PostgreSQL:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
server:
|
||||||
|
frontend_port: 16801
|
||||||
|
backend_port: 17861
|
||||||
|
|
||||||
|
database:
|
||||||
|
url: postgresql+psycopg://localhost:5432/yg_ft
|
||||||
|
username: yg_ft
|
||||||
|
password: "change_me"
|
||||||
|
```
|
||||||
|
|
||||||
|
本地启动前需要确保 PostgreSQL 已监听 YAML 配置的地址(默认 `localhost:5432`),
|
||||||
|
并已创建 `yg_ft` 数据库和 `yg_ft` 用户。后端启动后会自动创建当前运行表并写入
|
||||||
|
内置管理员账号,运行数据统一写入 PostgreSQL。
|
||||||
|
|
||||||
|
开发阶段内置登录账号:
|
||||||
|
|
||||||
|
| 角色 | 账号 | 密码 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 超级管理员 | `admin` | `admin123` | 拥有当前全部页面权限 |
|
||||||
|
| 操作员 | `operator` | `operator123` | 拥有业务操作相关页面权限 |
|
||||||
|
|
||||||
|
以上账号仅用于本地开发和联调。生产环境初始化后应立即修改密码,或改为企业统一身份认证/管理员初始化流程。
|
||||||
|
|
||||||
|
## 前端启动
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
npm install
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 启动开发服务器
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
开发服务器默认运行在 `http://localhost:6801`。
|
通过一键脚本启动时,前端端口和 Vite proxy 的后端端口均来自
|
||||||
|
`backend/config.yaml`。单独运行 `npm run dev` 时,默认使用前端 `16801`、后端
|
||||||
|
`17861`,也可以通过 `FRONTEND_PORT` 和 `BACKEND_PORT` 覆盖。
|
||||||
|
|
||||||
### 3. 构建生产包
|
## 算力服务启动
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build # 类型检查 + 生产构建,产物输出到 dist/
|
cd compute
|
||||||
npm run preview # 本地预览构建产物
|
uvicorn api.main:app --reload --port 19100
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. 类型检查
|
默认 `COMPUTE_MODE=real`。真实 GPU 接入时,在每台算力服务器上部署 Compute API、Agent、File Gateway 和 LLaMA-Factory,应用平台通过 `compute_nodes.api_base_url` 和 `compute_nodes.file_gateway_url` 主动轮询。仅在隔离联调环境可显式设置 `COMPUTE_MODE=simulator` 或 `COMPUTE_EXECUTION_MODE=simulator`。
|
||||||
|
|
||||||
|
## 日志
|
||||||
|
|
||||||
|
后端日志模块位于 `backend/app/core/logging.py`,说明文档见:
|
||||||
|
|
||||||
|
- `docs/backend-logging.md`
|
||||||
|
|
||||||
|
默认输出:
|
||||||
|
|
||||||
|
```text
|
||||||
|
logs/backend-YYYY-MM-DD.log
|
||||||
|
logs/error-YYYY-MM-DD.log
|
||||||
|
```
|
||||||
|
|
||||||
|
日志格式为 JSON Lines,单个文件不超过 20MB,只保留最近 10 天。
|
||||||
|
|
||||||
|
## 主要文档
|
||||||
|
|
||||||
|
- `docs/platform-architecture-requirements.md`:平台需求、功能模块、页面补全建议。
|
||||||
|
- `docs/menu-functional-requirements.md`:当前菜单、二级路由、规划菜单、功能需求、接口和数据库映射。
|
||||||
|
- `docs/backend-api-design.md`:FastAPI 接口分组、参数定义、权限说明。
|
||||||
|
- `docs/postgres-schema.sql`:PostgreSQL 数据库脚本,包含权限、用户中心、多租户、审批、审计等模型。
|
||||||
|
- `docs/system-development-plan.md`:多人协作开发计划,按前端、后端、DB、部署拆分。
|
||||||
|
- `docs/team-development-plan.md`:3-4 人并行开发分工计划,按人员边界标注页面、接口、数据库和交付节奏。
|
||||||
|
- `docs/first-version-development-plan.md`:当前系统主链路开发计划,覆盖前端、后端、DB、Compute API、GPU 和 LLaMA-Factory 适配。
|
||||||
|
- `docs/backend-logging.md`:后端日志模块使用说明。
|
||||||
|
- `docs/deployment-plan.md`:后期部署方案,覆盖单机算力服务器部署与应用/算力分离部署。
|
||||||
|
- `docker/README.md`:Docker 部署入口,包含应用服务器和算力服务器两套 Compose 使用方式。
|
||||||
|
|
||||||
|
## Docker 部署入口
|
||||||
|
|
||||||
|
应用服务器:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run type-check
|
cd docker/app
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
## 测试
|
算力服务器:
|
||||||
|
|
||||||
内置基于 Playwright 的 UI 回归脚本,首次运行前需安装浏览器:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx playwright install chromium
|
cd docker/compute
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
执行已注册的回归脚本:
|
两套 Compose 均采用代码外挂方式运行,镜像只包含运行时环境和第三方依赖。项目根目录不再保留 `Dockerfile` 和 `docker-compose.yml`,部署时统一进入 `docker/app` 或 `docker/compute` 目录执行。
|
||||||
|
|
||||||
```bash
|
## 后续开发原则
|
||||||
npm run test:data-process-wizard # 数据处理向导
|
|
||||||
npm run test:model-manage # 模型管理
|
|
||||||
npm run test:training-log-layout # 训练日志布局
|
|
||||||
npm run test:page-surface # 页面表层级
|
|
||||||
```
|
|
||||||
|
|
||||||
其余脚本可直接运行:
|
- 接口实现优先遵循 `docs/backend-api-design.md`。
|
||||||
|
- 数据库实现优先遵循 `docs/postgres-schema.sql`,后续通过 Alembic 迁移管理变更。
|
||||||
```bash
|
- 前端页面与后端接口、数据库表之间的映射以文档中的“对应页面/功能模块”为准。
|
||||||
node scripts/regression-back-navigation.mjs # 返回导航
|
- 训练引擎适配必须通过 `compute/engines/` 下的标准接口,不在应用平台后端直接拼接训练命令。
|
||||||
node scripts/regression-fine-tune-create-ui.mjs # 调优创建 UI
|
- 敏感信息不得写入日志,生产环境密钥通过环境变量或密钥管理系统注入。
|
||||||
```
|
|
||||||
|
|
||||||
> 回归脚本默认连接 `http://localhost:6801`,需先启动开发服务器。
|
|
||||||
|
|
||||||
## 目录结构
|
|
||||||
|
|
||||||
```
|
|
||||||
YG-FT/
|
|
||||||
├── frontend/ # 前端工程(Vue 3 SPA)
|
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── api/ # axios 封装 + 各业务模块 API
|
|
||||||
│ │ ├── components/ # 公共组件
|
|
||||||
│ │ ├── composables/ # 组合式函数
|
|
||||||
│ │ ├── constants/ # 常量与映射表
|
|
||||||
│ │ ├── layouts/ # 主布局
|
|
||||||
│ │ ├── mock/ # Mock 数据与适配器
|
|
||||||
│ │ ├── plugins/ # 第三方插件注册
|
|
||||||
│ │ ├── router/ # 路由配置 + 登录守卫
|
|
||||||
│ │ ├── stores/ # Pinia 状态
|
|
||||||
│ │ ├── styles/ # 全局样式
|
|
||||||
│ │ ├── types/ # TypeScript 类型定义
|
|
||||||
│ │ └── views/ # 业务页面
|
|
||||||
│ ├── scripts/ # UI 回归测试脚本
|
|
||||||
│ ├── public/ # 静态资源
|
|
||||||
│ └── vite.config.ts # Vite 构建与代理配置
|
|
||||||
├── docs/ # 设计文档与视觉走查记录
|
|
||||||
└── design-qa.md # 视觉走查汇总
|
|
||||||
```
|
|
||||||
|
|
||||||
## 端口与代理
|
|
||||||
|
|
||||||
| 服务 | 地址 |
|
|
||||||
|------|------|
|
|
||||||
| 前端开发服务器 | `http://localhost:6801` |
|
|
||||||
| 后端 API | `http://localhost:7861` |
|
|
||||||
|
|
||||||
前端统一使用 `/api` 相对路径发请求,由 Vite 开发代理转发到后端 `http://localhost:7861`(配置见 `frontend/vite.config.ts`)。
|
|
||||||
|
|
||||||
## 业务模块
|
|
||||||
|
|
||||||
| 模块 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| 登录 | 用户登录鉴权 |
|
|
||||||
| 模型调优 | 微调任务创建与管理 |
|
|
||||||
| 模型评测 | 评测任务与评测维度配置 |
|
|
||||||
| 模型推理 | 在线推理对话 |
|
|
||||||
| 模型对比 | 多模型对话与结果对比 |
|
|
||||||
| 模型管理 | 模型 CRUD 与权重合并 |
|
|
||||||
| 数据集 | 数据集管理与预览 |
|
|
||||||
| 数据处理 | 数据处理任务向导 |
|
|
||||||
| 工具 | 辅助工具集 |
|
|
||||||
| 系统 | 硬件监控、日志、训练日志 |
|
|
||||||
|
|||||||
18
backend/.env.example
Normal file
18
backend/.env.example
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# 默认自动读取 backend/config.yaml。设置此变量可切换到其他 YAML 文件。
|
||||||
|
BACKEND_CONFIG_FILE=./config.yaml
|
||||||
|
|
||||||
|
# 下列环境变量按需启用,并优先于 YAML 中的配置。
|
||||||
|
# DATABASE_BASE_URL=postgresql+psycopg://localhost:5432/yg_ft
|
||||||
|
# DATABASE_USERNAME=yg_ft
|
||||||
|
# DATABASE_PASSWORD=change_me
|
||||||
|
# DATABASE_URL=postgresql+psycopg://yg_ft:change_me@localhost:5432/yg_ft
|
||||||
|
# DATABASE_URL 是完整连接串覆盖项,优先级高于上面三个分离项。
|
||||||
|
# MODELTF_ROUTE_PREFIX=/modelTF
|
||||||
|
# FRONTEND_PORT=16801
|
||||||
|
# BACKEND_PORT=17861
|
||||||
|
# CORS_ALLOW_ORIGINS=http://localhost:16801,http://127.0.0.1:16801
|
||||||
|
# LOG_LEVEL=INFO
|
||||||
|
# LOG_DIR=./logs
|
||||||
|
# COMPUTE_MODE=real
|
||||||
|
# COMPUTE_STATUS_SYNC_MODE=polling
|
||||||
|
# COMPUTE_POLL_INTERVAL_SECONDS=3
|
||||||
113
backend/README.md
Normal file
113
backend/README.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# Backend Service
|
||||||
|
|
||||||
|
后端工程使用 FastAPI,定位为模型微调平台的应用平台服务,负责用户中心、多租户、权限隔离、项目、数据集、模型、训练任务、审批、审计和算力平台编排。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/
|
||||||
|
app/
|
||||||
|
main.py # FastAPI 应用入口
|
||||||
|
api/v1/ # 对前端暴露的 接口路由
|
||||||
|
core/ # 配置、日志、中间件、权限等基础能力
|
||||||
|
db/ # 数据库连接、迁移集成、事务工具
|
||||||
|
modules/ # 业务模块
|
||||||
|
auth/
|
||||||
|
tenant/
|
||||||
|
project/
|
||||||
|
model/
|
||||||
|
dataset/
|
||||||
|
data_process/
|
||||||
|
fine_tune/
|
||||||
|
eval/
|
||||||
|
inference/
|
||||||
|
approval/
|
||||||
|
audit/
|
||||||
|
compute_gateway/
|
||||||
|
file_gateway/
|
||||||
|
engine_registry/
|
||||||
|
retention/
|
||||||
|
system/
|
||||||
|
schemas/ # Pydantic 入参/出参模型
|
||||||
|
services/ # 跨模块应用服务
|
||||||
|
workers/ # 后台任务入口
|
||||||
|
requirements.txt # 后端第三方依赖
|
||||||
|
config.example.yaml # 可提交的脱敏配置模板
|
||||||
|
config.yaml # 本地配置,已忽略,环境变量可覆盖
|
||||||
|
logs/ # 本地开发日志目录,生产环境建议挂载到独立日志盘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
先复制本地配置并填写数据库凭据:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config.example.yaml config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
后端默认读取 `config.yaml`,配置优先级为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
环境变量 > BACKEND_CONFIG_FILE 指定的 YAML > backend/config.yaml > 代码默认值
|
||||||
|
```
|
||||||
|
|
||||||
|
前后端端口、数据库地址、跨域来源、日志路径和计算状态轮询参数均可在 YAML 中
|
||||||
|
配置。敏感信息或不同环境的差异建议通过环境变量覆盖,不要写入仓库。YAML 中的
|
||||||
|
相对日志路径以该 YAML 文件所在目录为基准。使用其他配置文件时:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BACKEND_CONFIG_FILE=./config.prod.yaml uvicorn app.main:app --reload --port 17861
|
||||||
|
```
|
||||||
|
|
||||||
|
本机直连 PostgreSQL 时,数据库配置拆分为地址、用户名和密码:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
server:
|
||||||
|
frontend_port: 16801
|
||||||
|
backend_port: 17861
|
||||||
|
|
||||||
|
database:
|
||||||
|
url: postgresql+psycopg://localhost:5432/yg_ft
|
||||||
|
username: yg_ft
|
||||||
|
password: "change_me"
|
||||||
|
```
|
||||||
|
|
||||||
|
服务启动时会安全拼装完整连接串;用户名或密码中的特殊字符会自动编码。Docker
|
||||||
|
和生产环境仍可用完整的 `DATABASE_URL` 环境变量覆盖以上三项。
|
||||||
|
|
||||||
|
这里的 `5432` 是宿主机 PostgreSQL 默认端口;如果数据库容器映射到 `15432`,
|
||||||
|
将 YAML 中的端口改为 `15432` 即可。
|
||||||
|
|
||||||
|
`server.frontend_port` 和 `server.backend_port` 由根目录的
|
||||||
|
`scripts/start-dev.sh` 读取,并分别传给 Vite 和 Uvicorn。未配置
|
||||||
|
`app.cors_allow_origins` 时,本机 CORS 来源会跟随前端端口。直接手工执行
|
||||||
|
`uvicorn` 时仍需通过 `--port` 指定监听端口。
|
||||||
|
|
||||||
|
## 本地启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
/opt/miniconda3/bin/python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
uvicorn app.main:app --reload --port 17861
|
||||||
|
```
|
||||||
|
|
||||||
|
健康检查:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /modelTF/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## 日志
|
||||||
|
|
||||||
|
日志模块位于 `app/core/logging.py`,使用说明见 `../docs/backend-logging.md`。
|
||||||
|
|
||||||
|
默认日志文件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
logs/backend-YYYY-MM-DD.log
|
||||||
|
logs/error-YYYY-MM-DD.log
|
||||||
|
```
|
||||||
|
|
||||||
|
文件日志为 JSON Lines 格式,单个文件不超过 20MB,只保存最近 10 天,错误日志按 `ERROR` 级别独立拆分,便于 ELK/日志平台采集。
|
||||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Application package."""
|
||||||
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""API package."""
|
||||||
1
backend/app/api/v1/__init__.py
Normal file
1
backend/app/api/v1/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Versioned API package."""
|
||||||
1
backend/app/api/v1/endpoints/__init__.py
Normal file
1
backend/app/api/v1/endpoints/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""API endpoint modules."""
|
||||||
14
backend/app/api/v1/endpoints/health.py
Normal file
14
backend/app/api/v1/endpoints/health.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.db.platform_store import get_platform_store
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health_check() -> dict[str, object]:
|
||||||
|
logger.info("health check requested")
|
||||||
|
return {"code": 0, "message": "ok", "data": get_platform_store().health_metrics()}
|
||||||
|
|
||||||
457
backend/app/api/v1/endpoints/platform.py
Normal file
457
backend/app/api/v1/endpoints/platform.py
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, File, HTTPException, Query, UploadFile
|
||||||
|
from fastapi.responses import PlainTextResponse
|
||||||
|
|
||||||
|
from app.db.platform_store import get_platform_store
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def ok(data: Any = None, message: str = "ok") -> dict[str, Any]:
|
||||||
|
return {"code": 0, "message": message, "data": data}
|
||||||
|
|
||||||
|
|
||||||
|
def fail(status_code: int, message: str) -> HTTPException:
|
||||||
|
return HTTPException(status_code=status_code, detail={"code": status_code, "message": message, "data": None})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def login(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||||
|
user = get_platform_store().login(payload.get("username", ""), payload.get("password", ""))
|
||||||
|
if not user:
|
||||||
|
raise fail(401, "invalid username or password")
|
||||||
|
return ok({"token": f"platform-token-{user['id']}", "user": user})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
async def me() -> dict[str, Any]:
|
||||||
|
return ok(get_platform_store().users()[0])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/overview")
|
||||||
|
async def dashboard_overview() -> dict[str, Any]:
|
||||||
|
store = get_platform_store()
|
||||||
|
tasks = store.tasks()
|
||||||
|
return ok(
|
||||||
|
{
|
||||||
|
"models": len(store.models()),
|
||||||
|
"datasets": len(store.datasets()),
|
||||||
|
"fine_tune_tasks": len(tasks),
|
||||||
|
"running_tasks": len([t for t in tasks if t["status"] in {"syncing", "queued", "running"}]),
|
||||||
|
"compute_nodes": len(store.compute_nodes()),
|
||||||
|
"gpus": len(store.gpus()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/system-info")
|
||||||
|
async def system_info() -> dict[str, Any]:
|
||||||
|
return ok(get_platform_store().system_info())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users")
|
||||||
|
async def users() -> dict[str, Any]:
|
||||||
|
return ok(get_platform_store().users())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users")
|
||||||
|
async def create_user(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||||
|
return ok(get_platform_store().create_user(payload))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/users/{user_id}")
|
||||||
|
async def update_user(user_id: str, payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ok(get_platform_store().update_user(user_id, payload))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "user not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}")
|
||||||
|
async def delete_user(user_id: str, current_username: str | None = Query(default=None)) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
get_platform_store().delete_user(user_id)
|
||||||
|
return ok({"deleted": user_id, "current_username": current_username})
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "user not found")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise fail(400, str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/model-manage/local-models")
|
||||||
|
async def local_models() -> dict[str, Any]:
|
||||||
|
models = [{"path": item.get("path") or "", "name": item["name"]} for item in get_platform_store().models()]
|
||||||
|
return ok({"models": models})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/model-manage/trained-models")
|
||||||
|
async def trained_models() -> dict[str, Any]:
|
||||||
|
return ok({"models": get_platform_store().trained_models()})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/model-manage/trained-models/{model_id}")
|
||||||
|
async def delete_trained_model(model_id: str, type: str = Query(default="merged")) -> dict[str, Any]:
|
||||||
|
return ok({"deleted": model_id, "type": type})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/model-manage/name/{name}")
|
||||||
|
async def model_by_name(name: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ok(get_platform_store().model_by_name(name))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "model not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/model-manage")
|
||||||
|
async def model_list() -> dict[str, Any]:
|
||||||
|
return ok(get_platform_store().models())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/model-manage")
|
||||||
|
async def create_model(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||||
|
return ok(get_platform_store().create_model(payload))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/model-manage/{model_id}")
|
||||||
|
async def model_detail(model_id: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ok(get_platform_store().model(model_id))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "model not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/model-manage/{model_id}")
|
||||||
|
async def update_model(model_id: str, payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ok(get_platform_store().update_model(model_id, payload))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "model not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/model-manage/{model_id}/purpose")
|
||||||
|
async def update_model_purpose(model_id: str, payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ok(get_platform_store().update_model(model_id, {"purpose": payload.get("purpose", "training")}))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "model not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/model-manage/{model_id}")
|
||||||
|
async def delete_model(model_id: str) -> dict[str, Any]:
|
||||||
|
get_platform_store().delete_model(model_id)
|
||||||
|
return ok({"deleted": model_id})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/model-manage/merge")
|
||||||
|
async def merge_model(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||||
|
return ok({"job_id": f"merge_{uuid.uuid4().hex[:12]}", "status": "queued", **payload})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dataset-manage/preview/{file_id}")
|
||||||
|
async def dataset_preview(file_id: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
row = get_platform_store().dataset_file(file_id)
|
||||||
|
return ok({"content": row["content"]})
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "dataset file not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dataset-manage/versions/{file_id}")
|
||||||
|
async def dataset_versions(file_id: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ok(get_platform_store().file_versions(file_id))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "dataset file not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dataset-manage/versions/{file_id}/{version_id}")
|
||||||
|
async def dataset_version_content(file_id: str, version_id: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
row = get_platform_store().dataset_file(file_id)
|
||||||
|
versions = get_platform_store().file_versions(file_id)["versions"]
|
||||||
|
version = next((item for item in versions if item["id"] == version_id), None)
|
||||||
|
if not version:
|
||||||
|
raise KeyError(version_id)
|
||||||
|
return ok({"version": version, "content": row["content"]})
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "dataset version not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dataset-manage/versions/{file_id}")
|
||||||
|
async def create_dataset_version(file_id: str, payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ok(get_platform_store().create_file_version(file_id, payload))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "dataset file not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/dataset-manage/versions/{file_id}/active")
|
||||||
|
async def activate_dataset_version(file_id: str, payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ok(get_platform_store().activate_file_version(file_id, payload["version_id"]))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "dataset version not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/dataset-manage/versions/{file_id}/{version_id}")
|
||||||
|
async def delete_dataset_version(file_id: str, version_id: str) -> dict[str, Any]:
|
||||||
|
return ok(get_platform_store().file_versions(file_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dataset-manage/upload/{dataset_id}")
|
||||||
|
async def upload_dataset_files(dataset_id: str, files: list[UploadFile] = File(default=[])) -> dict[str, Any]:
|
||||||
|
created: list[dict[str, Any]] = []
|
||||||
|
store = get_platform_store()
|
||||||
|
try:
|
||||||
|
store.dataset(dataset_id)
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "dataset not found")
|
||||||
|
with store.connect() as conn:
|
||||||
|
for file in files:
|
||||||
|
raw = await file.read()
|
||||||
|
content = raw.decode("utf-8", errors="replace")
|
||||||
|
created.append(store.add_dataset_file(conn, dataset_id, file.filename or "upload.jsonl", content))
|
||||||
|
return ok({"files": created})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dataset-manage/download/{dataset_id}")
|
||||||
|
async def download_dataset(dataset_id: str) -> PlainTextResponse:
|
||||||
|
dataset = get_platform_store().dataset(dataset_id)
|
||||||
|
content = "\n".join([f"{file['name']}" for file in dataset.get("files", [])])
|
||||||
|
return PlainTextResponse(content, media_type="text/plain")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dataset-manage/download/{dataset_id}/{file_id}")
|
||||||
|
async def download_dataset_file(dataset_id: str, file_id: str, version_id: str | None = Query(default=None)) -> PlainTextResponse:
|
||||||
|
row = get_platform_store().dataset_file(file_id)
|
||||||
|
return PlainTextResponse(row["content"], media_type="text/plain")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dataset-manage")
|
||||||
|
async def dataset_list() -> dict[str, Any]:
|
||||||
|
return ok(get_platform_store().datasets())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dataset-manage")
|
||||||
|
async def create_dataset(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||||
|
dataset = get_platform_store().create_dataset(payload)
|
||||||
|
return ok({"id": dataset["id"]})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dataset-manage/{dataset_id}")
|
||||||
|
async def dataset_detail(dataset_id: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ok(get_platform_store().dataset(dataset_id))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "dataset not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/dataset-manage/{dataset_id}")
|
||||||
|
async def update_dataset(dataset_id: str, payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ok(get_platform_store().update_dataset(dataset_id, payload))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "dataset not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/dataset-manage/{dataset_id}")
|
||||||
|
async def delete_dataset(dataset_id: str) -> dict[str, Any]:
|
||||||
|
get_platform_store().delete_dataset(dataset_id)
|
||||||
|
return ok({"deleted": dataset_id})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fine-tune/check-name")
|
||||||
|
async def check_fine_tune_name(name: str = Query(...)) -> dict[str, Any]:
|
||||||
|
exists = any(task["name"] == name for task in get_platform_store().tasks())
|
||||||
|
return ok({"exists": exists})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fine-tune/progress/{task_id}")
|
||||||
|
async def fine_tune_progress(task_id: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ok(get_platform_store().progress(task_id))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "fine tune task not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fine-tune/tensorboard/start")
|
||||||
|
async def tensorboard_start() -> dict[str, Any]:
|
||||||
|
return ok({"status": "running", "url": "http://localhost:6006"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fine-tune")
|
||||||
|
async def fine_tune_list() -> dict[str, Any]:
|
||||||
|
return ok(get_platform_store().tasks())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fine-tune")
|
||||||
|
async def create_fine_tune(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
task = get_platform_store().create_task(payload)
|
||||||
|
return ok({"id": task["id"]})
|
||||||
|
except ValueError as exc:
|
||||||
|
raise fail(400, str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fine-tune/start")
|
||||||
|
async def start_fine_tune(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ok(get_platform_store().start_task(payload))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "fine tune task not found")
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise fail(409, str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fine-tune/{task_id}")
|
||||||
|
async def fine_tune_detail(task_id: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ok(get_platform_store().task(task_id))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "fine tune task not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/fine-tune/{task_id}")
|
||||||
|
async def update_fine_tune(task_id: str, payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ok(get_platform_store().update_task(task_id, payload))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "fine tune task not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fine-tune/stop/{task_id}")
|
||||||
|
async def stop_fine_tune(task_id: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ok(get_platform_store().stop_task(task_id))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "fine tune task not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fine-tune/{task_id}/stop")
|
||||||
|
async def stop_fine_tune_alt(task_id: str) -> dict[str, Any]:
|
||||||
|
return await stop_fine_tune(task_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/fine-tune/{task_id}")
|
||||||
|
async def delete_fine_tune(task_id: str) -> dict[str, Any]:
|
||||||
|
get_platform_store().delete_task(task_id)
|
||||||
|
return ok({"deleted": task_id})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fine-tune/{task_id}/overview")
|
||||||
|
async def fine_tune_overview(task_id: str) -> dict[str, Any]:
|
||||||
|
task = get_platform_store().task(task_id)
|
||||||
|
return ok({"task": task, "progress": get_platform_store().progress(task_id)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fine-tune/{task_id}/checkpoints")
|
||||||
|
async def fine_tune_checkpoints(task_id: str) -> dict[str, Any]:
|
||||||
|
task = get_platform_store().task(task_id)
|
||||||
|
checkpoints = []
|
||||||
|
for step in [50, 100, 150]:
|
||||||
|
if task.get("progress", 0) >= min(100, step // 2):
|
||||||
|
checkpoints.append({"step": step, "path": f"/data/yg-ft/outputs/{task['name']}/checkpoint-{step}"})
|
||||||
|
return ok(checkpoints)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/compute/nodes")
|
||||||
|
async def compute_nodes() -> dict[str, Any]:
|
||||||
|
return ok(get_platform_store().compute_nodes())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/compute/nodes")
|
||||||
|
async def create_compute_node(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ok(get_platform_store().create_compute_node(payload))
|
||||||
|
except KeyError as exc:
|
||||||
|
raise fail(400, f"missing field: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/compute/nodes/{node_id}")
|
||||||
|
async def update_compute_node(node_id: str, payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ok(get_platform_store().update_compute_node(node_id, payload))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "compute node not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/compute/nodes/{node_id}/test-connection")
|
||||||
|
async def test_compute_node(node_id: str) -> dict[str, Any]:
|
||||||
|
return ok({"node_id": node_id, "success": True, "latency_ms": 12})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/compute/nodes/{node_id}/enable")
|
||||||
|
async def enable_compute_node(node_id: str) -> dict[str, Any]:
|
||||||
|
return ok(get_platform_store().update_compute_node(node_id, {"enabled": True, "scheduler_status": "online"}))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/compute/nodes/{node_id}/disable")
|
||||||
|
async def disable_compute_node(node_id: str) -> dict[str, Any]:
|
||||||
|
return ok(get_platform_store().update_compute_node(node_id, {"enabled": False, "scheduler_status": "offline"}))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/compute/nodes/{node_id}/drain")
|
||||||
|
async def drain_compute_node(node_id: str) -> dict[str, Any]:
|
||||||
|
return ok(get_platform_store().update_compute_node(node_id, {"scheduler_status": "draining"}))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/compute/nodes/{node_id}/replicas")
|
||||||
|
async def compute_node_replicas(node_id: str) -> dict[str, Any]:
|
||||||
|
return ok(get_platform_store().replicas(node_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/compute/gpus")
|
||||||
|
async def compute_gpus() -> dict[str, Any]:
|
||||||
|
return ok(get_platform_store().gpus())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/compute/queue")
|
||||||
|
async def compute_queue() -> dict[str, Any]:
|
||||||
|
return ok(get_platform_store().queue())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/internal/compute-sync/resources")
|
||||||
|
async def create_compute_sync(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||||
|
sync_id = get_platform_store().create_sync_job(payload.get("target_node_id", "node_01"), payload)
|
||||||
|
return ok(get_platform_store().sync_job(sync_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/internal/compute-sync/resources/{sync_id}")
|
||||||
|
async def compute_sync_detail(sync_id: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ok(get_platform_store().sync_job(sync_id))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "sync job not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/training-log-files")
|
||||||
|
async def training_log_files() -> dict[str, Any]:
|
||||||
|
return ok(get_platform_store().training_log_files())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/training-log-content")
|
||||||
|
async def training_log_content(file: str = Query(...)) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ok(get_platform_store().training_log_content(file))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "training log not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/log-files")
|
||||||
|
async def log_files(date: str | None = Query(default=None)) -> dict[str, Any]:
|
||||||
|
return ok(get_platform_store().log_files(date))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/log-content")
|
||||||
|
async def log_content(file: str = Query(...)) -> dict[str, Any]:
|
||||||
|
return ok(get_platform_store().log_content(file))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/web-log")
|
||||||
|
async def web_log(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||||
|
return ok({"received": True, **payload})
|
||||||
|
|
||||||
9
backend/app/api/v1/router.py
Normal file
9
backend/app/api/v1/router.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.v1.endpoints.platform import router as platform_router
|
||||||
|
from app.api.v1.endpoints.health import router as health_router
|
||||||
|
|
||||||
|
api_router = APIRouter()
|
||||||
|
api_router.include_router(health_router, tags=["health"])
|
||||||
|
api_router.include_router(platform_router, tags=["platform"])
|
||||||
|
|
||||||
1
backend/app/core/__init__.py
Normal file
1
backend/app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Core infrastructure modules."""
|
||||||
326
backend/app/core/config.py
Normal file
326
backend/app/core/config.py
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from functools import lru_cache
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import quote, urlsplit, urlunsplit
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
DEFAULT_CONFIG_PATH = BACKEND_ROOT / "config.yaml"
|
||||||
|
DEFAULT_DATABASE_BASE_URL = "postgresql+psycopg://localhost:5432/yg_ft"
|
||||||
|
DEFAULT_DATABASE_USERNAME = "yg_ft"
|
||||||
|
DEFAULT_DATABASE_PASSWORD = "change_me"
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigurationError(ValueError):
|
||||||
|
"""Raised when the backend configuration cannot be parsed or validated."""
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_config_path(config_path: str | Path | None = None) -> tuple[Path, bool]:
|
||||||
|
configured_path = config_path or os.getenv("BACKEND_CONFIG_FILE")
|
||||||
|
is_explicit = configured_path is not None
|
||||||
|
path = Path(configured_path).expanduser() if configured_path else DEFAULT_CONFIG_PATH
|
||||||
|
if not path.is_absolute():
|
||||||
|
path = BACKEND_ROOT / path
|
||||||
|
return path.resolve(), is_explicit
|
||||||
|
|
||||||
|
|
||||||
|
def _load_yaml(config_path: str | Path | None = None) -> tuple[dict[str, Any], Path]:
|
||||||
|
path, is_explicit = _resolve_config_path(config_path)
|
||||||
|
if not path.exists():
|
||||||
|
if is_explicit:
|
||||||
|
raise ConfigurationError(f"Backend config file does not exist: {path}")
|
||||||
|
return {}, path
|
||||||
|
|
||||||
|
try:
|
||||||
|
loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
|
except yaml.YAMLError as exc:
|
||||||
|
raise ConfigurationError(f"Invalid YAML in backend config file {path}: {exc}") from exc
|
||||||
|
|
||||||
|
if loaded is None:
|
||||||
|
return {}, path
|
||||||
|
if not isinstance(loaded, dict):
|
||||||
|
raise ConfigurationError(f"Backend config root must be a mapping: {path}")
|
||||||
|
return loaded, path
|
||||||
|
|
||||||
|
|
||||||
|
def _yaml_value(config: dict[str, Any], section: str, key: str, default: Any) -> Any:
|
||||||
|
section_value = config.get(section, {})
|
||||||
|
if section_value is None:
|
||||||
|
return default
|
||||||
|
if not isinstance(section_value, dict):
|
||||||
|
raise ConfigurationError(f"Config section '{section}' must be a mapping")
|
||||||
|
return section_value.get(key, default)
|
||||||
|
|
||||||
|
|
||||||
|
def _string_setting(
|
||||||
|
env_name: str,
|
||||||
|
config: dict[str, Any],
|
||||||
|
section: str,
|
||||||
|
key: str,
|
||||||
|
default: str,
|
||||||
|
) -> str:
|
||||||
|
env_value = os.getenv(env_name)
|
||||||
|
if env_value is not None:
|
||||||
|
return env_value
|
||||||
|
value = _yaml_value(config, section, key, default)
|
||||||
|
return default if value is None else str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _int_setting(
|
||||||
|
env_name: str,
|
||||||
|
config: dict[str, Any],
|
||||||
|
section: str,
|
||||||
|
key: str,
|
||||||
|
default: int,
|
||||||
|
) -> int:
|
||||||
|
env_value = os.getenv(env_name)
|
||||||
|
value = (
|
||||||
|
env_value
|
||||||
|
if env_value not in (None, "")
|
||||||
|
else _yaml_value(config, section, key, default)
|
||||||
|
)
|
||||||
|
if isinstance(value, (bool, float)):
|
||||||
|
raise ConfigurationError(f"Config value {section}.{key} must be an integer")
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ConfigurationError(f"Config value {section}.{key} must be an integer") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _port_setting(
|
||||||
|
env_name: str,
|
||||||
|
config: dict[str, Any],
|
||||||
|
key: str,
|
||||||
|
default: int,
|
||||||
|
) -> int:
|
||||||
|
port = _int_setting(env_name, config, "server", key, default)
|
||||||
|
if not 1 <= port <= 65535:
|
||||||
|
raise ConfigurationError(
|
||||||
|
f"Config value server.{key} must be between 1 and 65535"
|
||||||
|
)
|
||||||
|
return port
|
||||||
|
|
||||||
|
|
||||||
|
def _list_setting(
|
||||||
|
env_name: str,
|
||||||
|
config: dict[str, Any],
|
||||||
|
section: str,
|
||||||
|
key: str,
|
||||||
|
default: list[str],
|
||||||
|
) -> list[str]:
|
||||||
|
env_value = os.getenv(env_name)
|
||||||
|
value = (
|
||||||
|
env_value
|
||||||
|
if env_value and env_value.strip()
|
||||||
|
else _yaml_value(config, section, key, default)
|
||||||
|
)
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [item.strip() for item in value.split(",") if item.strip()]
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [str(item).strip() for item in value if str(item).strip()]
|
||||||
|
raise ConfigurationError(
|
||||||
|
f"Config value {section}.{key} must be a list or comma-separated string"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _path_setting(
|
||||||
|
env_name: str,
|
||||||
|
config: dict[str, Any],
|
||||||
|
section: str,
|
||||||
|
key: str,
|
||||||
|
default: str,
|
||||||
|
config_dir: Path,
|
||||||
|
) -> str:
|
||||||
|
env_value = os.getenv(env_name)
|
||||||
|
if env_value is not None:
|
||||||
|
return env_value
|
||||||
|
value = Path(str(_yaml_value(config, section, key, default))).expanduser()
|
||||||
|
if not value.is_absolute():
|
||||||
|
value = config_dir / value
|
||||||
|
return str(value.resolve())
|
||||||
|
|
||||||
|
|
||||||
|
def _build_database_url(base_url: str, username: str, password: str) -> str:
|
||||||
|
try:
|
||||||
|
parsed = urlsplit(base_url)
|
||||||
|
host = parsed.hostname
|
||||||
|
port = parsed.port
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ConfigurationError("Config value database.url is not a valid URL") from exc
|
||||||
|
|
||||||
|
if not parsed.scheme or not host:
|
||||||
|
raise ConfigurationError(
|
||||||
|
"Config value database.url must include a scheme and host"
|
||||||
|
)
|
||||||
|
if parsed.scheme not in {"postgresql", "postgresql+psycopg"}:
|
||||||
|
raise ConfigurationError(
|
||||||
|
"Config value database.url must use postgresql or postgresql+psycopg"
|
||||||
|
)
|
||||||
|
if parsed.path in {"", "/"}:
|
||||||
|
raise ConfigurationError("Config value database.url must include a database name")
|
||||||
|
if not username:
|
||||||
|
raise ConfigurationError("Config value database.username cannot be empty")
|
||||||
|
if not password:
|
||||||
|
raise ConfigurationError("Config value database.password cannot be empty")
|
||||||
|
|
||||||
|
formatted_host = f"[{host}]" if ":" in host and not host.startswith("[") else host
|
||||||
|
host_and_port = f"{formatted_host}:{port}" if port is not None else formatted_host
|
||||||
|
credentials = f"{quote(username, safe='')}:{quote(password, safe='')}"
|
||||||
|
parts = (
|
||||||
|
parsed.scheme,
|
||||||
|
f"{credentials}@{host_and_port}",
|
||||||
|
parsed.path,
|
||||||
|
parsed.query,
|
||||||
|
parsed.fragment,
|
||||||
|
)
|
||||||
|
return urlunsplit(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _database_url(config: dict[str, Any]) -> str:
|
||||||
|
# 保留原有完整连接串环境变量,便于 Docker 和生产环境注入密钥。
|
||||||
|
complete_url_override = os.getenv("DATABASE_URL")
|
||||||
|
if complete_url_override is not None:
|
||||||
|
return complete_url_override
|
||||||
|
|
||||||
|
base_url = _string_setting(
|
||||||
|
"DATABASE_BASE_URL",
|
||||||
|
config,
|
||||||
|
"database",
|
||||||
|
"url",
|
||||||
|
DEFAULT_DATABASE_BASE_URL,
|
||||||
|
)
|
||||||
|
username = _string_setting(
|
||||||
|
"DATABASE_USERNAME",
|
||||||
|
config,
|
||||||
|
"database",
|
||||||
|
"username",
|
||||||
|
DEFAULT_DATABASE_USERNAME,
|
||||||
|
)
|
||||||
|
password = _string_setting(
|
||||||
|
"DATABASE_PASSWORD",
|
||||||
|
config,
|
||||||
|
"database",
|
||||||
|
"password",
|
||||||
|
DEFAULT_DATABASE_PASSWORD,
|
||||||
|
)
|
||||||
|
return _build_database_url(base_url, username, password)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Settings:
|
||||||
|
app_name: str = "YG Fine-Tune Platform API"
|
||||||
|
app_env: str = "local"
|
||||||
|
route_prefix: str = "/modelTF"
|
||||||
|
app_mode: str = "local"
|
||||||
|
frontend_port: int = 16801
|
||||||
|
backend_port: int = 17861
|
||||||
|
database_url: str = field(
|
||||||
|
default="postgresql+psycopg://yg_ft:change_me@localhost:5432/yg_ft",
|
||||||
|
repr=False,
|
||||||
|
)
|
||||||
|
cors_allow_origins: list[str] = field(
|
||||||
|
default_factory=lambda: [
|
||||||
|
"http://localhost:16801",
|
||||||
|
"http://127.0.0.1:16801",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
compute_mode: str = "real"
|
||||||
|
compute_status_sync_mode: str = "polling"
|
||||||
|
compute_poll_interval_seconds: int = 3
|
||||||
|
log_level: str = "INFO"
|
||||||
|
log_dir: str = "./logs"
|
||||||
|
log_file_prefix: str = "backend"
|
||||||
|
log_error_file_prefix: str = "error"
|
||||||
|
log_max_bytes: int = 20 * 1024 * 1024
|
||||||
|
log_retention_days: int = 10
|
||||||
|
|
||||||
|
|
||||||
|
def load_settings(config_path: str | Path | None = None) -> Settings:
|
||||||
|
config, resolved_config_path = _load_yaml(config_path)
|
||||||
|
frontend_port = _port_setting(
|
||||||
|
"FRONTEND_PORT", config, "frontend_port", Settings.frontend_port
|
||||||
|
)
|
||||||
|
backend_port = _port_setting(
|
||||||
|
"BACKEND_PORT", config, "backend_port", Settings.backend_port
|
||||||
|
)
|
||||||
|
if frontend_port == backend_port:
|
||||||
|
raise ConfigurationError(
|
||||||
|
"Config values server.frontend_port and server.backend_port must be different"
|
||||||
|
)
|
||||||
|
default_cors = [
|
||||||
|
f"http://localhost:{frontend_port}",
|
||||||
|
f"http://127.0.0.1:{frontend_port}",
|
||||||
|
]
|
||||||
|
return Settings(
|
||||||
|
app_name=_string_setting("APP_NAME", config, "app", "name", Settings.app_name),
|
||||||
|
app_env=_string_setting("APP_ENV", config, "app", "env", Settings.app_env),
|
||||||
|
route_prefix=_string_setting(
|
||||||
|
"MODELTF_ROUTE_PREFIX", config, "app", "route_prefix", Settings.route_prefix
|
||||||
|
),
|
||||||
|
app_mode=_string_setting("APP_MODE", config, "app", "mode", Settings.app_mode),
|
||||||
|
frontend_port=frontend_port,
|
||||||
|
backend_port=backend_port,
|
||||||
|
database_url=_database_url(config),
|
||||||
|
cors_allow_origins=_list_setting(
|
||||||
|
"CORS_ALLOW_ORIGINS", config, "app", "cors_allow_origins", default_cors
|
||||||
|
),
|
||||||
|
compute_mode=_string_setting(
|
||||||
|
"COMPUTE_MODE", config, "compute", "mode", Settings.compute_mode
|
||||||
|
),
|
||||||
|
compute_status_sync_mode=_string_setting(
|
||||||
|
"COMPUTE_STATUS_SYNC_MODE",
|
||||||
|
config,
|
||||||
|
"compute",
|
||||||
|
"status_sync_mode",
|
||||||
|
Settings.compute_status_sync_mode,
|
||||||
|
),
|
||||||
|
compute_poll_interval_seconds=_int_setting(
|
||||||
|
"COMPUTE_POLL_INTERVAL_SECONDS",
|
||||||
|
config,
|
||||||
|
"compute",
|
||||||
|
"poll_interval_seconds",
|
||||||
|
Settings.compute_poll_interval_seconds,
|
||||||
|
),
|
||||||
|
log_level=_string_setting(
|
||||||
|
"LOG_LEVEL", config, "logging", "level", Settings.log_level
|
||||||
|
),
|
||||||
|
log_dir=_path_setting(
|
||||||
|
"LOG_DIR",
|
||||||
|
config,
|
||||||
|
"logging",
|
||||||
|
"directory",
|
||||||
|
Settings.log_dir,
|
||||||
|
resolved_config_path.parent,
|
||||||
|
),
|
||||||
|
log_file_prefix=_string_setting(
|
||||||
|
"LOG_FILE_PREFIX", config, "logging", "file_prefix", Settings.log_file_prefix
|
||||||
|
),
|
||||||
|
log_error_file_prefix=_string_setting(
|
||||||
|
"LOG_ERROR_FILE_PREFIX",
|
||||||
|
config,
|
||||||
|
"logging",
|
||||||
|
"error_file_prefix",
|
||||||
|
Settings.log_error_file_prefix,
|
||||||
|
),
|
||||||
|
log_max_bytes=_int_setting(
|
||||||
|
"LOG_MAX_BYTES", config, "logging", "max_bytes", Settings.log_max_bytes
|
||||||
|
),
|
||||||
|
log_retention_days=_int_setting(
|
||||||
|
"LOG_RETENTION_DAYS",
|
||||||
|
config,
|
||||||
|
"logging",
|
||||||
|
"retention_days",
|
||||||
|
Settings.log_retention_days,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return load_settings()
|
||||||
253
backend/app/core/logging.py
Normal file
253
backend/app/core/logging.py
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from logging import Handler, LogRecord
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
|
||||||
|
from app.core.config import Settings, get_settings
|
||||||
|
|
||||||
|
request_id_var: ContextVar[str] = ContextVar("request_id", default="-")
|
||||||
|
|
||||||
|
|
||||||
|
class RequestIdFilter(logging.Filter):
|
||||||
|
def filter(self, record: LogRecord) -> bool:
|
||||||
|
record.request_id = request_id_var.get()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class JsonLogFormatter(logging.Formatter):
|
||||||
|
"""Format one JSON object per line for ELK/Filebeat collection."""
|
||||||
|
|
||||||
|
def format(self, record: LogRecord) -> str:
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"@timestamp": datetime.fromtimestamp(record.created).astimezone().isoformat(
|
||||||
|
timespec="milliseconds"
|
||||||
|
),
|
||||||
|
"level": record.levelname,
|
||||||
|
"logger": record.name,
|
||||||
|
"message": record.getMessage(),
|
||||||
|
"module": record.module,
|
||||||
|
"function": record.funcName,
|
||||||
|
"file": record.pathname,
|
||||||
|
"line": record.lineno,
|
||||||
|
"process": record.process,
|
||||||
|
"thread": record.thread,
|
||||||
|
"thread_name": record.threadName,
|
||||||
|
"request_id": getattr(record, "request_id", "-"),
|
||||||
|
}
|
||||||
|
if record.exc_info:
|
||||||
|
payload["exception"] = self.formatException(record.exc_info)
|
||||||
|
if record.stack_info:
|
||||||
|
payload["stack"] = self.formatStack(record.stack_info)
|
||||||
|
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
class DateSizeRotatingFileHandler(Handler):
|
||||||
|
"""Rotate log files by date and size while keeping date in every file name."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
log_dir: str | Path,
|
||||||
|
file_prefix: str,
|
||||||
|
max_bytes: int,
|
||||||
|
retention_days: int,
|
||||||
|
encoding: str = "utf-8",
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.log_dir = Path(log_dir)
|
||||||
|
self.file_prefix = file_prefix
|
||||||
|
self.max_bytes = max_bytes
|
||||||
|
self.retention_days = retention_days
|
||||||
|
self.encoding = encoding
|
||||||
|
self._current_date: date | None = None
|
||||||
|
self._stream: Any | None = None
|
||||||
|
self._current_path: Path | None = None
|
||||||
|
self.log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def emit(self, record: LogRecord) -> None:
|
||||||
|
try:
|
||||||
|
message = self.format(record) + self.terminator
|
||||||
|
encoded_size = len(message.encode(self.encoding))
|
||||||
|
self._ensure_stream()
|
||||||
|
if self._should_rotate(encoded_size):
|
||||||
|
self._rotate_by_size()
|
||||||
|
self._ensure_stream(force=True)
|
||||||
|
self._stream.write(message)
|
||||||
|
self.flush()
|
||||||
|
self._cleanup_expired_files()
|
||||||
|
except Exception:
|
||||||
|
self.handleError(record)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def terminator(self) -> str:
|
||||||
|
return "\n"
|
||||||
|
|
||||||
|
def flush(self) -> None:
|
||||||
|
if self._stream and not self._stream.closed:
|
||||||
|
self._stream.flush()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
try:
|
||||||
|
if self._stream and not self._stream.closed:
|
||||||
|
self._stream.close()
|
||||||
|
finally:
|
||||||
|
self._stream = None
|
||||||
|
super().close()
|
||||||
|
|
||||||
|
def _dated_path(self, target_date: date) -> Path:
|
||||||
|
return self.log_dir / f"{self.file_prefix}-{target_date.isoformat()}.log"
|
||||||
|
|
||||||
|
def _ensure_stream(self, force: bool = False) -> None:
|
||||||
|
today = date.today()
|
||||||
|
if not force and self._stream and self._current_date == today:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._stream and not self._stream.closed:
|
||||||
|
self._stream.close()
|
||||||
|
|
||||||
|
self._current_date = today
|
||||||
|
self._current_path = self._dated_path(today)
|
||||||
|
self._stream = self._current_path.open("a", encoding=self.encoding)
|
||||||
|
|
||||||
|
def _should_rotate(self, incoming_size: int) -> bool:
|
||||||
|
if not self._current_path or self.max_bytes <= 0:
|
||||||
|
return False
|
||||||
|
if not self._current_path.exists():
|
||||||
|
return False
|
||||||
|
return self._current_path.stat().st_size + incoming_size > self.max_bytes
|
||||||
|
|
||||||
|
def _rotate_by_size(self) -> None:
|
||||||
|
if not self._current_path or not self._current_path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._stream and not self._stream.closed:
|
||||||
|
self._stream.close()
|
||||||
|
self._stream = None
|
||||||
|
|
||||||
|
stem = self._current_path.stem
|
||||||
|
suffix = self._current_path.suffix
|
||||||
|
index = 1
|
||||||
|
while True:
|
||||||
|
rotated_path = self.log_dir / f"{stem}.{index}{suffix}"
|
||||||
|
if not rotated_path.exists():
|
||||||
|
self._current_path.rename(rotated_path)
|
||||||
|
return
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
def _cleanup_expired_files(self) -> None:
|
||||||
|
if self.retention_days <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
cutoff = date.today() - timedelta(days=self.retention_days - 1)
|
||||||
|
pattern = re.compile(
|
||||||
|
rf"^{re.escape(self.file_prefix)}-(\d{{4}}-\d{{2}}-\d{{2}})(?:\.\d+)?\.log$"
|
||||||
|
)
|
||||||
|
for path in self.log_dir.glob(f"{self.file_prefix}-*.log"):
|
||||||
|
match = pattern.match(path.name)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
file_date = datetime.strptime(match.group(1), "%Y-%m-%d").date()
|
||||||
|
if file_date < cutoff:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(settings: Settings | None = None) -> None:
|
||||||
|
settings = settings or get_settings()
|
||||||
|
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
root_logger.handlers.clear()
|
||||||
|
root_logger.setLevel(settings.log_level.upper())
|
||||||
|
|
||||||
|
console_formatter = logging.Formatter(
|
||||||
|
fmt=(
|
||||||
|
"%(asctime)s | %(levelname)s | pid=%(process)d | %(threadName)s | "
|
||||||
|
"request_id=%(request_id)s | %(name)s | %(pathname)s:%(lineno)d | %(message)s"
|
||||||
|
),
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
json_formatter = JsonLogFormatter()
|
||||||
|
request_filter = RequestIdFilter()
|
||||||
|
|
||||||
|
console_handler = logging.StreamHandler()
|
||||||
|
console_handler.setFormatter(console_formatter)
|
||||||
|
console_handler.addFilter(request_filter)
|
||||||
|
|
||||||
|
file_handler = DateSizeRotatingFileHandler(
|
||||||
|
log_dir=settings.log_dir,
|
||||||
|
file_prefix=settings.log_file_prefix,
|
||||||
|
max_bytes=settings.log_max_bytes,
|
||||||
|
retention_days=settings.log_retention_days,
|
||||||
|
)
|
||||||
|
file_handler.setFormatter(json_formatter)
|
||||||
|
file_handler.addFilter(request_filter)
|
||||||
|
|
||||||
|
error_file_handler = DateSizeRotatingFileHandler(
|
||||||
|
log_dir=settings.log_dir,
|
||||||
|
file_prefix=settings.log_error_file_prefix,
|
||||||
|
max_bytes=settings.log_max_bytes,
|
||||||
|
retention_days=settings.log_retention_days,
|
||||||
|
)
|
||||||
|
error_file_handler.setLevel(logging.ERROR)
|
||||||
|
error_file_handler.setFormatter(json_formatter)
|
||||||
|
error_file_handler.addFilter(request_filter)
|
||||||
|
|
||||||
|
root_logger.addHandler(console_handler)
|
||||||
|
root_logger.addHandler(file_handler)
|
||||||
|
root_logger.addHandler(error_file_handler)
|
||||||
|
|
||||||
|
for logger_name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
|
||||||
|
logger = logging.getLogger(logger_name)
|
||||||
|
logger.handlers.clear()
|
||||||
|
logger.propagate = True
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str) -> logging.Logger:
|
||||||
|
return logging.getLogger(name)
|
||||||
|
|
||||||
|
|
||||||
|
def set_request_id(request_id: str) -> None:
|
||||||
|
request_id_var.set(request_id)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_request_logging(app: FastAPI) -> None:
|
||||||
|
logger = get_logger("app.access")
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def request_logging_middleware(request: Request, call_next): # type: ignore[no-untyped-def]
|
||||||
|
request_id = request.headers.get("X-Request-ID") or str(uuid4())
|
||||||
|
token = request_id_var.set(request_id)
|
||||||
|
started_at = time.perf_counter()
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
elapsed_ms = (time.perf_counter() - started_at) * 1000
|
||||||
|
logger.info(
|
||||||
|
"request completed method=%s path=%s status_code=%s duration_ms=%.2f client=%s",
|
||||||
|
request.method,
|
||||||
|
request.url.path,
|
||||||
|
response.status_code,
|
||||||
|
elapsed_ms,
|
||||||
|
request.client.host if request.client else "-",
|
||||||
|
)
|
||||||
|
response.headers["X-Request-ID"] = request_id
|
||||||
|
return response
|
||||||
|
except Exception:
|
||||||
|
elapsed_ms = (time.perf_counter() - started_at) * 1000
|
||||||
|
logger.exception(
|
||||||
|
"request failed method=%s path=%s duration_ms=%.2f client=%s",
|
||||||
|
request.method,
|
||||||
|
request.url.path,
|
||||||
|
elapsed_ms,
|
||||||
|
request.client.host if request.client else "-",
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
request_id_var.reset(token)
|
||||||
1
backend/app/db/__init__.py
Normal file
1
backend/app/db/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Database infrastructure package."""
|
||||||
1152
backend/app/db/platform_store.py
Normal file
1152
backend/app/db/platform_store.py
Normal file
File diff suppressed because it is too large
Load Diff
40
backend/app/db/session.py
Normal file
40
backend/app/db/session.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
DATABASE_URL = get_settings().database_url
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
DATABASE_URL,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
future=True,
|
||||||
|
)
|
||||||
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False, future=True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> Generator[Session, None, None]:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def session_scope() -> Generator[Session, None, None]:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
133
backend/app/db/sql/001_platform_runtime.sql
Normal file
133
backend/app/db/sql/001_platform_runtime.sql
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
permissions TEXT NOT NULL,
|
||||||
|
create_time TEXT NOT NULL,
|
||||||
|
last_login TEXT,
|
||||||
|
protected INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS models (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
purpose TEXT NOT NULL,
|
||||||
|
model_source TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
path TEXT,
|
||||||
|
api_url TEXT,
|
||||||
|
api_key TEXT,
|
||||||
|
online_model_name TEXT,
|
||||||
|
create_time TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS trained_models (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
train_methods TEXT NOT NULL,
|
||||||
|
base_model_path TEXT,
|
||||||
|
create_time TEXT NOT NULL,
|
||||||
|
merged INTEGER NOT NULL DEFAULT 0,
|
||||||
|
merging INTEGER NOT NULL DEFAULT 0,
|
||||||
|
merged_path TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS datasets (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
storage_type TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
task_id TEXT,
|
||||||
|
size TEXT,
|
||||||
|
count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
description TEXT,
|
||||||
|
create_time TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dataset_files (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
dataset_id TEXT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
size TEXT,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
active_version_id TEXT NOT NULL,
|
||||||
|
versions TEXT NOT NULL,
|
||||||
|
create_time TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS compute_nodes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
code TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
api_base_url TEXT NOT NULL,
|
||||||
|
file_gateway_url TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
scheduler_status TEXT NOT NULL,
|
||||||
|
scheduler_weight INTEGER NOT NULL DEFAULT 100,
|
||||||
|
tags TEXT NOT NULL,
|
||||||
|
gpu_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
current_running_jobs INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_parallel_jobs INTEGER NOT NULL DEFAULT 2,
|
||||||
|
data_root TEXT NOT NULL,
|
||||||
|
model_root TEXT NOT NULL,
|
||||||
|
log_root TEXT NOT NULL,
|
||||||
|
last_health_check_at TEXT,
|
||||||
|
health_detail TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS gpus (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
node_id TEXT NOT NULL REFERENCES compute_nodes(id) ON DELETE CASCADE,
|
||||||
|
gpu_index INTEGER NOT NULL,
|
||||||
|
uuid TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
memory_total_gb DOUBLE PRECISION NOT NULL,
|
||||||
|
power_limit_w DOUBLE PRECISION NOT NULL,
|
||||||
|
base_temperature INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS fine_tune_tasks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
progress INTEGER NOT NULL DEFAULT 0,
|
||||||
|
process_id INTEGER,
|
||||||
|
create_time TEXT NOT NULL,
|
||||||
|
start_time TEXT,
|
||||||
|
completed_at TEXT,
|
||||||
|
compute_node_id TEXT REFERENCES compute_nodes(id) ON DELETE SET NULL,
|
||||||
|
gpus TEXT NOT NULL,
|
||||||
|
sync_job_id TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS resource_replicas (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
node_id TEXT NOT NULL REFERENCES compute_nodes(id) ON DELETE CASCADE,
|
||||||
|
resource_type TEXT NOT NULL,
|
||||||
|
resource_id TEXT NOT NULL,
|
||||||
|
local_path TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
sync_status TEXT NOT NULL,
|
||||||
|
create_time TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS resource_sync_jobs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
target_node_id TEXT NOT NULL,
|
||||||
|
resources TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
progress INTEGER NOT NULL DEFAULT 0,
|
||||||
|
create_time TEXT NOT NULL,
|
||||||
|
completed_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fine_tune_status ON fine_tune_tasks(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dataset_files_dataset ON dataset_files(dataset_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gpus_node ON gpus(node_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_replicas_resource ON resource_replicas(resource_type, resource_id);
|
||||||
26
backend/app/main.py
Normal file
26
backend/app/main.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.api.v1.router import api_router
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.logging import configure_logging, setup_request_logging
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
settings = get_settings()
|
||||||
|
configure_logging(settings)
|
||||||
|
|
||||||
|
app = FastAPI(title=settings.app_name)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_allow_origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
setup_request_logging(app)
|
||||||
|
app.include_router(api_router, prefix=settings.route_prefix)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
15
backend/app/modules/README.md
Normal file
15
backend/app/modules/README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Backend Module Convention
|
||||||
|
|
||||||
|
每个业务模块建议保持一致结构:
|
||||||
|
|
||||||
|
```text
|
||||||
|
module_name/
|
||||||
|
__init__.py
|
||||||
|
router.py # FastAPI router
|
||||||
|
schemas.py # Pydantic request/response models
|
||||||
|
service.py # Business orchestration
|
||||||
|
repository.py # Database access
|
||||||
|
permissions.py # Optional resource permission checks
|
||||||
|
```
|
||||||
|
|
||||||
|
模块边界以 `docs/system-development-plan.md` 的页面模块开发工作包为准。
|
||||||
1
backend/app/modules/approval/__init__.py
Normal file
1
backend/app/modules/approval/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Approval workflow module."""
|
||||||
1
backend/app/modules/audit/__init__.py
Normal file
1
backend/app/modules/audit/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Audit log module."""
|
||||||
1
backend/app/modules/auth/__init__.py
Normal file
1
backend/app/modules/auth/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Authentication and user session module."""
|
||||||
1
backend/app/modules/compute_gateway/__init__.py
Normal file
1
backend/app/modules/compute_gateway/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Application-side compute platform gateway module."""
|
||||||
1
backend/app/modules/data_process/__init__.py
Normal file
1
backend/app/modules/data_process/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Data processing module."""
|
||||||
1
backend/app/modules/dataset/__init__.py
Normal file
1
backend/app/modules/dataset/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Dataset management module."""
|
||||||
1
backend/app/modules/engine_registry/__init__.py
Normal file
1
backend/app/modules/engine_registry/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Training engine registry module."""
|
||||||
1
backend/app/modules/eval/__init__.py
Normal file
1
backend/app/modules/eval/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Evaluation module."""
|
||||||
1
backend/app/modules/file_gateway/__init__.py
Normal file
1
backend/app/modules/file_gateway/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Application-side file gateway module."""
|
||||||
1
backend/app/modules/fine_tune/__init__.py
Normal file
1
backend/app/modules/fine_tune/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Fine-tuning task module."""
|
||||||
1
backend/app/modules/inference/__init__.py
Normal file
1
backend/app/modules/inference/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Inference and compare module."""
|
||||||
1
backend/app/modules/model/__init__.py
Normal file
1
backend/app/modules/model/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Model registry module."""
|
||||||
1
backend/app/modules/project/__init__.py
Normal file
1
backend/app/modules/project/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Project workspace and member module."""
|
||||||
1
backend/app/modules/retention/__init__.py
Normal file
1
backend/app/modules/retention/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Retention policy and cleanup module."""
|
||||||
1
backend/app/modules/system/__init__.py
Normal file
1
backend/app/modules/system/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""System health, metrics and logs module."""
|
||||||
1
backend/app/modules/tenant/__init__.py
Normal file
1
backend/app/modules/tenant/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Tenant management module."""
|
||||||
1
backend/app/schemas/__init__.py
Normal file
1
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Shared schemas package."""
|
||||||
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Cross-module services package."""
|
||||||
1
backend/app/workers/__init__.py
Normal file
1
backend/app/workers/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Background workers package."""
|
||||||
31
backend/config.example.yaml
Normal file
31
backend/config.example.yaml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# 后端配置模板。复制为 config.yaml 后填写本地数据库凭据。
|
||||||
|
# config.yaml 已加入 Git 忽略,不会提交真实密码。
|
||||||
|
|
||||||
|
app:
|
||||||
|
name: YG Fine-Tune Platform API
|
||||||
|
env: local
|
||||||
|
mode: local
|
||||||
|
route_prefix: /modelTF
|
||||||
|
|
||||||
|
server:
|
||||||
|
# 一键启动脚本统一读取这里的端口;端口被占用时只需修改这两项。
|
||||||
|
frontend_port: 16801
|
||||||
|
backend_port: 17861
|
||||||
|
|
||||||
|
database:
|
||||||
|
url: postgresql+psycopg://localhost:5432/yg_ft
|
||||||
|
username: yg_ft
|
||||||
|
password: "change_me"
|
||||||
|
|
||||||
|
compute:
|
||||||
|
mode: real
|
||||||
|
status_sync_mode: polling
|
||||||
|
poll_interval_seconds: 3
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: INFO
|
||||||
|
directory: ./logs
|
||||||
|
file_prefix: backend
|
||||||
|
error_file_prefix: error
|
||||||
|
max_bytes: 20971520
|
||||||
|
retention_days: 10
|
||||||
33
backend/pyproject.toml
Normal file
33
backend/pyproject.toml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
[project]
|
||||||
|
name = "yg-ft-backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Backend service for the model fine-tuning platform"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.111.0",
|
||||||
|
"uvicorn[standard]>=0.30.0",
|
||||||
|
"python-multipart>=0.0.9",
|
||||||
|
"pydantic>=2.7.0",
|
||||||
|
"sqlalchemy>=2.0.30",
|
||||||
|
"psycopg[binary]>=3.2.1",
|
||||||
|
"alembic>=1.13.1",
|
||||||
|
"redis>=5.0.4",
|
||||||
|
"httpx>=0.27.0",
|
||||||
|
"PyJWT>=2.8.0",
|
||||||
|
"passlib[bcrypt]>=1.7.4",
|
||||||
|
"python-dotenv>=1.0.1",
|
||||||
|
"PyYAML>=6.0.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.2.0",
|
||||||
|
"ruff>=0.5.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py312"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
13
backend/requirements.txt
Normal file
13
backend/requirements.txt
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
fastapi>=0.111.0
|
||||||
|
uvicorn[standard]>=0.30.0
|
||||||
|
python-multipart>=0.0.9
|
||||||
|
pydantic>=2.7.0
|
||||||
|
sqlalchemy>=2.0.30
|
||||||
|
psycopg[binary]>=3.2.1
|
||||||
|
alembic>=1.13.1
|
||||||
|
redis>=5.0.4
|
||||||
|
httpx>=0.27.0
|
||||||
|
PyJWT>=2.8.0
|
||||||
|
passlib[bcrypt]>=1.7.4
|
||||||
|
python-dotenv>=1.0.1
|
||||||
|
PyYAML>=6.0.2
|
||||||
196
backend/tests/test_config.py
Normal file
196
backend/tests/test_config.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.core.config import ConfigurationError, load_settings
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG_ENV_NAMES = (
|
||||||
|
"APP_NAME",
|
||||||
|
"APP_ENV",
|
||||||
|
"APP_MODE",
|
||||||
|
"MODELTF_ROUTE_PREFIX",
|
||||||
|
"FRONTEND_PORT",
|
||||||
|
"BACKEND_PORT",
|
||||||
|
"DATABASE_URL",
|
||||||
|
"DATABASE_BASE_URL",
|
||||||
|
"DATABASE_USERNAME",
|
||||||
|
"DATABASE_PASSWORD",
|
||||||
|
"CORS_ALLOW_ORIGINS",
|
||||||
|
"COMPUTE_MODE",
|
||||||
|
"COMPUTE_STATUS_SYNC_MODE",
|
||||||
|
"COMPUTE_POLL_INTERVAL_SECONDS",
|
||||||
|
"LOG_LEVEL",
|
||||||
|
"LOG_DIR",
|
||||||
|
"LOG_FILE_PREFIX",
|
||||||
|
"LOG_ERROR_FILE_PREFIX",
|
||||||
|
"LOG_MAX_BYTES",
|
||||||
|
"LOG_RETENTION_DAYS",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_config_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
for env_name in CONFIG_ENV_NAMES:
|
||||||
|
monkeypatch.delenv(env_name, raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
def write_config(path: Path) -> None:
|
||||||
|
path.write_text(
|
||||||
|
"""
|
||||||
|
app:
|
||||||
|
name: YAML API
|
||||||
|
route_prefix: /yaml-api
|
||||||
|
cors_allow_origins:
|
||||||
|
- http://yaml.example
|
||||||
|
server:
|
||||||
|
frontend_port: 18001
|
||||||
|
backend_port: 18002
|
||||||
|
database:
|
||||||
|
url: postgresql+psycopg://db:5432/yaml
|
||||||
|
username: yaml-user
|
||||||
|
password: yaml-secret
|
||||||
|
compute:
|
||||||
|
mode: simulated
|
||||||
|
poll_interval_seconds: 9
|
||||||
|
logging:
|
||||||
|
directory: ./yaml-logs
|
||||||
|
max_bytes: 1024
|
||||||
|
retention_days: 2
|
||||||
|
""".strip(),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_settings_from_yaml(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
clear_config_env(monkeypatch)
|
||||||
|
config_path = tmp_path / "config.yaml"
|
||||||
|
write_config(config_path)
|
||||||
|
|
||||||
|
settings = load_settings(config_path)
|
||||||
|
|
||||||
|
assert settings.app_name == "YAML API"
|
||||||
|
assert settings.route_prefix == "/yaml-api"
|
||||||
|
assert settings.frontend_port == 18001
|
||||||
|
assert settings.backend_port == 18002
|
||||||
|
assert settings.database_url == "postgresql+psycopg://yaml-user:yaml-secret@db:5432/yaml"
|
||||||
|
assert settings.cors_allow_origins == ["http://yaml.example"]
|
||||||
|
assert settings.compute_mode == "simulated"
|
||||||
|
assert settings.compute_poll_interval_seconds == 9
|
||||||
|
assert settings.log_dir == str(tmp_path / "yaml-logs")
|
||||||
|
assert settings.log_max_bytes == 1024
|
||||||
|
assert settings.log_retention_days == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_environment_overrides_yaml(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
clear_config_env(monkeypatch)
|
||||||
|
config_path = tmp_path / "config.yaml"
|
||||||
|
write_config(config_path)
|
||||||
|
monkeypatch.setenv("DATABASE_URL", "postgresql+psycopg://env:secret@db:5432/env")
|
||||||
|
monkeypatch.setenv("CORS_ALLOW_ORIGINS", "http://one.example,http://two.example")
|
||||||
|
monkeypatch.setenv("COMPUTE_POLL_INTERVAL_SECONDS", "15")
|
||||||
|
monkeypatch.setenv("FRONTEND_PORT", "19001")
|
||||||
|
monkeypatch.setenv("BACKEND_PORT", "19002")
|
||||||
|
|
||||||
|
settings = load_settings(config_path)
|
||||||
|
|
||||||
|
assert settings.database_url == "postgresql+psycopg://env:secret@db:5432/env"
|
||||||
|
assert settings.cors_allow_origins == ["http://one.example", "http://two.example"]
|
||||||
|
assert settings.compute_poll_interval_seconds == 15
|
||||||
|
assert settings.frontend_port == 19001
|
||||||
|
assert settings.backend_port == 19002
|
||||||
|
|
||||||
|
|
||||||
|
def test_separate_database_credentials_are_encoded(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
clear_config_env(monkeypatch)
|
||||||
|
config_path = tmp_path / "config.yaml"
|
||||||
|
write_config(config_path)
|
||||||
|
monkeypatch.setenv("DATABASE_USERNAME", "user@example.com")
|
||||||
|
monkeypatch.setenv("DATABASE_PASSWORD", "secret:/?#[]@")
|
||||||
|
|
||||||
|
settings = load_settings(config_path)
|
||||||
|
|
||||||
|
assert settings.database_url == (
|
||||||
|
"postgresql+psycopg://user%40example.com:secret%3A%2F%3F%23%5B%5D%40@db:5432/yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_missing_config_is_rejected(tmp_path: Path) -> None:
|
||||||
|
with pytest.raises(ConfigurationError, match="does not exist"):
|
||||||
|
load_settings(tmp_path / "missing.yaml")
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_cors_follows_frontend_port(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
clear_config_env(monkeypatch)
|
||||||
|
config_path = tmp_path / "config.yaml"
|
||||||
|
config_path.write_text(
|
||||||
|
"server:\n frontend_port: 28001\n backend_port: 28002\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = load_settings(config_path)
|
||||||
|
|
||||||
|
assert settings.cors_allow_origins == [
|
||||||
|
"http://localhost:28001",
|
||||||
|
"http://127.0.0.1:28001",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("frontend_port", [0, 65536, "invalid", True])
|
||||||
|
def test_invalid_server_port_is_rejected(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
frontend_port: object,
|
||||||
|
) -> None:
|
||||||
|
clear_config_env(monkeypatch)
|
||||||
|
config_path = tmp_path / "config.yaml"
|
||||||
|
config_path.write_text(
|
||||||
|
"server:\n"
|
||||||
|
f" frontend_port: {str(frontend_port).lower()}\n"
|
||||||
|
" backend_port: 28002\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ConfigurationError, match="server.frontend_port"):
|
||||||
|
load_settings(config_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_and_backend_ports_must_differ(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
clear_config_env(monkeypatch)
|
||||||
|
config_path = tmp_path / "config.yaml"
|
||||||
|
config_path.write_text(
|
||||||
|
"server:\n frontend_port: 28001\n backend_port: 28001\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ConfigurationError, match="must be different"):
|
||||||
|
load_settings(config_path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("database_base_url", "message"),
|
||||||
|
[
|
||||||
|
("mysql://localhost:3306/yg_ft", "must use postgresql"),
|
||||||
|
("postgresql+psycopg://localhost:5432", "must include a database name"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_database_url_is_rejected(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
database_base_url: str,
|
||||||
|
message: str,
|
||||||
|
) -> None:
|
||||||
|
clear_config_env(monkeypatch)
|
||||||
|
config_path = tmp_path / "config.yaml"
|
||||||
|
write_config(config_path)
|
||||||
|
monkeypatch.setenv("DATABASE_BASE_URL", database_base_url)
|
||||||
|
|
||||||
|
with pytest.raises(ConfigurationError, match=message):
|
||||||
|
load_settings(config_path)
|
||||||
29
compute/README.md
Normal file
29
compute/README.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Compute Platform
|
||||||
|
|
||||||
|
算力平台与应用平台分开部署,本目录用于后续实现单机多 GPU 调度、文件网关和训练引擎适配。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
compute/
|
||||||
|
api/ # 只允许应用平台访问的内部 Compute API
|
||||||
|
agent/ # 单机 Agent,负责 GPU、进程、工作区管理
|
||||||
|
engines/
|
||||||
|
llama_factory/ # LLaMA-Factory 训练引擎适配器
|
||||||
|
file_gateway/ # 本地磁盘上传、下载、预览、离线导入
|
||||||
|
tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 开发职责
|
||||||
|
|
||||||
|
- GPU 发现、状态上报、锁定和释放。
|
||||||
|
- 本地磁盘工作区管理。
|
||||||
|
- 创建、停止、查询训练/评测/推理/合并任务。
|
||||||
|
- LLaMA-Factory 命令生成、日志解析、产物收集。
|
||||||
|
- 分片上传、短时下载、离线导入。
|
||||||
|
- 通过服务间 token 接受应用平台调用。
|
||||||
|
|
||||||
|
## 运行模式
|
||||||
|
|
||||||
|
- 默认 `COMPUTE_EXECUTION_MODE=real`,Compute API 只暴露健康检查和接口契约;真实训练执行器完成前,创建作业会返回未实现错误。
|
||||||
|
- 仅隔离联调时可设置 `COMPUTE_EXECUTION_MODE=simulator`,启用内存状态机和合成 GPU/日志数据。该模式不得作为生产运行路径。
|
||||||
1
compute/__init__.py
Normal file
1
compute/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Compute platform package."""
|
||||||
1
compute/agent/__init__.py
Normal file
1
compute/agent/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Compute agent package."""
|
||||||
1
compute/api/__init__.py
Normal file
1
compute/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Compute API package."""
|
||||||
211
compute/api/main.py
Normal file
211
compute/api/main.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
|
||||||
|
from compute.engines.llama_factory.adapter import build_command, parse_log_line
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
app = FastAPI(title="YG Fine-Tune Compute API")
|
||||||
|
jobs: dict[str, dict[str, Any]] = {}
|
||||||
|
route_prefix = os.getenv("MODELTF_ROUTE_PREFIX", "/modelTF").rstrip("/") or "/modelTF"
|
||||||
|
|
||||||
|
def now() -> float:
|
||||||
|
return time.time()
|
||||||
|
|
||||||
|
def host_id() -> str:
|
||||||
|
return os.getenv("COMPUTE_HOST_ID", "gpu-node-01")
|
||||||
|
|
||||||
|
def execution_mode() -> str:
|
||||||
|
return os.getenv("COMPUTE_EXECUTION_MODE", os.getenv("COMPUTE_MODE", "real")).lower()
|
||||||
|
|
||||||
|
def job_status(job: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if execution_mode() != "simulator":
|
||||||
|
return job
|
||||||
|
elapsed = max(0, int(now() - job["created_at"]))
|
||||||
|
if job["status"] not in {"stopped", "failed", "completed"}:
|
||||||
|
if elapsed < 5:
|
||||||
|
job["status"] = "queued"
|
||||||
|
job["progress"] = 12 + elapsed * 3
|
||||||
|
elif elapsed < 60:
|
||||||
|
job["status"] = "running"
|
||||||
|
job["progress"] = min(96, 25 + int((elapsed - 5) / 55 * 70))
|
||||||
|
else:
|
||||||
|
job["status"] = "completed"
|
||||||
|
job["progress"] = 100
|
||||||
|
job["logs"] = generate_logs(job)
|
||||||
|
return job
|
||||||
|
|
||||||
|
def generate_logs(job: dict[str, Any]) -> str:
|
||||||
|
progress = int(job.get("progress", 0) or 0)
|
||||||
|
points = max(1, min(80, progress))
|
||||||
|
lines = [
|
||||||
|
f"[INFO] compute_host_id={host_id()} job_id={job['id']} engine=llama_factory",
|
||||||
|
f"[INFO] command={' '.join(job['command'])}",
|
||||||
|
]
|
||||||
|
for step in range(1, points + 1):
|
||||||
|
if step % 4 != 0 and step != points:
|
||||||
|
continue
|
||||||
|
loss = max(0.11, 2.5 * math.exp(-step / 40))
|
||||||
|
grad_norm = 0.4 + (step % 5) * 0.04
|
||||||
|
lr = 0.0002 * max(0.05, 1 - step / 100)
|
||||||
|
epoch = round(step / points * 3, 4)
|
||||||
|
lines.append(
|
||||||
|
"{"
|
||||||
|
f"'loss': {loss:.4f}, 'grad_norm': {grad_norm:.4f}, "
|
||||||
|
f"'learning_rate': {lr:.8f}, 'epoch': {epoch:.4f}"
|
||||||
|
"}"
|
||||||
|
)
|
||||||
|
if job.get("status") == "completed":
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"***** train metrics *****",
|
||||||
|
"epoch = 3",
|
||||||
|
"train_loss = 0.1181",
|
||||||
|
"train_runtime = 1m 0s",
|
||||||
|
"***** train metrics end *****",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def gpu_resources() -> list[dict[str, Any]]:
|
||||||
|
if execution_mode() != "simulator":
|
||||||
|
return []
|
||||||
|
active_jobs = [job_status(job) for job in jobs.values() if job["status"] in {"queued", "running"}]
|
||||||
|
gpus: list[dict[str, Any]] = []
|
||||||
|
for idx in range(4):
|
||||||
|
task = next((job for job in active_jobs if idx in job.get("gpus", [])), None)
|
||||||
|
busy = task is not None and task["status"] == "running"
|
||||||
|
reserved = task is not None and task["status"] == "queued"
|
||||||
|
gpus.append(
|
||||||
|
{
|
||||||
|
"id": idx,
|
||||||
|
"uuid": f"GPU-{host_id().upper()}-{idx}",
|
||||||
|
"name": os.getenv("COMPUTE_GPU_NAME", "NVIDIA A800-SXM4-80GB"),
|
||||||
|
"status": "busy" if busy else "reserved" if reserved else "idle",
|
||||||
|
"gpu_percent": 88 if busy else 25 if reserved else 4,
|
||||||
|
"memory_used_gb": 58 if busy else 12 if reserved else 2,
|
||||||
|
"memory_total_gb": 80,
|
||||||
|
"temperature": 61 if busy else 45 if reserved else 36,
|
||||||
|
"power_w": 215 if busy else 80 if reserved else 25,
|
||||||
|
"power_limit_w": 300,
|
||||||
|
"processes": [
|
||||||
|
{
|
||||||
|
"pid": task["pid"],
|
||||||
|
"name": "llamafactory-cli",
|
||||||
|
"task_name": task["name"],
|
||||||
|
"memory_used_gb": 58 if busy else 12,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
if task
|
||||||
|
else [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return gpus
|
||||||
|
|
||||||
|
@app.get(f"{route_prefix}/health")
|
||||||
|
async def health_check() -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"compute_host_id": os.getenv("COMPUTE_HOST_ID", "unknown"),
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get(f"{route_prefix}/v1/compute/health")
|
||||||
|
async def compute_health_check() -> dict[str, str | bool]:
|
||||||
|
data_root = Path(os.getenv("YG_FT_DATA_ROOT", "/data/yg-ft"))
|
||||||
|
llama_factory_home = Path(os.getenv("LLAMA_FACTORY_HOME", "/app/LLaMA-Factory"))
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"compute_host_id": os.getenv("COMPUTE_HOST_ID", "unknown"),
|
||||||
|
"app_callback_enabled": os.getenv("ENABLE_APP_CALLBACK", "false").lower() == "true",
|
||||||
|
"data_root": str(data_root),
|
||||||
|
"data_root_exists": data_root.exists(),
|
||||||
|
"llama_factory_home": str(llama_factory_home),
|
||||||
|
"llama_factory_home_exists": llama_factory_home.exists(),
|
||||||
|
"execution_mode": execution_mode(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get(f"{route_prefix}/v1/compute/jobs")
|
||||||
|
async def list_jobs_alias() -> dict[str, list[dict[str, Any]]]:
|
||||||
|
return {"items": [job_status(job) for job in jobs.values()]}
|
||||||
|
|
||||||
|
@app.get(f"{route_prefix}/compute/resources/gpus")
|
||||||
|
async def list_gpus() -> dict[str, Any]:
|
||||||
|
return {"items": gpu_resources(), "compute_host_id": host_id()}
|
||||||
|
|
||||||
|
@app.post(f"{route_prefix}/compute/jobs")
|
||||||
|
async def create_job(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
command = build_command(payload, os.getenv("LLAMA_FACTORY_HOME", "/app/LLaMA-Factory"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
if execution_mode() != "simulator":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=501,
|
||||||
|
detail="real compute executor is not implemented yet; set COMPUTE_EXECUTION_MODE=simulator only for isolated development",
|
||||||
|
)
|
||||||
|
job_id = str(payload.get("id") or f"job_{int(now() * 1000)}")
|
||||||
|
job = {
|
||||||
|
"id": job_id,
|
||||||
|
"name": payload.get("name", job_id),
|
||||||
|
"status": "queued",
|
||||||
|
"progress": 10,
|
||||||
|
"pid": int(52000 + now() % 10000),
|
||||||
|
"gpus": payload.get("gpus") or [0],
|
||||||
|
"created_at": now(),
|
||||||
|
"command": command.command,
|
||||||
|
"work_dir": command.work_dir,
|
||||||
|
"artifacts": [],
|
||||||
|
"logs": "",
|
||||||
|
}
|
||||||
|
jobs[job_id] = job
|
||||||
|
return job_status(job)
|
||||||
|
|
||||||
|
@app.get(f"{route_prefix}/compute/jobs")
|
||||||
|
async def list_jobs() -> dict[str, Any]:
|
||||||
|
return {"items": [job_status(job) for job in jobs.values()]}
|
||||||
|
|
||||||
|
@app.get(f"{route_prefix}/compute/jobs/{{job_id}}")
|
||||||
|
async def get_job(job_id: str) -> dict[str, Any]:
|
||||||
|
job = jobs.get(job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="job not found")
|
||||||
|
return job_status(job)
|
||||||
|
|
||||||
|
@app.post(f"{route_prefix}/compute/jobs/{{job_id}}/stop")
|
||||||
|
async def stop_job(job_id: str) -> dict[str, Any]:
|
||||||
|
job = jobs.get(job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="job not found")
|
||||||
|
job["status"] = "stopped"
|
||||||
|
job["progress"] = min(job.get("progress", 0), 99)
|
||||||
|
return job
|
||||||
|
|
||||||
|
@app.get(f"{route_prefix}/compute/jobs/{{job_id}}/logs")
|
||||||
|
async def job_logs(job_id: str) -> dict[str, Any]:
|
||||||
|
job = jobs.get(job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="job not found")
|
||||||
|
job = job_status(job)
|
||||||
|
metrics = [parse_log_line(line) for line in job["logs"].splitlines()]
|
||||||
|
return {"job_id": job_id, "content": job["logs"], "metrics": [m for m in metrics if m]}
|
||||||
|
|
||||||
|
@app.post(f"{route_prefix}/compute/files/upload")
|
||||||
|
async def upload_file(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
file_id = str(payload.get("id") or f"file_{int(now() * 1000)}")
|
||||||
|
return {"id": file_id, "status": "available", "local_path": f"/data/yg-ft/uploads/{file_id}"}
|
||||||
|
|
||||||
|
@app.get(f"{route_prefix}/compute/files/{{file_id}}/download")
|
||||||
|
async def download_file(file_id: str) -> dict[str, Any]:
|
||||||
|
return {"id": file_id, "status": "ready", "download_url": f"{route_prefix}/compute/files/{file_id}/download"}
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
1
compute/engines/__init__.py
Normal file
1
compute/engines/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Training engine adapters package."""
|
||||||
1
compute/engines/llama_factory/__init__.py
Normal file
1
compute/engines/llama_factory/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""LLaMA-Factory engine adapter package."""
|
||||||
80
compute/engines/llama_factory/adapter.py
Normal file
80
compute/engines/llama_factory/adapter.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LlamaFactoryCommand:
|
||||||
|
command: list[str]
|
||||||
|
work_dir: str
|
||||||
|
env: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_config(config: dict[str, Any]) -> list[str]:
|
||||||
|
errors: list[str] = []
|
||||||
|
if not config.get("base_model") and not config.get("model_name_or_path"):
|
||||||
|
errors.append("base_model or model_name_or_path is required")
|
||||||
|
if not config.get("dataset") and not config.get("dataset_dir"):
|
||||||
|
errors.append("dataset or dataset_dir is required")
|
||||||
|
learning_rate = float(config.get("learning_rate", 0.0002))
|
||||||
|
if learning_rate <= 0:
|
||||||
|
errors.append("learning_rate must be greater than zero")
|
||||||
|
epochs = int(config.get("n_epochs", config.get("num_train_epochs", 1)))
|
||||||
|
if epochs <= 0:
|
||||||
|
errors.append("n_epochs must be greater than zero")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def build_command(config: dict[str, Any], llama_factory_home: str = "/app/LLaMA-Factory") -> LlamaFactoryCommand:
|
||||||
|
errors = validate_config(config)
|
||||||
|
if errors:
|
||||||
|
raise ValueError("; ".join(errors))
|
||||||
|
|
||||||
|
model_path = config.get("base_model") or config.get("model_name_or_path")
|
||||||
|
dataset = config.get("dataset") or config.get("dataset_dir")
|
||||||
|
output_dir = config.get("output_dir") or f"/data/yg-ft/outputs/{config.get('name', 'training-job')}"
|
||||||
|
command = [
|
||||||
|
"llamafactory-cli",
|
||||||
|
"train",
|
||||||
|
"--stage",
|
||||||
|
str(config.get("stage", "sft")).lower(),
|
||||||
|
"--do_train",
|
||||||
|
"true",
|
||||||
|
"--model_name_or_path",
|
||||||
|
str(model_path),
|
||||||
|
"--dataset",
|
||||||
|
str(dataset),
|
||||||
|
"--template",
|
||||||
|
str(config.get("template", "qwen")),
|
||||||
|
"--finetuning_type",
|
||||||
|
str(config.get("train_method", config.get("finetuning_type", "lora"))),
|
||||||
|
"--output_dir",
|
||||||
|
str(output_dir),
|
||||||
|
"--per_device_train_batch_size",
|
||||||
|
str(config.get("batch_size", 2)),
|
||||||
|
"--learning_rate",
|
||||||
|
str(config.get("learning_rate", 0.0002)),
|
||||||
|
"--num_train_epochs",
|
||||||
|
str(config.get("n_epochs", 3)),
|
||||||
|
"--save_steps",
|
||||||
|
str(config.get("save_steps", 50)),
|
||||||
|
]
|
||||||
|
quantization_bit = int(config.get("quantization_bit", 0) or 0)
|
||||||
|
if quantization_bit in {4, 8}:
|
||||||
|
command.extend(["--quantization_bit", str(quantization_bit)])
|
||||||
|
return LlamaFactoryCommand(command=command, work_dir=str(Path(llama_factory_home)), env={})
|
||||||
|
|
||||||
|
|
||||||
|
def parse_log_line(line: str) -> dict[str, float] | None:
|
||||||
|
if "loss" not in line or "learning_rate" not in line:
|
||||||
|
return None
|
||||||
|
result: dict[str, float] = {}
|
||||||
|
for key in ["loss", "grad_norm", "learning_rate", "epoch"]:
|
||||||
|
match = re.search(rf"['\"]?{key}['\"]?\s*:\s*([-+]?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)", line)
|
||||||
|
if match:
|
||||||
|
result[key] = float(match.group(1))
|
||||||
|
return result or None
|
||||||
|
|
||||||
1
compute/file_gateway/__init__.py
Normal file
1
compute/file_gateway/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Local file gateway package."""
|
||||||
5
compute/requirements.txt
Normal file
5
compute/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
fastapi>=0.111.0
|
||||||
|
uvicorn[standard]>=0.30.0
|
||||||
|
pydantic>=2.7.0
|
||||||
|
python-dotenv>=1.0.1
|
||||||
|
httpx>=0.27.0
|
||||||
1
compute/tests/__init__.py
Normal file
1
compute/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Compute platform tests package."""
|
||||||
102
design-qa.md
102
design-qa.md
@@ -325,3 +325,105 @@ Verification evidence:
|
|||||||
Design-QA final result: blocked
|
Design-QA final result: blocked
|
||||||
|
|
||||||
final result: blocked
|
final result: blocked
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Dataset Version Actions Design QA
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- Source visual truth: `/Users/caoxiaozhu/.codex/generated_images/019f5e2e-3bee-77f0-b285-89ef139db56c/exec-48a163b6-d4c9-4646-9199-135957c6e72e.png`
|
||||||
|
- Historical-version implementation: `/Users/caoxiaozhu/.codex/visualizations/2026/07/14/019f5e2e-3bee-77f0-b285-89ef139db56c/dataset-version-build/implementation-historical-version-final.png`
|
||||||
|
- Delete-confirm implementation: `/Users/caoxiaozhu/.codex/visualizations/2026/07/14/019f5e2e-3bee-77f0-b285-89ef139db56c/dataset-version-build/implementation-delete-confirm.png`
|
||||||
|
- Full-view comparison: `/Users/caoxiaozhu/.codex/visualizations/2026/07/14/019f5e2e-3bee-77f0-b285-89ef139db56c/dataset-version-build/comparison-full.png`
|
||||||
|
- Focused version-control comparison: `/Users/caoxiaozhu/.codex/visualizations/2026/07/14/019f5e2e-3bee-77f0-b285-89ef139db56c/dataset-version-build/comparison-version-controls-final.png`
|
||||||
|
- Viewport: 1316 × 768 browser window; source crop normalized for the focused comparison.
|
||||||
|
- State: authenticated dataset detail, V3 current, V2 selected as a read-only historical version.
|
||||||
|
|
||||||
|
## Full-view comparison
|
||||||
|
|
||||||
|
The existing product shell, dataset summary, version selector, read-only alert, file toolbar, and sample table remain unchanged. The former standalone primary action has been replaced by one compact rounded-square overflow button at the far right of the version-control row, matching the selected hierarchy.
|
||||||
|
|
||||||
|
## Focused comparison and required fidelity surfaces
|
||||||
|
|
||||||
|
- Fonts and typography: existing system/PingFang stack, 13px labels, 12px metadata, and Element Plus menu text are preserved.
|
||||||
|
- Spacing and layout rhythm: the 40px overflow trigger aligns with the version selector and leaves the central status copy flexible; the 168px menu provides 40px action rows.
|
||||||
|
- Colors and visual tokens: the existing indigo primary token is used for the activate icon; the delete item and confirmation action use the danger token.
|
||||||
|
- Image and icon fidelity: no new raster assets are needed. Existing Font Awesome ellipsis, check-circle, and trash icons match the repository's icon system.
|
||||||
|
- Copy and content: the menu contains exactly “设为当前版本” and “删除版本”, separated visually; the confirmation names V2 and explains that current V3 is unaffected.
|
||||||
|
|
||||||
|
## Interaction checks
|
||||||
|
|
||||||
|
- Created V2 and V3 through the real edit-and-save flow, then switched from current V3 to historical V2.
|
||||||
|
- Historical records became read-only and the overflow trigger appeared; current V3 showed no history-operation trigger.
|
||||||
|
- Opening the trigger exposed exactly two accessible menu items: “设为当前版本” and “删除版本”.
|
||||||
|
- Choosing “删除版本” opened the danger confirmation dialog; cancelling returned focus without deleting data.
|
||||||
|
- Actual deletion behavior, protected-version rejection, optimistic-lock handling, and non-reused version numbers are covered by `test:dataset-preview`.
|
||||||
|
|
||||||
|
## Comparison history
|
||||||
|
|
||||||
|
### Iteration 1 — blocked
|
||||||
|
|
||||||
|
- [P2] The overflow trigger was circular while the selected mock used a small rounded square.
|
||||||
|
- [P2] The menu did not explicitly lock its target width or primary-action icon color.
|
||||||
|
|
||||||
|
Fixes:
|
||||||
|
|
||||||
|
- Replaced the circular trigger with a 40px square and 10px radius.
|
||||||
|
- Set the menu minimum width to 168px, action height to 40px, and the activate icon to the product primary color.
|
||||||
|
|
||||||
|
### Iteration 2 — passed
|
||||||
|
|
||||||
|
No actionable P0/P1/P2 differences remain. The desktop capture API does not retain the transient popup layer in screenshots, so the open-menu labels were additionally verified through the accessibility tree; exact popup shadow rendering remains a non-blocking P3 capture gap.
|
||||||
|
|
||||||
|
final result: passed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Login Page Responsive Design QA
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- Source visual truth: `/Users/caoxiaozhu/.codex/generated_images/019f5f3f-f7e1-7e41-9605-ea307d9f09e6/exec-d376c603-0c47-45ad-86fd-3e99c1a95ec7.png`
|
||||||
|
- Implementation route: `http://localhost:6801/login`
|
||||||
|
- Implementation screenshot: unavailable because the in-app browser runtime could not initialize in this session.
|
||||||
|
- Intended desktop viewport: 1536 × 1024.
|
||||||
|
- Intended laptop viewports: 1366 × 768 and 1280 × 720.
|
||||||
|
- State: unauthenticated login page, default username and password populated.
|
||||||
|
|
||||||
|
## Static and automated evidence
|
||||||
|
|
||||||
|
- Added a 1440px width breakpoint that shifts the split from 58/42 to 55/45 and caps the form at 440px.
|
||||||
|
- Added a short-screen breakpoint for heights up to 820px that reduces title, form, input, footer, and panel spacing without hiding the left visual.
|
||||||
|
- Kept the single-column fallback at 900px and below.
|
||||||
|
- `regression-login-layout.mjs`: passed.
|
||||||
|
- `regression-default-dashboard.mjs`: passed.
|
||||||
|
- `vue-tsc -b --noEmit`: passed.
|
||||||
|
- Vite dev transform for `LoginView.vue`: HTTP 200.
|
||||||
|
- Production build: blocked by the pre-existing missing route component `UserPermissionView.vue`, outside the login-page change.
|
||||||
|
|
||||||
|
## Required fidelity surfaces
|
||||||
|
|
||||||
|
- Fonts and typography: code uses the existing product font stack with laptop-specific display-size reductions; visual comparison remains unavailable.
|
||||||
|
- Spacing and layout rhythm: dedicated width and height media queries are present; rendered measurements remain unavailable.
|
||||||
|
- Colors and visual tokens: existing indigo tokens and the selected dark-purple visual asset are preserved.
|
||||||
|
- Image quality and asset fidelity: the generated `login-hero-flow.png` is used directly; the official `logo.png` is reused for the brand lockup.
|
||||||
|
- Copy and content: platform title, supporting copy, form labels, actions, and footer match the selected design.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
- [P2] Browser-rendered laptop comparison unavailable
|
||||||
|
Location: login page at 1366 × 768 and 1280 × 720.
|
||||||
|
Evidence: the in-app browser runtime failed during initialization, so no implementation screenshot or side-by-side comparison could be captured.
|
||||||
|
Impact: static checks cannot prove that all visible spacing and crop details match the selected design at laptop sizes.
|
||||||
|
Fix: capture both laptop viewports in a working in-app browser session, compare them with the source, and resolve any remaining P0/P1/P2 differences.
|
||||||
|
|
||||||
|
## Comparison history
|
||||||
|
|
||||||
|
### Iteration 1 — blocked
|
||||||
|
|
||||||
|
- User reported that the initial implementation was optimized for large displays and did not compose well on laptop screens.
|
||||||
|
- Added explicit laptop-width and short-screen layout rules and passed targeted regression/type checks.
|
||||||
|
- Post-fix visual evidence remains unavailable because browser capture is blocked.
|
||||||
|
|
||||||
|
final result: blocked
|
||||||
|
|||||||
267
docker/README.md
Normal file
267
docker/README.md
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
# Docker 部署说明
|
||||||
|
|
||||||
|
本目录按应用服务器和算力服务器拆分 Dockerfile 与 Docker Compose 文件。Compose 文件不包含 `build:`,不会在 `docker compose up` 时自动构建业务镜像。所有业务镜像需要先通过手动 `docker build` 构建,再由 Compose 启动。
|
||||||
|
|
||||||
|
## 基础镜像
|
||||||
|
|
||||||
|
| 镜像 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| `python:3.12-slim` | 应用后端基础镜像,后端运行环境要求 Python 3.12 及以上 |
|
||||||
|
| `nginx:1.27-alpine` | 前端静态资源与 `/modelTF` 反向代理运行镜像 |
|
||||||
|
| `hiyouga/llamafactory:latest` | 算力服务基础镜像,基于 LLaMA-Factory 官方镜像扩展 Compute API |
|
||||||
|
| `postgres:16-alpine` | 开发阶段内置 PostgreSQL |
|
||||||
|
| `redis:7-alpine` | 开发阶段内置 Redis |
|
||||||
|
|
||||||
|
一键拉取基础镜像:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull python:3.12-slim && \
|
||||||
|
docker pull nginx:1.27-alpine && \
|
||||||
|
docker pull hiyouga/llamafactory:latest && \
|
||||||
|
docker pull postgres:16-alpine && \
|
||||||
|
docker pull redis:7-alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$images = @(
|
||||||
|
"python:3.12-slim",
|
||||||
|
"nginx:1.27-alpine",
|
||||||
|
"hiyouga/llamafactory:latest",
|
||||||
|
"postgres:16-alpine",
|
||||||
|
"redis:7-alpine"
|
||||||
|
)
|
||||||
|
$images | ForEach-Object { docker pull $_ }
|
||||||
|
```
|
||||||
|
|
||||||
|
如果部署环境不能访问外网,需要提前在可联网环境执行上述拉取命令,再用 `docker save` / `docker load` 导出导入。
|
||||||
|
|
||||||
|
## 业务镜像
|
||||||
|
|
||||||
|
| 镜像 | Dockerfile | 构建命令 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `yg-ft-backend-api:latest` | `docker/app/Dockerfile.backend` | `docker build -f docker/app/Dockerfile.backend -t yg-ft-backend-api:latest .` |
|
||||||
|
| `yg-ft-frontend-runtime:latest` | `docker/app/Dockerfile.frontend` | `docker build -f docker/app/Dockerfile.frontend -t yg-ft-frontend-runtime:latest .` |
|
||||||
|
| `yg-ft-compute-api:latest` | `docker/compute/Dockerfile.compute` | `docker build -f docker/compute/Dockerfile.compute -t yg-ft-compute-api:latest .` |
|
||||||
|
|
||||||
|
## 对外端口
|
||||||
|
|
||||||
|
所有宿主机对外端口统一使用 5 位端口。容器内部端口保持镜像默认端口,便于容器内服务和健康检查稳定。
|
||||||
|
|
||||||
|
| 服务 | 宿主机对外端口 | 容器内部端口 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 前端 Nginx | `16801` | `80` | 前端页面入口 |
|
||||||
|
| 后端 API | `17861` | `8000` | FastAPI 服务 |
|
||||||
|
| PostgreSQL | `15432` | `5432` | 开发阶段内置数据库 |
|
||||||
|
| Redis | `16379` | `6379` | 开发阶段内置缓存 |
|
||||||
|
| Compute API | `19100` | `9100` | 算力服务器 API |
|
||||||
|
| File Gateway | `19101` | 后续服务端口 | 当前预留,后续拆出文件网关服务时使用 |
|
||||||
|
|
||||||
|
注意:`8000` 是后端容器内部端口,不作为宿主机对外访问端口。宿主机或浏览器应访问 `http://<app-server-ip>:17861/modelTF/health`;前端 Nginx 容器在 Docker 网络内部访问 `http://backend-api:8000/modelTF/...`。
|
||||||
|
|
||||||
|
对应配置文件:
|
||||||
|
|
||||||
|
- `docker/app/.env.example`
|
||||||
|
- `FRONTEND_PORT=16801`
|
||||||
|
- `BACKEND_API_PORT=17861`
|
||||||
|
- `POSTGRES_PORT=15432`
|
||||||
|
- `REDIS_PORT=16379`
|
||||||
|
- `docker/compute/.env.example`
|
||||||
|
- `COMPUTE_API_PORT=19100`
|
||||||
|
- `FILE_GATEWAY_PORT=19101`
|
||||||
|
|
||||||
|
## 运行模式
|
||||||
|
|
||||||
|
- 应用侧默认 `COMPUTE_MODE=real`,任务状态必须由真实算力同步逻辑更新。
|
||||||
|
- 算力侧默认 `COMPUTE_EXECUTION_MODE=real`,真实执行器未完成前不会伪造训练作业。
|
||||||
|
- 仅隔离联调时可显式设置 `COMPUTE_MODE=simulator` 或 `COMPUTE_EXECUTION_MODE=simulator`,该模式不得用于测试环境、生产环境或生产升级基线。
|
||||||
|
|
||||||
|
## 应用服务器部署
|
||||||
|
|
||||||
|
应用服务器包含前端 Nginx、Backend API、PostgreSQL、Redis。
|
||||||
|
|
||||||
|
当前 Compose 内置 PostgreSQL 使用 `backend/app/db/sql/001_platform_runtime.sql` 初始化运行库。`docs/postgres-schema.sql` 是完整目标架构设计,不应直接挂载为当前运行库初始化脚本,否则会与当前后端代码的运行表结构不兼容。
|
||||||
|
|
||||||
|
首次部署:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <repo-root>
|
||||||
|
|
||||||
|
# 1. 使用当前 Windows/宿主机 npm 构建前端静态产物
|
||||||
|
cd frontend
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# 2. 手动构建业务镜像
|
||||||
|
docker build -f docker/app/Dockerfile.backend -t yg-ft-backend-api:latest .
|
||||||
|
docker build -f docker/app/Dockerfile.frontend -t yg-ft-frontend-runtime:latest .
|
||||||
|
|
||||||
|
# 3. 启动应用服务
|
||||||
|
cd docker/app
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
后端镜像构建过程中会执行依赖导入自检,确认 `fastapi`、`uvicorn`、`psycopg`、`sqlalchemy`、`redis` 等运行依赖已安装。构建后也可以手动检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm yg-ft-backend-api:latest python -c "import psycopg; print(psycopg.__version__)"
|
||||||
|
```
|
||||||
|
|
||||||
|
默认访问地址:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://<app-server-ip>:16801
|
||||||
|
```
|
||||||
|
|
||||||
|
应用侧代码和数据外挂:
|
||||||
|
|
||||||
|
```text
|
||||||
|
../../backend -> /app
|
||||||
|
../../frontend/dist -> /usr/share/nginx/html
|
||||||
|
../../runtime/app/logs/backend -> /opt/yg-ft/logs/backend
|
||||||
|
../../runtime/app/data -> /data/yg-ft
|
||||||
|
```
|
||||||
|
|
||||||
|
前端容器启动前必须确保 `../../frontend/dist/index.html` 已存在。若前端 Nginx 日志出现 `directory index of "/usr/share/nginx/html/" is forbidden` 或 `rewrite or internal redirection cycle while internally redirecting to "/index.html"`,通常表示当前执行 `docker compose` 的项目目录下没有构建好的 `frontend/dist`,或挂载路径不是同一份代码目录。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在执行 docker compose 的同一份代码目录中检查
|
||||||
|
cd <repo-root>/frontend
|
||||||
|
npm run build
|
||||||
|
test -f dist/index.html && ls -lh dist/index.html
|
||||||
|
|
||||||
|
cd ../docker/app
|
||||||
|
docker compose up -d --force-recreate frontend
|
||||||
|
docker compose logs --tail=80 frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
如果使用 Windows npm 构建、WSL 中运行 Docker Compose,需要确认 Windows 路径和 WSL 路径指向同一份仓库。例如在 `D:\...\YG_FT\frontend` 构建不会自动生成 `/mnt/d/wuyongtao/Code/YG_FT/frontend/dist` 下的产物,除非二者本就是同一个目录。
|
||||||
|
|
||||||
|
如果使用企业统一 PostgreSQL/Redis,修改 `docker/app/.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgresql+psycopg://<user>:<password>@<postgres-host>:15432/<db>
|
||||||
|
REDIS_URL=redis://<redis-host>:16379/0
|
||||||
|
USE_BUILTIN_POSTGRES=false
|
||||||
|
USE_BUILTIN_REDIS=false
|
||||||
|
```
|
||||||
|
|
||||||
|
生产环境如完全使用外部基础设施,可以删除或注释 Compose 中的 `postgres`、`redis` 服务及 `backend-api.depends_on` 中对应依赖。
|
||||||
|
|
||||||
|
## 算力服务器部署
|
||||||
|
|
||||||
|
算力服务器包含 Compute API、后续 Compute Agent、File Gateway、GPU runtime、本地训练数据目录和 LLaMA-Factory。`Dockerfile.compute` 基于 LLaMA-Factory 官方镜像:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM hiyouga/llamafactory:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
部署前需要安装:
|
||||||
|
|
||||||
|
- NVIDIA Driver
|
||||||
|
- NVIDIA Container Toolkit
|
||||||
|
- Docker Engine 和 Docker Compose Plugin
|
||||||
|
- 本地训练数据目录,默认 `/data/yg-ft`
|
||||||
|
|
||||||
|
首次部署:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <repo-root>
|
||||||
|
|
||||||
|
# 手动构建算力业务镜像
|
||||||
|
docker build -f docker/compute/Dockerfile.compute -t yg-ft-compute-api:latest .
|
||||||
|
|
||||||
|
# 启动算力服务
|
||||||
|
cd docker/compute
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
健康检查:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET http://<compute-server-ip>:19100/modelTF/health
|
||||||
|
GET http://<compute-server-ip>:19100/modelTF/v1/compute/health
|
||||||
|
```
|
||||||
|
|
||||||
|
算力侧代码和数据外挂:
|
||||||
|
|
||||||
|
```text
|
||||||
|
../../compute -> /app/compute
|
||||||
|
${YG_FT_DATA_ROOT_HOST} -> /data/yg-ft
|
||||||
|
../../runtime/compute/logs -> /opt/yg-ft/logs/compute
|
||||||
|
../../runtime/compute/training-logs -> /opt/yg-ft/logs/training
|
||||||
|
```
|
||||||
|
|
||||||
|
## 应用与算力分离部署
|
||||||
|
|
||||||
|
应用服务器只需要主动访问算力服务器,不要求算力服务器回调应用服务器。
|
||||||
|
|
||||||
|
在 `docker/app/.env` 中配置:
|
||||||
|
|
||||||
|
```env
|
||||||
|
COMPUTE_API_BASE_URL=http://<compute-server-ip>:19100
|
||||||
|
FILE_GATEWAY_BASE_URL=http://<compute-server-ip>:19101
|
||||||
|
COMPUTE_SERVICE_TOKEN=change_me
|
||||||
|
COMPUTE_STATUS_SYNC_MODE=polling
|
||||||
|
COMPUTE_POLL_INTERVAL_SECONDS=10
|
||||||
|
COMPUTE_POLL_BATCH_SIZE=100
|
||||||
|
```
|
||||||
|
|
||||||
|
交互链路:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Frontend
|
||||||
|
-> Backend API
|
||||||
|
-> Compute API
|
||||||
|
-> Compute Agent / LLaMA-Factory
|
||||||
|
-> 本地数据目录 / 模型目录 / 训练产物
|
||||||
|
<- Backend Worker 定时轮询 Compute API
|
||||||
|
```
|
||||||
|
|
||||||
|
## 多算力节点部署
|
||||||
|
|
||||||
|
多算力节点仍按“单机多 GPU 节点”部署。每台 GPU 服务器都独立部署一套 `docker/compute`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
gpu-node-01: docker/compute + /data/yg-ft + 19100/19101
|
||||||
|
gpu-node-02: docker/compute + /data/yg-ft + 19100/19101
|
||||||
|
gpu-node-03: docker/compute + /data/yg-ft + 19100/19101
|
||||||
|
```
|
||||||
|
|
||||||
|
节点之间默认不互访。应用平台主动访问每个节点的 Compute API/File Gateway,并通过 `compute_nodes`、`resource_replicas`、`resource_sync_jobs` 统一调度和同步。
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
|
重新构建应用镜像:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -f docker/app/Dockerfile.backend -t yg-ft-backend-api:latest .
|
||||||
|
docker build -f docker/app/Dockerfile.frontend -t yg-ft-frontend-runtime:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
重新构建算力镜像:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -f docker/compute/Dockerfile.compute -t yg-ft-compute-api:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
启动服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd docker/app
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
cd ../compute
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
查看服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
45
docker/app/.env.example
Normal file
45
docker/app/.env.example
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
APP_ENV=prod
|
||||||
|
APP_NAME=YG Fine-Tune Platform API
|
||||||
|
MODELTF_ROUTE_PREFIX=/modelTF
|
||||||
|
CORS_ALLOW_ORIGINS=http://localhost:16801,http://127.0.0.1:16801
|
||||||
|
|
||||||
|
FRONTEND_IMAGE=yg-ft-frontend-runtime:latest
|
||||||
|
BACKEND_API_IMAGE=yg-ft-backend-api:latest
|
||||||
|
|
||||||
|
# Five-digit host ports exposed outside the application server.
|
||||||
|
FRONTEND_PORT=16801
|
||||||
|
BACKEND_API_PORT=17861
|
||||||
|
POSTGRES_PORT=15432
|
||||||
|
REDIS_PORT=16379
|
||||||
|
|
||||||
|
POSTGRES_DB=yg_ft
|
||||||
|
POSTGRES_USER=yg_ft
|
||||||
|
POSTGRES_PASSWORD=change_me
|
||||||
|
DATABASE_URL=postgresql+psycopg://yg_ft:change_me@postgres:5432/yg_ft
|
||||||
|
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
|
||||||
|
# Development uses the built-in PostgreSQL/Redis services in docker-compose.yml.
|
||||||
|
# For enterprise infrastructure, replace DATABASE_URL/REDIS_URL and remove or disable those services.
|
||||||
|
USE_BUILTIN_POSTGRES=true
|
||||||
|
USE_BUILTIN_REDIS=true
|
||||||
|
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
LOG_DIR=/opt/yg-ft/logs/backend
|
||||||
|
LOG_FILE_PREFIX=backend
|
||||||
|
LOG_ERROR_FILE_PREFIX=error
|
||||||
|
LOG_MAX_BYTES=20971520
|
||||||
|
LOG_RETENTION_DAYS=10
|
||||||
|
|
||||||
|
BACKEND_PROXY_PASS=http://backend-api:8000
|
||||||
|
|
||||||
|
# Split deployment: set these to the compute server address, for example http://10.10.20.31:19100.
|
||||||
|
COMPUTE_API_BASE_URL=http://compute-api:9100
|
||||||
|
COMPUTE_SERVICE_TOKEN=change_me
|
||||||
|
FILE_GATEWAY_BASE_URL=http://compute-api:9101
|
||||||
|
|
||||||
|
# The application side polls Compute API for job state to avoid opening reverse network access.
|
||||||
|
COMPUTE_MODE=real
|
||||||
|
COMPUTE_STATUS_SYNC_MODE=polling
|
||||||
|
COMPUTE_POLL_INTERVAL_SECONDS=10
|
||||||
|
COMPUTE_POLL_BATCH_SIZE=100
|
||||||
21
docker/app/Dockerfile.backend
Normal file
21
docker/app/Dockerfile.backend
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY backend/requirements.txt /tmp/requirements.txt
|
||||||
|
RUN pip install --upgrade pip -i https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||||
|
&& pip install -r /tmp/requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||||
|
&& rm -f /tmp/requirements.txt
|
||||||
|
|
||||||
|
RUN python -c "import fastapi, uvicorn, psycopg, sqlalchemy, redis, yaml, jwt, passlib, httpx, alembic; print('backend dependency check ok')"
|
||||||
|
|
||||||
|
RUN mkdir -p /opt/yg-ft/logs/backend /data/yg-ft \
|
||||||
|
&& chmod -R 0775 /opt/yg-ft /data/yg-ft
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
9
docker/app/Dockerfile.frontend
Normal file
9
docker/app/Dockerfile.frontend
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
RUN mkdir -p /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||||
|
CMD test -f /usr/share/nginx/html/index.html && wget -qO- http://127.0.0.1/index.html >/dev/null || exit 1
|
||||||
123
docker/app/docker-compose.yml
Normal file
123
docker/app/docker-compose.yml
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
image: ${FRONTEND_IMAGE:-yg-ft-frontend-runtime:latest}
|
||||||
|
container_name: yg-ft-frontend
|
||||||
|
depends_on:
|
||||||
|
backend-api:
|
||||||
|
condition: service_started
|
||||||
|
ports:
|
||||||
|
- "${FRONTEND_PORT:-16801}:80"
|
||||||
|
environment:
|
||||||
|
BACKEND_PROXY_PASS: ${BACKEND_PROXY_PASS:-http://backend-api:8000}
|
||||||
|
volumes:
|
||||||
|
- ../../frontend/dist:/usr/share/nginx/html:ro
|
||||||
|
- ../nginx.conf.template:/etc/nginx/templates/default.conf.template:ro
|
||||||
|
command:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
if [ ! -f /usr/share/nginx/html/index.html ]; then
|
||||||
|
echo "frontend dist is missing: build frontend first and ensure ../../frontend/dist is mounted";
|
||||||
|
ls -la /usr/share/nginx/html;
|
||||||
|
exit 1;
|
||||||
|
fi;
|
||||||
|
nginx -g 'daemon off;'
|
||||||
|
networks:
|
||||||
|
- yg-ft-app
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
backend-api:
|
||||||
|
image: ${BACKEND_API_IMAGE:-yg-ft-backend-api:latest}
|
||||||
|
container_name: yg-ft-backend-api
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
expose:
|
||||||
|
- "8000"
|
||||||
|
ports:
|
||||||
|
- "${BACKEND_API_PORT:-17861}:8000"
|
||||||
|
environment:
|
||||||
|
APP_ENV: ${APP_ENV:-prod}
|
||||||
|
APP_NAME: ${APP_NAME:-YG Fine-Tune Platform API}
|
||||||
|
MODELTF_ROUTE_PREFIX: ${MODELTF_ROUTE_PREFIX:-/modelTF}
|
||||||
|
CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-http://localhost:16801,http://127.0.0.1:16801}
|
||||||
|
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://yg_ft:change_me@postgres:5432/yg_ft}
|
||||||
|
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
|
||||||
|
USE_BUILTIN_POSTGRES: ${USE_BUILTIN_POSTGRES:-true}
|
||||||
|
USE_BUILTIN_REDIS: ${USE_BUILTIN_REDIS:-true}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
LOG_DIR: ${LOG_DIR:-/opt/yg-ft/logs/backend}
|
||||||
|
LOG_FILE_PREFIX: ${LOG_FILE_PREFIX:-backend}
|
||||||
|
LOG_ERROR_FILE_PREFIX: ${LOG_ERROR_FILE_PREFIX:-error}
|
||||||
|
LOG_MAX_BYTES: ${LOG_MAX_BYTES:-20971520}
|
||||||
|
LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-10}
|
||||||
|
COMPUTE_API_BASE_URL: ${COMPUTE_API_BASE_URL:-http://compute-api:9100}
|
||||||
|
COMPUTE_SERVICE_TOKEN: ${COMPUTE_SERVICE_TOKEN:-change_me}
|
||||||
|
FILE_GATEWAY_BASE_URL: ${FILE_GATEWAY_BASE_URL:-http://compute-api:9101}
|
||||||
|
COMPUTE_MODE: ${COMPUTE_MODE:-real}
|
||||||
|
COMPUTE_STATUS_SYNC_MODE: ${COMPUTE_STATUS_SYNC_MODE:-polling}
|
||||||
|
COMPUTE_POLL_INTERVAL_SECONDS: ${COMPUTE_POLL_INTERVAL_SECONDS:-10}
|
||||||
|
COMPUTE_POLL_BATCH_SIZE: ${COMPUTE_POLL_BATCH_SIZE:-100}
|
||||||
|
PYTHONPATH: /app
|
||||||
|
volumes:
|
||||||
|
- ../../backend:/app:ro
|
||||||
|
- ../../runtime/app/logs/backend:/opt/yg-ft/logs/backend
|
||||||
|
- ../../runtime/app/data:/data/yg-ft
|
||||||
|
networks:
|
||||||
|
- yg-ft-app
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/modelTF/health', timeout=3).read()\""]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: yg-ft-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-yg_ft}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-yg_ft}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change_me}
|
||||||
|
PGDATA: /var/lib/postgresql/data/pgdata
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ../../backend/app/db/sql/001_platform_runtime.sql:/docker-entrypoint-initdb.d/001-platform-runtime.sql:ro
|
||||||
|
ports:
|
||||||
|
- "${POSTGRES_PORT:-15432}:5432"
|
||||||
|
networks:
|
||||||
|
- yg-ft-app
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: yg-ft-redis
|
||||||
|
command: ["redis-server", "--appendonly", "yes"]
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
ports:
|
||||||
|
- "${REDIS_PORT:-16379}:6379"
|
||||||
|
networks:
|
||||||
|
- yg-ft-app
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
yg-ft-app:
|
||||||
|
name: yg-ft-app
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
23
docker/compute/.env.example
Normal file
23
docker/compute/.env.example
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
COMPUTE_ENV=prod
|
||||||
|
COMPUTE_HOST_ID=gpu-node-01
|
||||||
|
COMPUTE_EXECUTION_MODE=real
|
||||||
|
MODELTF_ROUTE_PREFIX=/modelTF
|
||||||
|
# Five-digit host ports exposed outside the compute server.
|
||||||
|
COMPUTE_API_PORT=19100
|
||||||
|
FILE_GATEWAY_PORT=19101
|
||||||
|
COMPUTE_API_IMAGE=yg-ft-compute-api:latest
|
||||||
|
|
||||||
|
# The application server actively polls Compute API; compute server does not need reverse access.
|
||||||
|
COMPUTE_SERVICE_TOKEN=change_me
|
||||||
|
ENABLE_APP_CALLBACK=false
|
||||||
|
|
||||||
|
# LLaMA-Factory is provided by the official hiyouga/llamafactory base image.
|
||||||
|
LLAMA_FACTORY_HOME=/app/LLaMA-Factory
|
||||||
|
|
||||||
|
YG_FT_DATA_ROOT=/data/yg-ft
|
||||||
|
YG_FT_DATA_ROOT_HOST=/data/yg-ft
|
||||||
|
|
||||||
|
LOG_DIR=/opt/yg-ft/logs/compute
|
||||||
|
CUDA_VISIBLE_DEVICES=all
|
||||||
|
NVIDIA_VISIBLE_DEVICES=all
|
||||||
|
NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||||
27
docker/compute/Dockerfile.compute
Normal file
27
docker/compute/Dockerfile.compute
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
|
||||||
|
FROM hiyouga/llamafactory:latest
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends tini \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY compute/requirements.txt /tmp/requirements.txt
|
||||||
|
RUN pip install --upgrade pip -i https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||||
|
&& pip install -r /tmp/requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||||
|
&& rm -f /tmp/requirements.txt
|
||||||
|
|
||||||
|
RUN mkdir -p /opt/yg-ft/logs/compute /opt/yg-ft/logs/training /data/yg-ft /app/LLaMA-Factory \
|
||||||
|
&& chmod -R 0775 /opt/yg-ft /data/yg-ft /app/LLaMA-Factory
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||||
|
|
||||||
|
EXPOSE 9100
|
||||||
|
|
||||||
|
CMD ["uvicorn", "compute.api.main:app", "--host", "0.0.0.0", "--port", "9100"]
|
||||||
39
docker/compute/docker-compose.yml
Normal file
39
docker/compute/docker-compose.yml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
services:
|
||||||
|
compute-api:
|
||||||
|
image: ${COMPUTE_API_IMAGE:-yg-ft-compute-api:latest}
|
||||||
|
container_name: yg-ft-compute-api
|
||||||
|
gpus: all
|
||||||
|
ports:
|
||||||
|
- "${COMPUTE_API_PORT:-19100}:9100"
|
||||||
|
environment:
|
||||||
|
COMPUTE_ENV: ${COMPUTE_ENV:-prod}
|
||||||
|
COMPUTE_HOST_ID: ${COMPUTE_HOST_ID:-gpu-node-01}
|
||||||
|
COMPUTE_EXECUTION_MODE: ${COMPUTE_EXECUTION_MODE:-real}
|
||||||
|
MODELTF_ROUTE_PREFIX: ${MODELTF_ROUTE_PREFIX:-/modelTF}
|
||||||
|
COMPUTE_SERVICE_TOKEN: ${COMPUTE_SERVICE_TOKEN:-change_me}
|
||||||
|
ENABLE_APP_CALLBACK: ${ENABLE_APP_CALLBACK:-false}
|
||||||
|
LLAMA_FACTORY_HOME: ${LLAMA_FACTORY_HOME:-/app/LLaMA-Factory}
|
||||||
|
YG_FT_DATA_ROOT: ${YG_FT_DATA_ROOT:-/data/yg-ft}
|
||||||
|
LOG_DIR: ${LOG_DIR:-/opt/yg-ft/logs/compute}
|
||||||
|
CUDA_VISIBLE_DEVICES: ${CUDA_VISIBLE_DEVICES:-all}
|
||||||
|
NVIDIA_VISIBLE_DEVICES: ${NVIDIA_VISIBLE_DEVICES:-all}
|
||||||
|
NVIDIA_DRIVER_CAPABILITIES: ${NVIDIA_DRIVER_CAPABILITIES:-compute,utility}
|
||||||
|
PYTHONPATH: /app
|
||||||
|
volumes:
|
||||||
|
- ../../compute:/app/compute:ro
|
||||||
|
- ${YG_FT_DATA_ROOT_HOST:-/data/yg-ft}:${YG_FT_DATA_ROOT:-/data/yg-ft}
|
||||||
|
- ../../runtime/compute/logs:/opt/yg-ft/logs/compute
|
||||||
|
- ../../runtime/compute/training-logs:/opt/yg-ft/logs/training
|
||||||
|
networks:
|
||||||
|
- yg-ft-compute
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:9100/modelTF/health', timeout=3).read()\""]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
yg-ft-compute:
|
||||||
|
name: yg-ft-compute
|
||||||
30
docker/nginx.conf.template
Normal file
30
docker/nginx.conf.template
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
client_max_body_size 200m;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /modelTF {
|
||||||
|
proxy_pass ${BACKEND_PROXY_PASS};
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.(?:js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ {
|
||||||
|
try_files $uri =404;
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
1161
docs/backend-api-design.md
Normal file
1161
docs/backend-api-design.md
Normal file
File diff suppressed because it is too large
Load Diff
109
docs/backend-logging.md
Normal file
109
docs/backend-logging.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# 后端日志模块说明
|
||||||
|
|
||||||
|
本文档对应页面/功能模块:全平台通用能力、系统设置、审计中心、任务详情、训练任务日志、运维监控。
|
||||||
|
|
||||||
|
## 设计目标
|
||||||
|
|
||||||
|
- 后端服务统一使用 `backend/app/core/logging.py` 初始化日志。
|
||||||
|
- 日志文件按日期命名,单个文件超过 20MB 自动滚动。
|
||||||
|
- 日志只保留最近 10 天,过期文件自动清理。
|
||||||
|
- 业务日志使用 JSON Lines 格式,便于 Filebeat、Vector、Logstash、ELK、OpenSearch 等日志平台采集。
|
||||||
|
- `ERROR` 及以上日志独立写入错误日志文件,便于告警与问题定位。
|
||||||
|
- 日志字段必须包含代码文件、行号、函数、日志内容、请求 ID、进程和线程信息。
|
||||||
|
|
||||||
|
## 文件命名
|
||||||
|
|
||||||
|
默认日志目录由 `LOG_DIR` 控制,本地默认是 `./logs`。
|
||||||
|
|
||||||
|
```text
|
||||||
|
logs/
|
||||||
|
backend-2026-07-16.log # INFO/ERROR 等全部应用日志,JSON Lines
|
||||||
|
backend-2026-07-16.1.log # 当天主日志超过 20MB 后滚动产生
|
||||||
|
error-2026-07-16.log # ERROR/CRITICAL 错误日志,JSON Lines
|
||||||
|
error-2026-07-16.1.log # 当天错误日志超过 20MB 后滚动产生
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
```env
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
LOG_DIR=./logs
|
||||||
|
LOG_FILE_PREFIX=backend
|
||||||
|
LOG_ERROR_FILE_PREFIX=error
|
||||||
|
LOG_MAX_BYTES=20971520
|
||||||
|
LOG_RETENTION_DAYS=10
|
||||||
|
```
|
||||||
|
|
||||||
|
## JSON 字段
|
||||||
|
|
||||||
|
每一行都是一个完整 JSON 对象。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"@timestamp": "2026-07-16T13:20:10.123",
|
||||||
|
"level": "INFO",
|
||||||
|
"logger": "app.access",
|
||||||
|
"message": "request completed method=GET path=/modelTF/health status_code=200 duration_ms=3.12 client=127.0.0.1",
|
||||||
|
"module": "logging",
|
||||||
|
"function": "request_logging_middleware",
|
||||||
|
"file": "D:\\AI\\codex-code\\YG_FT\\backend\\app\\core\\logging.py",
|
||||||
|
"line": 169,
|
||||||
|
"process": 1234,
|
||||||
|
"thread": 5678,
|
||||||
|
"thread_name": "MainThread",
|
||||||
|
"request_id": "6f9d1c3c-8be0-4c8d-a5b2-18f9d41f9a0c"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
异常日志会额外包含:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"exception": "Traceback ..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用方式
|
||||||
|
|
||||||
|
业务代码中不要直接 `print`,统一使用:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
logger.info("dataset uploaded dataset_id=%s", dataset_id)
|
||||||
|
logger.warning("gpu queue is busy project_id=%s", project_id)
|
||||||
|
logger.exception("training job failed job_id=%s", job_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
`logger.exception(...)` 只能在 `except` 代码块中使用,它会自动写入堆栈信息,并同时进入主日志和错误日志。
|
||||||
|
|
||||||
|
## FastAPI 接入
|
||||||
|
|
||||||
|
应用入口 `backend/app/main.py` 已完成接入:
|
||||||
|
|
||||||
|
```python
|
||||||
|
settings = get_settings()
|
||||||
|
configure_logging(settings)
|
||||||
|
setup_request_logging(app)
|
||||||
|
```
|
||||||
|
|
||||||
|
请求日志会自动生成或透传 `X-Request-ID`,并在响应头中返回同一个请求 ID,方便前端、后端、算力服务、日志平台串联排障。
|
||||||
|
|
||||||
|
## ELK/日志平台采集建议
|
||||||
|
|
||||||
|
- 采集路径:`/app/logs/*.log` 或生产环境挂载后的日志目录。
|
||||||
|
- 解析方式:按行读取,每行作为 JSON 文档解析。
|
||||||
|
- 索引建议:
|
||||||
|
- 主日志:`yg-ft-backend-*`
|
||||||
|
- 错误日志:`yg-ft-backend-error-*`
|
||||||
|
- 推荐保留字段:`@timestamp`、`level`、`logger`、`message`、`file`、`line`、`function`、`request_id`、`tenant_id`、`project_id`、`job_id`。
|
||||||
|
- 业务开发后续应在关键模块日志中补充 `tenant_id`、`project_id`、`job_id` 等上下文字段,便于企业审计和问题定位。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 当前日志落本地磁盘,生产环境建议把日志目录挂载到独立数据盘。
|
||||||
|
- 日志文件保留 10 天是应用侧兜底策略,企业侧长期留存应由 ELK、对象存储或归档服务承担。
|
||||||
|
- 敏感字段如 token、密码、密钥、原始用户数据内容不得写入日志。
|
||||||
|
- 算力节点和应用节点分开部署时,建议两侧都采用 JSON Lines 格式,并使用统一 `request_id/job_id` 贯穿链路。
|
||||||
406
docs/deployment-plan.md
Normal file
406
docs/deployment-plan.md
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
# 模型微调平台后期部署方案
|
||||||
|
|
||||||
|
本文档对应页面/功能模块:系统设置、算力资源、训练任务、任务详情、模型管理、数据集管理、审批中心、审计中心、运维监控。
|
||||||
|
|
||||||
|
## 1. 部署目标
|
||||||
|
|
||||||
|
平台需要支持单机多 GPU 训练、本地磁盘文件存储、LLaMA-Factory 训练框架,并预留未来接入其他训练平台的能力。部署设计需要把“应用平台”和“算力平台”边界明确拆开:
|
||||||
|
|
||||||
|
- 应用平台:面向用户、权限、项目、模型、数据集、审批、审计、任务编排和 API。
|
||||||
|
- 算力平台:面向 GPU、训练进程、训练框架、本地工作目录、训练日志和产物。
|
||||||
|
- 训练框架:当前固定 LLaMA-Factory,后续通过 Engine Adapter 标准接入其他框架。
|
||||||
|
|
||||||
|
结论:算力平台和训练框架应该部署在 GPU 算力服务器上。原因是训练框架需要直接访问 GPU、CUDA、驱动、模型权重、本地数据集切片、训练工作目录和训练进程。应用平台可以与算力平台同机部署,也可以独立部署,但不建议在无 GPU 的应用服务器上直接运行 LLaMA-Factory。
|
||||||
|
|
||||||
|
## 2. 服务清单
|
||||||
|
|
||||||
|
| 服务 | 部署位置 | 职责 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Nginx | 应用服务器或算力服务器 | 前端静态资源、反向代理、TLS 终止 |
|
||||||
|
| Frontend | Nginx 静态目录 | 平台控制台 |
|
||||||
|
| Backend API | 应用服务器 | FastAPI 接口、鉴权、元数据、审批、审计、任务编排 |
|
||||||
|
| Backend Worker | 应用服务器 | 异步任务、状态同步、通知、审计归档 |
|
||||||
|
| PostgreSQL | 应用服务器或独立数据库服务器 | 业务元数据、权限、审批、审计 |
|
||||||
|
| Redis | 应用服务器或独立缓存服务器 | 队列、锁、短期状态、幂等控制 |
|
||||||
|
| Compute API | GPU 算力服务器 | 只对应用平台开放的内部算力接口 |
|
||||||
|
| Compute Agent | GPU 算力服务器 | GPU 发现、资源锁定、训练进程管理 |
|
||||||
|
| File Gateway | GPU 算力服务器 | 本地文件上传、下载、离线导入、产物访问 |
|
||||||
|
| LLaMA-Factory | GPU 算力服务器 | 实际训练、评测、合并、导出 |
|
||||||
|
| 日志采集 Agent | 两侧服务器 | 采集应用日志、训练日志、系统日志 |
|
||||||
|
|
||||||
|
## 3. 目录与存储规划
|
||||||
|
|
||||||
|
建议生产环境把文件、日志、数据库数据分盘挂载:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/opt/yg-ft/
|
||||||
|
app/ # 应用服务代码
|
||||||
|
compute/ # 算力服务代码
|
||||||
|
config/ # 环境配置和服务配置
|
||||||
|
logs/
|
||||||
|
backend/ # 后端 JSON Lines 日志
|
||||||
|
compute/ # 算力服务日志
|
||||||
|
training/ # 训练过程日志
|
||||||
|
data/
|
||||||
|
datasets/ # 数据集文件
|
||||||
|
models/ # 基座模型、微调模型、导出模型
|
||||||
|
jobs/ # 训练任务工作目录
|
||||||
|
artifacts/ # 评测报告、adapter、checkpoint、导出包
|
||||||
|
```
|
||||||
|
|
||||||
|
本地文件存储建议按租户、项目、资源类型分区:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/data/yg-ft/
|
||||||
|
tenants/{tenant_id}/
|
||||||
|
projects/{project_id}/
|
||||||
|
datasets/{dataset_id}/
|
||||||
|
models/{model_id}/
|
||||||
|
jobs/{job_id}/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 方案一:所有服务部署在算力服务器
|
||||||
|
|
||||||
|
### 4.1 适用场景
|
||||||
|
|
||||||
|
- 开发联调、单机试运行、资源受限的早期上线环境。
|
||||||
|
- 小团队共用一台单机多 GPU 服务器。
|
||||||
|
- 网络隔离要求不高,部署资源有限。
|
||||||
|
|
||||||
|
### 4.2 拓扑
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
U["用户浏览器"] --> N["Nginx/Frontend"]
|
||||||
|
N --> B["Backend API"]
|
||||||
|
B --> DB["PostgreSQL"]
|
||||||
|
B --> R["Redis"]
|
||||||
|
B --> C["Compute API"]
|
||||||
|
C --> A["Compute Agent"]
|
||||||
|
A --> L["LLaMA-Factory"]
|
||||||
|
A --> G["GPU/CUDA"]
|
||||||
|
A --> FS["本地磁盘文件存储"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 部署方式
|
||||||
|
|
||||||
|
同一台 GPU 服务器部署:
|
||||||
|
|
||||||
|
- `frontend` 构建后由 Nginx 托管。
|
||||||
|
- `backend-api` 使用 Uvicorn/Gunicorn 或容器运行。
|
||||||
|
- `backend-worker` 独立进程运行。
|
||||||
|
- `postgres` 和 `redis` 可使用 Docker Compose 或系统服务。
|
||||||
|
- `compute-api`、`compute-agent`、`file-gateway` 与 LLaMA-Factory 在同机运行。
|
||||||
|
- 训练产物、数据集、模型和日志都放在本地数据盘。
|
||||||
|
|
||||||
|
### 4.4 优点
|
||||||
|
|
||||||
|
- 部署简单,路径共享容易。
|
||||||
|
- 上传数据、训练读取、产物归档都在本机完成,I/O 链路短。
|
||||||
|
- 适合快速验证平台功能。
|
||||||
|
|
||||||
|
### 4.5 风险
|
||||||
|
|
||||||
|
- 应用服务、数据库、训练任务抢占同一台服务器资源。
|
||||||
|
- GPU 训练高负载可能影响 API 响应。
|
||||||
|
- 数据库与文件存储容灾能力弱。
|
||||||
|
- 安全边界不清晰,企业生产不推荐长期使用。
|
||||||
|
|
||||||
|
### 4.6 端口建议
|
||||||
|
|
||||||
|
| 服务 | 端口 | 暴露范围 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Nginx | 80/443 | 用户网段 |
|
||||||
|
| Backend API | 17861 | 仅 Nginx、本机 |
|
||||||
|
| Compute API | 19100 | 仅 Backend API、本机 |
|
||||||
|
| File Gateway | 19101 | 仅 Backend API、本机 |
|
||||||
|
| PostgreSQL | 15432 | 本机或内网 |
|
||||||
|
| Redis | 16379 | 本机或内网 |
|
||||||
|
|
||||||
|
## 5. 方案二:应用服务与算力/训练服务独立部署
|
||||||
|
|
||||||
|
### 5.1 适用场景
|
||||||
|
|
||||||
|
- 企业生产环境。
|
||||||
|
- 有独立应用服务器、数据库服务器和 GPU 算力服务器。
|
||||||
|
- 需要清晰网络边界、权限边界和运维职责。
|
||||||
|
- 未来可能扩展多台 GPU 服务器或多种训练框架。
|
||||||
|
|
||||||
|
### 5.2 拓扑
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
U["用户浏览器"] --> N["应用区 Nginx/Frontend"]
|
||||||
|
N --> B["应用区 Backend API"]
|
||||||
|
B --> DB["PostgreSQL"]
|
||||||
|
B --> R["Redis"]
|
||||||
|
B -- "内部 HTTPS/mTLS + 服务 Token" --> C["算力区 Compute API"]
|
||||||
|
C --> A["Compute Agent"]
|
||||||
|
A --> L["LLaMA-Factory"]
|
||||||
|
A --> G["GPU/CUDA"]
|
||||||
|
A --> FS["算力服务器本地磁盘"]
|
||||||
|
B -- "定时轮询任务状态/指标/产物索引" --> C
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 部署边界
|
||||||
|
|
||||||
|
应用服务器部署:
|
||||||
|
|
||||||
|
- Nginx。
|
||||||
|
- Frontend。
|
||||||
|
- Backend API。
|
||||||
|
- Backend Worker。
|
||||||
|
- PostgreSQL 或数据库连接。
|
||||||
|
- Redis 或队列连接。
|
||||||
|
- 审批、审计、系统配置、用户中心等应用能力。
|
||||||
|
|
||||||
|
GPU 算力服务器部署:
|
||||||
|
|
||||||
|
- Compute API。
|
||||||
|
- Compute Agent。
|
||||||
|
- File Gateway。
|
||||||
|
- LLaMA-Factory。
|
||||||
|
- CUDA、NVIDIA Driver、NCCL、PyTorch、训练依赖。
|
||||||
|
- 本地训练工作目录、模型目录、数据集缓存、产物目录。
|
||||||
|
|
||||||
|
### 5.4 互通方式
|
||||||
|
|
||||||
|
应用平台调用算力平台:
|
||||||
|
|
||||||
|
- 协议:内部 HTTPS REST,后续可扩展 gRPC。
|
||||||
|
- 鉴权:服务间 Token,生产建议 mTLS + IP 白名单。
|
||||||
|
- 幂等:训练任务提交使用 `Idempotency-Key` 或 `job_id`。
|
||||||
|
- 状态同步:默认由应用平台定时轮询 Compute API,拉取任务状态、指标摘要和产物索引。
|
||||||
|
- 回调策略:第一阶段关闭算力侧回调,避免算力服务器访问应用服务器,减少双向网络策略开通。
|
||||||
|
|
||||||
|
文件互通:
|
||||||
|
|
||||||
|
- 小文件:前端上传到 Backend API,再由 Backend API 转发或同步到 File Gateway。
|
||||||
|
- 大文件:Backend API 创建上传会话,前端通过受控地址分片上传到 File Gateway。
|
||||||
|
- 离线数据:管理员把数据放到算力服务器指定目录,应用平台登记离线导入任务。
|
||||||
|
- 产物下载:应用平台校验权限后,向 File Gateway 申请短期下载地址。
|
||||||
|
|
||||||
|
状态互通:
|
||||||
|
|
||||||
|
- Backend API 是业务状态的最终来源。
|
||||||
|
- Compute Agent 是训练进程状态的事实来源。
|
||||||
|
- Worker 定时对账,把 `queued/running/succeeded/failed/cancelled` 等状态同步回业务库。
|
||||||
|
|
||||||
|
### 5.5 优点
|
||||||
|
|
||||||
|
- 应用服务稳定性不受 GPU 训练高负载直接影响。
|
||||||
|
- 数据库和审计能力更适合纳入企业基础设施。
|
||||||
|
- 算力节点可以逐步扩展,不影响前端和应用后端。
|
||||||
|
- 安全边界更清晰,便于设置防火墙、堡垒机、服务账号和审计策略。
|
||||||
|
|
||||||
|
### 5.6 风险
|
||||||
|
|
||||||
|
- 文件传输链路比单机部署复杂。
|
||||||
|
- 需要处理跨服务器网络失败、轮询延迟、任务状态对账。
|
||||||
|
- 需要明确模型、数据集、产物在应用侧和算力侧的索引关系。
|
||||||
|
|
||||||
|
### 5.7 多算力节点部署约定
|
||||||
|
|
||||||
|
多算力节点阶段仍然按“单机多 GPU 节点”部署,每台 GPU 服务器都是一个独立算力节点。每个参与调度的节点都必须部署:
|
||||||
|
|
||||||
|
- Compute API。
|
||||||
|
- Compute Agent。
|
||||||
|
- File Gateway。
|
||||||
|
- LLaMA-Factory 宿主机目录和训练依赖。
|
||||||
|
- CUDA、NVIDIA Driver、NCCL、PyTorch。
|
||||||
|
- 本地数据盘 `/data/yg-ft`。
|
||||||
|
- 本地日志和训练产物目录。
|
||||||
|
|
||||||
|
网络策略保持单向:
|
||||||
|
|
||||||
|
```text
|
||||||
|
应用服务器 -> 算力节点 A Compute API/File Gateway
|
||||||
|
应用服务器 -> 算力节点 B Compute API/File Gateway
|
||||||
|
应用服务器 -> 算力节点 C Compute API/File Gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
默认不要求:
|
||||||
|
|
||||||
|
```text
|
||||||
|
算力节点 -> 应用服务器
|
||||||
|
算力节点 A -> 算力节点 B
|
||||||
|
```
|
||||||
|
|
||||||
|
多节点任务调度由应用平台统一完成。应用平台从 `compute_nodes` 读取节点地址、权重、标签、启用状态、维护状态和健康检查结果;从 `resource_replicas` 判断目标节点是否已有所需数据集/模型副本;缺失时创建 `resource_sync_jobs`,通过目标节点 File Gateway 同步资源。
|
||||||
|
|
||||||
|
调度策略:
|
||||||
|
|
||||||
|
- 默认自动调度,按节点健康、标签、GPU 空闲、队列长度、节点权重和资源副本命中率排序。
|
||||||
|
- 支持管理员/高级用户手动指定节点或 GPU。
|
||||||
|
- `disabled` 节点不参与调度。
|
||||||
|
- `draining` 节点不接收新任务,但允许已有任务跑完。
|
||||||
|
- `maintenance/offline` 节点只允许查看和清理,不允许提交训练任务。
|
||||||
|
|
||||||
|
## 6. Compute API 接入标准
|
||||||
|
|
||||||
|
为预留其他训练平台,应用平台只依赖统一算力接口,不直接依赖 LLaMA-Factory 命令。
|
||||||
|
|
||||||
|
训练引擎适配器应提供:
|
||||||
|
|
||||||
|
- `validate_config(config)`:校验训练参数和模板。
|
||||||
|
- `build_command(job)`:生成训练命令或执行计划。
|
||||||
|
- `start(job)`:启动训练进程。
|
||||||
|
- `stop(job_id)`:停止训练进程。
|
||||||
|
- `status(job_id)`:查询训练状态。
|
||||||
|
- `collect_metrics(job_id)`:采集 loss、learning rate、epoch、step 等指标。
|
||||||
|
- `collect_artifacts(job_id)`:登记 checkpoint、adapter、导出模型、评测报告。
|
||||||
|
- `parse_log(line)`:解析训练日志。
|
||||||
|
|
||||||
|
第一版适配器:
|
||||||
|
|
||||||
|
```text
|
||||||
|
compute/engines/llama_factory/
|
||||||
|
```
|
||||||
|
|
||||||
|
后续其他框架:
|
||||||
|
|
||||||
|
```text
|
||||||
|
compute/engines/xtuner/
|
||||||
|
compute/engines/deepspeed_custom/
|
||||||
|
compute/engines/openrlhf/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 环境变量建议
|
||||||
|
|
||||||
|
应用平台:
|
||||||
|
|
||||||
|
```env
|
||||||
|
APP_ENV=prod
|
||||||
|
MODELTF_ROUTE_PREFIX=/modelTF
|
||||||
|
DATABASE_URL=postgresql+psycopg://yg_ft:***@postgres:5432/yg_ft
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
LOG_DIR=/opt/yg-ft/logs/backend
|
||||||
|
COMPUTE_API_BASE_URL=https://compute.internal:19100
|
||||||
|
COMPUTE_SERVICE_TOKEN=***
|
||||||
|
FILE_GATEWAY_BASE_URL=https://compute.internal:19101
|
||||||
|
COMPUTE_STATUS_SYNC_MODE=polling
|
||||||
|
COMPUTE_POLL_INTERVAL_SECONDS=10
|
||||||
|
COMPUTE_POLL_BATCH_SIZE=100
|
||||||
|
```
|
||||||
|
|
||||||
|
算力平台:
|
||||||
|
|
||||||
|
```env
|
||||||
|
COMPUTE_ENV=prod
|
||||||
|
COMPUTE_HOST_ID=gpu-node-01
|
||||||
|
COMPUTE_API_PORT=19100
|
||||||
|
FILE_GATEWAY_PORT=19101
|
||||||
|
COMPUTE_SERVICE_TOKEN=***
|
||||||
|
ENABLE_APP_CALLBACK=false
|
||||||
|
LLAMA_FACTORY_HOME=/app/LLaMA-Factory
|
||||||
|
YG_FT_DATA_ROOT=/data/yg-ft
|
||||||
|
LOG_DIR=/opt/yg-ft/logs/compute
|
||||||
|
CUDA_VISIBLE_DEVICES=0,1,2,3
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 日志与监控
|
||||||
|
|
||||||
|
应用平台:
|
||||||
|
|
||||||
|
- 采集 `backend-YYYY-MM-DD.log` 和 `error-YYYY-MM-DD.log`。
|
||||||
|
- 按 `request_id`、`tenant_id`、`project_id`、`job_id` 检索。
|
||||||
|
- ERROR 日志触发告警。
|
||||||
|
|
||||||
|
算力平台:
|
||||||
|
|
||||||
|
- 采集 Compute API 日志、Agent 日志、训练原始日志。
|
||||||
|
- 训练日志需要按 `job_id` 独立归档。
|
||||||
|
- 关键指标包括 GPU 利用率、显存、磁盘容量、训练队列长度、失败率。
|
||||||
|
|
||||||
|
## 9. 安全要求
|
||||||
|
|
||||||
|
- Compute API 不对公网开放。
|
||||||
|
- 应用平台和算力平台之间使用服务账号鉴权,生产建议 mTLS。
|
||||||
|
- File Gateway 下载地址必须短期有效,并绑定租户、项目、资源权限。
|
||||||
|
- 日志不得输出密码、Token、密钥、数据集原文敏感内容。
|
||||||
|
- 审计日志留存周期按租户或企业配置执行,应用日志短期留存,长期归档交给日志平台。
|
||||||
|
|
||||||
|
## 10. 部署检查清单
|
||||||
|
|
||||||
|
- PostgreSQL 已初始化当前运行脚本 `backend/app/db/sql/001_platform_runtime.sql`;`docs/postgres-schema.sql` 作为目标架构设计,后续通过迁移体系逐步收敛。
|
||||||
|
- Redis 可连通。
|
||||||
|
- 后端 `GET /modelTF/health` 正常。
|
||||||
|
- Compute API 健康检查正常。
|
||||||
|
- Compute Agent 能识别 GPU、显存、CUDA 版本。
|
||||||
|
- LLaMA-Factory 能在命令行完成最小训练作业。
|
||||||
|
- 应用平台能提交训练任务到 Compute API。
|
||||||
|
- 任务状态能从算力平台同步回应用平台。
|
||||||
|
- 数据集上传、离线导入、产物下载路径权限正确。
|
||||||
|
- 后端 JSON 日志可被日志平台解析。
|
||||||
|
- ERROR 日志能触发告警。
|
||||||
|
- 日志、数据集、模型、产物所在磁盘容量有监控和告警。
|
||||||
|
|
||||||
|
## 11. Docker Compose 文件规划
|
||||||
|
|
||||||
|
当前项目按应用服务器和算力服务器拆分了两套 Docker 部署文件,均采用代码外挂方式运行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docker/
|
||||||
|
app/
|
||||||
|
Dockerfile.backend # Backend API 运行时镜像,代码通过 volume 挂载到 /app
|
||||||
|
Dockerfile.frontend # Nginx 前端运行时镜像,frontend/dist 通过 volume 挂载
|
||||||
|
docker-compose.yml # 应用服务器:frontend、backend-api、postgres、redis
|
||||||
|
.env.example
|
||||||
|
compute/
|
||||||
|
Dockerfile.compute # CUDA + Python + Compute API 运行时镜像
|
||||||
|
docker-compose.yml # 算力服务器:compute-api,预留 agent/file gateway 拆分
|
||||||
|
.env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
项目根目录不再保留 `Dockerfile` 和 `docker-compose.yml`,避免与拆分部署入口混淆。
|
||||||
|
|
||||||
|
应用服务器启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd docker/app
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
算力服务器启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd docker/compute
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
应用服务器与算力服务器独立部署时,需要在 `docker/app/.env` 中配置:
|
||||||
|
|
||||||
|
```env
|
||||||
|
COMPUTE_API_BASE_URL=http://<compute-server-ip>:19100
|
||||||
|
FILE_GATEWAY_BASE_URL=http://<compute-server-ip>:19101
|
||||||
|
COMPUTE_SERVICE_TOKEN=change_me
|
||||||
|
```
|
||||||
|
|
||||||
|
这些地址在当前 Docker 阶段通过环境变量动态配置。后续多算力节点阶段建议升级为数据库配置,由应用平台从 `compute_nodes` 表读取节点地址、权重、标签、健康状态和启用状态,并在“算力节点管理”页面维护。
|
||||||
|
|
||||||
|
多节点后,每台算力服务器各自进入 `docker/compute` 启动一套算力服务,并在应用平台中登记为一条 `compute_nodes` 记录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
gpu-node-01 -> http://10.10.20.31:19100 / http://10.10.20.31:19101
|
||||||
|
gpu-node-02 -> http://10.10.20.32:19100 / http://10.10.20.32:19101
|
||||||
|
gpu-node-03 -> http://10.10.20.33:19100 / http://10.10.20.33:19101
|
||||||
|
```
|
||||||
|
|
||||||
|
算力服务器需要在 `docker/compute/.env` 中配置:
|
||||||
|
|
||||||
|
```env
|
||||||
|
ENABLE_APP_CALLBACK=false
|
||||||
|
COMPUTE_SERVICE_TOKEN=change_me
|
||||||
|
YG_FT_DATA_ROOT_HOST=/data/yg-ft
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. 仍需确认的问题
|
||||||
|
|
||||||
|
- 生产环境是否已有统一 ELK/OpenSearch、Filebeat/Vector 标准配置。
|
||||||
|
- PostgreSQL/Redis 开发阶段采用项目自带部署;生产阶段是否切换企业统一基础设施,以及对应 SLA 仍需确认。
|
||||||
|
- 是否需要 PostgreSQL 主备、备份恢复、审计日志长期归档的明确 SLA。
|
||||||
|
- 大文件上传是否需要断点续传、限速、病毒扫描或 DLP 检测。
|
||||||
|
- 应用服务器与算力服务器默认只开通应用侧主动访问算力侧;如后续需要实时回调,再单独评估双向网络策略。
|
||||||
|
- 多算力节点已按单机多 GPU 节点扩展设计;仍需确认是否需要节点组、租户绑定节点、同步限速和资源副本清理审批。
|
||||||
145
docs/first-version-development-plan.md
Normal file
145
docs/first-version-development-plan.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# 当前系统主链路开发计划
|
||||||
|
|
||||||
|
> 说明:本计划描述当前正在开发的系统能力。代码、接口和 SQL 均按后续生产演进基线维护,不以一次性演示、静态 Mock 或样例数据作为开发准则。联调辅助能力必须显式配置启用,并不得成为默认运行路径。
|
||||||
|
|
||||||
|
## 1. 阶段目标
|
||||||
|
|
||||||
|
当前阶段需要完成模型微调平台的主链路工程基础:
|
||||||
|
|
||||||
|
```text
|
||||||
|
登录
|
||||||
|
-> 模型管理
|
||||||
|
-> 数据集管理
|
||||||
|
-> 创建微调任务
|
||||||
|
-> 调度算力节点与 GPU
|
||||||
|
-> 检查并同步模型/数据集资源
|
||||||
|
-> 启动训练任务
|
||||||
|
-> 轮询任务状态、GPU 占用、训练日志、loss 曲线
|
||||||
|
-> 训练完成后登记训练产物
|
||||||
|
```
|
||||||
|
|
||||||
|
该阶段是正式系统的第一批可运行能力,不再初始化业务样例数据。系统只允许初始化内置管理员/运维账号,模型、数据集、算力节点、GPU、训练任务和资源副本必须通过页面、接口、算力 Agent 扫描或正式导入流程产生。
|
||||||
|
|
||||||
|
## 2. 运行模式
|
||||||
|
|
||||||
|
| 模式 | 说明 | 当前要求 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `real` | 面向真实部署,等待 Compute API、Agent、File Gateway 和 LLaMA-Factory 执行器回写状态 | 默认模式 |
|
||||||
|
| `simulator` | 仅用于隔离联调,无真实 GPU 时临时推进任务状态、GPU 状态和训练日志 | 必须显式开启,不得用于生产基线 |
|
||||||
|
|
||||||
|
后端默认 `COMPUTE_MODE=real`。在该模式下,任务状态不再按时间自动推进,必须由后续真实算力同步逻辑更新。算力服务默认 `COMPUTE_EXECUTION_MODE=real`,真实训练执行器未完成前,创建训练作业会返回明确的未实现错误,避免误认为已经完成生产训练能力。
|
||||||
|
|
||||||
|
训练相关能力必须沉淀在 `compute/engines/` 适配层,不允许在应用平台后端直接拼接或执行训练命令。
|
||||||
|
|
||||||
|
## 3. 当前开发范围
|
||||||
|
|
||||||
|
### 3.1 应用平台后端
|
||||||
|
|
||||||
|
对应目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/app/
|
||||||
|
api/v1/endpoints/platform.py
|
||||||
|
core/
|
||||||
|
db/
|
||||||
|
```
|
||||||
|
|
||||||
|
已建立能力:
|
||||||
|
- 统一 API 响应结构 `{ code, message, data }`。
|
||||||
|
- PostgreSQL 运行表初始化,当前执行脚本位于 `backend/app/db/sql/001_platform_runtime.sql`。
|
||||||
|
- 内置管理员账号初始化,业务数据不再自动写入样例记录。
|
||||||
|
- 登录、当前用户、用户列表与权限页面接口。
|
||||||
|
- 模型管理、训练产物列表、权重合并任务入口。
|
||||||
|
- 数据集管理、文件上传、预览、版本管理和下载。
|
||||||
|
- 微调任务创建、启动、停止、删除、进度查询、checkpoint 查询。
|
||||||
|
- 系统健康指标、系统信息、训练日志、系统日志接口。
|
||||||
|
- 算力节点、GPU、队列、资源副本、资源同步任务接口。
|
||||||
|
|
||||||
|
待继续开发:
|
||||||
|
- 接入正式 ORM/Repository/Service 分层和 Alembic 迁移。
|
||||||
|
- 将任务状态更新改为应用侧定时轮询 Compute API/File Gateway 后落库。
|
||||||
|
- 完成项目/模型/数据集级权限隔离校验。
|
||||||
|
- 完成审批流、审计留存、配额、资源申请和多租户上下文。
|
||||||
|
- 增加正式异常码、接口鉴权、中间件、幂等控制和分页规范。
|
||||||
|
|
||||||
|
### 3.2 算力平台服务
|
||||||
|
|
||||||
|
对应目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
compute/
|
||||||
|
api/main.py
|
||||||
|
agent/
|
||||||
|
engines/llama_factory/
|
||||||
|
file_gateway/
|
||||||
|
```
|
||||||
|
|
||||||
|
已建立能力:
|
||||||
|
- `/modelTF/health` 与 `/modelTF/v1/compute/health` 节点健康检查。
|
||||||
|
- LLaMA-Factory 参数校验、命令生成和训练日志指标解析。
|
||||||
|
- Compute API 作业、GPU、文件网关接口壳。
|
||||||
|
- 显式 `simulator` 模式下的内存状态机,用于隔离联调。
|
||||||
|
|
||||||
|
待继续开发:
|
||||||
|
- 真实 GPU 发现:接入 `nvidia-smi` 或 NVML。
|
||||||
|
- GPU 锁定与释放:一张 GPU 同一时间只分配给一个训练或推理任务。
|
||||||
|
- LLaMA-Factory 真实执行器:生成 YAML/命令、启动进程、停止进程、采集 PID。
|
||||||
|
- 训练日志采集:读取宿主机挂载日志文件,解析 loss、learning rate、epoch 等指标。
|
||||||
|
- Checkpoint/adapter/merged model 扫描与产物登记。
|
||||||
|
- File Gateway:本地磁盘文件上传、下载、校验、导入和跨节点资源同步。
|
||||||
|
|
||||||
|
### 3.3 前端页面
|
||||||
|
|
||||||
|
已接入页面:
|
||||||
|
- `/login`:登录接口。
|
||||||
|
- `/model-manage`:模型列表、模型来源、训练产物。
|
||||||
|
- `/dataset`、`/dataset/:id/preview`:数据集列表、预览、版本。
|
||||||
|
- `/fine-tune`、`/fine-tune/create`:微调任务创建、启动、状态轮询。
|
||||||
|
- `/training-log/:id`:训练日志和 loss 曲线。
|
||||||
|
- `/hardware`:平台 GPU 与系统性能。
|
||||||
|
- `/compute`:算力节点、GPU、队列、资源副本。
|
||||||
|
|
||||||
|
前端 Mock 默认关闭。仅在隔离前端开发时可设置 `VITE_ENABLE_MOCK=true`,真实联调和后续生产演进均以 `/modelTF` 后端接口为准。
|
||||||
|
|
||||||
|
待继续开发:
|
||||||
|
- 补齐多租户、项目管理、审批中心、审计中心、配额管理页面。
|
||||||
|
- 完成算力节点管理表单,包括节点地址、权重、标签、启用状态和健康检查结果。
|
||||||
|
- 完成模型/数据集导入页面,支持本地路径扫描和归属项目选择。
|
||||||
|
- 推理服务页面需接入真实后端任务接口,移除页面内本地假对话路径。
|
||||||
|
|
||||||
|
### 3.4 数据库
|
||||||
|
|
||||||
|
当前运行 SQL:
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/app/db/sql/001_platform_runtime.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
架构目标 SQL:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/postgres-schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
当前运行 SQL 用于支持已开发接口落库;架构目标 SQL 包含用户中心、多租户、项目隔离、审批、审计、配额、评测等完整模型。后续需要通过 Alembic 将二者收敛为统一迁移体系,生产升级只走迁移脚本,不依赖手工改表。
|
||||||
|
|
||||||
|
## 4. 验收标准
|
||||||
|
|
||||||
|
- 启动后端必须连接 PostgreSQL,不允许回退到 SQLite。
|
||||||
|
- 后端启动只初始化系统内置账号,不初始化模型、数据集、算力节点、GPU、训练任务等业务样例数据。
|
||||||
|
- 前端默认请求真实 `/modelTF` 接口,除非显式设置 `VITE_ENABLE_MOCK=true`。
|
||||||
|
- 默认 `real` 模式下任务状态不自动伪造完成,必须等待真实算力同步。
|
||||||
|
- 显式 `simulator` 模式只能用于隔离联调,部署文档必须标注不得用于生产。
|
||||||
|
- 登录后可以进入主界面,并可通过页面/API 创建真实业务记录。
|
||||||
|
- 代码、接口路由、配置项、数据库表名不得使用 `demo` 命名。
|
||||||
|
|
||||||
|
## 5. 后续开发计划
|
||||||
|
|
||||||
|
| 阶段 | 重点 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 阶段 1 | 数据库迁移体系 | 将当前运行 SQL 与架构 SQL 收敛到 Alembic 迁移 |
|
||||||
|
| 阶段 2 | 后端领域分层 | 拆分用户、模型、数据集、训练、算力、审计等模块 |
|
||||||
|
| 阶段 3 | 真实 Compute Agent | GPU 发现、资源锁定、进程管理、日志采集 |
|
||||||
|
| 阶段 4 | LLaMA-Factory 训练执行 | YAML/命令生成、进程启动/停止、checkpoint 和 adapter 扫描 |
|
||||||
|
| 阶段 5 | 企业治理 | 多租户、项目隔离、审批流、审计留存、配额和资源申请 |
|
||||||
|
| 阶段 6 | 多算力节点调度 | 基于 `compute_nodes`、标签、权重、资源副本和节点健康实现调度策略 |
|
||||||
98
docs/menu-functional-requirements.md
Normal file
98
docs/menu-functional-requirements.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# 菜单与功能需求总览
|
||||||
|
|
||||||
|
> 本文根据当前前端侧边栏、路由、需求文档、接口文档、部署文档和 SQL 脚本整理。当前代码和 SQL 均按正式系统开发基线维护;Mock、Simulator 只能作为显式联调能力,不作为默认开发准则。
|
||||||
|
|
||||||
|
## 1. 菜单分层
|
||||||
|
|
||||||
|
### 1.1 当前侧边栏菜单
|
||||||
|
|
||||||
|
| 一级分组 | 菜单 | 路由 | 权限码 | 当前状态 | 主要功能 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 服务看板 | 服务看板 | `/dashboard` | `dashboard` | 已有页面,接口需继续完善 | 总览指标、服务状态、训练统计、最近任务、健康入口 |
|
||||||
|
| 模型服务 | 模型训练 | `/fine-tune` | `fine-tune` | 已接入主链路 | 训练任务列表、创建训练、启动/停止、进度、训练日志、checkpoint |
|
||||||
|
| 模型服务 | 模型评测 | `/model-eval` | `model-eval` | 前端页面已有,后端待完整实现 | 评测任务、评测维度、样本评分、综合结果 |
|
||||||
|
| 模型服务 | 模型推理 | `/model-inference` | `model-inference` | 前端页面已有,后端待完整实现 | 推理任务、模型加载、单模型对话、模型对比入口 |
|
||||||
|
| 模型服务 | 模型管理 | `/model-manage` | `model-manage` | 已接入主链路 | 基座模型登记、本地/API 模型、训练产物、权重合并、模型导出 |
|
||||||
|
| 数据治理 | 数据集管理 | `/dataset` | `dataset` | 已接入主链路 | 数据集列表、上传、预览、在线编辑、版本、下载、删除审批入口 |
|
||||||
|
| 数据治理 | 数据处理 | `/data-process` | `data-process` | 前端页面已有,后端待完整实现 | 文档上传、切片预览、LLM 生成、结果编辑、发布数据集 |
|
||||||
|
| 其他工具 | 数据类型转换 | `/data-convert` | `data-convert` | 前端页面已有,后端待实现 | JSON/JSONL/Markdown 等格式转换任务 |
|
||||||
|
| 算力资源 | 算力节点 | `/compute` | `compute` | 已接入节点管理接口 | 节点地址、权重、标签、启用状态、GPU、队列、资源副本 |
|
||||||
|
| 系统设置 | 用户设置 | `/user-settings` | `user-settings` | 已接入基础用户接口 | 用户列表、创建用户、启停、页面权限 |
|
||||||
|
| 系统设置 | 平台性能 | `/hardware` | `hardware` | 已有接口,需接真实采集 | CPU、内存、磁盘、GPU、进程、网络监控 |
|
||||||
|
| 系统设置 | 查看日志 | `/logs` | `logs` | 已有接口,需接真实日志文件 | 后端日志、error 日志、训练日志索引、日志内容查看 |
|
||||||
|
|
||||||
|
### 1.2 当前二级和隐藏路由
|
||||||
|
|
||||||
|
| 页面 | 路由 | 归属菜单 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 登录 | `/login` | 独立入口 | 登录后进入主界面 |
|
||||||
|
| 使用文档 | `/guide` | 独立入口 | 当前系统使用说明 |
|
||||||
|
| 创建训练任务 | `/fine-tune/create` | 模型训练 | 训练参数、模型/数据集/GPU 选择 |
|
||||||
|
| 训练日志 | `/training-log/:id` | 模型训练 | 日志、指标、checkpoint、任务概览 |
|
||||||
|
| 新建评测 | `/model-eval/create` | 模型评测 | 模型、数据集、维度、GPU 选择 |
|
||||||
|
| 评测详情 | `/model-eval/:id` | 模型评测 | 维度汇总、样本结果、人工复核预留 |
|
||||||
|
| 评测维度创建/编辑 | `/model-eval/dimension/create`、`/model-eval/dimension/:id/edit` | 模型评测 | 评测规则、Prompt、评分器配置 |
|
||||||
|
| 新建推理 | `/model-inference/create` | 模型推理 | 推理任务和模型加载配置 |
|
||||||
|
| 模型对话 | `/model-inference/chat/:id` | 模型推理 | 单模型对话 |
|
||||||
|
| 模型对比 | `/model-compare/chat/:id`、`/model-compare/result` | 模型推理 | 多模型对比和结果页 |
|
||||||
|
| 添加/编辑模型 | `/model-manage/create`、`/model-manage/:id/edit` | 模型管理 | 模型登记、用途、来源、路径/API 配置 |
|
||||||
|
| 合并权重 | `/model-manage/merge` | 模型管理 | LoRA/Adapter 合并任务 |
|
||||||
|
| 数据处理创建/详情 | `/data-process/create`、`/data-process/:id` | 数据处理 | 数据处理向导和任务详情 |
|
||||||
|
| 数据集创建/编辑/预览 | `/dataset/create`、`/dataset/:id/edit`、`/dataset/:id/preview` | 数据集管理 | 数据集元数据、文件、版本与内容 |
|
||||||
|
| 自定义工具 | `/tools`、`/tools/create`、`/tools/:id/edit` | 规划入口 | 路由存在,当前侧边栏未展示,后续可归入“其他工具” |
|
||||||
|
| 算力子页 | `/compute/gpus`、`/compute/queue`、`/compute/nodes` | 算力节点 | 当前可作为页签或深链 |
|
||||||
|
| 创建用户/权限设置 | `/user-settings/create`、`/user-settings/:id/permission` | 用户设置 | 用户创建和页面权限 |
|
||||||
|
| 无权限页 | `/permission-denied` | 系统页 | 路由守卫无权限跳转 |
|
||||||
|
|
||||||
|
### 1.3 企业治理待补菜单
|
||||||
|
|
||||||
|
| 建议菜单分组 | 菜单 | 建议路由 | 优先级 | 必要性 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 组织与项目 | 租户管理 | `/tenants`、`/tenants/:id` | P0 | 多租户隔离、配额、留存策略入口 |
|
||||||
|
| 组织与项目 | 项目空间 | `/projects`、`/projects/:id`、`/projects/:id/members` | P0 | 项目级模型/数据集/任务隔离 |
|
||||||
|
| 组织与项目 | 资源授权 | `/projects/:id/permissions` 或资源详情弹窗 | P0 | 模型/数据集/任务级 ACL |
|
||||||
|
| 治理中心 | 审批中心 | `/approvals`、`/approvals/:id` | P0 | 删除、发布、导出、停止他人任务等高风险动作 |
|
||||||
|
| 治理中心 | 审批设置 | `/approval-settings` | P1 | 审批模板、审批人规则、超时策略 |
|
||||||
|
| 治理中心 | 审计中心 | `/audit-logs`、`/login-logs`、`/download-logs` | P1 | 操作审计、登录审计、下载审计、导出 |
|
||||||
|
| 运维中心 | 存储管理 | `/storage` | P1 | 本地磁盘占用、临时文件、checkpoint 清理、留存 |
|
||||||
|
| 运维中心 | 训练引擎管理 | `/training-engines` | P2 | LLaMA-Factory 和后续引擎能力 schema、健康检查 |
|
||||||
|
| 模型服务 | 模型服务治理 | `/model-services`、`/model-services/:id` | P1 | 测试/生产服务发布、调用统计、下线审批 |
|
||||||
|
|
||||||
|
## 2. 菜单对应接口和数据库
|
||||||
|
|
||||||
|
| 菜单/模块 | 主要接口 | 当前运行 SQL | 目标 SQL |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 登录、用户设置 | `/modelTF/login`、`/modelTF/me`、`/modelTF/users` | `users` | `users`、`login_sessions`、`permissions`、`role_permissions`、`user_permission_overrides` |
|
||||||
|
| 服务看板 | `/modelTF/dashboard/overview`、`/modelTF/health` | 复用模型/数据集/任务/算力表 | `system_metric_snapshots`、`web_logs`、各业务表聚合 |
|
||||||
|
| 模型管理 | `/modelTF/model-manage`、`/modelTF/model-manage/trained-models`、`/modelTF/model-manage/merge` | `models`、`trained_models` | `models`、`trained_models`、`storage_objects`、`local_import_jobs`、`resource_acl` |
|
||||||
|
| 数据集管理 | `/modelTF/dataset-manage`、`/modelTF/dataset-manage/upload/{id}`、`/preview`、`/versions` | `datasets`、`dataset_files` | `datasets`、`dataset_files`、`dataset_file_versions`、`dataset_records`、`storage_objects` |
|
||||||
|
| 模型训练 | `/modelTF/fine-tune`、`/start`、`/progress`、`/checkpoints` | `fine_tune_tasks`、`trained_models` | `fine_tune_tasks`、`fine_tune_metrics`、`fine_tune_checkpoints`、`compute_jobs`、`gpu_allocations` |
|
||||||
|
| 训练日志 | `/modelTF/training-log-files`、`/modelTF/training-log-content` | 由任务表生成索引 | 日志文件元数据、`fine_tune_metrics`、`audit_logs` |
|
||||||
|
| 算力节点 | `/modelTF/compute/nodes`、`/compute/gpus`、`/compute/queue`、`/compute/nodes/{id}/replicas` | `compute_nodes`、`gpus`、`resource_replicas`、`resource_sync_jobs` | `compute_nodes`、`gpu_devices`、`compute_node_engines`、`compute_jobs`、`resource_replicas`、`resource_sync_jobs` |
|
||||||
|
| 平台性能 | `/modelTF/system-info`、`/modelTF/compute/gpus` | `gpus`、任务表 | `system_metric_snapshots`、`gpu_devices`、`compute_jobs` |
|
||||||
|
| 查看日志 | `/modelTF/log-files`、`/modelTF/log-content`、`/modelTF/web-log` | 文件日志 | `web_logs`、`audit_logs`,大日志进入日志平台 |
|
||||||
|
| 模型评测 | `/modelTF/model-eval`、`/modelTF/dimension` | 当前运行 SQL 未覆盖 | `eval_tasks`、`eval_dimensions`、`eval_sample_results`、`eval_dimension_summaries` |
|
||||||
|
| 模型推理/对比 | `/modelTF/model-compare`、`/modelTF/model-chat/*` | 当前运行 SQL 未覆盖 | `inference_tasks`、`inference_task_models`、`chat_sessions`、`chat_messages` |
|
||||||
|
| 数据处理 | `/modelTF/data-process/*` | 当前运行 SQL 未覆盖 | `data_process_tasks`、`data_process_source_files`、`data_process_preview_items`、`data_process_results` |
|
||||||
|
| 数据转换/自定义工具 | `/modelTF/data-convert/jobs`、`/modelTF/tools` | 当前运行 SQL 未覆盖 | `data_convert_jobs`、`custom_tools` |
|
||||||
|
| 租户/项目/资源授权 | `/modelTF/tenants`、`/modelTF/projects`、`/modelTF/resources/{type}/{id}/acl` | 当前运行 SQL 未覆盖 | `tenants`、`tenant_users`、`projects`、`project_members`、`resource_acl` |
|
||||||
|
| 审批/审计/留存/配额 | `/modelTF/approvals`、`/modelTF/audit-logs`、`/modelTF/retention-policies`、`/modelTF/quotas/usage` | 当前运行 SQL 未覆盖 | `approval_templates`、`approval_instances`、`approval_steps`、`audit_logs`、`retention_policies`、`quotas`、`quota_usage` |
|
||||||
|
|
||||||
|
## 3. 文档和脚本检查结论
|
||||||
|
|
||||||
|
| 对象 | 当前结论 | 本次补充 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 需求文档 | `docs/platform-architecture-requirements.md` 和 `docs/system-development-plan.md` 已覆盖多租户、项目隔离、审批、审计、多算力节点、应用/算力分离部署;缺少一份按当前菜单组织的总览 | 新增本文作为菜单和功能需求总览 |
|
||||||
|
| 接口文档 | `docs/backend-api-design.md` 已统一 `/modelTF`,并已有页面/接口映射;需要明确引用菜单总览,避免开发只看接口不看页面入口 | 在接口文档增加菜单总览引用 |
|
||||||
|
| 开发计划 | `docs/system-development-plan.md` 已按工作包列出页面、接口和 DB;需要把本文作为任务认领入口 | 在开发计划增加菜单总览引用 |
|
||||||
|
| 部署文档 | `docs/deployment-plan.md`、`docker/README.md` 已覆盖应用/算力分离、单机多 GPU、本地磁盘、真实模式默认、Docker 拆分 | 暂无新增部署配置要求 |
|
||||||
|
| 目标 SQL | `docs/postgres-schema.sql` 覆盖完整目标模型,包含用户、权限、多租户、项目、审批、审计、模型、数据集、训练、评测、推理、算力、存储、导入、服务治理 | 暂不需要新增目标表 |
|
||||||
|
| 当前运行 SQL | `backend/app/db/sql/001_platform_runtime.sql` 只覆盖已接入运行接口的最小表集 | 后续每实现一个 P0/P1 菜单模块,应同步补运行 SQL 或迁移脚本;不能再以样例数据补功能 |
|
||||||
|
|
||||||
|
## 4. 后续补充原则
|
||||||
|
|
||||||
|
- 新增侧边栏菜单时,必须同步补齐:路由、权限码、接口文档、DB 表/迁移、审计动作、部署依赖。
|
||||||
|
- 新增后端接口时,必须在 `docs/backend-api-design.md` 标注对应页面/功能模块。
|
||||||
|
- 新增表结构时,目标模型写入 `docs/postgres-schema.sql`,当前可执行落库写入 `backend/app/db/sql/` 或 Alembic 迁移。
|
||||||
|
- 与训练、评测、推理、数据处理相关的异步任务必须落库,不能依赖前端本地状态。
|
||||||
|
- 与算力相关的功能默认走真实模式;Simulator 只能显式开启用于隔离联调。
|
||||||
772
docs/platform-architecture-requirements.md
Normal file
772
docs/platform-architecture-requirements.md
Normal file
@@ -0,0 +1,772 @@
|
|||||||
|
# 模型训练平台架构与功能需求设计
|
||||||
|
|
||||||
|
> 本文基于当前前端页面、已有接口/数据库设计,以及用户补充的 6 条约束进行补全。目标是把平台从“单前端原型 + 后端接口草案”扩展为企业可落地的模型训练平台方案。
|
||||||
|
|
||||||
|
## 1. 补充需求结论
|
||||||
|
|
||||||
|
### 1.1 已确认约束
|
||||||
|
|
||||||
|
1. 部署形态:单机多 GPU。
|
||||||
|
2. 文件存储:本地磁盘。
|
||||||
|
3. 训练框架:当前固定 LLaMA-Factory,但要预留其他训练平台接入标准。
|
||||||
|
4. 权限粒度:需要到项目、模型、数据集级隔离。
|
||||||
|
5. 企业治理:需要多租户、审批流、审计留存周期。
|
||||||
|
6. 部署边界:算力平台与应用平台分开部署。
|
||||||
|
|
||||||
|
### 1.2 对整体设计的影响
|
||||||
|
|
||||||
|
- 不能只做页面级 RBAC,需要引入“租户 -> 项目 -> 资源 -> 成员/角色/ACL”的资源权限模型。
|
||||||
|
- 单机多 GPU 不等于简单指定 GPU ID,需要有 GPU 资源池、锁定、排队、抢占策略和异常释放机制。
|
||||||
|
- 本地磁盘存储与应用/算力分离存在天然冲突:文件不能只存应用服务器本地,也不能让应用直接读算力服务器目录。建议将训练文件、模型文件、日志文件统一落在算力节点本地磁盘,由算力平台提供文件网关 API;应用平台只保存元数据和访问路径。
|
||||||
|
- LLaMA-Factory 应作为第一个训练引擎插件,而不是写死到业务流程里。后续接入其他平台时只需实现同一套训练引擎协议。
|
||||||
|
- 审批流、审计和保留策略必须从第一版进入数据模型和接口,否则后期补会牵动大量资源表。
|
||||||
|
|
||||||
|
## 2. 总体架构设计
|
||||||
|
|
||||||
|
### 2.1 逻辑分层
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
U["用户浏览器"] --> APP["应用平台 Web / FastAPI"]
|
||||||
|
APP --> DB["PostgreSQL"]
|
||||||
|
APP --> REDIS["Redis / 任务队列"]
|
||||||
|
APP --> CPAPI["算力平台 API"]
|
||||||
|
CPAPI --> AGENT["算力节点 Agent"]
|
||||||
|
AGENT --> GPU["单机多 GPU"]
|
||||||
|
AGENT --> DISK["本地磁盘工作区"]
|
||||||
|
AGENT --> LF["LLaMA-Factory 引擎"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 应用平台职责
|
||||||
|
|
||||||
|
应用平台负责业务编排和企业治理,不直接执行训练命令:
|
||||||
|
|
||||||
|
- 用户、租户、项目、权限、审批、审计。
|
||||||
|
- 模型、数据集、任务、评测、推理任务元数据。
|
||||||
|
- 前端 API、OpenAPI、统一认证、统一响应。
|
||||||
|
- 训练/评测/数据处理任务创建、状态查询、审批校验。
|
||||||
|
- 与算力平台通信,下发任务、查询进度、拉取日志、下载产物。
|
||||||
|
|
||||||
|
建议部署组件:
|
||||||
|
|
||||||
|
- Nginx:静态前端与反向代理。
|
||||||
|
- FastAPI:业务 API。
|
||||||
|
- PostgreSQL:业务元数据。
|
||||||
|
- Redis:缓存、任务队列、SSE 状态缓存、分布式锁。
|
||||||
|
- Worker:审批通知、审计归档、周期清理、异步导出。
|
||||||
|
|
||||||
|
### 2.3 算力平台职责
|
||||||
|
|
||||||
|
算力平台负责“实际占用 GPU 和磁盘”的事情:
|
||||||
|
|
||||||
|
- GPU 发现、资源上报、锁定、释放。
|
||||||
|
- 本地磁盘工作区管理。
|
||||||
|
- 数据集文件接收、校验、解压、版本目录管理。
|
||||||
|
- LLaMA-Factory 命令生成、执行、停止、日志采集。
|
||||||
|
- 训练产物、LoRA adapter、merged model、checkpoint 管理。
|
||||||
|
- 推理服务进程管理、端口分配、健康检查。
|
||||||
|
- 系统/GPU 监控指标采集。
|
||||||
|
|
||||||
|
建议部署组件:
|
||||||
|
|
||||||
|
- Compute API:只暴露给应用平台访问。
|
||||||
|
- Compute Agent:本机服务,具备启动/停止进程权限。
|
||||||
|
- 本地文件网关:上传、下载、预览、断点续传、文件校验。
|
||||||
|
- Engine Adapter:LLaMA-Factory 适配器,后续扩展其他训练引擎。
|
||||||
|
|
||||||
|
### 2.4 应用与算力平台通信
|
||||||
|
|
||||||
|
建议统一使用内部 HTTP/gRPC API,并配置服务间认证:
|
||||||
|
|
||||||
|
- 应用平台调用算力平台必须携带 `X-Service-Token` 或 mTLS 证书。
|
||||||
|
- 算力平台不信任前端用户身份,只信任应用平台下发的租户、项目、任务和资源上下文。
|
||||||
|
- 第一阶段不默认启用算力侧回调,应用平台通过定时轮询 Compute API 同步任务状态;如未来启用回调,必须带签名,避免伪造状态。
|
||||||
|
|
||||||
|
核心通信接口:
|
||||||
|
|
||||||
|
| 方向 | 接口 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 应用 -> 算力 | `POST /modelTF/compute/jobs` | 创建训练/评测/数据处理/推理任务 |
|
||||||
|
| 应用 -> 算力 | `POST /modelTF/compute/jobs/{id}/stop` | 停止任务 |
|
||||||
|
| 应用 -> 算力 | `GET /modelTF/compute/jobs/{id}` | 查询任务状态 |
|
||||||
|
| 应用 -> 算力 | `GET /modelTF/compute/jobs/{id}/logs` | 拉取日志 |
|
||||||
|
| 应用 -> 算力 | `GET /modelTF/compute/resources/gpus` | 查询 GPU 状态 |
|
||||||
|
| 应用 -> 算力 | `POST /modelTF/compute/files/upload` | 上传文件到算力本地磁盘 |
|
||||||
|
| 应用 -> 算力 | `GET /modelTF/compute/files/{object_id}/download` | 下载文件 |
|
||||||
|
| 应用 -> 算力 | `GET /modelTF/compute/jobs?status=running` | 定时轮询任务状态、指标、产物索引 |
|
||||||
|
|
||||||
|
## 3. 本地磁盘存储设计
|
||||||
|
|
||||||
|
### 3.1 存储根目录
|
||||||
|
|
||||||
|
由于算力和应用分离,建议文件主存储放在算力节点本地磁盘:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/data/ft-platform/
|
||||||
|
tenants/{tenant_id}/
|
||||||
|
projects/{project_id}/
|
||||||
|
datasets/{dataset_id}/
|
||||||
|
models/base/{model_id}/
|
||||||
|
models/trained/{trained_model_id}/
|
||||||
|
jobs/{job_id}/
|
||||||
|
input/
|
||||||
|
output/
|
||||||
|
logs/
|
||||||
|
checkpoints/
|
||||||
|
tmp/
|
||||||
|
```
|
||||||
|
|
||||||
|
应用平台数据库保存:
|
||||||
|
|
||||||
|
- `storage_type=local`。
|
||||||
|
- `storage_node_id`。
|
||||||
|
- `relative_path`。
|
||||||
|
- `checksum_sha256`。
|
||||||
|
- `byte_size`。
|
||||||
|
- `tenant_id/project_id/resource_id`。
|
||||||
|
|
||||||
|
### 3.2 文件访问原则
|
||||||
|
|
||||||
|
- 前端不直接访问磁盘路径。
|
||||||
|
- 应用平台生成短时下载凭证,转发或重定向到算力文件网关。
|
||||||
|
- 预览内容只读取前 N 行或指定区间,避免大文件撑爆 API。
|
||||||
|
- 大文件上传必须支持分片上传、校验、断点续传。
|
||||||
|
- 删除操作采用软删除 + 延迟清理,等待审计留存和审批结果。
|
||||||
|
|
||||||
|
### 3.3 磁盘配额
|
||||||
|
|
||||||
|
配额应分三层:
|
||||||
|
|
||||||
|
- 租户配额:总容量、模型容量、数据容量、日志容量。
|
||||||
|
- 项目配额:可用容量、最大文件大小、最大任务产物保留数。
|
||||||
|
- 用户配额:可上传文件总量、并发任务产物占用。
|
||||||
|
|
||||||
|
超过配额时:
|
||||||
|
|
||||||
|
- 禁止新建任务或上传文件。
|
||||||
|
- 允许下载和清理。
|
||||||
|
- 提示可清理的 checkpoint、过期日志、失败任务临时目录。
|
||||||
|
|
||||||
|
## 4. 单机多 GPU 资源调度
|
||||||
|
|
||||||
|
第一版可以先以单个算力节点跑通主链路,但数据模型、接口和页面需要按多算力节点预留。多节点阶段仍然采用“每台 GPU 服务器 = 一个单机多 GPU 节点”的模式,不引入 Kubernetes。
|
||||||
|
|
||||||
|
### 4.1 GPU 资源模型
|
||||||
|
|
||||||
|
每张 GPU 需要记录:
|
||||||
|
|
||||||
|
- `gpu_index`、`uuid`、型号、显存、驱动版本。
|
||||||
|
- 当前利用率、显存占用、温度、功耗。
|
||||||
|
- 当前锁定任务、进程 PID、端口。
|
||||||
|
- 状态:`idle`、`reserved`、`running`、`draining`、`offline`、`error`。
|
||||||
|
|
||||||
|
### 4.2 调度策略
|
||||||
|
|
||||||
|
第一版建议支持三种模式:
|
||||||
|
|
||||||
|
- 手动指定 GPU:兼容当前前端选择 GPU 的模式。
|
||||||
|
- 自动选择 GPU:按空闲显存、温度、任务队列选择。
|
||||||
|
- 项目配额调度:项目最多可占用 N 张 GPU,避免单项目占满机器。
|
||||||
|
|
||||||
|
训练任务启动流程:
|
||||||
|
|
||||||
|
1. 应用平台校验权限、配额、审批状态。
|
||||||
|
2. 生成任务,状态为 `pending`。
|
||||||
|
3. 算力平台尝试锁定 GPU。
|
||||||
|
4. 锁定成功后创建工作区并启动 LLaMA-Factory。
|
||||||
|
5. Agent 持续上报进度、日志、指标。
|
||||||
|
6. 任务完成后释放 GPU,登记模型产物。
|
||||||
|
|
||||||
|
### 4.3 并发与排队
|
||||||
|
|
||||||
|
- 同一 GPU 同时只允许一个训练任务。
|
||||||
|
- 推理服务可与训练互斥,默认不允许混跑;如后续允许,需要显存保留策略。
|
||||||
|
- 数据处理如果只调用 API 模型,可以不占 GPU;如果调用本地模型生成,需要占用 GPU。
|
||||||
|
- 支持任务队列优先级:`low`、`normal`、`high`、`urgent`。
|
||||||
|
- 高优任务是否可抢占低优任务,需要审批或管理员权限。
|
||||||
|
|
||||||
|
### 4.4 多算力节点升级策略
|
||||||
|
|
||||||
|
多算力节点阶段的推荐决策:
|
||||||
|
|
||||||
|
- 每个可执行训练任务的 GPU 节点都部署 `Compute API`、`Compute Agent`、`File Gateway`、LLaMA-Factory、CUDA/PyTorch 训练环境和本地数据盘。
|
||||||
|
- 应用服务器可以主动访问所有算力节点的 `Compute API/File Gateway`。
|
||||||
|
- 算力节点之间默认不互相访问,不做节点间点对点同步;所有调度、状态同步和资源分发由应用平台统一编排。
|
||||||
|
- 长期坚持每台算力服务器本地磁盘,因此需要 `resource_replicas` 记录数据集、基座模型、checkpoint、adapter、导出模型在哪些节点已有本地副本。
|
||||||
|
- 调度前必须检查目标节点是否已有模型和数据集副本;缺失时由应用平台通过目标节点 File Gateway 创建同步任务,完成后再启动训练。
|
||||||
|
- 调度支持自动和手动两种模式:普通用户默认自动调度,管理员/高级用户可手动指定节点、GPU、标签或节点组。
|
||||||
|
|
||||||
|
多节点自动调度建议:
|
||||||
|
|
||||||
|
1. 过滤 `enabled = true` 且 `scheduler_status = online` 的节点。
|
||||||
|
2. 按训练引擎、GPU 型号、显存、节点标签、租户/项目配额过滤。
|
||||||
|
3. 优先选择已存在所需模型/数据集副本的节点,减少跨节点复制。
|
||||||
|
4. 同等条件下按空闲 GPU、队列长度、节点权重和最近健康检查排序。
|
||||||
|
5. `draining` 节点不接收新任务,但允许已有任务完成。
|
||||||
|
|
||||||
|
## 5. 训练引擎接入标准
|
||||||
|
|
||||||
|
### 5.1 引擎抽象
|
||||||
|
|
||||||
|
LLaMA-Factory 是第一实现,但业务系统只依赖统一训练引擎接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
TrainingEngine
|
||||||
|
validate_config(config)
|
||||||
|
prepare_workspace(job_context)
|
||||||
|
build_command(job_context)
|
||||||
|
start(job_context)
|
||||||
|
stop(job_id)
|
||||||
|
parse_progress(log_line)
|
||||||
|
collect_artifacts(job_id)
|
||||||
|
export_model(job_id, export_config)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 引擎注册信息
|
||||||
|
|
||||||
|
每个训练引擎需要声明:
|
||||||
|
|
||||||
|
- 引擎编码:`llama_factory`。
|
||||||
|
- 支持任务:`SFT`、`DPO`、`CPT`。
|
||||||
|
- 支持方法:`lora`、`qlora`、`full`。
|
||||||
|
- 支持模型模板:`qwen`、`llama3` 等。
|
||||||
|
- 支持数据格式:Alpaca、ShareGPT、DPO pair、pretrain text。
|
||||||
|
- 支持量化和导出格式。
|
||||||
|
- 参数 schema。
|
||||||
|
- 命令模板或启动方式。
|
||||||
|
|
||||||
|
### 5.3 LLaMA-Factory 适配要求
|
||||||
|
|
||||||
|
LLaMA-Factory 适配器负责:
|
||||||
|
|
||||||
|
- 将平台训练参数转换为 YAML/CLI 参数。
|
||||||
|
- 根据项目工作区生成数据集配置。
|
||||||
|
- 自动设置 `CUDA_VISIBLE_DEVICES`。
|
||||||
|
- 解析训练日志中的 loss、epoch、learning_rate、ETA。
|
||||||
|
- 收集 checkpoint、adapter、merged model、training_args、trainer_state。
|
||||||
|
- 支持训练停止和失败恢复。
|
||||||
|
|
||||||
|
### 5.4 后续接入其他平台的标准
|
||||||
|
|
||||||
|
其他训练平台只要实现以下契约即可接入:
|
||||||
|
|
||||||
|
- 输入:模型引用、数据集引用、训练配置、资源需求、输出目录。
|
||||||
|
- 输出:任务状态、进度、日志、指标、产物清单、失败原因。
|
||||||
|
- 生命周期:`prepare`、`start`、`running`、`stop`、`complete`、`cleanup`。
|
||||||
|
- 安全:不能越权访问其他租户/项目目录。
|
||||||
|
- 可观测:必须输出结构化事件和日志。
|
||||||
|
|
||||||
|
## 6. 多租户与项目级隔离
|
||||||
|
|
||||||
|
### 6.1 租户模型
|
||||||
|
|
||||||
|
需要新增租户管理:
|
||||||
|
|
||||||
|
- 租户名称、编码、状态。
|
||||||
|
- 租户管理员。
|
||||||
|
- 租户资源配额:GPU 并发数、磁盘容量、最大项目数。
|
||||||
|
- 租户审计策略、保留周期、审批策略。
|
||||||
|
|
||||||
|
### 6.2 项目空间
|
||||||
|
|
||||||
|
所有业务资源必须归属项目:
|
||||||
|
|
||||||
|
- 数据集。
|
||||||
|
- 模型。
|
||||||
|
- 训练任务。
|
||||||
|
- 评测任务。
|
||||||
|
- 推理任务。
|
||||||
|
- 数据处理任务。
|
||||||
|
- 自定义工具。
|
||||||
|
|
||||||
|
项目字段:
|
||||||
|
|
||||||
|
- 项目名称、描述、所属租户。
|
||||||
|
- 项目管理员、成员。
|
||||||
|
- 默认资源权限。
|
||||||
|
- GPU/磁盘/任务并发配额。
|
||||||
|
- 项目状态:启用、归档、禁用。
|
||||||
|
|
||||||
|
### 6.3 资源级权限
|
||||||
|
|
||||||
|
建议采用 RBAC + ACL 混合:
|
||||||
|
|
||||||
|
- RBAC 决定用户是否能访问模块,例如能否进入模型管理。
|
||||||
|
- 项目角色决定用户是否能管理项目内资源。
|
||||||
|
- 资源 ACL 处理特殊授权,例如某个数据集只给指定成员可读。
|
||||||
|
|
||||||
|
项目角色建议:
|
||||||
|
|
||||||
|
| 角色 | 权限 |
|
||||||
|
| --- | --- |
|
||||||
|
| Project Owner | 项目设置、成员、资源、审批策略全权限 |
|
||||||
|
| Project Maintainer | 创建/编辑模型、数据集、任务,可发起发布和删除 |
|
||||||
|
| Developer | 创建训练、评测、推理、数据处理任务 |
|
||||||
|
| Reviewer | 审批、复核、查看评测结果 |
|
||||||
|
| Viewer | 只读查看 |
|
||||||
|
|
||||||
|
资源权限建议:
|
||||||
|
|
||||||
|
- `read`:查看资源。
|
||||||
|
- `write`:编辑元数据和内容。
|
||||||
|
- `execute`:用于训练/评测/推理。
|
||||||
|
- `download`:下载文件和模型。
|
||||||
|
- `delete`:删除或申请删除。
|
||||||
|
- `manage_acl`:管理资源授权。
|
||||||
|
|
||||||
|
### 6.4 数据隔离要求
|
||||||
|
|
||||||
|
- API 查询必须默认带 `tenant_id` 和用户可访问项目范围。
|
||||||
|
- 数据库所有核心资源表增加 `tenant_id`、`project_id`。
|
||||||
|
- 本地磁盘路径包含租户和项目 ID,防止路径混用。
|
||||||
|
- 算力任务上下文必须携带租户/项目,Agent 只允许访问对应工作区。
|
||||||
|
- 日志、审计、下载链接也必须按租户隔离。
|
||||||
|
|
||||||
|
## 7. 企业治理设计
|
||||||
|
|
||||||
|
### 7.1 审批流
|
||||||
|
|
||||||
|
建议第一版支持可配置审批模板:
|
||||||
|
|
||||||
|
| 场景 | 是否建议审批 | 原因 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 删除数据集 | 是 | 数据不可逆风险高 |
|
||||||
|
| 删除模型 | 是 | 影响训练/推理依赖 |
|
||||||
|
| 模型发布为可推理服务 | 是 | 影响生产资源 |
|
||||||
|
| 停止他人训练任务 | 是或管理员直通 | 影响计算成本和他人工作 |
|
||||||
|
| 导出模型 | 可配置 | 涉及资产外流 |
|
||||||
|
| 下载敏感数据集 | 可配置 | 涉及数据安全 |
|
||||||
|
| 提高 GPU 配额 | 是 | 涉及资源竞争 |
|
||||||
|
|
||||||
|
审批流能力:
|
||||||
|
|
||||||
|
- 发起审批。
|
||||||
|
- 指定审批人/审批组。
|
||||||
|
- 多级审批。
|
||||||
|
- 通过、驳回、撤回、转交。
|
||||||
|
- 审批超时提醒。
|
||||||
|
- 审批结果回写原业务动作。
|
||||||
|
|
||||||
|
### 7.2 审计留存周期
|
||||||
|
|
||||||
|
建议支持租户级配置:
|
||||||
|
|
||||||
|
- 操作审计:默认 180 天,可配置 90/180/365/永久。
|
||||||
|
- 登录审计:默认 180 天。
|
||||||
|
- 训练日志:默认 90 天。
|
||||||
|
- 系统监控:原始采样默认 30 天,聚合指标保留 1 年。
|
||||||
|
- 模型产物:默认长期保留,删除需审批。
|
||||||
|
- 临时文件:默认 7 天清理。
|
||||||
|
- 失败任务工作区:默认 14 天清理。
|
||||||
|
|
||||||
|
审计不可被普通管理员物理删除,只能由系统归档任务按策略处理。
|
||||||
|
|
||||||
|
### 7.3 安全策略
|
||||||
|
|
||||||
|
需要补充:
|
||||||
|
|
||||||
|
- API Key 加密存储和脱敏展示。
|
||||||
|
- 外部数据源密码加密存储或不落库。
|
||||||
|
- 下载链接短时有效。
|
||||||
|
- 敏感操作二次确认。
|
||||||
|
- 审批通过后动作有效期,例如 24 小时内执行。
|
||||||
|
- IP 白名单和服务间 token。
|
||||||
|
- 操作审计记录 before/after 数据。
|
||||||
|
|
||||||
|
## 8. 需要补全的页面和功能
|
||||||
|
|
||||||
|
### 8.1 租户与项目页面
|
||||||
|
|
||||||
|
当前前端缺失,建议新增:
|
||||||
|
|
||||||
|
1. 租户管理页
|
||||||
|
- 租户列表、创建、禁用、配额设置、审计策略。
|
||||||
|
- 仅平台管理员可见。
|
||||||
|
|
||||||
|
2. 项目空间页
|
||||||
|
- 项目列表、创建项目、归档项目。
|
||||||
|
- 展示项目资源概览:模型数、数据集数、任务数、磁盘占用、GPU 使用。
|
||||||
|
|
||||||
|
3. 项目成员页
|
||||||
|
- 添加/移除成员。
|
||||||
|
- 设置项目角色。
|
||||||
|
- 查看成员最近操作。
|
||||||
|
|
||||||
|
4. 项目资源权限页
|
||||||
|
- 模型/数据集/任务级授权。
|
||||||
|
- 支持按用户、用户组、项目角色授权。
|
||||||
|
|
||||||
|
### 8.2 用户中心补全
|
||||||
|
|
||||||
|
前端路由已有但页面文件缺失或未完成:
|
||||||
|
|
||||||
|
- `PermissionDeniedView.vue`
|
||||||
|
- `UserSettingsView.vue`
|
||||||
|
- `UserCreateView.vue`
|
||||||
|
- `UserPermissionView.vue`
|
||||||
|
|
||||||
|
建议功能:
|
||||||
|
|
||||||
|
- 用户列表、创建、禁用、重置密码。
|
||||||
|
- 角色管理。
|
||||||
|
- 页面权限管理。
|
||||||
|
- 用户所属租户/项目。
|
||||||
|
- 用户可用 GPU/磁盘配额查看。
|
||||||
|
- 登录记录和操作审计入口。
|
||||||
|
|
||||||
|
### 8.3 审批中心
|
||||||
|
|
||||||
|
新增页面:
|
||||||
|
|
||||||
|
- 我的申请。
|
||||||
|
- 待我审批。
|
||||||
|
- 已办审批。
|
||||||
|
- 审批详情。
|
||||||
|
- 审批模板配置。
|
||||||
|
|
||||||
|
审批详情需要展示:
|
||||||
|
|
||||||
|
- 申请人、申请时间、动作类型、目标资源。
|
||||||
|
- 变更前后信息。
|
||||||
|
- 风险提示。
|
||||||
|
- 审批记录。
|
||||||
|
- 通过/驳回意见。
|
||||||
|
|
||||||
|
### 8.4 算力资源中心
|
||||||
|
|
||||||
|
当前只有硬件监控页,建议扩展为算力资源中心:
|
||||||
|
|
||||||
|
- GPU 拓扑和状态。
|
||||||
|
- GPU 当前任务占用。
|
||||||
|
- GPU 锁定/释放记录。
|
||||||
|
- 队列中的任务。
|
||||||
|
- 资源配额:租户/项目/用户维度。
|
||||||
|
- 算力节点 Agent 状态。
|
||||||
|
- 算力节点新增/编辑、连接测试、启用/禁用、维护模式。
|
||||||
|
- 节点权重、标签、训练引擎版本、LLaMA-Factory 健康状态。
|
||||||
|
- 节点本地资源副本:数据集、模型、checkpoint、adapter 和导出模型缓存。
|
||||||
|
- 训练引擎健康状态。
|
||||||
|
|
||||||
|
### 8.5 文件与存储管理
|
||||||
|
|
||||||
|
新增页面:
|
||||||
|
|
||||||
|
- 存储总览。
|
||||||
|
- 租户/项目磁盘占用。
|
||||||
|
- 大文件列表。
|
||||||
|
- 临时文件清理。
|
||||||
|
- Checkpoint 管理。
|
||||||
|
- 日志保留策略。
|
||||||
|
- 文件下载审计。
|
||||||
|
|
||||||
|
### 8.6 训练引擎管理
|
||||||
|
|
||||||
|
新增页面:
|
||||||
|
|
||||||
|
- 引擎列表。
|
||||||
|
- LLaMA-Factory 版本和路径。
|
||||||
|
- 引擎能力声明。
|
||||||
|
- 参数 schema 管理。
|
||||||
|
- 引擎健康检查。
|
||||||
|
- 引擎接入文档。
|
||||||
|
|
||||||
|
### 8.7 任务队列与运行控制
|
||||||
|
|
||||||
|
新增页面:
|
||||||
|
|
||||||
|
- 全局任务队列。
|
||||||
|
- 项目任务队列。
|
||||||
|
- 任务优先级调整。
|
||||||
|
- 任务重试。
|
||||||
|
- 任务停止审批。
|
||||||
|
- 失败任务诊断。
|
||||||
|
|
||||||
|
### 8.8 模型发布与服务治理
|
||||||
|
|
||||||
|
当前推理/对比页面已有基础能力,但缺少企业化发布能力:
|
||||||
|
|
||||||
|
- 模型发布申请。
|
||||||
|
- 推理服务实例配置。
|
||||||
|
- 端口、GPU、并发、超时、最大上下文限制。
|
||||||
|
- 服务启停记录。
|
||||||
|
- 调用统计。
|
||||||
|
- 服务下线审批。
|
||||||
|
|
||||||
|
## 9. 后端模块补全
|
||||||
|
|
||||||
|
### 9.1 新增核心模块
|
||||||
|
|
||||||
|
| 模块 | 职责 |
|
||||||
|
| --- | --- |
|
||||||
|
| tenant | 租户管理、租户配额、租户策略 |
|
||||||
|
| project | 项目空间、成员、项目角色 |
|
||||||
|
| resource_acl | 模型/数据集/任务级资源授权 |
|
||||||
|
| approval | 审批模板、审批实例、审批动作 |
|
||||||
|
| quota | GPU、磁盘、任务并发配额 |
|
||||||
|
| compute_gateway | 应用平台与算力平台通信 |
|
||||||
|
| file_gateway | 本地磁盘文件上传、下载、预览 |
|
||||||
|
| engine_registry | 训练引擎注册与能力发现 |
|
||||||
|
| retention | 审计、日志、临时文件保留策略 |
|
||||||
|
|
||||||
|
### 9.2 数据模型补充
|
||||||
|
|
||||||
|
在前一版 SQL 基础上,应新增或调整:
|
||||||
|
|
||||||
|
- `tenants`
|
||||||
|
- `tenant_users`
|
||||||
|
- `projects`
|
||||||
|
- `project_members`
|
||||||
|
- `resource_acl`
|
||||||
|
- `approval_templates`
|
||||||
|
- `approval_instances`
|
||||||
|
- `approval_steps`
|
||||||
|
- `quotas`
|
||||||
|
- `quota_usage`
|
||||||
|
- `compute_nodes`
|
||||||
|
- `gpu_devices`
|
||||||
|
- `gpu_allocations`
|
||||||
|
- `compute_jobs`
|
||||||
|
- `training_engines`
|
||||||
|
- `retention_policies`
|
||||||
|
|
||||||
|
核心资源表需要补充字段:
|
||||||
|
|
||||||
|
- `tenant_id`
|
||||||
|
- `project_id`
|
||||||
|
- `visibility`
|
||||||
|
- `owner_id`
|
||||||
|
- `approval_status`
|
||||||
|
- `storage_node_id`
|
||||||
|
|
||||||
|
需要调整的已有表:
|
||||||
|
|
||||||
|
- `models`
|
||||||
|
- `trained_models`
|
||||||
|
- `datasets`
|
||||||
|
- `dataset_files`
|
||||||
|
- `data_process_tasks`
|
||||||
|
- `fine_tune_tasks`
|
||||||
|
- `eval_tasks`
|
||||||
|
- `inference_tasks`
|
||||||
|
- `custom_tools`
|
||||||
|
- `audit_logs`
|
||||||
|
- `storage_objects`
|
||||||
|
|
||||||
|
### 9.3 接口补充
|
||||||
|
|
||||||
|
租户:
|
||||||
|
|
||||||
|
- `GET /modelTF/tenants`
|
||||||
|
- `POST /modelTF/tenants`
|
||||||
|
- `GET /modelTF/tenants/{id}`
|
||||||
|
- `PUT /modelTF/tenants/{id}`
|
||||||
|
- `PUT /modelTF/tenants/{id}/quota`
|
||||||
|
- `PUT /modelTF/tenants/{id}/retention-policy`
|
||||||
|
|
||||||
|
项目:
|
||||||
|
|
||||||
|
- `GET /modelTF/projects`
|
||||||
|
- `POST /modelTF/projects`
|
||||||
|
- `GET /modelTF/projects/{id}`
|
||||||
|
- `PUT /modelTF/projects/{id}`
|
||||||
|
- `POST /modelTF/projects/{id}/archive`
|
||||||
|
- `GET /modelTF/projects/{id}/members`
|
||||||
|
- `POST /modelTF/projects/{id}/members`
|
||||||
|
- `PUT /modelTF/projects/{id}/members/{user_id}`
|
||||||
|
- `DELETE /modelTF/projects/{id}/members/{user_id}`
|
||||||
|
|
||||||
|
资源授权:
|
||||||
|
|
||||||
|
- `GET /modelTF/resources/{resource_type}/{resource_id}/acl`
|
||||||
|
- `PUT /modelTF/resources/{resource_type}/{resource_id}/acl`
|
||||||
|
- `POST /modelTF/resources/{resource_type}/{resource_id}/share`
|
||||||
|
|
||||||
|
审批:
|
||||||
|
|
||||||
|
- `GET /modelTF/approvals`
|
||||||
|
- `POST /modelTF/approvals`
|
||||||
|
- `GET /modelTF/approvals/{id}`
|
||||||
|
- `POST /modelTF/approvals/{id}/approve`
|
||||||
|
- `POST /modelTF/approvals/{id}/reject`
|
||||||
|
- `POST /modelTF/approvals/{id}/cancel`
|
||||||
|
|
||||||
|
算力:
|
||||||
|
|
||||||
|
- `GET /modelTF/compute/nodes`
|
||||||
|
- `GET /modelTF/compute/gpus`
|
||||||
|
- `GET /modelTF/compute/queue`
|
||||||
|
- `POST /modelTF/compute/jobs/{id}/retry`
|
||||||
|
- `POST /modelTF/compute/jobs/{id}/priority`
|
||||||
|
|
||||||
|
训练引擎:
|
||||||
|
|
||||||
|
- `GET /modelTF/training-engines`
|
||||||
|
- `GET /modelTF/training-engines/{id}`
|
||||||
|
- `POST /modelTF/training-engines/{id}/health-check`
|
||||||
|
- `GET /modelTF/training-engines/{id}/schema`
|
||||||
|
|
||||||
|
## 10. 端到端业务流程
|
||||||
|
|
||||||
|
### 10.1 数据集上传
|
||||||
|
|
||||||
|
1. 用户进入项目空间。
|
||||||
|
2. 用户创建数据集。
|
||||||
|
3. 应用平台校验项目写权限和磁盘配额。
|
||||||
|
4. 前端上传文件到应用平台。
|
||||||
|
5. 应用平台转发到算力文件网关,保存到项目目录。
|
||||||
|
6. 算力平台返回文件元数据和 checksum。
|
||||||
|
7. 应用平台登记数据集文件版本。
|
||||||
|
8. 审计记录上传行为。
|
||||||
|
|
||||||
|
### 10.2 微调训练
|
||||||
|
|
||||||
|
1. 用户选择项目、模型、数据集、训练参数和 GPU。
|
||||||
|
2. 应用平台检查模型/数据集 `execute` 权限。
|
||||||
|
3. 检查项目 GPU 并发配额。
|
||||||
|
4. 如果策略要求审批,先创建审批单。
|
||||||
|
5. 审批通过后创建 compute job。
|
||||||
|
6. 算力平台锁定 GPU,生成 LLaMA-Factory 配置,启动训练。
|
||||||
|
7. 训练日志和指标实时回传。
|
||||||
|
8. 完成后登记 trained model。
|
||||||
|
9. 如启用自动合并,进入合并任务。
|
||||||
|
10. 审计记录任务全生命周期。
|
||||||
|
|
||||||
|
### 10.3 模型发布
|
||||||
|
|
||||||
|
1. 用户选择训练产物。
|
||||||
|
2. 提交发布申请。
|
||||||
|
3. 审批通过后算力平台启动推理服务。
|
||||||
|
4. 分配端口和 GPU。
|
||||||
|
5. 应用平台登记服务实例。
|
||||||
|
6. 前端推理页面调用服务。
|
||||||
|
7. 监控调用量、延迟、错误率、GPU 占用。
|
||||||
|
|
||||||
|
## 11. 当前功能完整性评估
|
||||||
|
|
||||||
|
补充 6 条需求后,平台设计已经覆盖完整模型训练平台的主链路:
|
||||||
|
|
||||||
|
- 数据准备。
|
||||||
|
- 数据处理。
|
||||||
|
- 模型登记。
|
||||||
|
- 微调训练。
|
||||||
|
- 训练日志和指标。
|
||||||
|
- 模型合并和导出。
|
||||||
|
- 模型评测。
|
||||||
|
- 推理服务。
|
||||||
|
- 模型对比。
|
||||||
|
- 用户、权限、租户、项目隔离。
|
||||||
|
- 审批、审计、保留策略。
|
||||||
|
- 算力资源调度。
|
||||||
|
|
||||||
|
但如果目标是企业级生产平台,还建议继续确认以下缺口。
|
||||||
|
|
||||||
|
## 12. 仍需确认的问题
|
||||||
|
|
||||||
|
1. 本地磁盘是否在算力服务器上,应用服务器是否完全不保存训练文件?如果应用服务器也要保存上传临时文件,需要确认临时文件保留周期和容量。
|
||||||
|
2. 单机多 GPU 是否需要支持 MIG、GPU 分片或多进程共享,还是一张 GPU 同一时间只给一个任务?
|
||||||
|
3. 是否允许训练任务抢占?高优先级任务是否能停止低优先级任务?
|
||||||
|
4. 是否需要离线导入已有模型和已有数据集目录,还是所有文件都必须从平台上传?
|
||||||
|
5. 模型发布是否区分“测试服务”和“生产服务”?生产发布是否必须审批?
|
||||||
|
6. 是否需要数据集脱敏、敏感字段识别和数据质量评分作为内置流程?
|
||||||
|
7. 是否需要人工评测/人工复核结果沉淀为新数据集?
|
||||||
|
8. 是否需要训练任务失败后的断点续训?
|
||||||
|
9. 是否需要 checkpoint 自动清理策略,例如只保留最近 N 个或最好 N 个?
|
||||||
|
10. 是否需要对外提供标准 API 给其他系统调用训练、评测、推理能力?
|
||||||
|
11. 是否需要接入企业统一身份认证,例如 LDAP、OIDC、企业微信、钉钉?
|
||||||
|
12. 是否需要成本核算:按租户/项目统计 GPU 小时、磁盘占用、模型调用量?
|
||||||
|
13. 多算力节点是否需要节点组、租户绑定节点或项目绑定节点策略?
|
||||||
|
14. 跨节点资源同步是否需要限速、同步窗口和管理员审批?
|
||||||
|
|
||||||
|
## 13. 推荐决策补充
|
||||||
|
|
||||||
|
`system-development-plan.md` 已对上述问题给出第一版建议,需求设计以以下决策为准。
|
||||||
|
|
||||||
|
1. 本地磁盘主存储放在算力服务器,应用服务器只保留上传临时文件。临时文件默认保留 24 小时,成功转发到算力文件网关后可立即进入清理队列。
|
||||||
|
2. 第一版不支持 MIG、GPU 分片和多任务共享同一张 GPU。一张 GPU 同一时间只分配给一个训练任务或一个推理服务。GPU 数据模型预留 `partition_type`、`parent_gpu_uuid`、`memory_total_mb`,便于后续扩展 MIG。
|
||||||
|
3. 第一版不做自动抢占。支持任务优先级和排队;停止他人任务需要审批或平台管理员权限。
|
||||||
|
4. 多算力节点仍按“单机多 GPU 节点”管理,每个节点独立部署算力服务和 LLaMA-Factory;节点之间不互相访问,由应用平台统一调度和资源同步。
|
||||||
|
5. 多节点调度默认自动选择节点,同时支持管理员/高级用户手动指定节点;调度优先考虑节点健康、标签、权重、空闲 GPU、队列长度和资源副本是否已存在。
|
||||||
|
6. 第一版必须支持离线导入已有模型和数据集目录。导入由算力 Agent 扫描、校验、登记,并归属指定租户和项目。
|
||||||
|
7. 模型发布区分测试服务和生产服务。测试服务项目内可启动并默认限流;生产服务必须审批。
|
||||||
|
8. 数据脱敏和数据质量评分作为数据处理模块的一等能力进入第一期,先实现规则版脱敏、格式校验、重复率、完整性、长度分布等指标。
|
||||||
|
9. 人工评测/复核作为第二期功能,但第一期需在数据库和页面入口预留人工复核状态与修订字段。
|
||||||
|
10. 第一版支持从 checkpoint 手动恢复训练,不做自动失败续训。失败任务可选择 checkpoint 重试。
|
||||||
|
11. 第一版必须支持 checkpoint 自动清理策略:默认保留最近 3 个、最优 2 个;已发布模型关联 checkpoint 不自动删除;失败任务 checkpoint 默认保留 14 天。
|
||||||
|
12. 第一版提供内部 API,第二期再开放面向其他系统的标准 API、API Key、限流和 Webhook。
|
||||||
|
13. 第一版使用本地账号,预留 OIDC/LDAP 字段和认证 provider 抽象;第二期接入企业统一身份认证。
|
||||||
|
14. 第一版做 GPU 小时、磁盘占用、任务时长、推理调用量等用量统计;第二期再做成本单价和账单核算。
|
||||||
|
|
||||||
|
以上决策需要同步反映在接口文档、数据库 SQL、前端页面和部署方案中。第一版实现不再阻塞于这些问题的反复确认,除非实际部署环境与假设明显冲突。
|
||||||
|
|
||||||
|
## 14. 页面功能模块映射
|
||||||
|
|
||||||
|
本节用于帮助前端、后端、DB 和测试人员理解需求对应到哪些页面与功能模块。页面路径以当前 Vue 路由和新增规划路由为准。
|
||||||
|
|
||||||
|
### 14.1 平台入口与全局能力
|
||||||
|
|
||||||
|
| 页面模块 | 路由/入口 | 对应需求 | 主要功能 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 登录页 | `/login` | 用户认证、本地账号、后续预留 OIDC/LDAP | 登录、会话创建、权限加载 |
|
||||||
|
| 主布局 | `/` | 全局项目上下文、权限控制 | 菜单、顶部状态、项目切换器、用户信息 |
|
||||||
|
| 无权限页 | `/permission-denied` | 页面权限和资源权限兜底 | 展示无权限原因、返回可访问页面 |
|
||||||
|
| 服务看板 | `/dashboard` | 平台运行总览 | 服务健康、任务概览、训练统计、用户操作分布 |
|
||||||
|
|
||||||
|
### 14.2 租户、项目与权限治理
|
||||||
|
|
||||||
|
| 页面模块 | 建议路由 | 对应需求 | 主要功能 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 租户管理 | `/tenants`、`/tenants/:id` | 多租户、租户配额、留存策略 | 租户列表、创建/禁用租户、配额、审计留存策略 |
|
||||||
|
| 项目空间 | `/projects`、`/projects/:id` | 项目级隔离、项目资源聚合 | 项目列表、项目概览、资源统计、项目归档 |
|
||||||
|
| 项目成员 | `/projects/:id/members` | 项目角色 | 添加成员、移除成员、设置 owner/maintainer/developer/reviewer/viewer |
|
||||||
|
| 资源授权 | `/projects/:id/permissions` 或资源详情弹窗 | 模型/数据集/任务级 ACL | 按用户、项目角色授权 read/write/execute/download/delete/manage_acl |
|
||||||
|
| 用户中心 | `/user-settings`、`/user-settings/create`、`/user-settings/:id/permission` | 用户、角色、页面权限 | 用户列表、创建用户、禁用、重置密码、分配页面权限和项目 |
|
||||||
|
|
||||||
|
### 14.3 数据链路
|
||||||
|
|
||||||
|
| 页面模块 | 路由/入口 | 对应需求 | 主要功能 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 数据集列表 | `/dataset` | 数据集管理、项目隔离 | 列表、搜索、下载、删除审批入口 |
|
||||||
|
| 数据集创建/编辑 | `/dataset/create`、`/dataset/:id/edit` | 上传、本地磁盘、文件网关 | 创建数据集、上传文件、离线导入、元数据编辑 |
|
||||||
|
| 数据集预览 | `/dataset/:id/preview` | 文件版本、在线编辑、乐观锁 | 文件预览、版本切换、保存新版本、下载 |
|
||||||
|
| 数据处理列表 | `/data-process` | 数据处理任务管理 | 任务列表、状态、输出数据集跳转、删除审批 |
|
||||||
|
| 数据处理创建向导 | `/data-process/create` | 清洗、切片、生成、质量评分、脱敏 | 任务配置、模型选择、源文件/外部源、预览切片、生成、结果编辑、发布数据集 |
|
||||||
|
| 数据处理详情 | `/data-process/:id` | 处理统计和结果追踪 | 运行信息、处理统计、失败原因、结果明细 |
|
||||||
|
| 数据转换 | `/data-convert` | JSON/JSONL 转换 | 上传 JSON、转换任务、下载结果 |
|
||||||
|
|
||||||
|
### 14.4 模型训练链路
|
||||||
|
|
||||||
|
| 页面模块 | 路由/入口 | 对应需求 | 主要功能 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 模型管理 | `/model-manage` | 模型登记、离线导入、资源 ACL | 基座模型/API 模型列表、用途变更、授权、删除审批 |
|
||||||
|
| 模型创建/编辑 | `/model-manage/create`、`/model-manage/:id/edit` | 本地模型/API 模型登记 | 选择本地路径、填写 API 模型信息、加密 API Key |
|
||||||
|
| 权重合并 | `/model-manage/merge` | LoRA 合并、产物管理 | 选择训练产物、合并权重、生成 merged model |
|
||||||
|
| 微调任务列表 | `/fine-tune` | 训练任务管理 | 任务列表、状态、进度、停止/删除审批 |
|
||||||
|
| 微调创建 | `/fine-tune/create` | LLaMA-Factory 训练配置、GPU 调度 | 选择模型/数据集/GPU、训练参数、量化导出、提交审批或启动 |
|
||||||
|
| 训练日志详情 | `/training-log/:id` | 日志、指标、checkpoint、恢复训练 | 日志 tail、loss 曲线、GPU 状态、checkpoint 列表、恢复/重试 |
|
||||||
|
| 训练引擎管理 | `/training-engines` | LLaMA-Factory 插件化和后续引擎接入 | 引擎列表、能力声明、schema、健康检查 |
|
||||||
|
|
||||||
|
### 14.5 评测、推理和发布
|
||||||
|
|
||||||
|
| 页面模块 | 路由/入口 | 对应需求 | 主要功能 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 评测列表 | `/model-eval` | 模型评测 | 评测任务列表、状态、分数、删除 |
|
||||||
|
| 评测创建 | `/model-eval/create` | 自动评测、LLM Judge、基础指标 | 选择模型/数据集/维度/GPU、配置指标、启动评测 |
|
||||||
|
| 评测详情 | `/model-eval/:id` | 样本级结果、人工复核预留 | 综合评价、维度汇总、样本评分、错误类型 |
|
||||||
|
| 评测维度 | `/model-eval/dimension/:id/edit` | 维度和评测 Prompt 管理 | 创建/编辑维度、评分范围、Prompt、启用状态 |
|
||||||
|
| 推理列表 | `/model-inference` | 测试推理服务 | 推理任务列表、加载/卸载、服务状态 |
|
||||||
|
| 推理创建 | `/model-inference/create` | 模型服务资源申请 | 选择模型、GPU、端口策略、并发参数 |
|
||||||
|
| 推理对话 | `/model-inference/chat/:id` | 模型对话 | 单模型流式对话、会话记录 |
|
||||||
|
| 模型对比 | `/model-compare/chat/:id`、`/model-compare/result` | 多模型对比 | 多模型加载、并行对话、对比结果 |
|
||||||
|
| 模型发布治理 | `/model-services`、`/model-services/:id` | 测试/生产服务、发布审批 | 测试服务启动、生产发布申请、调用统计、下线审批 |
|
||||||
|
|
||||||
|
### 14.6 算力、审批、审计和运维
|
||||||
|
|
||||||
|
| 页面模块 | 建议路由 | 对应需求 | 主要功能 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 算力资源中心 | `/compute`、`/compute/gpus` | 单机多 GPU、资源锁定、队列 | GPU 卡片、任务占用、节点状态、队列、优先级 |
|
||||||
|
| 存储管理 | `/storage` | 本地磁盘、配额、清理 | 租户/项目占用、大文件、临时文件、checkpoint 清理 |
|
||||||
|
| 审批中心 | `/approvals`、`/approvals/pending`、`/approvals/mine`、`/approvals/:id` | 审批流 | 我的申请、待我审批、审批详情、通过/驳回/撤回 |
|
||||||
|
| 审批模板 | `/approval-settings` | 审批策略配置 | 按动作配置审批人、超时、风险级别 |
|
||||||
|
| 审计中心 | `/audit-logs`、`/login-logs`、`/download-logs` | 操作审计和留存 | 操作审计、登录审计、下载审计、筛选导出 |
|
||||||
|
| 平台性能 | `/hardware` | 系统监控 | CPU、内存、磁盘、GPU、进程 |
|
||||||
|
| 系统日志 | `/logs` | 日志查看 | 系统日志、训练日志、tail/offset 查询 |
|
||||||
1496
docs/postgres-schema.sql
Normal file
1496
docs/postgres-schema.sql
Normal file
File diff suppressed because it is too large
Load Diff
@@ -431,9 +431,9 @@ Expected: 三项检查全部 PASS。
|
|||||||
|
|
||||||
- [ ] **Step 3: 启动页面并逐步验证四步交互**
|
- [ ] **Step 3: 启动页面并逐步验证四步交互**
|
||||||
|
|
||||||
Run: `cd frontend && npm run dev -- --host 0.0.0.0 --port 6801`
|
Run: `cd frontend && npm run dev -- --host 0.0.0.0 --port 16801`
|
||||||
|
|
||||||
Browser checks at `http://localhost:6801/data-process/create`:
|
Browser checks at `http://localhost:16801/data-process/create`:
|
||||||
|
|
||||||
1. 第一步上传文本并选择非结构化数据。
|
1. 第一步上传文本并选择非结构化数据。
|
||||||
2. 第二步点击至少三个右侧切片,确认左侧滚动目标和高亮范围变化。
|
2. 第二步点击至少三个右侧切片,确认左侧滚动目标和高亮范围变化。
|
||||||
|
|||||||
1118
docs/system-development-plan.md
Normal file
1118
docs/system-development-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
208
docs/team-development-plan.md
Normal file
208
docs/team-development-plan.md
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
# 多人并行开发分工计划
|
||||||
|
|
||||||
|
> 本文基于 `docs/menu-functional-requirements.md`、`docs/backend-api-design.md`、`docs/postgres-schema.sql`、`docs/system-development-plan.md` 和当前前端路由整理,用于 3-4 人并行开发。开发口径以正式系统演进为准,不以临时演示或静态 Mock 作为交付标准。
|
||||||
|
|
||||||
|
## 1. 分工原则
|
||||||
|
|
||||||
|
- 每位开发尽量独立负责一组页面、后端模块、数据库表和联调脚本,避免多人同时改同一个业务文件。
|
||||||
|
- 公共接口契约先在 `docs/backend-api-design.md` 更新,再进入代码实现。
|
||||||
|
- 前端 API 模块按业务域维护:`system.ts`、`model.ts`、`dataset.ts`、`fineTune.ts`、`compute.ts`、`eval.ts`、`log.ts`。
|
||||||
|
- 后端业务代码按 `backend/app/modules/<domain>/` 拆分,路由统一挂载 `/modelTF`。
|
||||||
|
- 数据库按迁移脚本推进,目标模型以 `docs/postgres-schema.sql` 为准,当前运行库脚本以 `backend/app/db/sql/` 为准。
|
||||||
|
- 所有写操作必须预留审计;涉及删除、导出、发布、停止他人任务等高风险动作必须预留审批入口。
|
||||||
|
|
||||||
|
## 2. 4 人开发拆分
|
||||||
|
|
||||||
|
### A. 平台基础与企业治理
|
||||||
|
|
||||||
|
负责人边界:
|
||||||
|
|
||||||
|
- 前端目录:`frontend/src/views/login/`、`frontend/src/views/system/`,后续新增 `tenants`、`projects`、`approvals`、`audit` 页面目录。
|
||||||
|
- 前端 API:`frontend/src/api/modules/system.ts`、`frontend/src/api/modules/log.ts`。
|
||||||
|
- 后端模块:`auth`、`tenant`、`project`、`approval`、`audit`、`retention`、`system`。
|
||||||
|
- 数据库表:`users`、`permissions`、`roles`、`role_permissions`、`user_permission_overrides`、`tenants`、`tenant_users`、`projects`、`project_members`、`resource_acl`、`approval_templates`、`approval_instances`、`approval_steps`、`audit_logs`、`retention_policies`。
|
||||||
|
|
||||||
|
对应页面和功能:
|
||||||
|
|
||||||
|
| 页面/模块 | 路由 | 功能点 | 接口 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 登录 | `/login` | 登录、Token 写入、登录失败提示、会话恢复 | `POST /modelTF/login`、`GET /modelTF/me` |
|
||||||
|
| 用户设置 | `/user-settings`、`/user-settings/create`、`/user-settings/:id/permission` | 用户列表、创建、启停、重置密码、页面权限 | `/modelTF/users/*`、`/modelTF/permissions` |
|
||||||
|
| 平台性能 | `/hardware` | 系统资源、进程、GPU 摘要 | `/modelTF/system-info`、`/modelTF/compute/gpus` |
|
||||||
|
| 查看日志 | `/logs`、`/training-log/:id` | 后端日志、error 日志、训练日志索引和内容 | `/modelTF/log-files`、`/modelTF/log-content`、`/modelTF/training-log-*` |
|
||||||
|
| 租户管理 | `/tenants`、`/tenants/:id` | 租户、配额、留存策略 | `/modelTF/tenants/*`、`/modelTF/retention-policies/*` |
|
||||||
|
| 项目空间 | `/projects`、`/projects/:id`、`/projects/:id/members` | 项目、成员、项目角色 | `/modelTF/projects/*` |
|
||||||
|
| 资源授权 | `/resources/:type/:id/acl` 或弹窗 | 模型/数据集/任务 ACL | `/modelTF/resources/{type}/{id}/acl` |
|
||||||
|
| 审批中心 | `/approvals`、`/approval-settings` | 审批待办、审批历史、审批模板 | `/modelTF/approvals/*`、`/modelTF/approval-templates/*` |
|
||||||
|
| 审计中心 | `/audit-logs` | 操作审计、登录审计、导出 | `/modelTF/audit-logs` |
|
||||||
|
|
||||||
|
开发计划:
|
||||||
|
|
||||||
|
| 阶段 | 交付内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 第 1 周 | 完成登录、当前用户、用户列表、权限码、日志查询接口;完善当前运行 SQL。 |
|
||||||
|
| 第 2 周 | 完成租户、项目、项目成员、资源 ACL 后端和基础页面。 |
|
||||||
|
| 第 3 周 | 完成审批实例、审批模板、审计日志查询和导出。 |
|
||||||
|
| 第 4 周 | 接入其他模块写操作审计和审批拦截,补充权限测试。 |
|
||||||
|
|
||||||
|
验收标准:
|
||||||
|
|
||||||
|
- 所有业务列表按租户、项目、资源 ACL 过滤。
|
||||||
|
- 普通用户无法访问未授权项目、模型和数据集。
|
||||||
|
- 高风险动作有审批或管理员旁路规则。
|
||||||
|
- 日志文件和审计日志可按时间、用户、动作、资源筛选。
|
||||||
|
|
||||||
|
### B. 模型资产、训练与 LLaMA-Factory 任务
|
||||||
|
|
||||||
|
负责人边界:
|
||||||
|
|
||||||
|
- 前端目录:`frontend/src/views/model/`、`frontend/src/views/fine-tune/`。
|
||||||
|
- 前端 API:`frontend/src/api/modules/model.ts`、`frontend/src/api/modules/fineTune.ts`。
|
||||||
|
- 后端模块:`model`、`fine_tune`、`engine_registry`、`compute_gateway` 中的训练编排部分。
|
||||||
|
- 数据库表:`models`、`trained_models`、`fine_tune_tasks`、`fine_tune_metrics`、`fine_tune_checkpoints`、`training_engines`、`training_engine_capabilities`、`compute_jobs`、`gpu_allocations`。
|
||||||
|
|
||||||
|
对应页面和功能:
|
||||||
|
|
||||||
|
| 页面/模块 | 路由 | 功能点 | 接口 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 模型管理 | `/model-manage` | 基座模型、API 模型、训练产物列表、筛选、删除审批入口 | `/modelTF/model-manage`、`/modelTF/model-manage/trained-models` |
|
||||||
|
| 添加/编辑模型 | `/model-manage/create`、`/model-manage/:id/edit` | 模型登记、本地路径/API 配置、能力标签 | `/modelTF/model-manage`、`/modelTF/model-manage/{id}` |
|
||||||
|
| 合并权重 | `/model-manage/merge` | LoRA/Adapter 合并、产物登记 | `/modelTF/model-manage/merge` |
|
||||||
|
| 模型训练 | `/fine-tune` | 训练任务列表、状态、启动、停止、删除审批入口 | `/modelTF/fine-tune`、`/modelTF/fine-tune/{id}/start`、`/stop` |
|
||||||
|
| 创建训练任务 | `/fine-tune/create` | 选择模型、数据集、超参、GPU、节点策略 | `/modelTF/fine-tune`、`/modelTF/model-manage`、`/modelTF/dataset-manage`、`/modelTF/compute/*` |
|
||||||
|
| 训练日志 | `/training-log/:id` | 实时日志、loss 曲线、checkpoint、产物 | `/modelTF/fine-tune/{id}/progress`、`/metrics`、`/checkpoints`、`/modelTF/training-log-*` |
|
||||||
|
|
||||||
|
开发计划:
|
||||||
|
|
||||||
|
| 阶段 | 交付内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 第 1 周 | 完成模型 CRUD、训练任务 CRUD、训练参数校验和接口联调。 |
|
||||||
|
| 第 2 周 | 完成训练启动、停止、状态轮询、日志和指标落库。 |
|
||||||
|
| 第 3 周 | 完成 LLaMA-Factory 参数映射、checkpoint 列表、训练产物登记。 |
|
||||||
|
| 第 4 周 | 完成权重合并、失败恢复、权限隔离和审计接入。 |
|
||||||
|
|
||||||
|
验收标准:
|
||||||
|
|
||||||
|
- 训练任务不能绕过项目、模型、数据集权限。
|
||||||
|
- 训练任务状态以应用侧轮询 Compute API 为主。
|
||||||
|
- 训练命令只能由训练引擎适配层生成,不在页面或应用 API 中拼命令。
|
||||||
|
- checkpoint、日志、产物均可追溯到任务、节点、GPU 和项目。
|
||||||
|
|
||||||
|
### C. 数据集、数据处理、评测与推理
|
||||||
|
|
||||||
|
负责人边界:
|
||||||
|
|
||||||
|
- 前端目录:`frontend/src/views/dataset/`、`frontend/src/views/data-process/`、`frontend/src/views/data-convert/`、`frontend/src/views/eval/`、`frontend/src/views/inference/`、`frontend/src/views/compare/`、`frontend/src/views/tools/`。
|
||||||
|
- 前端 API:`dataset.ts`、`eval.ts`、`compare.ts`,必要时新增 `dataProcess.ts`、`dataConvert.ts`、`inference.ts`。
|
||||||
|
- 后端模块:`dataset`、`data_process`、`eval`、`inference`、`file_gateway` 中的数据资产登记部分。
|
||||||
|
- 数据库表:`datasets`、`dataset_files`、`dataset_file_versions`、`dataset_records`、`data_process_tasks`、`data_process_source_files`、`data_process_preview_items`、`data_process_results`、`data_convert_jobs`、`eval_tasks`、`eval_dimensions`、`eval_sample_results`、`inference_tasks`、`chat_sessions`、`chat_messages`、`custom_tools`。
|
||||||
|
|
||||||
|
对应页面和功能:
|
||||||
|
|
||||||
|
| 页面/模块 | 路由 | 功能点 | 接口 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 数据集管理 | `/dataset` | 数据集列表、搜索、版本、下载、删除审批入口 | `/modelTF/dataset-manage` |
|
||||||
|
| 数据集创建/编辑 | `/dataset/create`、`/dataset/:id/edit` | 元数据、文件上传、格式识别、项目归属 | `/modelTF/dataset-manage`、`/upload/{id}` |
|
||||||
|
| 数据集预览 | `/dataset/:id/preview` | 分页预览、在线编辑、版本对比 | `/modelTF/dataset-manage/{id}/preview`、`/versions` |
|
||||||
|
| 数据处理 | `/data-process`、`/data-process/create`、`/data-process/:id` | 文档上传、切片、脱敏、质量评分、发布数据集 | `/modelTF/data-process/*` |
|
||||||
|
| 数据类型转换 | `/data-convert` | JSON/JSONL/Markdown 转换任务 | `/modelTF/data-convert/jobs/*` |
|
||||||
|
| 模型评测 | `/model-eval`、`/model-eval/create`、`/model-eval/:id` | 评测任务、维度、样本级结果、人工复核预留 | `/modelTF/model-eval/*`、`/modelTF/dimension/*` |
|
||||||
|
| 模型推理/对比 | `/model-inference/*`、`/model-compare/*` | 模型加载、对话、对比、结果沉淀 | `/modelTF/model-chat/*`、`/modelTF/model-compare/*` |
|
||||||
|
| 自定义工具 | `/tools`、`/tools/create`、`/tools/:id/edit` | 工具登记、参数 schema、启停 | `/modelTF/tools/*` |
|
||||||
|
|
||||||
|
开发计划:
|
||||||
|
|
||||||
|
| 阶段 | 交付内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 第 1 周 | 完成数据集 CRUD、上传、预览、版本接口和页面联调。 |
|
||||||
|
| 第 2 周 | 完成数据处理任务、切片预览、质量评分和发布数据集。 |
|
||||||
|
| 第 3 周 | 完成评测任务、评测维度、样本结果查询。 |
|
||||||
|
| 第 4 周 | 完成推理会话、模型对比、数据转换、自定义工具基础能力。 |
|
||||||
|
|
||||||
|
验收标准:
|
||||||
|
|
||||||
|
- 数据文件必须登记存储对象、checksum、版本和项目归属。
|
||||||
|
- 数据处理产物发布为数据集时保留来源链路。
|
||||||
|
- 评测和推理必须记录使用的模型版本、数据集版本和参数快照。
|
||||||
|
- 下载、删除、导出等动作必须接入审计和审批策略。
|
||||||
|
|
||||||
|
### D. 算力平台、部署与运维
|
||||||
|
|
||||||
|
负责人边界:
|
||||||
|
|
||||||
|
- 前端目录:`frontend/src/views/compute/`,协助 `system/HardwareView.vue`。
|
||||||
|
- 前端 API:`frontend/src/api/modules/compute.ts`。
|
||||||
|
- 后端模块:`compute_gateway`、`engine_registry`、`file_gateway`、`system` 中的资源采集部分。
|
||||||
|
- 算力目录:`compute/api/`、`compute/agent/`、`compute/engines/llama_factory/`、`compute/file_gateway/`。
|
||||||
|
- 部署目录:`docker/app/`、`docker/compute/`、`docker/README.md`、`docs/deployment-plan.md`。
|
||||||
|
- 数据库表:`compute_nodes`、`gpu_devices`、`compute_node_engines`、`compute_jobs`、`gpu_allocations`、`resource_replicas`、`resource_sync_jobs`、`system_metric_snapshots`、`storage_objects`。
|
||||||
|
|
||||||
|
对应页面和功能:
|
||||||
|
|
||||||
|
| 页面/模块 | 路由 | 功能点 | 接口 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 算力节点 | `/compute`、`/compute?tab=nodes` | 节点地址、File Gateway 地址、权重、标签、启用状态、连接测试 | `/modelTF/compute/nodes/*` |
|
||||||
|
| GPU 资源 | `/compute?tab=gpus`、`/hardware` | GPU 显存、利用率、温度、分配状态、节点归属 | `/modelTF/compute/gpus`、`/modelTF/system-info` |
|
||||||
|
| 任务队列 | `/compute?tab=queue` | 队列、优先级、占用 GPU、任务状态 | `/modelTF/compute/queue` |
|
||||||
|
| 资源副本 | `/compute` 节点详情 | 模型/数据集在算力节点上的同步状态 | `/modelTF/compute/nodes/{id}/replicas` |
|
||||||
|
| 文件网关 | 无独立页面,供模型/数据/训练调用 | 上传、下载、离线导入、产物归档 | 应用侧 `/modelTF/*` 编排,算力侧内部 File Gateway API |
|
||||||
|
| 部署运维 | 文档和 Compose | 应用/算力分离部署、端口、镜像、日志、健康检查 | Docker Compose、健康检查接口 |
|
||||||
|
|
||||||
|
开发计划:
|
||||||
|
|
||||||
|
| 阶段 | 交付内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 第 1 周 | 完成 Compute API 健康检查、GPU 发现、节点登记和连接测试。 |
|
||||||
|
| 第 2 周 | 完成任务状态查询、应用侧轮询、资源副本状态同步。 |
|
||||||
|
| 第 3 周 | 完成 LLaMA-Factory 容器/宿主机路径适配、日志采集、训练进程管理。 |
|
||||||
|
| 第 4 周 | 完成应用/算力两套 Docker Compose、部署文档、故障排查脚本。 |
|
||||||
|
|
||||||
|
验收标准:
|
||||||
|
|
||||||
|
- 多算力节点阶段仍按“每台算力服务器 = 单机多 GPU 节点”设计。
|
||||||
|
- 每台算力服务器都部署 Compute API、Agent、File Gateway 和 LLaMA-Factory。
|
||||||
|
- 应用服务器只需主动访问所有算力节点,不要求算力节点反向访问应用服务器。
|
||||||
|
- 节点地址、权重、标签、启用状态必须可动态维护。
|
||||||
|
|
||||||
|
## 3. 3 人开发合并方案
|
||||||
|
|
||||||
|
如果团队只有 3 人,建议合并为:
|
||||||
|
|
||||||
|
| 开发人员 | 合并内容 | 不建议合并的原因 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A | 平台基础与企业治理 | 该部分是所有模块的权限和隔离底座,不宜再叠加训练或数据主链路。 |
|
||||||
|
| B | 模型资产、训练与 LLaMA-Factory 任务 | 模型和训练强耦合,适合一人端到端打通。 |
|
||||||
|
| C | 数据集、数据处理、评测、推理、算力部署协同 | 数据链路和评测推理使用相同数据/模型资产;算力底层可先由 C 搭骨架,后续扩人拆出 D。 |
|
||||||
|
|
||||||
|
若进入真实 GPU 联调阶段,必须优先把 D 独立出来,否则训练问题、部署问题和业务问题会混在一起,排障效率会明显下降。
|
||||||
|
|
||||||
|
## 4. 公共契约和协作节奏
|
||||||
|
|
||||||
|
公共契约负责人建议由 A 兼任,所有人遵守:
|
||||||
|
|
||||||
|
| 契约 | 文件 | 变更规则 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 路由前缀 | `backend/app/core/config.py`、`backend/app/api/v1/router.py`、接口文档 | 统一 `/modelTF`,不得新增 `/api` 前缀 |
|
||||||
|
| 响应结构 | `frontend/src/api/request.ts`、后端 schema | 统一 `{ code, message, data }` |
|
||||||
|
| 权限码 | `frontend/src/types/index.ts`、`permissions` 表、接口文档 | 新菜单先登记权限码再开发 |
|
||||||
|
| 项目隔离 | `project_id`、`tenant_id`、`resource_acl` | 所有模型、数据集、任务必须带项目归属 |
|
||||||
|
| 审计动作 | `audit_logs`、后端审计中间件/服务 | 写操作默认审计 |
|
||||||
|
| 异步状态 | 任务表、`compute_jobs` | 统一 `pending/running/completed/failed/stopped` |
|
||||||
|
| 文件存储 | `storage_objects`、File Gateway | 不暴露宿主机绝对路径给前端 |
|
||||||
|
|
||||||
|
建议节奏:
|
||||||
|
|
||||||
|
- 每周一上午同步接口契约和数据库迁移计划。
|
||||||
|
- 每天下午固定一次跨模块联调窗口,优先处理阻塞其他人的接口。
|
||||||
|
- 每个模块 PR 必须包含页面入口、接口说明、SQL/迁移、最小验证步骤。
|
||||||
|
- 公共文件如 `frontend/src/types/index.ts`、`backend/app/core/*`、`docs/backend-api-design.md` 由对应 owner 统一合并,其他人通过小 PR 提交变更。
|
||||||
|
|
||||||
|
## 5. 里程碑
|
||||||
|
|
||||||
|
| 里程碑 | 目标 | 必须完成 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| M1 基础可用 | 用户登录、模型/数据集/训练主链路可运行 | A 登录权限,B 模型训练,C 数据集,D 单节点 GPU 状态 |
|
||||||
|
| M2 企业隔离 | 多租户、项目、资源 ACL 接入主链路 | 所有资源按租户/项目过滤,审计落库 |
|
||||||
|
| M3 训练闭环 | LLaMA-Factory 真实训练、日志、checkpoint、产物登记 | 应用侧轮询 Compute API,训练产物可在模型管理查看 |
|
||||||
|
| M4 治理闭环 | 审批、审计、留存、导出、部署文档完善 | 高风险动作审批,审计可检索,应用/算力分离部署可复现 |
|
||||||
|
|
||||||
2
frontend/.gitignore
vendored
2
frontend/.gitignore
vendored
@@ -1,5 +1,7 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
!dist/
|
||||||
|
!dist/**
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,17 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
开发服务器默认运行在 `http://localhost:6801`。
|
通过根目录 `scripts/start-dev.sh` 启动时,开发服务器端口和后端代理端口来自
|
||||||
|
`backend/config.yaml` 的 `server` 配置。单独运行 `npm run dev` 时默认使用前端
|
||||||
|
`16801` 和后端 `17861`;可用 `FRONTEND_PORT`、`BACKEND_PORT` 环境变量覆盖。
|
||||||
|
|
||||||
后端 API 默认通过 Vite 代理转发到 `http://localhost:7861`(见 `vite.config.ts`)。
|
开发环境默认联调真实后端接口。如需进行隔离前端开发,可显式启用 Mock:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VITE_ENABLE_MOCK=true npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
真实联调、测试环境和生产环境不应启用 Mock。
|
||||||
|
|
||||||
## 构建
|
## 构建
|
||||||
|
|
||||||
|
|||||||
1
frontend/dist/assets/AppConfirmDialog-g7rxg-F7.js
vendored
Normal file
1
frontend/dist/assets/AppConfirmDialog-g7rxg-F7.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{d as C,bn as g,D as E,H as O,o as _,e as B,s as D,Z as $,w as A,c as I,aL as K,q as a,n as v,aa as r,g as q,bm as M,y as u,z as N,P as k}from"./index-BKKvzUDD.js";import{_ as R}from"./_plugin-vue_export-helper-DlAUqK2U.js";const V={class:"app-confirm-header"},z={class:"app-confirm-heading"},H={class:"app-confirm-icon","aria-hidden":"true"},L={class:"app-confirm-body"},P={class:"app-confirm-actions"},S=C({__name:"AppConfirmDialog",setup(j,{expose:x}){const c=u(!1),m=u(),p=u(),d=`app-confirm-title-${g()}`,y=`app-confirm-message-${g()}`,n=N({title:"请确认操作",message:"",confirmText:"确定",cancelText:"取消",tone:"warning",closeOnOverlay:!1});let o=null,i=null;function s(t){c.value=!1;const e=o;o=null,e==null||e(t)}function h(t){return o&&s(!1),Object.assign(n,{confirmText:"确定",cancelText:"取消",tone:"warning",closeOnOverlay:!1,...t}),c.value=!0,new Promise(e=>{o=e})}function w(){n.closeOnOverlay&&s(!1)}function T(t){var b;if(t.key==="Escape"){t.preventDefault(),s(!1);return}if(t.key!=="Tab")return;const e=Array.from(((b=m.value)==null?void 0:b.querySelectorAll("button:not([disabled])"))??[]),l=e[0],f=e[e.length-1];!l||!f||(t.shiftKey&&document.activeElement===l?(t.preventDefault(),f.focus()):!t.shiftKey&&document.activeElement===f&&(t.preventDefault(),l.focus()))}return E(c,async t=>{var e;if(t){i=document.activeElement instanceof HTMLElement?document.activeElement:null,await k(),(e=p.value)==null||e.focus();return}await k(),i==null||i.focus(),i=null}),O(()=>{o==null||o(!1),o=null}),x({open:h}),(t,e)=>(_(),B(M,{to:"body"},[D($,{name:"app-confirm"},{default:A(()=>[c.value?(_(),I("div",{key:0,class:"app-confirm-overlay",onMousedown:K(w,["self"])},[a("section",{ref_key:"dialogRef",ref:m,class:v(["app-confirm-dialog",`is-${n.tone}`]),role:"alertdialog","aria-modal":!0,"aria-labelledby":d,"aria-describedby":y,onKeydown:T},[a("header",V,[a("div",z,[a("span",H,[a("i",{class:v(n.tone==="primary"?"fa fa-question-circle":"fa fa-exclamation-triangle")},null,2)]),a("h2",{id:d},r(n.title),1)]),a("button",{class:"app-confirm-close",type:"button","aria-label":"关闭确认弹窗",onClick:e[0]||(e[0]=l=>s(!1))},[...e[3]||(e[3]=[a("i",{class:"fa fa-times","aria-hidden":"true"},null,-1)])])]),a("div",L,[a("p",{id:y},r(n.message),1)]),a("footer",P,[a("button",{ref_key:"cancelButtonRef",ref:p,class:"app-confirm-button is-cancel",type:"button",onClick:e[1]||(e[1]=l=>s(!1))},r(n.cancelText),513),a("button",{class:"app-confirm-button is-confirm",type:"button",onClick:e[2]||(e[2]=l=>s(!0))},r(n.confirmText),1)])],34)],32)):q("",!0)]),_:1})]))}}),G=R(S,[["__scopeId","data-v-398df98e"]]);export{G as A};
|
||||||
1
frontend/dist/assets/AppConfirmDialog-zFD3Iwu_.css
vendored
Normal file
1
frontend/dist/assets/AppConfirmDialog-zFD3Iwu_.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.app-confirm-overlay[data-v-398df98e]{position:fixed;z-index:2000;top:0;right:0;bottom:0;left:0;display:grid;place-items:center;padding:20px;box-sizing:border-box;background:#0f172a70}.app-confirm-dialog[data-v-398df98e]{position:relative;width:min(480px,100%);overflow:hidden;background:#fff;border:1px solid #dfe3ea;border-radius:8px;box-shadow:0 12px 28px #0f172a29}.app-confirm-header[data-v-398df98e]{display:flex;min-height:52px;align-items:center;justify-content:space-between;gap:16px;padding:0 10px 0 20px;border-bottom:1px solid #e7eaf0}.app-confirm-heading[data-v-398df98e]{display:flex;min-width:0;align-items:center;gap:10px}.app-confirm-heading h2[data-v-398df98e]{margin:0;overflow:hidden;color:#273142;font-size:15px;font-weight:650;line-height:1.4;text-overflow:ellipsis;white-space:nowrap}.app-confirm-close[data-v-398df98e]{display:inline-grid;width:32px;height:32px;flex:0 0 32px;place-items:center;padding:0;color:#7b8495;background:transparent;border:0;border-radius:4px;cursor:pointer;transition:color .18s ease,background-color .18s ease}.app-confirm-close[data-v-398df98e]:hover{color:#273142;background:#f2f4f7}.app-confirm-close[data-v-398df98e]:focus-visible{outline:2px solid rgba(91,80,242,.45);outline-offset:1px}.app-confirm-icon[data-v-398df98e]{display:inline-grid;width:28px;height:28px;flex:0 0 28px;place-items:center;color:#a15c07;background:#fff8e6;border:1px solid #f3dfad;border-radius:6px;font-size:13px}.app-confirm-dialog.is-danger .app-confirm-icon[data-v-398df98e]{color:#c43232;background:#fff1f1;border-color:#f2c7c7}.app-confirm-dialog.is-primary .app-confirm-icon[data-v-398df98e]{color:#4f46e5;background:#f3f2ff;border-color:#d9d6ff}.app-confirm-body[data-v-398df98e]{padding:16px 20px 18px}.app-confirm-body p[data-v-398df98e]{margin:0;color:#5f6878;font-size:13px;line-height:1.7}.app-confirm-actions[data-v-398df98e]{display:flex;justify-content:flex-end;gap:8px;padding:10px 14px;background:#f8f9fb;border-top:1px solid #e7eaf0}.app-confirm-button[data-v-398df98e]{height:34px;min-width:72px;padding:0 13px;color:#344054;font-size:13px;font-weight:500;background:#fff;border:1px solid #cfd5df;border-radius:4px;cursor:pointer;transition:border-color .18s ease,background-color .18s ease,color .18s ease}.app-confirm-button[data-v-398df98e]:hover{color:#273142;background:#f2f4f7;border-color:#b9c1cd}.app-confirm-button[data-v-398df98e]:focus-visible{outline:2px solid rgba(91,80,242,.45);outline-offset:1px}.app-confirm-button.is-confirm[data-v-398df98e]{color:#fff;background:#a15c07;border-color:#a15c07}.app-confirm-button.is-confirm[data-v-398df98e]:hover{background:#844b06;border-color:#844b06}.app-confirm-dialog.is-danger .app-confirm-button.is-confirm[data-v-398df98e]{background:#c43232;border-color:#c43232}.app-confirm-dialog.is-danger .app-confirm-button.is-confirm[data-v-398df98e]:hover{background:#a92828;border-color:#a92828}.app-confirm-dialog.is-primary .app-confirm-button.is-confirm[data-v-398df98e]{background:#4f46e5;border-color:#4f46e5}.app-confirm-dialog.is-primary .app-confirm-button.is-confirm[data-v-398df98e]:hover{background:#4338ca;border-color:#4338ca}.app-confirm-enter-active[data-v-398df98e],.app-confirm-leave-active[data-v-398df98e]{transition:opacity .18s ease}.app-confirm-enter-active .app-confirm-dialog[data-v-398df98e],.app-confirm-leave-active .app-confirm-dialog[data-v-398df98e]{transition:opacity .18s ease,transform .18s ease}.app-confirm-enter-from[data-v-398df98e],.app-confirm-leave-to[data-v-398df98e]{opacity:0}.app-confirm-enter-from .app-confirm-dialog[data-v-398df98e],.app-confirm-leave-to .app-confirm-dialog[data-v-398df98e]{opacity:0;transform:translateY(4px)}@media(max-width:520px){.app-confirm-overlay[data-v-398df98e]{padding:12px}.app-confirm-actions[data-v-398df98e]{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.app-confirm-button[data-v-398df98e]{height:auto;min-width:0;min-height:44px}}@media(prefers-reduced-motion:reduce){.app-confirm-enter-active[data-v-398df98e],.app-confirm-leave-active[data-v-398df98e],.app-confirm-enter-active .app-confirm-dialog[data-v-398df98e],.app-confirm-leave-active .app-confirm-dialog[data-v-398df98e]{transition:none}}
|
||||||
1
frontend/dist/assets/CompareChatView-BZJACmmb.js
vendored
Normal file
1
frontend/dist/assets/CompareChatView-BZJACmmb.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{a as N,E as B}from"./el-form-item-D5kF3B90.js";import{E as K}from"./index-TFUf94PZ.js";import{E as M}from"./index-BjEW7-SA.js";import{E as h}from"./index-BDEF353-.js";import{E as I}from"./el-divider-DjByQoml.js";import{E as R}from"./el-slider-DYENF1-i.js";import{d as F,G as z,e as V,w as l,ac as D,y as L,o as f,s as a,x as p,q as $,c as k,ad as j,aa as E,M as A,g as G,f as J,v as O,z as H,j as _,A as P}from"./index-BKKvzUDD.js";import"./el-popper-D6_hxRbQ.js";import"./el-tooltip-l0sNRNKZ.js";import"./el-input-number-BFR4pu1i.js";/* empty css */import{P as Q}from"./PageCard-BoKXOzst.js";import{u as W}from"./usePolling-C6448AR2.js";import{a as X}from"./compare-CZ4TIoIW.js";import{_ as Y}from"./_plugin-vue_export-helper-DlAUqK2U.js";import"./castArray-5uErZEc3.js";import"./_baseClone-B5RBzbh5.js";import"./raf-C-x62Pcl.js";import"./index-DLzof2Fz.js";import"./index-GAnQrJsQ.js";import"./debounce-ByXPNh5F.js";import"./toNumber-Dkj3QRv9.js";import"./clamp-CbbY8h6F.js";import"./index-CWUnzf90.js";import"./index-BENk7lZo.js";import"./el-card-CApHJ1Gj.js";const Z={class:"model-list"},tt={key:0,class:"empty-hint"},et=F({__name:"CompareChatView",setup(ot){const b=D(),y=O(),x=b.params.id,n=L(null),e=H({systemPrompt:"",question:"",temperature:.7,topP:.9,topK:40,maxTokens:2048}),m=_(()=>{var s;if(!((s=n.value)!=null&&s.load_status))return[];try{return(typeof n.value.load_status=="string"?JSON.parse(n.value.load_status):n.value.load_status).loaded_models||[]}catch{return[]}}),T=_(()=>m.value.length>0&&m.value.every(s=>s.status==="ready"||s.status==="running")),g=_(()=>m.value.some(s=>s.status==="starting"));async function c(){try{n.value=await X(x)}catch{}}function C(){var u,i;if(!e.question.trim()){P.warning("请输入问题");return}if(g.value){P.warning("模型仍在启动中,请稍候");return}const s=new URLSearchParams({taskId:x,taskName:((u=n.value)==null?void 0:u.model_name)||((i=n.value)==null?void 0:i.name)||"",question:e.question,systemPrompt:e.systemPrompt,temperature:String(e.temperature),topP:String(e.topP),topK:String(e.topK),maxTokens:String(e.maxTokens)}),t=y.resolve(`/model-compare/result?${s.toString()}`).href;window.open(t,"_blank")}const{start:S}=W(c,5e3,{immediate:!1});return z(async()=>{await c(),S()}),(s,t)=>{const u=I,i=h,v=K,r=N,d=R,w=M,q=B;return f(),V(Q,{title:"模型对比配置"},{default:l(()=>[a(u,{"content-position":"left"},{default:l(()=>[...t[7]||(t[7]=[p("已启动模型",-1)])]),_:1}),$("div",Z,[(f(!0),k(A,null,j(m.value,(o,U)=>(f(),V(i,{key:U,type:o.status==="ready"||o.status==="running"?"success":o.status==="starting"?"warning":"danger",size:"large"},{default:l(()=>[p(E(o.model_name)+" ("+E(o.status)+") ",1)]),_:2},1032,["type"]))),128)),m.value.length?G("",!0):(f(),k("span",tt,"暂无已启动模型"))]),a(u,{"content-position":"left"},{default:l(()=>[...t[8]||(t[8]=[p("对话配置",-1)])]),_:1}),a(q,{"label-width":"120px",style:{"max-width":"700px"}},{default:l(()=>[a(r,{label:"系统提示词"},{default:l(()=>[a(v,{modelValue:e.systemPrompt,"onUpdate:modelValue":t[0]||(t[0]=o=>e.systemPrompt=o),type:"textarea",rows:3,placeholder:"可选"},null,8,["modelValue"])]),_:1}),a(r,{label:"问题"},{default:l(()=>[a(v,{modelValue:e.question,"onUpdate:modelValue":t[1]||(t[1]=o=>e.question=o),type:"textarea",rows:4,placeholder:"请输入要对比的问题"},null,8,["modelValue"])]),_:1}),a(r,{label:"Temperature"},{default:l(()=>[a(d,{modelValue:e.temperature,"onUpdate:modelValue":t[2]||(t[2]=o=>e.temperature=o),min:0,max:2,step:.1,"show-input":"",style:{"max-width":"500px"}},null,8,["modelValue"])]),_:1}),a(r,{label:"Top-p"},{default:l(()=>[a(d,{modelValue:e.topP,"onUpdate:modelValue":t[3]||(t[3]=o=>e.topP=o),min:0,max:1,step:.05,"show-input":"",style:{"max-width":"500px"}},null,8,["modelValue"])]),_:1}),a(r,{label:"Top-k"},{default:l(()=>[a(d,{modelValue:e.topK,"onUpdate:modelValue":t[4]||(t[4]=o=>e.topK=o),min:1,max:100,step:1,"show-input":"",style:{"max-width":"500px"}},null,8,["modelValue"])]),_:1}),a(r,{label:"Max Tokens"},{default:l(()=>[a(d,{modelValue:e.maxTokens,"onUpdate:modelValue":t[5]||(t[5]=o=>e.maxTokens=o),min:256,max:4096,step:128,"show-input":"",style:{"max-width":"500px"}},null,8,["modelValue"])]),_:1}),a(r,null,{default:l(()=>[a(w,{type:"primary",disabled:!T.value||g.value,onClick:C},{default:l(()=>[...t[9]||(t[9]=[p(" 开始对比 ",-1)])]),_:1},8,["disabled"]),a(w,{onClick:t[6]||(t[6]=o=>J(y).back())},{default:l(()=>[...t[10]||(t[10]=[p("返回",-1)])]),_:1})]),_:1})]),_:1})]),_:1})}}}),qt=Y(et,[["__scopeId","data-v-5da55d96"]]);export{qt as default};
|
||||||
1
frontend/dist/assets/CompareChatView-wm3b2ZD9.css
vendored
Normal file
1
frontend/dist/assets/CompareChatView-wm3b2ZD9.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.model-list[data-v-5da55d96]{display:flex;flex-wrap:wrap;gap:12px;margin-bottom:12px}.empty-hint[data-v-5da55d96]{color:#909399}
|
||||||
1
frontend/dist/assets/CompareResultView-DlLosvn8.css
vendored
Normal file
1
frontend/dist/assets/CompareResultView-DlLosvn8.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.compare-result[data-v-4c4e03d7]{max-width:1200px;margin:0 auto}.result-header[data-v-4c4e03d7]{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.result-header h2[data-v-4c4e03d7]{font-size:18px;font-weight:500;margin:0}.question-box[data-v-4c4e03d7]{margin-bottom:20px}.result-grid[data-v-4c4e03d7]{display:grid;grid-template-columns:repeat(auto-fit,minmax(420px,1fr));gap:16px}.result-card .card-header[data-v-4c4e03d7]{display:flex;align-items:center;justify-content:space-between}.result-card .card-header .model-name[data-v-4c4e03d7]{font-weight:500;color:#303133}.result-card .loading-text[data-v-4c4e03d7],.result-card .error-text[data-v-4c4e03d7]{color:#909399;min-height:80px;display:flex;align-items:center;justify-content:center}.result-card .error-text[data-v-4c4e03d7]{color:#f56c6c}.result-card .streaming-text[data-v-4c4e03d7]{min-height:80px;line-height:1.7;white-space:pre-wrap;word-break:break-word}.result-card .result-stats[data-v-4c4e03d7]{display:flex;gap:16px;margin-top:12px;padding-top:12px;border-top:1px solid #ebeef5;font-size:12px;color:#909399}
|
||||||
1
frontend/dist/assets/CompareResultView-yTJ0EdKT.js
vendored
Normal file
1
frontend/dist/assets/CompareResultView-yTJ0EdKT.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{E as D}from"./el-alert-xVLeNUgJ.js";import{E as $}from"./index-BjEW7-SA.js";import{E as z}from"./index-BDEF353-.js";import{E as F}from"./el-card-CApHJ1Gj.js";import{d as O,G as U,H as J,c as d,q as i,aa as c,f as v,s as N,w as m,M as K,ad as A,ac as G,y as g,o as r,x as p,e as y,g as H}from"./index-BKKvzUDD.js";/* empty css */import{_ as L}from"./MarkdownView.vue_vue_type_style_index_0_lang-BPqmX54I.js";import{a as W,c as j}from"./compare-CZ4TIoIW.js";import{_ as Q}from"./_plugin-vue_export-helper-DlAUqK2U.js";import"./vnode-78qeDweP.js";const X={class:"compare-result"},Y={class:"result-header"},Z={class:"header-actions"},tt={class:"result-grid"},et={class:"card-header"},st={class:"model-name"},ot={key:0,class:"error-text"},at={key:1,class:"streaming-text"},nt={key:3,class:"loading-text"},lt={key:4,class:"result-stats"},rt=O({__name:"CompareResultView",setup(it){const u=G(),q=u.query.taskId,k=decodeURIComponent(u.query.question||""),h=decodeURIComponent(u.query.systemPrompt||""),E=Number(u.query.temperature||.7),P=Number(u.query.topP||.9),I=Number(u.query.topK||40),b=Number(u.query.maxTokens||2048),w=u.query.taskName,l=g([]),C=g(!1),x=g([]),f=new Set;async function B(){if(!C.value){C.value=!0;try{const s=await W(q);let t=[];s.load_status&&(t=(typeof s.load_status=="string"?JSON.parse(s.load_status):s.load_status).loaded_models||[]),x.value=t,l.value=t.map(o=>({name:o.model_name||"模型",content:"",displayContent:"",isTyping:!1,status:"loading"})),await Promise.all(t.map((o,e)=>S(o,e)))}catch{}}}async function S(s,t){const o=Date.now();try{const e=await V(j({port:s.port,model_name:s.model_name,messages:[...h?[{role:"system",content:h}]:[],{role:"user",content:k}],temperature:E,top_p:P,top_k:I,max_tokens:b}),3e5),a=(e==null?void 0:e.response)||(e==null?void 0:e.content)||(e==null?void 0:e.data)||JSON.stringify(e),_=(Date.now()-o)/1e3;l.value[t].content=a,l.value[t].status="done",l.value[t].stats={totalTime:_,charsPerSec:_>0?Number((a.length/_).toFixed(1)):0},M(t,a)}catch(e){l.value[t].content="推理失败: "+(e.message||""),l.value[t].status="error"}}async function V(s,t){let o=null;try{return await Promise.race([s,new Promise((e,a)=>{o=setTimeout(()=>a(new Error("推理超时")),t)})])}finally{o&&clearTimeout(o)}}function M(s,t){let o=0;l.value[s].isTyping=!0;const e=Math.max(2,Math.ceil(t.length/30)),a=setInterval(()=>{o+=e,l.value[s].displayContent=t.slice(0,o),o>=t.length&&(clearInterval(a),f.delete(a),l.value[s].displayContent=t,l.value[s].isTyping=!1)},50);f.add(a)}return U(B),J(()=>{f.forEach(clearInterval),f.clear()}),(s,t)=>{const o=$,e=D,a=z,_=F;return r(),d("div",X,[i("div",Y,[i("h2",null,"对比结果"+c(v(w)?` - ${v(w)}`:""),1),i("div",Z,[N(o,{onClick:t[0]||(t[0]=n=>s.$router.push("/model-inference"))},{default:m(()=>[...t[1]||(t[1]=[p("返回列表",-1)])]),_:1})])]),N(e,{type:"info",closable:!1,"show-icon":"",class:"question-box"},{title:m(()=>[t[2]||(t[2]=i("strong",null,"问题:",-1)),p(c(v(k)),1)]),_:1}),i("div",tt,[(r(!0),d(K,null,A(l.value,(n,R)=>(r(),y(_,{key:R,shadow:"hover",class:"result-card"},{header:m(()=>[i("div",et,[i("span",st,c(n.name),1),n.status==="loading"?(r(),y(a,{key:0,type:"warning",size:"small"},{default:m(()=>[...t[3]||(t[3]=[p("生成中...",-1)])]),_:1})):n.status==="done"?(r(),y(a,{key:1,type:"success",size:"small"},{default:m(()=>[...t[4]||(t[4]=[p("完成",-1)])]),_:1})):(r(),y(a,{key:2,type:"danger",size:"small"},{default:m(()=>[...t[5]||(t[5]=[p("失败",-1)])]),_:1}))])]),default:m(()=>{var T;return[n.status==="error"?(r(),d("div",ot,c(n.content),1)):n.isTyping?(r(),d("div",at,c(n.displayContent),1)):n.displayContent?(r(),y(L,{key:2,content:n.displayContent},null,8,["content"])):(r(),d("div",nt,[...t[6]||(t[6]=[i("i",{class:"fa fa-spinner fa-spin"},null,-1),p(" 正在生成回答... ",-1)])])),n.stats?(r(),d("div",lt,[i("span",null,"耗时 "+c((T=n.stats.totalTime)==null?void 0:T.toFixed(1))+"s",1),i("span",null,"速度 "+c(n.stats.charsPerSec)+" 字/秒",1)])):H("",!0)]}),_:2},1024))),128))])])}}}),kt=Q(rt,[["__scopeId","data-v-4c4e03d7"]]);export{kt as default};
|
||||||
1
frontend/dist/assets/ComputeNodesView-BKuDW-um.css
vendored
Normal file
1
frontend/dist/assets/ComputeNodesView-BKuDW-um.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.compute-page[data-v-daf09639]{display:flex;flex-direction:column;gap:16px;min-height:0;height:100%;padding:24px;background:#fff}.compute-header[data-v-daf09639]{display:flex;justify-content:space-between;gap:16px;align-items:flex-start}.compute-header h1[data-v-daf09639]{margin:0;font-size:24px;font-weight:650;color:#111827}.compute-header p[data-v-daf09639]{margin:8px 0 0;color:#64748b}.header-actions[data-v-daf09639]{display:flex;align-items:center;gap:12px}.last-updated[data-v-daf09639],.muted[data-v-daf09639]{color:#64748b;font-size:12px}.summary-grid[data-v-daf09639]{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.summary-tile[data-v-daf09639]{border:1px solid #e5e7eb;border-radius:8px;padding:14px 16px;background:#f8fafc}.summary-tile span[data-v-daf09639]{display:block;color:#64748b;font-size:12px}.summary-tile strong[data-v-daf09639]{display:block;margin-top:8px;color:#111827;font-size:24px}.compute-tabs[data-v-daf09639]{flex:1;min-height:0}.compute-tabs[data-v-daf09639] .el-tabs__content{height:calc(100% - 56px)}.compute-tabs[data-v-daf09639] .el-tab-pane{height:100%}.mono[data-v-daf09639]{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px}.replica-toolbar[data-v-daf09639]{display:flex;gap:12px;align-items:center;margin-bottom:12px}.replica-toolbar .el-select[data-v-daf09639]{width:280px}@media(max-width:960px){.compute-header[data-v-daf09639],.header-actions[data-v-daf09639],.replica-toolbar[data-v-daf09639]{flex-direction:column;align-items:stretch}.summary-grid[data-v-daf09639]{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||||
1
frontend/dist/assets/ComputeNodesView-Cy5hxd1h.js
vendored
Normal file
1
frontend/dist/assets/ComputeNodesView-Cy5hxd1h.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/DashboardView-BMPZwafh.css
vendored
Normal file
1
frontend/dist/assets/DashboardView-BMPZwafh.css
vendored
Normal file
File diff suppressed because one or more lines are too long
2
frontend/dist/assets/DashboardView-JKcOpMUU.js
vendored
Normal file
2
frontend/dist/assets/DashboardView-JKcOpMUU.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/DataConvertView-DJuX-JIl.css
vendored
Normal file
1
frontend/dist/assets/DataConvertView-DJuX-JIl.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.converter-panel[data-v-fcb69543]{width:100%;min-height:calc(100vh - 220px);border:1px solid #e4e7ed;border-radius:8px;background:#fff;display:flex;flex-direction:column}.panel-header[data-v-fcb69543]{min-height:72px;padding:16px 20px;border-bottom:1px solid #ebeef5;background:#fafafa;display:flex;align-items:center;gap:12px;box-sizing:border-box}.panel-header .tool-icon[data-v-fcb69543]{width:38px;height:38px;flex:0 0 auto;border-radius:6px;background:var(--el-color-primary-light-9);color:var(--primary-color);display:flex;align-items:center;justify-content:center}.panel-header h3[data-v-fcb69543]{margin:0;color:#303133;font-size:15px;font-weight:600}.panel-header p[data-v-fcb69543]{margin:4px 0 0;color:#909399;font-size:12px}.converter-form[data-v-fcb69543]{flex:1;padding:22px 24px 6px}.converter-form[data-v-fcb69543] .el-form-item{margin-bottom:20px}.converter-form[data-v-fcb69543] .el-form-item__label{padding-bottom:8px;color:#606266;font-size:13px}.format-field[data-v-fcb69543]{width:100%;min-height:40px;padding:0 14px;border:1px solid #dcdfe6;border-radius:4px;background:#f5f7fa;color:#303133;display:flex;align-items:center;gap:14px;box-sizing:border-box;font-size:13px;font-weight:500}.format-field i[data-v-fcb69543]{color:#909399}.upload-zone[data-v-fcb69543]{width:100%;min-height:112px;padding:20px;border:1px dashed #b8c4d1;border-radius:6px;background:#fafcff;color:#606266;cursor:pointer;font:inherit;display:flex;align-items:center;gap:14px;text-align:left;transition:border-color .2s ease,background .2s ease}.upload-zone[data-v-fcb69543]:hover,.upload-zone[data-v-fcb69543]:focus-visible{border-color:var(--primary-color);background:var(--el-color-primary-light-9);outline:none}.upload-zone>i[data-v-fcb69543]{color:var(--primary-color);font-size:24px}.upload-zone .upload-content[data-v-fcb69543]{min-width:0;display:flex;flex:1;flex-direction:column;gap:5px}.upload-zone strong[data-v-fcb69543]{color:#303133;font-size:13px;font-weight:500}.upload-zone small[data-v-fcb69543]{color:#909399;font-size:12px}.upload-zone .select-button[data-v-fcb69543]{min-height:32px;padding:0 14px;border:1px solid #dcdfe6;border-radius:4px;background:#fff;color:#606266;display:inline-flex;align-items:center;white-space:nowrap}.form-row[data-v-fcb69543]{display:grid;grid-template-columns:minmax(0,2fr) minmax(180px,1fr);gap:16px}.format-tip[data-v-fcb69543]{min-height:38px;padding:9px 12px;border-radius:4px;background:var(--el-color-primary-light-9);color:#606266;display:flex;align-items:center;gap:8px;box-sizing:border-box;font-size:12px}.format-tip i[data-v-fcb69543]{color:var(--primary-color)}.panel-footer[data-v-fcb69543]{min-height:64px;padding:12px 24px;border-top:1px solid #ebeef5;background:#fafafa;display:flex;align-items:center;justify-content:space-between;gap:20px;box-sizing:border-box}.panel-footer .prototype-label[data-v-fcb69543]{color:#909399;font-size:12px}.panel-footer .actions[data-v-fcb69543]{display:flex;gap:10px}@media(max-width:640px){.converter-form[data-v-fcb69543]{padding:18px 16px 4px}.form-row[data-v-fcb69543]{grid-template-columns:1fr;gap:0}.upload-zone[data-v-fcb69543]{align-items:flex-start;flex-wrap:wrap}.upload-zone .select-button[data-v-fcb69543]{margin-left:38px}.panel-footer[data-v-fcb69543]{padding:12px 16px;align-items:flex-end;flex-direction:column}}
|
||||||
1
frontend/dist/assets/DataConvertView-DYHO4l1y.js
vendored
Normal file
1
frontend/dist/assets/DataConvertView-DYHO4l1y.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{a as N,E as v}from"./el-form-item-D5kF3B90.js";import{E as b}from"./el-popper-D6_hxRbQ.js";import{E as O}from"./index-TFUf94PZ.js";import{E as S}from"./index-BjEW7-SA.js";import{E as g,a as E}from"./el-select-Dl-FRZQd.js";import{d as J,e as V,w as e,o as w,q as t,s as l,x as i,z as x,A as C}from"./index-BKKvzUDD.js";import"./el-tooltip-l0sNRNKZ.js";import"./el-scrollbar-ClJnvz-9.js";/* empty css */import{P as U}from"./PageCard-BoKXOzst.js";import{_ as y}from"./_plugin-vue_export-helper-DlAUqK2U.js";import"./castArray-5uErZEc3.js";import"./_baseClone-B5RBzbh5.js";import"./index-GAnQrJsQ.js";import"./index-CWUnzf90.js";import"./raf-C-x62Pcl.js";import"./index-DLzof2Fz.js";import"./vnode-78qeDweP.js";import"./index-D5ryD3I5.js";import"./index-BDEF353-.js";import"./scroll-BkzZKETR.js";import"./clamp-CbbY8h6F.js";import"./toNumber-Dkj3QRv9.js";import"./_baseIteratee-BAilevJ_.js";import"./el-card-CApHJ1Gj.js";const F={class:"converter-panel"},L={class:"form-row"},T={class:"panel-footer"},B={class:"actions"},k=J({__name:"DataConvertView",setup(I){const a=x({outputName:"converted-data",encoding:"UTF-8"});function p(){C.info("当前仅完成界面设计,转换功能将在后续接入")}function d(){Object.assign(a,{outputName:"converted-data",encoding:"UTF-8"})}return(j,o)=>{const n=N,m=O,u=E,f=g,c=v,r=S,_=b;return w(),V(U,{class:"data-convert-page",title:"数据类型转换",subtitle:"将 JSON 文件转换为便于训练和评测使用的 JSONL 格式"},{default:e(()=>[t("div",F,[o[9]||(o[9]=t("div",{class:"panel-header"},[t("div",{class:"tool-icon","aria-hidden":"true"},[t("i",{class:"fa fa-exchange"})]),t("div",null,[t("h3",null,"JSON 转 JSONL"),t("p",null,"每条 JSON 数据将输出为 JSONL 文件中的一行记录")])],-1)),l(c,{class:"converter-form","label-position":"top"},{default:e(()=>[l(n,{label:"转换类型"},{default:e(()=>[...o[2]||(o[2]=[t("div",{class:"format-field","aria-label":"JSON 转 JSONL"},[t("span",null,"JSON"),t("i",{class:"fa fa-long-arrow-right","aria-hidden":"true"}),t("span",null,"JSONL")],-1)])]),_:1}),l(n,{label:"源文件",required:""},{default:e(()=>[t("button",{class:"upload-zone",type:"button",onClick:p},[...o[3]||(o[3]=[t("i",{class:"fa fa-cloud-upload","aria-hidden":"true"},null,-1),t("span",{class:"upload-content"},[t("strong",null,"点击选择或拖拽 JSON 文件到此处"),t("small",null,"仅支持 .json 格式,单文件不超过 200 MB")],-1),t("span",{class:"select-button"},"选择文件",-1)])])]),_:1}),t("div",L,[l(n,{label:"输出文件名"},{default:e(()=>[l(m,{modelValue:a.outputName,"onUpdate:modelValue":o[0]||(o[0]=s=>a.outputName=s)},{append:e(()=>[...o[4]||(o[4]=[i(".jsonl",-1)])]),_:1},8,["modelValue"])]),_:1}),l(n,{label:"字符编码"},{default:e(()=>[l(f,{modelValue:a.encoding,"onUpdate:modelValue":o[1]||(o[1]=s=>a.encoding=s),style:{width:"100%"}},{default:e(()=>[l(u,{label:"UTF-8",value:"UTF-8"})]),_:1},8,["modelValue"])]),_:1})]),o[5]||(o[5]=t("div",{class:"format-tip"},[t("i",{class:"fa fa-info-circle","aria-hidden":"true"}),t("span",null,"支持由 JSON 数组转换为 JSONL,每个数组元素输出为一行。")],-1))]),_:1}),t("div",T,[o[8]||(o[8]=t("span",{class:"prototype-label"},"当前为 UI 原型,暂不执行实际转换",-1)),t("div",B,[l(r,{onClick:d},{default:e(()=>[...o[6]||(o[6]=[i("重置",-1)])]),_:1}),l(_,{content:"转换功能将在后续开发中接入",placement:"top"},{default:e(()=>[t("span",null,[l(r,{type:"primary",disabled:""},{default:e(()=>[...o[7]||(o[7]=[i("开始转换",-1)])]),_:1})])]),_:1})])])])]),_:1})}}}),rt=y(k,[["__scopeId","data-v-fcb69543"]]);export{rt as default};
|
||||||
12
frontend/dist/assets/DataProcessCreateView-BEQjbuB9.js
vendored
Normal file
12
frontend/dist/assets/DataProcessCreateView-BEQjbuB9.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/DataProcessCreateView-DyU55R2b.css
vendored
Normal file
1
frontend/dist/assets/DataProcessCreateView-DyU55R2b.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/DataProcessDetailView-B1q0P6u8.css
vendored
Normal file
1
frontend/dist/assets/DataProcessDetailView-B1q0P6u8.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/DataProcessDetailView-DrQeE7-I.js
vendored
Normal file
1
frontend/dist/assets/DataProcessDetailView-DrQeE7-I.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/DataProcessListView-D6X_r5Q_.css
vendored
Normal file
1
frontend/dist/assets/DataProcessListView-D6X_r5Q_.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.action-buttons[data-v-c0da9336]{display:flex;justify-content:center;gap:8px}
|
||||||
1
frontend/dist/assets/DataProcessListView-Ds7sKAJx.js
vendored
Normal file
1
frontend/dist/assets/DataProcessListView-Ds7sKAJx.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{E as b}from"./index-BjEW7-SA.js";import{E as k}from"./index-BDEF353-.js";import{E as x}from"./el-table-D8K5zLyz.js";import{d as v,c as m,s as e,w as a,y as D,o as l,q as p,x as i,e as C,aa as n,A as E,v as T}from"./index-BKKvzUDD.js";/* empty css */import"./el-checkbox-qGZ2-RM2.js";import{D as z}from"./DataTablePage-C-EM-7-s.js";import{_ as B}from"./ModelStatusTag.vue_vue_type_script_setup_true_lang-VFIQNBU2.js";import{_ as V}from"./_plugin-vue_export-helper-DlAUqK2U.js";import"./el-scrollbar-ClJnvz-9.js";import"./index-GAnQrJsQ.js";import"./el-popper-D6_hxRbQ.js";import"./index-CWUnzf90.js";import"./_baseClone-B5RBzbh5.js";import"./_baseIteratee-BAilevJ_.js";import"./castArray-5uErZEc3.js";import"./debounce-ByXPNh5F.js";import"./toNumber-Dkj3QRv9.js";import"./raf-C-x62Pcl.js";import"./omit-BTSq4AYh.js";import"./index-TFUf94PZ.js";import"./index-DLzof2Fz.js";import"./el-card-CApHJ1Gj.js";import"./el-pagination-DOPYW-qb.js";import"./el-select-Dl-FRZQd.js";import"./vnode-78qeDweP.js";import"./index-D5ryD3I5.js";import"./scroll-BkzZKETR.js";import"./clamp-CbbY8h6F.js";import"./directive-DsZckUTJ.js";import"./index-dNo5H1ie.js";import"./validator-Fn0bm4xU.js";import"./index-CQXVR1Ud.js";import"./index-DfyOU94W.js";import"./el-tooltip-l0sNRNKZ.js";const L={class:"data-process-page",style:{height:"100%"}},N={key:1},I={class:"action-buttons"},P=v({__name:"DataProcessListView",setup(S){const u={structured:"结构化数据",unstructured:"非结构化数据",external:"外来数据源拉取"},d=T(),_=D([{id:183921,name:"客服问答数据清洗",status:"completed",process_type:"structured",source_dataset:"客服对话原始集",output_dataset:"客服对话清洗集",create_time:"2026-07-08 14:23:00"},{id:492015,name:"指令微调数据构造",status:"running",process_type:"unstructured",source_dataset:"通用语料库",output_dataset:"SFT 指令集",create_time:"2026-07-09 09:10:00"},{id:731948,name:"敏感信息脱敏处理",status:"pending",process_type:"structured",source_dataset:"用户反馈数据",create_time:"2026-07-09 16:45:00"},{id:582012,name:"多轮对话拼接",status:"failed",process_type:"structured",source_dataset:"单轮问答集",create_time:"2026-07-10 08:30:00"}]);function f(s){const r=s.id;d.push({name:"data-process-detail",params:{id:r}})}function g(s){E.info("删除功能开发中...")}function y(s){return s?new Date(s).toLocaleString("zh-CN",{hour12:!1}):"-"}return(s,r)=>{const o=x,h=k,c=b;return l(),m("div",L,[e(z,{title:"",data:_.value,searchable:"","search-fields":["name"],"create-text":"新建数据处理","create-to":"/data-process/create","row-key":"id","page-size":10},{columns:a(()=>[e(o,{label:"任务ID",prop:"id",align:"center",width:"100"}),e(o,{label:"任务名称",prop:"name",align:"center","show-overflow-tooltip":""}),e(o,{label:"任务状态",align:"center",width:"110"},{default:a(({row:t})=>[e(B,{status:t.status},null,8,["status"])]),_:1}),e(o,{label:"处理类型",align:"center",width:"140"},{default:a(({row:t})=>[t.process_type?(l(),C(h,{key:0,size:"small",type:"info",effect:"plain"},{default:a(()=>[i(n(u[t.process_type]||t.process_type),1)]),_:2},1024)):(l(),m("span",N,"-"))]),_:1}),e(o,{label:"源数据集",align:"center","show-overflow-tooltip":""},{default:a(({row:t})=>[i(n(t.source_dataset||"-"),1)]),_:1}),e(o,{label:"输出数据集",align:"center","show-overflow-tooltip":""},{default:a(({row:t})=>[i(n(t.output_dataset||"-"),1)]),_:1}),e(o,{label:"创建时间",align:"center",width:"190"},{default:a(({row:t})=>[i(n(y(t.create_time)),1)]),_:1})]),actions:a(({row:t})=>[p("div",I,[e(c,{type:"primary",link:"",size:"small",onClick:w=>f(t)},{default:a(()=>[...r[0]||(r[0]=[p("i",{class:"fa fa-file-text-o",style:{"margin-right":"4px"}},null,-1),i("详情 ",-1)])]),_:1},8,["onClick"]),e(c,{type:"danger",link:"",size:"small",onClick:w=>g(t)},{default:a(()=>[...r[1]||(r[1]=[p("i",{class:"fa fa-trash-o",style:{"margin-right":"4px"}},null,-1),i("删除 ",-1)])]),_:1},8,["onClick"])])]),_:1},8,["data"])])}}}),yt=V(P,[["__scopeId","data-v-c0da9336"]]);export{yt as default};
|
||||||
1
frontend/dist/assets/DataTablePage-C-EM-7-s.js
vendored
Normal file
1
frontend/dist/assets/DataTablePage-C-EM-7-s.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{E as M}from"./index-TFUf94PZ.js";import{E as Z}from"./index-BjEW7-SA.js";import{E as A}from"./el-card-CApHJ1Gj.js";import{E as G}from"./el-pagination-DOPYW-qb.js";import{E as H,a as J}from"./el-table-D8K5zLyz.js";import{v as O}from"./directive-DsZckUTJ.js";import{E as b}from"./index-dNo5H1ie.js";import{d as Q,y as f,D as _,c as D,s as p,w as r,o as u,q as s,h as w,e as g,g as v,x as y,aa as k,Z as W,K as X,j as $,v as Y}from"./index-BKKvzUDD.js";import"./el-scrollbar-ClJnvz-9.js";import"./el-popper-D6_hxRbQ.js";/* empty css */import"./el-select-Dl-FRZQd.js";import"./el-tooltip-l0sNRNKZ.js";import"./el-checkbox-qGZ2-RM2.js";import{_ as ee}from"./_plugin-vue_export-helper-DlAUqK2U.js";const te={class:"data-table-page"},ae={class:"table-toolbar"},le={class:"table-title"},oe={class:"toolbar-actions"},se={key:0,class:"batch-bar"},ne={class:"batch-info"},re={class:"table-body"},ie={class:"table-pagination"},ce=Q({__name:"DataTablePage",props:{title:{},data:{},loading:{type:Boolean,default:!1},searchable:{type:Boolean,default:!1},searchFields:{},multiSelect:{type:Boolean,default:!1},createText:{},createTo:{},deleteFn:{},rowKey:{default:"id"},emptyText:{default:"暂无数据"},pageSize:{default:10},pageSizes:{default:()=>[10,20,50,100]}},emits:["create","refresh"],setup(l,{expose:F,emit:V}){const a=l,x=V,P=Y(),S=f(),o=f(new Set),d=f(""),m=f(1),h=f(a.pageSize);function z(t){return t[a.rowKey]??t.name??t.id}const T=$(()=>{var e;if(!d.value.trim()||!((e=a.searchFields)!=null&&e.length))return a.data;const t=d.value.toLowerCase().trim();return a.data.filter(i=>a.searchFields.some(n=>String(i[n]??"").toLowerCase().includes(t)))}),I=$(()=>{const t=(m.value-1)*h.value;return T.value.slice(t,t+h.value)});function K(t){o.value=new Set(t.map(z))}async function E(t,e="确定要删除这条记录吗?"){if(a.deleteFn)try{await b.confirm(e,"确认删除",{type:"warning",confirmButtonText:"确定",cancelButtonText:"取消"}),await a.deleteFn(t),x("refresh")}catch{}}async function N(){if(!(o.value.size===0||!a.deleteFn))try{await b.confirm(`确定要删除选中的 ${o.value.size} 条记录吗?`,"批量删除",{type:"warning"});const t=a.data.filter(n=>o.value.has(z(n)));let e=0,i=0;for(const n of t)try{await a.deleteFn(n),e++}catch{i++}o.value.clear(),x("refresh"),i===0?b.alert(`成功删除 ${e} 条记录`,"成功",{type:"success"}):b.alert(`成功 ${e} 条,失败 ${i} 条`,"部分失败",{type:"warning"})}catch{}}function C(){var t;o.value.clear(),(t=S.value)==null||t.clearSelection()}function L(){a.createTo?P.push(a.createTo):x("create")}return _(()=>a.data,()=>{var t;o.value.clear(),m.value=1,(t=S.value)==null||t.clearSelection()}),_(()=>d.value,()=>{m.value=1}),F({handleDelete:E,clearSelection:C}),(t,e)=>{const i=M,n=Z,B=H,R=J,U=G,j=A,q=O;return u(),D("div",te,[p(j,{shadow:"never",class:"table-card"},{default:r(()=>[s("div",ae,[w(t.$slots,"title",{},()=>[s("h2",le,k(l.title),1)],!0),s("div",oe,[l.searchable?(u(),g(i,{key:0,modelValue:d.value,"onUpdate:modelValue":e[0]||(e[0]=c=>d.value=c),placeholder:`搜索${l.title}...`,clearable:"",class:"search-input"},{prefix:r(()=>[...e[3]||(e[3]=[s("i",{class:"fa fa-search"},null,-1)])]),_:1},8,["modelValue","placeholder"])):v("",!0),l.createText?(u(),g(n,{key:1,type:"primary",onClick:L},{default:r(()=>[e[4]||(e[4]=s("i",{class:"fa fa-plus",style:{"margin-right":"4px"}},null,-1)),y(" "+k(l.createText),1)]),_:1})):v("",!0),w(t.$slots,"toolbar-extra",{},void 0,!0)])]),p(W,{name:"el-zoom-in-top"},{default:r(()=>[l.multiSelect&&o.value.size>0?(u(),D("div",se,[s("span",ne,[e[6]||(e[6]=y(" 已选择 ",-1)),s("strong",null,k(o.value.size),1),e[7]||(e[7]=y(" 项 ",-1)),p(n,{link:"",type:"primary",onClick:C},{default:r(()=>[...e[5]||(e[5]=[y("取消选择",-1)])]),_:1})]),p(n,{type:"danger",size:"small",onClick:N},{default:r(()=>[e[8]||(e[8]=s("i",{class:"fa fa-trash",style:{"margin-right":"4px"}},null,-1)),y(" 批量删除 ("+k(o.value.size)+") ",1)]),_:1})])):v("",!0)]),_:1}),s("div",re,[X((u(),g(R,{ref_key:"tableRef",ref:S,data:I.value,"row-key":l.rowKey,height:"100%",style:{width:"100%"},"empty-text":l.emptyText,onSelectionChange:K},{default:r(()=>[l.multiSelect?(u(),g(B,{key:0,type:"selection",width:"50",align:"center","reserve-selection":"",fixed:"left"})):v("",!0),w(t.$slots,"columns",{},void 0,!0),t.$slots.actions?(u(),g(B,{key:1,label:"操作",align:"center",width:"220",fixed:"right"},{default:r(({row:c})=>[w(t.$slots,"actions",{row:c,handleDelete:E},void 0,!0)]),_:3})):v("",!0)]),_:3},8,["data","row-key","empty-text"])),[[q,l.loading]])]),s("div",ie,[p(U,{"current-page":m.value,"onUpdate:currentPage":e[1]||(e[1]=c=>m.value=c),"page-size":h.value,"onUpdate:pageSize":e[2]||(e[2]=c=>h.value=c),background:"",layout:"total, sizes, prev, pager, next, jumper","page-sizes":l.pageSizes,total:T.value.length},null,8,["current-page","page-size","page-sizes","total"])])]),_:3})])}}}),Te=ee(ce,[["__scopeId","data-v-e6e41965"]]);export{Te as D};
|
||||||
1
frontend/dist/assets/DataTablePage-CHPswbwi.css
vendored
Normal file
1
frontend/dist/assets/DataTablePage-CHPswbwi.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@charset "UTF-8";.data-table-page[data-v-e6e41965]{width:100%;height:100%;box-sizing:border-box;display:flex;flex-direction:column;min-height:0}.data-table-page .table-card[data-v-e6e41965]{margin-bottom:0;width:100%;flex:1;display:flex;flex-direction:column;min-height:0;overflow:hidden}.data-table-page .table-toolbar[data-v-e6e41965]{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;padding:0 12px;flex-wrap:wrap;gap:12px}.data-table-page .table-title[data-v-e6e41965]{font-size:18px;font-weight:600;color:#1e293b;margin:0;letter-spacing:-.3px}.data-table-page .toolbar-actions[data-v-e6e41965]{display:flex;align-items:center;gap:12px}.data-table-page .search-input[data-v-e6e41965]{width:280px}.data-table-page .search-input[data-v-e6e41965] .el-input__wrapper{border-radius:8px;box-shadow:0 0 0 1px #e2e8f0 inset;transition:all .2s}.data-table-page .search-input[data-v-e6e41965] .el-input__wrapper.is-focus{box-shadow:0 0 0 1px var(--primary-color) inset}.data-table-page .batch-bar[data-v-e6e41965]{display:flex;align-items:center;justify-content:space-between;background:#4f46e50d;border:1px solid rgba(79,70,229,.1);border-radius:8px;padding:10px 16px;margin:0 24px 16px}.data-table-page .batch-bar .batch-info[data-v-e6e41965]{font-size:13px;color:var(--primary-color)}.data-table-page .batch-bar .batch-info strong[data-v-e6e41965]{font-weight:600;margin:0 4px}.data-table-page .table-body[data-v-e6e41965]{flex:1;min-height:0}.data-table-page .table-pagination[data-v-e6e41965]{display:flex;justify-content:flex-end;align-items:center;padding:16px 24px;border-top:1px solid #f1f5f9}.data-table-page .table-pagination[data-v-e6e41965] .el-pagination{margin-top:0}.data-table-page[data-v-e6e41965] .el-card__body{display:flex;flex-direction:column;padding:12px 0 0;flex:1;min-height:0}.data-table-page[data-v-e6e41965] .el-table__inner-wrapper:before{display:none}.data-table-page[data-v-e6e41965] .el-table td.el-table__cell{padding:18px 0!important}.data-table-page[data-v-e6e41965] .el-table__cell .cell{line-height:22px;white-space:nowrap}.data-table-page[data-v-e6e41965] .el-table .action-buttons{display:flex;align-items:center;justify-content:center;flex-wrap:nowrap;gap:8px}
|
||||||
2
frontend/dist/assets/DatasetCreateView-ChBCun2m.js
vendored
Normal file
2
frontend/dist/assets/DatasetCreateView-ChBCun2m.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/DatasetCreateView-D2g2qvbH.css
vendored
Normal file
1
frontend/dist/assets/DatasetCreateView-D2g2qvbH.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.file-item[data-v-85ac7099]{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;border:1px solid #ebeef5;border-radius:4px;margin-top:8px}.file-info[data-v-85ac7099]{display:flex;align-items:center;gap:8px}.file-name[data-v-85ac7099]{color:#303133}.file-size[data-v-85ac7099]{color:#909399;font-size:12px}.record-count[data-v-85ac7099]{margin-top:8px;font-size:13px;color:#909399}
|
||||||
1
frontend/dist/assets/DatasetListView-BD0YX55E.js
vendored
Normal file
1
frontend/dist/assets/DatasetListView-BD0YX55E.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{E as z}from"./index-BjEW7-SA.js";import{E}from"./index-BDEF353-.js";import{E as T}from"./el-table-D8K5zLyz.js";import{d as L,G as A,e as b,w as e,y as u,j as V,o as k,q as r,s as a,x as s,g as $,aa as p,f as y,b1 as B,b2 as N,n as w,v as P,A as S}from"./index-BKKvzUDD.js";/* empty css */import"./el-checkbox-qGZ2-RM2.js";import{D as M}from"./DataTablePage-C-EM-7-s.js";import{g as R,d as G,a as I}from"./dataset-BJ2ikehQ.js";import{_ as j}from"./_plugin-vue_export-helper-DlAUqK2U.js";import"./el-scrollbar-ClJnvz-9.js";import"./index-GAnQrJsQ.js";import"./el-popper-D6_hxRbQ.js";import"./index-CWUnzf90.js";import"./_baseClone-B5RBzbh5.js";import"./_baseIteratee-BAilevJ_.js";import"./castArray-5uErZEc3.js";import"./debounce-ByXPNh5F.js";import"./toNumber-Dkj3QRv9.js";import"./raf-C-x62Pcl.js";import"./omit-BTSq4AYh.js";import"./index-TFUf94PZ.js";import"./index-DLzof2Fz.js";import"./el-card-CApHJ1Gj.js";import"./el-pagination-DOPYW-qb.js";import"./el-select-Dl-FRZQd.js";import"./vnode-78qeDweP.js";import"./index-D5ryD3I5.js";import"./scroll-BkzZKETR.js";import"./clamp-CbbY8h6F.js";import"./directive-DsZckUTJ.js";import"./index-dNo5H1ie.js";import"./validator-Fn0bm4xU.js";import"./index-CQXVR1Ud.js";import"./index-DfyOU94W.js";import"./el-tooltip-l0sNRNKZ.js";const q={class:"capsule-tabs"},O={class:"action-buttons"},U=L({__name:"DatasetListView",setup(Y){const C=P(),c=u(!1),m=u([]),n=u("upload"),D=V(()=>n.value==="task"?m.value.filter(l=>l.source==="task"):m.value.filter(l=>l.source!=="task"));async function f(){c.value=!0;try{m.value=await R()||[]}catch{}finally{c.value=!1}}async function _(l){await G(l.id),S.success("删除成功")}function h(l){C.push(`/dataset/${l.id}/preview`)}function x(l){window.open(I(l.id),"_blank")}return A(f),(l,i)=>{const o=T,g=E,d=z;return k(),b(M,{title:"数据集管理",data:D.value,loading:c.value,searchable:"","search-fields":["name","description"],"create-text":n.value==="upload"?"上传数据集":"","create-to":"/dataset/create","delete-fn":_,"row-key":"id",onRefresh:f},{title:e(()=>[r("div",q,[r("button",{class:w(["capsule-tab-item",{active:n.value==="upload"}]),onClick:i[0]||(i[0]=t=>n.value="upload")}," 上传任务 ",2),r("button",{class:w(["capsule-tab-item",{active:n.value==="task"}]),onClick:i[1]||(i[1]=t=>n.value="task")}," 数据任务 ",2)])]),columns:e(()=>[n.value==="task"?(k(),b(o,{key:0,label:"任务ID",prop:"task_id",align:"center",width:"100"})):$("",!0),a(o,{label:"数据集名称",prop:"name",align:"center"}),a(o,{label:"数据类型",align:"center",width:"110"},{default:e(({row:t})=>[a(g,{type:"primary",size:"small"},{default:e(()=>[s(p(y(B)[String(t.type).toLowerCase()]||t.type||"-"),1)]),_:2},1024)]),_:1}),a(o,{label:"存储位置",align:"center",width:"110"},{default:e(({row:t})=>[a(g,{type:"success",size:"small"},{default:e(()=>[s(p(y(N)[t.storage_type]||t.storage_type||"-"),1)]),_:2},1024)]),_:1}),a(o,{label:"大小",align:"center",width:"100"},{default:e(({row:t})=>[s(p(t.size&&t.size!=="0 B"&&t.size!=="0"?t.size:"-"),1)]),_:1}),a(o,{label:"数据条数",align:"center",width:"100"},{default:e(({row:t})=>[s(p(t.count||0),1)]),_:1}),a(o,{label:"描述",align:"center","show-overflow-tooltip":""},{default:e(({row:t})=>[s(p(t.description||"-"),1)]),_:1}),a(o,{label:"创建时间",align:"center",width:"180"},{default:e(({row:t})=>[s(p(t.create_time?new Date(t.create_time).toLocaleString("zh-CN"):"-"),1)]),_:1})]),actions:e(({row:t})=>[r("div",O,[a(d,{type:"primary",link:"",size:"small",onClick:v=>h(t)},{default:e(()=>[...i[2]||(i[2]=[r("i",{class:"fa fa-eye",style:{"margin-right":"4px"}},null,-1),s("详情 ",-1)])]),_:1},8,["onClick"]),a(d,{type:"success",link:"",size:"small",onClick:v=>x(t)},{default:e(()=>[...i[3]||(i[3]=[r("i",{class:"fa fa-download",style:{"margin-right":"4px"}},null,-1),s("下载 ",-1)])]),_:1},8,["onClick"]),a(d,{type:"danger",link:"",size:"small",onClick:v=>_(t)},{default:e(()=>[...i[4]||(i[4]=[r("i",{class:"fa fa-trash-o",style:{"margin-right":"4px"}},null,-1),s("删除 ",-1)])]),_:1},8,["onClick"])])]),_:1},8,["data","loading","create-text"])}}}),Et=j(U,[["__scopeId","data-v-a2aaedec"]]);export{Et as default};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user