From a72b8f1e4ba91bd45d343dfbededa6bf1b8d1b2e Mon Sep 17 00:00:00 2001 From: wuyongtao Date: Tue, 21 Jul 2026 10:55:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E6=A8=A1=E5=9D=97=E3=80=81=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E3=80=81Compute=E5=BC=95=E6=93=8E=E5=8F=8A=E5=A4=9A?= =?UTF-8?q?=E9=A1=B9=E9=85=8D=E7=BD=AE=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新 backend 平台 API、platform_store、session 数据库模块 - 新增 backend SQL 初始化脚本 - 更新 compute 引擎适配器及 README - 更新 Docker 部署配置(app/compute) - 更新前端入口、环境类型声明及 README - 新增 docs/menu-functional-requirements.md 菜单功能需求文档 - 更新多项项目文档 Co-Authored-By: Claude --- README.md | 19 +- backend/app/api/v1/endpoints/platform.py | 15 +- backend/app/core/config.py | 4 +- backend/app/db/platform_store.py | 710 ++++++-------------- backend/app/db/session.py | 3 +- backend/app/db/sql/001_platform_runtime.sql | 133 ++++ backend/pyproject.toml | 6 +- backend/requirements.txt | 2 +- compute/README.md | 7 +- compute/api/main.py | 15 +- compute/engines/llama_factory/adapter.py | 2 +- docker/README.md | 8 +- docker/app/.env.example | 3 +- docker/app/docker-compose.yml | 3 +- docker/compute/.env.example | 1 + docker/compute/docker-compose.yml | 1 + docs/backend-api-design.md | 12 +- docs/deployment-plan.md | 6 +- docs/first-version-development-plan.md | 138 ++-- docs/menu-functional-requirements.md | 98 +++ docs/system-development-plan.md | 2 +- frontend/README.md | 7 +- frontend/src/env.d.ts | 2 +- frontend/src/main.ts | 4 +- 24 files changed, 571 insertions(+), 630 deletions(-) create mode 100644 backend/app/db/sql/001_platform_runtime.sql create mode 100644 docs/menu-functional-requirements.md diff --git a/README.md b/README.md index 5e49720..3843b52 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ YG_FT 是一个面向企业治理场景的模型微调平台,覆盖用户中心、多租户、项目隔离、数据集管理、模型管理、训练任务、评测、推理、审批流、审计留存、算力调度和训练引擎适配。 -当前前端已有基础页面,后端与算力平台已按多人协作开发方式建立工程骨架,并开始实现第一版可演示闭环能力。第一版实现不是临时代码,工程模块、配置项、接口标签和数据库对象均按后续正式开发可延续的方式命名。 +当前前端已有基础页面,后端与算力平台已按多人协作开发方式建立工程骨架,并开始实现正式系统主链路能力。当前代码和 SQL 均作为后续生产演进基线维护,不再以一次性演示或静态 Mock 为开发准则。 ## 总体架构 @@ -38,12 +38,12 @@ YG_FT/ | 训练引擎 | 当前固定接入 LLaMA-Factory,预留其他训练平台适配标准 | `compute/engines/` | | 数据层 | PostgreSQL、Redis、本地文件存储、日志归档 | `docs/postgres-schema.sql` | -## 第一版能力 +## 当前开发基线 - 使用 FastAPI 提供统一 API 响应结构 `{ code, message, data }`。 -- 本地运行阶段使用 SQLite 初始化样例用户、模型、数据集、算力节点、GPU 和微调任务。 +- 本地运行阶段统一使用 PostgreSQL,后端启动时会在 PG 中初始化当前运行表和系统内置账号;模型、数据集、算力节点、GPU、微调任务等业务数据必须通过页面、接口或正式导入流程产生。 - 支持登录、模型管理、数据集管理、微调任务创建/启动/停止/进度轮询。 -- 支持训练日志、loss 指标、checkpoint 和训练产物的模拟生成。 +- 支持训练日志、loss 指标、checkpoint 和训练产物接口;真实训练执行器接入前,联调状态机必须通过显式环境变量开启。 - 支持多算力节点、GPU、任务队列、资源副本和资源同步状态接口。 - 前端新增 `/compute` 算力节点页面,展示节点地址、权重、标签、启用状态、GPU、队列和资源副本。 - `compute/engines/llama_factory/adapter.py` 提供 LLaMA-Factory 参数校验、命令生成和日志解析基础能力。 @@ -69,12 +69,14 @@ GET /modelTF/fine-tune GET /modelTF/compute/nodes ``` -本地运行时默认数据库路径: +本地运行时默认 PostgreSQL 连接: ```text -LOCAL_DB_PATH=./runtime/platform.db +DATABASE_URL=postgresql+psycopg://yg_ft:change_me@localhost:15432/yg_ft ``` +本地启动前需要确保 PostgreSQL 已监听 `localhost:15432`,并已创建 `yg_ft` 数据库和 `yg_ft` 用户。后端启动后会自动创建当前运行表并写入内置管理员账号,运行数据统一写入 PostgreSQL。 + ## 前端启动 ```bash @@ -92,7 +94,7 @@ cd compute uvicorn api.main:app --reload --port 19100 ``` -第一版默认 `COMPUTE_MODE=simulator`。后续真实 GPU 接入时,在算力服务器上部署 Compute API、Agent、File Gateway 和 LLaMA-Factory,应用平台通过 `compute_nodes.api_base_url` 和 `compute_nodes.file_gateway_url` 主动轮询。 +默认 `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`。 ## 日志 @@ -112,10 +114,11 @@ logs/error-YYYY-MM-DD.log ## 主要文档 - `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/first-version-development-plan.md`:第一版可演示闭环开发计划,覆盖前端、后端、DB、Compute Simulator、GPU 和 LLaMA-Factory 适配。 +- `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 使用方式。 diff --git a/backend/app/api/v1/endpoints/platform.py b/backend/app/api/v1/endpoints/platform.py index d7d260c..74ebfd6 100644 --- a/backend/app/api/v1/endpoints/platform.py +++ b/backend/app/api/v1/endpoints/platform.py @@ -1,6 +1,7 @@ from __future__ import annotations from typing import Any +import uuid from fastapi import APIRouter, Body, File, HTTPException, Query, UploadFile from fastapi.responses import PlainTextResponse @@ -147,7 +148,7 @@ async def delete_model(model_id: str) -> dict[str, Any]: @router.post("/model-manage/merge") async def merge_model(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: - return ok({"job_id": "merge-sample-job", "status": "queued", **payload}) + return ok({"job_id": f"merge_{uuid.uuid4().hex[:12]}", "status": "queued", **payload}) @router.get("/dataset-manage/preview/{file_id}") @@ -289,8 +290,11 @@ async def fine_tune_list() -> dict[str, Any]: @router.post("/fine-tune") async def create_fine_tune(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: - task = get_platform_store().create_task(payload) - return ok({"id": task["id"]}) + 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") @@ -361,7 +365,10 @@ async def compute_nodes() -> dict[str, Any]: @router.post("/compute/nodes") async def create_compute_node(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: - return ok({"id": "node_sample_new", **payload, "status": "created"}) + 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}") diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 9472ce8..bf11085 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -16,8 +16,8 @@ class Settings: app_env: str = os.getenv("APP_ENV", "local") route_prefix: str = os.getenv("MODELTF_ROUTE_PREFIX", "/modelTF") app_mode: str = os.getenv("APP_MODE", "local") - local_db_path: str = os.getenv("LOCAL_DB_PATH", "./runtime/platform.db") - compute_mode: str = os.getenv("COMPUTE_MODE", "simulator") + database_url: str = os.getenv("DATABASE_URL", "postgresql+psycopg://yg_ft:change_me@localhost:15432/yg_ft") + compute_mode: str = os.getenv("COMPUTE_MODE", "real") compute_status_sync_mode: str = os.getenv("COMPUTE_STATUS_SYNC_MODE", "polling") compute_poll_interval_seconds: int = _int_env("COMPUTE_POLL_INTERVAL_SECONDS", 3) log_level: str = os.getenv("LOG_LEVEL", "INFO") diff --git a/backend/app/db/platform_store.py b/backend/app/db/platform_store.py index 092aa16..8f87d8f 100644 --- a/backend/app/db/platform_store.py +++ b/backend/app/db/platform_store.py @@ -1,8 +1,10 @@ -from __future__ import annotations +from __future__ import annotations import json +import hashlib +import hmac import math -import sqlite3 +import secrets import time import uuid from contextlib import contextmanager @@ -10,6 +12,8 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any, Iterator +import psycopg + from app.core.config import get_settings @@ -53,175 +57,133 @@ def new_id(prefix: str) -> str: return f"{prefix}_{uuid.uuid4().hex[:12]}" -class PlatformStore: - """Small SQLite-backed store for the first runnable platform version. +PASSWORD_HASH_ITERATIONS = 390_000 - The production model is PostgreSQL. This store mirrors the API-facing subset - needed by the first system iteration so developers can run the app without - provisioning enterprise infrastructure first. + +def hash_password(password: str) -> str: + salt = secrets.token_hex(16) + digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), PASSWORD_HASH_ITERATIONS) + return f"pbkdf2_sha256${PASSWORD_HASH_ITERATIONS}${salt}${digest.hex()}" + + +def verify_password(password: str, stored: str) -> tuple[bool, bool]: + if not stored.startswith("pbkdf2_sha256$"): + return hmac.compare_digest(password, stored), True + try: + _, iterations, salt, expected = stored.split("$", 3) + digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), int(iterations)).hex() + return hmac.compare_digest(digest, expected), False + except ValueError: + return False, False + + +def _psycopg_url(database_url: str) -> str: + return database_url.replace("postgresql+psycopg://", "postgresql://") + + +def _pg_sql(sql: str) -> str: + return sql.replace("?", "%s") + + +class PgRow(dict): + def __init__(self, columns: list[str], values: tuple[Any, ...]) -> None: + super().__init__(zip(columns, values)) + self._values = values + + def __getitem__(self, key: str | int) -> Any: + if isinstance(key, int): + return self._values[key] + return super().__getitem__(key) + + +class PgCursor: + def __init__(self, cursor: psycopg.Cursor[Any]) -> None: + self.cursor = cursor + + def execute(self, sql: str, params: tuple[Any, ...] | list[Any] | None = None) -> "PgCursor": + self.cursor.execute(_pg_sql(sql), params) + return self + + def fetchone(self) -> PgRow | None: + row = self.cursor.fetchone() + if row is None: + return None + return PgRow(self._columns(), tuple(row)) + + def fetchall(self) -> list[PgRow]: + columns = self._columns() + return [PgRow(columns, tuple(row)) for row in self.cursor.fetchall()] + + def _columns(self) -> list[str]: + return [col.name for col in self.cursor.description or []] + + +class PgConnection: + def __init__(self, conn: psycopg.Connection[Any]) -> None: + self.conn = conn + + def execute(self, sql: str, params: tuple[Any, ...] | list[Any] | None = None) -> PgCursor: + cursor = PgCursor(self.conn.cursor()) + return cursor.execute(sql, params) + + def executemany(self, sql: str, params_seq: list[tuple[Any, ...]] | list[list[Any]]) -> None: + with self.conn.cursor() as cursor: + cursor.executemany(_pg_sql(sql), params_seq) + + def executescript(self, sql: str) -> None: + with self.conn.cursor() as cursor: + for statement in sql.split(";"): + statement = statement.strip() + if statement: + cursor.execute(statement) + + def commit(self) -> None: + self.conn.commit() + + def rollback(self) -> None: + self.conn.rollback() + + def close(self) -> None: + self.conn.close() + + +class PlatformStore: + """PostgreSQL-backed store for the first runnable platform version. + + This store mirrors the API-facing subset needed by the first system + iteration while using the same PostgreSQL dependency as later production + development. """ - def __init__(self, db_path: str | None = None) -> None: + def __init__(self, database_url: str | None = None) -> None: settings = get_settings() - raw_path = db_path or settings.local_db_path - self.db_path = Path(raw_path) - if not self.db_path.is_absolute(): - self.db_path = Path.cwd() / self.db_path - self.db_path.parent.mkdir(parents=True, exist_ok=True) + self.database_url = _psycopg_url(database_url or settings.database_url) self.ensure_schema() self.ensure_seed_data() @contextmanager - def connect(self) -> Iterator[sqlite3.Connection]: - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row + def connect(self) -> Iterator["PgConnection"]: + raw_conn = psycopg.connect(self.database_url) + conn = PgConnection(raw_conn) try: yield conn conn.commit() + except Exception: + conn.rollback() + raise finally: conn.close() def ensure_schema(self) -> None: + schema_path = Path(__file__).with_name("sql") / "001_platform_runtime.sql" with self.connect() as conn: - conn.executescript( - """ - CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL UNIQUE, - password 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, - 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, - FOREIGN KEY(dataset_id) REFERENCES datasets(id) ON DELETE CASCADE - ); - - 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, - gpu_index INTEGER NOT NULL, - uuid TEXT NOT NULL, - name TEXT NOT NULL, - memory_total_gb REAL NOT NULL, - power_limit_w REAL NOT NULL, - base_temperature INTEGER NOT NULL, - FOREIGN KEY(node_id) REFERENCES compute_nodes(id) ON DELETE CASCADE - ); - - 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, - gpus TEXT NOT NULL, - sync_job_id TEXT - ); - - CREATE TABLE IF NOT EXISTS resource_replicas ( - id TEXT PRIMARY KEY, - node_id TEXT NOT NULL, - 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); - """ - ) + conn.executescript(schema_path.read_text(encoding="utf-8")) + columns = conn.execute( + "SELECT column_name FROM information_schema.columns WHERE table_name='users'" + ).fetchall() + column_names = {row["column_name"] for row in columns} + if "password" in column_names and "password_hash" not in column_names: + conn.execute("ALTER TABLE users RENAME COLUMN password TO password_hash") def ensure_seed_data(self) -> None: with self.connect() as conn: @@ -245,336 +207,12 @@ class PlatformStore: conn.executemany( """ INSERT INTO users - (id, username, password, display_name, role, status, permissions, create_time, protected) + (id, username, password_hash, display_name, role, status, permissions, create_time, protected) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, - [(u[0], u[1], u[2], u[3], u[4], u[5], json_dumps(u[6]), now, u[7]) for u in users], + [(u[0], u[1], hash_password(u[2]), u[3], u[4], u[5], json_dumps(u[6]), now, u[7]) for u in users], ) - models = [ - ( - "m_qwen25_7b", - "Qwen2.5-7B-Instruct", - "LLM", - "training", - "local", - "Sample base model for SFT and LoRA training.", - "/models/Qwen2.5-7B-Instruct", - ), - ( - "m_llama31_8b", - "Llama-3.1-8B-Instruct", - "LLM", - "training", - "local", - "Reserved base model path for LLaMA-Factory dry-run.", - "/models/Llama-3.1-8B-Instruct", - ), - ] - conn.executemany( - """ - INSERT INTO models - (id, name, type, purpose, model_source, description, path, create_time) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - [(*m, now) for m in models], - ) - - self._insert_dataset( - conn, - "ds_finance_sft", - "finance-sft-sample", - "train", - "Financial QA SFT samples for sample training.", - "finance-sft-sample.jsonl", - "\n".join( - [ - '{"instruction":"Summarize revenue growth.","input":"Revenue grew from 10M to 13M.","output":"Revenue increased 30% year over year."}', - '{"instruction":"Classify risk.","input":"Customer has overdue payment for 90 days.","output":"High credit risk."}', - '{"instruction":"Draft an analyst note.","input":"Gross margin improved by 4 points.","output":"Margin expansion indicates stronger operating leverage."}', - ] - ), - now, - ) - self._insert_dataset( - conn, - "ds_customer_service", - "customer-service-sample", - "train", - "Customer-service instruction tuning samples.", - "customer-service-sample.jsonl", - "\n".join( - [ - '{"instruction":"Respond politely.","input":"My package is late.","output":"I am sorry for the delay. I can help check the latest shipment status."}', - '{"instruction":"Escalate request.","input":"I need a refund for a defective item.","output":"I will create a refund case and share the next steps."}', - ] - ), - now, - ) - - node_rows = [ - ( - "node_01", - "gpu-node-01", - "GPU Node 01", - "http://gpu-node-01:19100", - "http://gpu-node-01:19101", - 1, - "online", - 100, - ["A800", "80GB", "llama_factory"], - 4, - 0, - 2, - "/data/yg-ft", - "/models", - "/data/yg-ft/training-logs", - now, - {"mode": "simulator", "heartbeat": "ok"}, - ), - ( - "node_02", - "gpu-node-02", - "GPU Node 02", - "http://gpu-node-02:19100", - "http://gpu-node-02:19101", - 1, - "online", - 60, - ["4090", "24GB", "llama_factory"], - 4, - 0, - 1, - "/data/yg-ft", - "/models", - "/data/yg-ft/training-logs", - now, - {"mode": "simulator", "heartbeat": "ok"}, - ), - ] - conn.executemany( - """ - INSERT INTO compute_nodes - (id, code, name, api_base_url, file_gateway_url, enabled, scheduler_status, - scheduler_weight, tags, gpu_count, current_running_jobs, max_parallel_jobs, - data_root, model_root, log_root, last_health_check_at, health_detail) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - [ - ( - r[0], - r[1], - r[2], - r[3], - r[4], - r[5], - r[6], - r[7], - json_dumps(r[8]), - r[9], - r[10], - r[11], - r[12], - r[13], - r[14], - r[15], - json_dumps(r[16]), - ) - for r in node_rows - ], - ) - for node_id, model_name, mem in [ - ("node_01", "NVIDIA A800-SXM4-80GB", 80), - ("node_02", "NVIDIA GeForce RTX 4090", 24), - ]: - for idx in range(4): - conn.execute( - """ - INSERT INTO gpus - (id, node_id, gpu_index, uuid, name, memory_total_gb, power_limit_w, base_temperature) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - f"{node_id}_gpu_{idx}", - node_id, - idx, - f"GPU-{node_id.upper()}-{idx}", - model_name, - mem, - 300 if mem >= 80 else 450, - 35 + idx, - ), - ) - - self._insert_task( - conn, - task_id="ft_pending_sample", - name="pending-sft-sample", - status="pending", - progress=0, - create_time=now, - start_time=None, - completed_at=None, - node_id=None, - gpus=[], - process_id=None, - ) - started = datetime.now(timezone.utc).timestamp() - 22 - self._insert_task( - conn, - task_id="ft_running_sample", - name="running-sft-sample", - status="running", - progress=45, - create_time=now, - start_time=datetime.fromtimestamp(started, timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), - completed_at=None, - node_id="node_01", - gpus=[0, 1], - process_id=42001, - ) - self._insert_task( - conn, - task_id="ft_completed_sample", - name="completed-sft-sample", - status="completed", - progress=100, - create_time=now, - start_time=now, - completed_at=now, - node_id="node_01", - gpus=[2], - process_id=42002, - ) - conn.execute( - """ - INSERT INTO trained_models - (id, name, train_methods, base_model_path, create_time, merged, merging, merged_path) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - "tm_completed_sample", - "completed-sft-sample-lora", - json_dumps([{"name": "lora"}]), - "/models/Qwen2.5-7B-Instruct", - now, - 0, - 0, - "/data/yg-ft/outputs/completed-sft-sample/adapter", - ), - ) - - def _insert_dataset( - self, - conn: sqlite3.Connection, - dataset_id: str, - name: str, - dataset_type: str, - description: str, - file_name: str, - content: str, - now: str, - ) -> None: - lines = [line for line in content.splitlines() if line.strip()] - size = f"{max(1, len(content.encode('utf-8')) // 1024)} KB" - file_id = f"{dataset_id}_file_1" - version_id = f"{file_id}_v1" - conn.execute( - """ - INSERT INTO datasets - (id, name, type, storage_type, source, size, count, description, create_time) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - (dataset_id, name, dataset_type, "local", "upload", size, len(lines), description, now), - ) - conn.execute( - """ - INSERT INTO dataset_files - (id, dataset_id, name, size, content, active_version_id, versions, create_time) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - file_id, - dataset_id, - file_name, - size, - content, - version_id, - json_dumps([{"id": version_id, "version": 1, "create_time": now, "description": "initial seed"}]), - now, - ), - ) - - def _insert_task( - self, - conn: sqlite3.Connection, - task_id: str, - name: str, - status: str, - progress: int, - create_time: str, - start_time: str | None, - completed_at: str | None, - node_id: str | None, - gpus: list[int], - process_id: int | None, - ) -> None: - payload = { - "id": task_id, - "name": name, - "description": "Seeded sample fine-tune task.", - "status": status, - "train_type": "SFT", - "train_method": "lora", - "template": "qwen", - "base_model": "m_qwen25_7b", - "train_dataset_id": "ds_finance_sft", - "auto_merge": False, - "output_model_name": f"{name}-lora", - "gpus": gpus, - "batch_size": 2, - "learning_rate": 0.0002, - "n_epochs": 3, - "save_steps": 50, - "lr_scheduler_type": "cosine", - "max_length": 2048, - "warmup_ratio": 0.03, - "weight_decay": 0.01, - "lora_alpha": 16, - "lora_dropout": 0.05, - "lora_rank": 8, - "quantization_bit": 4, - "export_quantized": False, - "quant_method": "bnb", - "quant_bits": 4, - "quant_group_size": 128, - "export_format": "safetensors", - "progress": progress, - "process_id": process_id, - "train_duration": self._duration(start_time, completed_at), - "create_time": create_time, - } - conn.execute( - """ - INSERT INTO fine_tune_tasks - (id, name, payload, status, progress, process_id, create_time, start_time, completed_at, compute_node_id, gpus) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - task_id, - name, - json_dumps(payload), - status, - progress, - process_id, - create_time, - start_time, - completed_at, - node_id, - json_dumps(gpus), - ), - ) - def _duration(self, start_time: str | None, end_time: str | None = None) -> str: start = parse_time(start_time) if not start: @@ -590,6 +228,9 @@ class PlatformStore: return f"{sec}s" def refresh_runtime_state(self) -> None: + if get_settings().compute_mode != "simulator": + return + with self.connect() as conn: rows = conn.execute( "SELECT * FROM fine_tune_tasks WHERE status IN ('syncing','queued','running')" @@ -644,7 +285,7 @@ class PlatformStore: (status, progress, completed_at, row["id"]), ) - def _ensure_trained_model(self, conn: sqlite3.Connection, task: dict[str, Any]) -> None: + def _ensure_trained_model(self, conn: PgConnection, task: dict[str, Any]) -> None: name = task.get("output_model_name") or f"{task['name']}-lora" exists = conn.execute("SELECT id FROM trained_models WHERE name=?", (name,)).fetchone() if exists: @@ -676,10 +317,19 @@ class PlatformStore: def login(self, username: str, password: str) -> dict[str, Any] | None: with self.connect() as conn: row = conn.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone() - if not row or row["password"] != password or row["status"] != "active": + if not row or row["status"] != "active": + return None + matched, legacy_plaintext = verify_password(password, row["password_hash"]) + if not matched: return None last_login = utcnow() - conn.execute("UPDATE users SET last_login=? WHERE id=?", (last_login, row["id"])) + if legacy_plaintext: + conn.execute( + "UPDATE users SET password_hash=?, last_login=? WHERE id=?", + (hash_password(password), last_login, row["id"]), + ) + else: + conn.execute("UPDATE users SET last_login=? WHERE id=?", (last_login, row["id"])) data = self._user(row) data["last_login"] = last_login return data @@ -691,13 +341,13 @@ class PlatformStore: conn.execute( """ INSERT INTO users - (id, username, password, display_name, role, status, permissions, create_time, protected) + (id, username, password_hash, display_name, role, status, permissions, create_time, protected) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0) """, ( user_id, payload["username"], - payload.get("password", "platform123"), + hash_password(payload.get("password", "platform123")), payload.get("display_name") or payload["username"], payload.get("role", "viewer"), payload.get("status", "active"), @@ -732,7 +382,7 @@ class PlatformStore: raise ValueError("protected user cannot be deleted") conn.execute("DELETE FROM users WHERE id=?", (user_id,)) - def _user(self, row: sqlite3.Row) -> dict[str, Any]: + def _user(self, row: PgRow) -> dict[str, Any]: return { "id": row["id"], "username": row["username"], @@ -843,7 +493,7 @@ class PlatformStore: raise KeyError(dataset_id) return self._dataset(conn, row) - def _dataset(self, conn: sqlite3.Connection, row: sqlite3.Row) -> dict[str, Any]: + def _dataset(self, conn: PgConnection, row: PgRow) -> dict[str, Any]: files = conn.execute( "SELECT id, name, size, active_version_id, create_time FROM dataset_files WHERE dataset_id=? ORDER BY create_time", (row["id"],), @@ -884,9 +534,6 @@ class PlatformStore: utcnow(), ), ) - if not payload.get("files"): - content = '{"instruction":"Sample instruction","input":"Sample input","output":"Sample output"}' - self.add_dataset_file(conn, dataset_id, "sample.jsonl", content) return self._dataset(conn, conn.execute("SELECT * FROM datasets WHERE id=?", (dataset_id,)).fetchone()) def update_dataset(self, dataset_id: str, payload: dict[str, Any]) -> dict[str, Any]: @@ -918,7 +565,7 @@ class PlatformStore: conn.execute("DELETE FROM dataset_files WHERE dataset_id=?", (dataset_id,)) conn.execute("DELETE FROM datasets WHERE id=?", (dataset_id,)) - def add_dataset_file(self, conn: sqlite3.Connection, dataset_id: str, name: str, content: str) -> dict[str, Any]: + def add_dataset_file(self, conn: PgConnection, dataset_id: str, name: str, content: str) -> dict[str, Any]: now = utcnow() file_id = new_id("file") version_id = f"{file_id}_v1" @@ -947,7 +594,7 @@ class PlatformStore: ) return {"id": file_id, "name": name, "size": size} - def dataset_file(self, file_id: str) -> sqlite3.Row: + def dataset_file(self, file_id: str) -> PgRow: with self.connect() as conn: row = conn.execute("SELECT * FROM dataset_files WHERE id=?", (file_id,)).fetchone() if not row: @@ -1008,7 +655,7 @@ class PlatformStore: raise KeyError(task_id) return self._task(row) - def _task(self, row: sqlite3.Row) -> dict[str, Any]: + def _task(self, row: PgRow) -> dict[str, Any]: payload = json_loads(row["payload"], {}) payload.update( { @@ -1028,6 +675,12 @@ class PlatformStore: def create_task(self, payload: dict[str, Any]) -> dict[str, Any]: task_id = str(payload.get("task_id") or payload.get("id") or new_id("ft")) name = payload.get("name") or f"fine-tune-{task_id[-6:]}" + base_model = payload.get("base_model") or payload.get("base_model_id") + train_dataset_id = payload.get("train_dataset_id") + if not base_model: + raise ValueError("base_model or base_model_id is required") + if not train_dataset_id: + raise ValueError("train_dataset_id is required") now = utcnow() task = { "id": task_id, @@ -1037,8 +690,8 @@ class PlatformStore: "train_type": payload.get("train_type", "SFT"), "train_method": payload.get("train_method", "lora"), "template": payload.get("template", "qwen"), - "base_model": payload.get("base_model") or payload.get("base_model_id") or "m_qwen25_7b", - "train_dataset_id": payload.get("train_dataset_id") or "ds_finance_sft", + "base_model": base_model, + "train_dataset_id": train_dataset_id, "auto_merge": bool(payload.get("auto_merge", False)), "output_model_name": payload.get("output_model_name") or f"{name}-lora", "gpus": payload.get("gpus") or [], @@ -1173,7 +826,7 @@ class PlatformStore: "pending": "waiting for start", "syncing": "syncing model and dataset to compute node", "queued": "waiting for GPU slot", - "running": "training with LLaMA-Factory simulator", + "running": "training with LLaMA-Factory", "completed": "training completed", "failed": "training stopped", } @@ -1183,7 +836,7 @@ class PlatformStore: "status": status, "progress": progress, "step": labels.get(status, status), - "speed": "42.5 samples/s" if status == "running" else "--", + "speed": task.get("train_speed") or "--", "eta": eta, } @@ -1234,6 +887,40 @@ class PlatformStore: ) return next(n for n in self.compute_nodes() if n["id"] == node_id) + def create_compute_node(self, payload: dict[str, Any]) -> dict[str, Any]: + node_id = payload.get("id") or new_id("node") + now = utcnow() + tags = payload.get("tags") or [] + with self.connect() as conn: + conn.execute( + """ + INSERT INTO compute_nodes + (id, code, name, api_base_url, file_gateway_url, enabled, scheduler_status, + scheduler_weight, tags, gpu_count, current_running_jobs, max_parallel_jobs, + data_root, model_root, log_root, last_health_check_at, health_detail) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?) + """, + ( + node_id, + payload["code"], + payload.get("name") or payload["code"], + payload["api_base_url"], + payload.get("file_gateway_url") or payload["api_base_url"], + 1 if payload.get("enabled", True) else 0, + payload.get("scheduler_status", "offline"), + int(payload.get("scheduler_weight", 100)), + json_dumps(tags), + int(payload.get("gpu_count", 0)), + int(payload.get("max_parallel_jobs", 1)), + payload.get("data_root", "/data/yg-ft"), + payload.get("model_root", "/models"), + payload.get("log_root", "/data/yg-ft/training-logs"), + now, + json_dumps(payload.get("health_detail") or {"status": "registered"}), + ), + ) + return next(node for node in self.compute_nodes() if node["id"] == node_id) + def gpus(self) -> list[dict[str, Any]]: self.refresh_runtime_state() with self.connect() as conn: @@ -1367,30 +1054,7 @@ class PlatformStore: "SELECT * FROM resource_replicas WHERE node_id=? ORDER BY create_time DESC", (node_id,), ).fetchall() - if rows: - return [dict(row) for row in rows] - return [ - { - "id": f"rep_{node_id}_model_qwen", - "node_id": node_id, - "resource_type": "model", - "resource_id": "m_qwen25_7b", - "local_path": "/models/Qwen2.5-7B-Instruct", - "status": "available", - "sync_status": "completed", - "create_time": utcnow(), - }, - { - "id": f"rep_{node_id}_dataset_finance", - "node_id": node_id, - "resource_type": "dataset", - "resource_id": "ds_finance_sft", - "local_path": "/data/yg-ft/datasets/finance-sft-sample.jsonl", - "status": "available", - "sync_status": "completed", - "create_time": utcnow(), - }, - ] + return [dict(row) for row in rows] def sync_job(self, sync_id: str) -> dict[str, Any]: self.refresh_runtime_state() @@ -1423,7 +1087,7 @@ class PlatformStore: progress = int(task.get("progress", 0) or 0) points = max(1, min(80, progress)) lines = [ - f"[INFO] task={task['name']} engine=llama_factory mode=simulator status={task['status']}", + f"[INFO] task={task['name']} engine=llama_factory status={task['status']}", f"[INFO] base_model={task.get('base_model')} dataset={task.get('train_dataset_id')} gpus={task.get('gpus', [])}", "[INFO] command=llamafactory-cli train --stage sft --finetuning_type lora --do_train true", ] diff --git a/backend/app/db/session.py b/backend/app/db/session.py index 9aa8c33..befe080 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -8,13 +8,12 @@ from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker -DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./runtime/platform.db") +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+psycopg://yg_ft:change_me@localhost:15432/yg_ft") engine = create_engine( DATABASE_URL, pool_pre_ping=True, future=True, - connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}, ) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False, future=True) diff --git a/backend/app/db/sql/001_platform_runtime.sql b/backend/app/db/sql/001_platform_runtime.sql new file mode 100644 index 0000000..be7daa5 --- /dev/null +++ b/backend/app/db/sql/001_platform_runtime.sql @@ -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); diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 14ee2de..042a8ab 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -2,14 +2,14 @@ name = "yg-ft-backend" version = "0.1.0" description = "Backend service for the model fine-tuning platform" -requires-python = ">=3.11" +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", - "asyncpg>=0.29.0", + "psycopg[binary]>=3.2.1", "alembic>=1.13.1", "redis>=5.0.4", "httpx>=0.27.0", @@ -26,7 +26,7 @@ dev = [ [tool.ruff] line-length = 100 -target-version = "py311" +target-version = "py312" [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/backend/requirements.txt b/backend/requirements.txt index 036a07c..6ef0933 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,7 +3,7 @@ uvicorn[standard]>=0.30.0 python-multipart>=0.0.9 pydantic>=2.7.0 sqlalchemy>=2.0.30 -asyncpg>=0.29.0 +psycopg[binary]>=3.2.1 alembic>=1.13.1 redis>=5.0.4 httpx>=0.27.0 diff --git a/compute/README.md b/compute/README.md index f0091f8..ac094ea 100644 --- a/compute/README.md +++ b/compute/README.md @@ -14,7 +14,7 @@ compute/ tests/ ``` -## 第一版职责 +## 开发职责 - GPU 发现、状态上报、锁定和释放。 - 本地磁盘工作区管理。 @@ -22,3 +22,8 @@ compute/ - LLaMA-Factory 命令生成、日志解析、产物收集。 - 分片上传、短时下载、离线导入。 - 通过服务间 token 接受应用平台调用。 + +## 运行模式 + +- 默认 `COMPUTE_EXECUTION_MODE=real`,Compute API 只暴露健康检查和接口契约;真实训练执行器完成前,创建作业会返回未实现错误。 +- 仅隔离联调时可设置 `COMPUTE_EXECUTION_MODE=simulator`,启用内存状态机和合成 GPU/日志数据。该模式不得作为生产运行路径。 diff --git a/compute/api/main.py b/compute/api/main.py index 9370b6b..b90201a 100644 --- a/compute/api/main.py +++ b/compute/api/main.py @@ -22,7 +22,12 @@ def create_app() -> FastAPI: 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: @@ -41,7 +46,7 @@ def create_app() -> FastAPI: 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 mode=simulator", + 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): @@ -70,6 +75,8 @@ def create_app() -> FastAPI: 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): @@ -121,6 +128,7 @@ def create_app() -> FastAPI: "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") @@ -137,6 +145,11 @@ def create_app() -> FastAPI: 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, diff --git a/compute/engines/llama_factory/adapter.py b/compute/engines/llama_factory/adapter.py index bb22029..7e5fc17 100644 --- a/compute/engines/llama_factory/adapter.py +++ b/compute/engines/llama_factory/adapter.py @@ -35,7 +35,7 @@ def build_command(config: dict[str, Any], llama_factory_home: str = "/app/LLaMA- 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', 'sample-job')}" + output_dir = config.get("output_dir") or f"/data/yg-ft/outputs/{config.get('name', 'training-job')}" command = [ "llamafactory-cli", "train", diff --git a/docker/README.md b/docker/README.md index 672a856..1fc5b98 100644 --- a/docker/README.md +++ b/docker/README.md @@ -69,6 +69,12 @@ $images | ForEach-Object { docker pull $_ } - `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。 @@ -112,7 +118,7 @@ http://:16801 如果使用企业统一 PostgreSQL/Redis,修改 `docker/app/.env`: ```env -DATABASE_URL=postgresql+asyncpg://:@:15432/ +DATABASE_URL=postgresql+psycopg://:@:15432/ REDIS_URL=redis://:16379/0 USE_BUILTIN_POSTGRES=false USE_BUILTIN_REDIS=false diff --git a/docker/app/.env.example b/docker/app/.env.example index bea8bd3..e734944 100644 --- a/docker/app/.env.example +++ b/docker/app/.env.example @@ -14,7 +14,7 @@ REDIS_PORT=16379 POSTGRES_DB=yg_ft POSTGRES_USER=yg_ft POSTGRES_PASSWORD=change_me -DATABASE_URL=postgresql+asyncpg://yg_ft:change_me@postgres:5432/yg_ft +DATABASE_URL=postgresql+psycopg://yg_ft:change_me@postgres:5432/yg_ft REDIS_URL=redis://redis:6379/0 @@ -38,6 +38,7 @@ 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 diff --git a/docker/app/docker-compose.yml b/docker/app/docker-compose.yml index ff853cd..cb7307b 100644 --- a/docker/app/docker-compose.yml +++ b/docker/app/docker-compose.yml @@ -32,7 +32,7 @@ services: APP_ENV: ${APP_ENV:-prod} APP_NAME: ${APP_NAME:-YG Fine-Tune Platform API} MODELTF_ROUTE_PREFIX: ${MODELTF_ROUTE_PREFIX:-/modelTF} - DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://yg_ft:change_me@postgres:5432/yg_ft} + 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} @@ -45,6 +45,7 @@ services: 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} diff --git a/docker/compute/.env.example b/docker/compute/.env.example index b5b6d36..3106017 100644 --- a/docker/compute/.env.example +++ b/docker/compute/.env.example @@ -1,5 +1,6 @@ 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 diff --git a/docker/compute/docker-compose.yml b/docker/compute/docker-compose.yml index e2fe8b1..70f859e 100644 --- a/docker/compute/docker-compose.yml +++ b/docker/compute/docker-compose.yml @@ -8,6 +8,7 @@ services: 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} diff --git a/docs/backend-api-design.md b/docs/backend-api-design.md index 0aa86b8..80da56b 100644 --- a/docs/backend-api-design.md +++ b/docs/backend-api-design.md @@ -1,6 +1,8 @@ # 模型微调平台后端接口设计 -> 后端建议使用 FastAPI,统一挂载 `/modelTF` 前缀。本文根据当前 Vue 前端路由、API 模块、mock 数据和页面交互反推接口,并补充完整微调平台必须具备的用户中心、权限控制、审计、异步任务、文件版本与监控能力。 +> 后端建议使用 FastAPI,统一挂载 `/modelTF` 前缀。本文根据当前 Vue 前端路由、API 模块和页面交互契约梳理接口,并补充完整微调平台必须具备的用户中心、权限控制、审计、异步任务、文件版本与监控能力。前端 Mock 仅作为隔离开发辅助,不作为接口设计准则。 + +菜单、二级路由、规划菜单、接口和数据库的总览映射见 `docs/menu-functional-requirements.md`。后续新增接口时,必须同步标注对应页面/功能模块。 ## 1. 通用约定 @@ -88,7 +90,7 @@ page=1&page_size=20&keyword=xxx&sort=-created_at } ``` -说明:前端当前登录接口已经要求返回 `user`,mock 里只返回 token,正式后端必须返回完整用户信息。 +说明:前端当前登录接口已经要求返回 `user`,正式后端必须返回完整用户信息。 ### 2.2 当前用户 @@ -158,7 +160,7 @@ page=1&page_size=20&keyword=xxx&sort=-created_at } ``` -说明:首页当前完全使用前端 mock,后端应提供聚合接口,避免前端拼多接口导致加载慢。 +说明:首页需要由后端提供正式聚合接口,避免前端拼多接口导致加载慢。 ### 3.2 健康指标 @@ -318,7 +320,7 @@ page=1&page_size=20&keyword=xxx&sort=-created_at ## 6. 数据处理 -当前数据处理页面为本地模拟,正式后端建议实现以下接口。 +当前数据处理页面需要后端正式承接上传、切片、生成、编辑和发布流程,建议实现以下接口。 ### 6.1 任务列表与详情 @@ -678,7 +680,7 @@ page=1&page_size=20&keyword=xxx&sort=-created_at ## 11. 后端开发需要补齐的关键点 1. 前端路由已有 `user-settings`、`user-create`、`user-permission`、`permission-denied`,但当前仓库缺少对应 Vue 文件;后端仍应先实现用户中心和权限接口。 -2. 数据处理主流程目前全在浏览器本地模拟,后端需要正式实现上传、切片、LLM 生成、结果编辑、发布数据集。 +2. 数据处理主流程需要后端正式实现上传、切片、LLM 生成、结果编辑、发布数据集。 3. 训练、评测、数据处理、模型加载都不应同步阻塞 HTTP;建议接 Celery/RQ/Arq 或 FastAPI BackgroundTasks + 独立 worker。 4. 文件内容不要全部入库;数据库保存元数据、版本、校验和、对象存储路径,内容放本地 NAS/MinIO。 5. API Key、外部数据源密码必须加密存储,接口只回显脱敏。 diff --git a/docs/deployment-plan.md b/docs/deployment-plan.md index 3370fa2..fde215f 100644 --- a/docs/deployment-plan.md +++ b/docs/deployment-plan.md @@ -63,7 +63,7 @@ ### 4.1 适用场景 -- PoC、试点环境、演示环境。 +- 开发联调、单机试运行、资源受限的早期上线环境。 - 小团队共用一台单机多 GPU 服务器。 - 网络隔离要求不高,部署资源有限。 @@ -272,7 +272,7 @@ compute/engines/openrlhf/ ```env APP_ENV=prod MODELTF_ROUTE_PREFIX=/modelTF -DATABASE_URL=postgresql+asyncpg://yg_ft:***@postgres:5432/yg_ft +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 @@ -327,7 +327,7 @@ CUDA_VISIBLE_DEVICES=0,1,2,3 - 后端 `GET /modelTF/health` 正常。 - Compute API 健康检查正常。 - Compute Agent 能识别 GPU、显存、CUDA 版本。 -- LLaMA-Factory 能在命令行完成最小训练样例。 +- LLaMA-Factory 能在命令行完成最小训练作业。 - 应用平台能提交训练任务到 Compute API。 - 任务状态能从算力平台同步回应用平台。 - 数据集上传、离线导入、产物下载路径权限正确。 diff --git a/docs/first-version-development-plan.md b/docs/first-version-development-plan.md index 490f034..db16039 100644 --- a/docs/first-version-development-plan.md +++ b/docs/first-version-development-plan.md @@ -1,10 +1,10 @@ -# 第一版系统开发计划 +# 当前系统主链路开发计划 -> 说明:本计划虽然以“可演示闭环”为阶段目标,但实现内容按正式系统第一版开发,不在工程模块、配置项、接口标签、数据库对象中使用临时代码命名。初始化数据仅作为样例种子数据,后续可在此基础上继续扩展为生产能力。 +> 说明:本计划描述当前正在开发的系统能力。代码、接口和 SQL 均按后续生产演进基线维护,不以一次性演示、静态 Mock 或样例数据作为开发准则。联调辅助能力必须显式配置启用,并不得成为默认运行路径。 ## 1. 阶段目标 -第一版系统需要在没有完整生产环境、没有真实 GPU 或没有 LLaMA-Factory 运行环境时,仍然可以跑通模型微调平台的主链路: +当前阶段需要完成模型微调平台的主链路工程基础: ```text 登录 @@ -12,24 +12,26 @@ -> 数据集管理 -> 创建微调任务 -> 调度算力节点与 GPU - -> 模拟资源副本检查和同步 + -> 检查并同步模型/数据集资源 -> 启动训练任务 - -> 查看任务状态、GPU 占用、训练日志、loss 曲线 + -> 轮询任务状态、GPU 占用、训练日志、loss 曲线 -> 训练完成后登记训练产物 ``` -该阶段不是一次性展示代码,而是后续系统继续开发的基础版本。算力、训练框架、资源同步、任务状态机均按真实平台边界设计,当前仅以 `simulator` 模式补齐无 GPU 环境下的可运行能力。 +该阶段是正式系统的第一批可运行能力,不再初始化业务样例数据。系统只允许初始化内置管理员/运维账号,模型、数据集、算力节点、GPU、训练任务和资源副本必须通过页面、接口、算力 Agent 扫描或正式导入流程产生。 ## 2. 运行模式 -| 模式 | 说明 | 当前状态 | +| 模式 | 说明 | 当前要求 | | --- | --- | --- | -| `simulator` | 模拟 GPU、训练进程、日志、指标、checkpoint 和训练产物 | 第一版默认实现 | -| `real` | 预留真实 `nvidia-smi`、LLaMA-Factory 进程启动、真实日志解析和产物扫描 | 后续接入 | +| `real` | 面向真实部署,等待 Compute API、Agent、File Gateway 和 LLaMA-Factory 执行器回写状态 | 默认模式 | +| `simulator` | 仅用于隔离联调,无真实 GPU 时临时推进任务状态、GPU 状态和训练日志 | 必须显式开启,不得用于生产基线 | -第一版实现必须保留 `real` 模式边界,不允许把训练命令直接写死在应用平台后端。训练相关能力统一沉淀在 `compute/engines/` 适配层。 +后端默认 `COMPUTE_MODE=real`。在该模式下,任务状态不再按时间自动推进,必须由后续真实算力同步逻辑更新。算力服务默认 `COMPUTE_EXECUTION_MODE=real`,真实训练执行器未完成前,创建训练作业会返回明确的未实现错误,避免误认为已经完成生产训练能力。 -## 3. 第一批开发范围 +训练相关能力必须沉淀在 `compute/engines/` 适配层,不允许在应用平台后端直接拼接或执行训练命令。 + +## 3. 当前开发范围 ### 3.1 应用平台后端 @@ -38,24 +40,27 @@ ```text backend/app/ api/v1/endpoints/platform.py - db/platform_store.py - db/session.py + core/ + db/ ``` -已覆盖能力: +已建立能力: - 统一 API 响应结构 `{ code, message, data }`。 -- 初始化样例用户、模型、数据集、算力节点、GPU 和微调任务。 -- 登录、当前用户、用户列表与权限。 -- 模型管理、训练产物列表、权重合并任务占位。 +- PostgreSQL 运行表初始化,当前执行脚本位于 `backend/app/db/sql/001_platform_runtime.sql`。 +- 内置管理员账号初始化,业务数据不再自动写入样例记录。 +- 登录、当前用户、用户列表与权限页面接口。 +- 模型管理、训练产物列表、权重合并任务入口。 - 数据集管理、文件上传、预览、版本管理和下载。 - 微调任务创建、启动、停止、删除、进度查询、checkpoint 查询。 -- 系统健康指标、系统信息、训练日志、系统日志。 +- 系统健康指标、系统信息、训练日志、系统日志接口。 - 算力节点、GPU、队列、资源副本、资源同步任务接口。 -开发约定: -- 当前本地可运行存储使用 SQLite,配置项为 `LOCAL_DB_PATH`。 -- 生产数据库仍以 PostgreSQL 为目标,正式表结构以 `docs/postgres-schema.sql` 为准。 -- `backend/app/db/session.py` 已提供 SQLAlchemy session 入口,后续替换正式 ORM 时从此处收口。 +待继续开发: +- 接入正式 ORM/Repository/Service 分层和 Alembic 迁移。 +- 将任务状态更新改为应用侧定时轮询 Compute API/File Gateway 后落库。 +- 完成项目/模型/数据集级权限隔离校验。 +- 完成审批流、审计留存、配额、资源申请和多租户上下文。 +- 增加正式异常码、接口鉴权、中间件、幂等控制和分页规范。 ### 3.2 算力平台服务 @@ -64,72 +69,77 @@ backend/app/ ```text compute/ api/main.py - engines/llama_factory/adapter.py + agent/ + engines/llama_factory/ + file_gateway/ ``` -已覆盖能力: +已建立能力: - `/modelTF/health` 与 `/modelTF/v1/compute/health` 节点健康检查。 -- `/modelTF/compute/resources/gpus` GPU 状态模拟。 -- `/modelTF/compute/jobs` 创建、查询、停止训练任务。 -- `/modelTF/compute/jobs/{id}/logs` 拉取训练日志与解析指标。 -- `/modelTF/compute/files/upload` 与 `/modelTF/compute/files/{id}/download` 文件网关占位。 -- LLaMA-Factory 参数校验、命令生成、日志指标解析。 +- LLaMA-Factory 参数校验、命令生成和训练日志指标解析。 +- Compute API 作业、GPU、文件网关接口壳。 +- 显式 `simulator` 模式下的内存状态机,用于隔离联调。 -设计约定: -- 多算力节点阶段,每台单机多 GPU 服务器都部署一套 Compute API、Agent、File Gateway 和训练框架。 -- 应用平台通过 `compute_nodes` 中维护的 `api_base_url`、`file_gateway_url` 主动轮询算力节点。 -- 算力节点之间默认不互访,资源副本和同步状态由应用平台统一编排。 +待继续开发: +- 真实 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`:数据集列表、预览和版本。 +- `/model-manage`:模型列表、模型来源、训练产物。 +- `/dataset`、`/dataset/:id/preview`:数据集列表、预览、版本。 - `/fine-tune`、`/fine-tune/create`:微调任务创建、启动、状态轮询。 - `/training-log/:id`:训练日志和 loss 曲线。 - `/hardware`:平台 GPU 与系统性能。 - `/compute`:算力节点、GPU、队列、资源副本。 -新增前端能力: -- `frontend/src/api/modules/compute.ts`:算力节点 API 包装。 -- `frontend/src/views/compute/ComputeNodesView.vue`:节点、GPU、队列、资源副本四个视图。 -- 权限枚举增加 `compute`,菜单增加“算力节点”入口。 +前端 Mock 默认关闭。仅在隔离前端开发时可设置 `VITE_ENABLE_MOCK=true`,真实联调和后续生产演进均以 `/modelTF` 后端接口为准。 + +待继续开发: +- 补齐多租户、项目管理、审批中心、审计中心、配额管理页面。 +- 完成算力节点管理表单,包括节点地址、权重、标签、启用状态和健康检查结果。 +- 完成模型/数据集导入页面,支持本地路径扫描和归属项目选择。 +- 推理服务页面需接入真实后端任务接口,移除页面内本地假对话路径。 ### 3.4 数据库 -第一版运行时使用本地 SQLite 以降低开发环境门槛,但数据库模型仍按 PostgreSQL 正式设计推进。 +当前运行 SQL: -核心表范围: -- 用户、租户、项目、角色、权限。 -- 模型、训练产物、数据集、数据集文件、存储对象。 -- 微调任务、训练指标、checkpoint、审计日志。 -- 算力节点、GPU 设备、算力任务、GPU 分配。 -- 资源副本、资源同步任务。 +```text +backend/app/db/sql/001_platform_runtime.sql +``` -优化方向: -- 任务状态、租户/项目隔离字段、资源副本定位字段需要建立索引。 -- 审计日志、训练指标、日志归档后续建议按时间分区。 -- 大文件只落本地磁盘或对象存储,数据库保存元数据和版本关系。 +架构目标 SQL: + +```text +docs/postgres-schema.sql +``` + +当前运行 SQL 用于支持已开发接口落库;架构目标 SQL 包含用户中心、多租户、项目隔离、审批、审计、配额、评测等完整模型。后续需要通过 Alembic 将二者收敛为统一迁移体系,生产升级只走迁移脚本,不依赖手工改表。 ## 4. 验收标准 -第一版完成后应满足: -- 前端不启用静态 Mock 时,可以通过 FastAPI 获取主要页面数据。 -- 登录后可以看到模型、数据集、微调任务和算力节点。 -- 创建微调任务后可以启动任务,任务状态自动流转。 -- GPU 状态会随任务进入 `syncing`、`queued`、`running` 发生变化。 -- 训练日志持续生成,日志详情页能解析 loss、grad norm、learning rate、epoch。 -- 任务完成后训练产物出现在模型管理的训练产物列表中。 -- 算力节点页面可展示节点地址、权重、标签、启用状态、队列和资源副本。 -- 工程命名、配置和接口分组均可作为后续正式开发基础。 +- 启动后端必须连接 PostgreSQL,不允许回退到 SQLite。 +- 后端启动只初始化系统内置账号,不初始化模型、数据集、算力节点、GPU、训练任务等业务样例数据。 +- 前端默认请求真实 `/modelTF` 接口,除非显式设置 `VITE_ENABLE_MOCK=true`。 +- 默认 `real` 模式下任务状态不自动伪造完成,必须等待真实算力同步。 +- 显式 `simulator` 模式只能用于隔离联调,部署文档必须标注不得用于生产。 +- 登录后可以进入主界面,并可通过页面/API 创建真实业务记录。 +- 代码、接口路由、配置项、数据库表名不得使用 `demo` 命名。 ## 5. 后续开发计划 | 阶段 | 重点 | 说明 | | --- | --- | --- | -| 第二阶段 | PostgreSQL ORM 与 Alembic | 将 `platform_store.py` 的能力迁移为正式 ORM、迁移脚本和 repository/service 分层 | -| 第三阶段 | 真实 Compute Agent | 接入 `nvidia-smi`、GPU 锁定、进程管理、训练日志文件采集 | -| 第四阶段 | LLaMA-Factory 真实训练 | 生成 YAML/命令、启动训练进程、停止任务、扫描 checkpoint 和 adapter | -| 第五阶段 | 企业治理 | 多租户、项目/模型/数据集隔离、审批流、审计留存、配额和资源申请 | -| 第六阶段 | 多节点调度 | 基于 `compute_nodes`、资源副本和节点标签实现自动/手动调度策略 | +| 阶段 1 | 数据库迁移体系 | 将当前运行 SQL 与架构 SQL 收敛到 Alembic 迁移 | +| 阶段 2 | 后端领域分层 | 拆分用户、模型、数据集、训练、算力、审计等模块 | +| 阶段 3 | 真实 Compute Agent | GPU 发现、资源锁定、进程管理、日志采集 | +| 阶段 4 | LLaMA-Factory 训练执行 | YAML/命令生成、进程启动/停止、checkpoint 和 adapter 扫描 | +| 阶段 5 | 企业治理 | 多租户、项目隔离、审批流、审计留存、配额和资源申请 | +| 阶段 6 | 多算力节点调度 | 基于 `compute_nodes`、标签、权重、资源副本和节点健康实现调度策略 | diff --git a/docs/menu-functional-requirements.md b/docs/menu-functional-requirements.md new file mode 100644 index 0000000..e73fdc3 --- /dev/null +++ b/docs/menu-functional-requirements.md @@ -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 只能显式开启用于隔离联调。 diff --git a/docs/system-development-plan.md b/docs/system-development-plan.md index a033b66..445b4b7 100644 --- a/docs/system-development-plan.md +++ b/docs/system-development-plan.md @@ -1038,7 +1038,7 @@ YG_FT/ ## 16. 页面模块开发工作包 -本节用于多人并行开发时认领任务。每个工作包都标明对应页面、前端内容、后端接口、DB 表和部署/算力依赖。 +本节用于多人并行开发时认领任务。每个工作包都标明对应页面、前端内容、后端接口、DB 表和部署/算力依赖。当前菜单、二级路由、规划菜单和接口/数据库映射总览见 `docs/menu-functional-requirements.md`。 ### 16.1 基础入口与用户权限 diff --git a/frontend/README.md b/frontend/README.md index a9a1f35..7220265 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -26,14 +26,13 @@ npm run dev 后端 API 默认通过 Vite 代理转发到 `http://localhost:17861`(见 `vite.config.ts`)。 -开发环境默认启用前端 Mock。如需联调真实后端,使用: +开发环境默认联调真实后端接口。如需进行隔离前端开发,可显式启用 Mock: ```bash -VITE_ENABLE_MOCK=false npm run dev +VITE_ENABLE_MOCK=true npm run dev ``` -生产构建默认不包含 Mock;仅在演示构建中可显式设置 -`VITE_ENABLE_MOCK=true`。 +真实联调、测试环境和生产环境不应启用 Mock。 ## 构建 diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts index 443a7fa..9137277 100644 --- a/frontend/src/env.d.ts +++ b/frontend/src/env.d.ts @@ -1,7 +1,7 @@ /// interface ImportMetaEnv { - /** 是否启用前端 Mock;开发环境默认开启,生产环境默认关闭。 */ + /** 是否启用前端 Mock;默认关闭,仅限隔离前端开发时显式开启。 */ readonly VITE_ENABLE_MOCK?: 'true' | 'false' } diff --git a/frontend/src/main.ts b/frontend/src/main.ts index b4496e6..f111253 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -17,10 +17,8 @@ app.use(createPinia()) app.use(router) async function bootstrap() { - // 开发环境默认使用 Mock;生产环境只有显式开启时才加载整套 Mock 数据。 - // 这样真实部署不会被前端适配器截断请求,也不会把 Mock 数据打进首屏包。 + // Mock 仅允许显式开启。开发环境默认联调真实后端契约。 const shouldEnableMock = import.meta.env.VITE_ENABLE_MOCK === 'true' - || (import.meta.env.DEV && import.meta.env.VITE_ENABLE_MOCK !== 'false') if (shouldEnableMock) { const { installMockAdapter } = await import('@/mock/adapter')