Compare commits
6 Commits
15c4223f2c
...
5cc306eb0a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cc306eb0a | ||
|
|
cc08b164d0 | ||
|
|
24c77a990a | ||
|
|
46d343fb63 | ||
|
|
0c39f2f5b9 | ||
|
|
c7c9ed925b |
@@ -1,7 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
from app.db.platform_store import get_platform_store
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -10,5 +9,9 @@ logger = get_logger(__name__)
|
|||||||
@router.get("/health")
|
@router.get("/health")
|
||||||
async def health_check() -> dict[str, object]:
|
async def health_check() -> dict[str, object]:
|
||||||
logger.info("health check requested")
|
logger.info("health check requested")
|
||||||
return {"code": 0, "message": "ok", "data": get_platform_store().health_metrics()}
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok",
|
||||||
|
"data": {"cpu_percent": 0.0, "memory_percent": 0.0, "disk_percent": 0.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from app.core.auth import filter_accessible_resource_ids, get_current_user, has_
|
|||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.db.platform_store import get_platform_store
|
from app.db.platform_store import get_platform_store
|
||||||
from app.modules.compute_gateway.client import ComputeNodeClient
|
from app.modules.compute_gateway.client import ComputeNodeClient
|
||||||
from app.modules.compute_gateway.sync import poll_compute_jobs_once
|
from app.modules.compute_gateway.sync import fetch_eval_result_content, poll_compute_jobs_once
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -33,6 +33,62 @@ def _select_first_online_node(store: Any) -> dict[str, Any] | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_messages_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Convert frontend inference payload to compute API messages format.
|
||||||
|
|
||||||
|
Accepts both:
|
||||||
|
- OpenAI-style: {messages: [{role, content}, ...], temperature, ...}
|
||||||
|
- Frontend-style: {user_question, system_prompt, temperature, ...}
|
||||||
|
"""
|
||||||
|
if payload.get("messages"):
|
||||||
|
messages = payload["messages"]
|
||||||
|
# messages already in OpenAI format; pass through with optional system prompt
|
||||||
|
if payload.get("system_prompt") and not any(m.get("role") == "system" for m in messages):
|
||||||
|
messages = [{"role": "system", "content": payload["system_prompt"]}] + list(messages)
|
||||||
|
else:
|
||||||
|
messages = []
|
||||||
|
if payload.get("system_prompt"):
|
||||||
|
messages.append({"role": "system", "content": payload["system_prompt"]})
|
||||||
|
question = payload.get("user_question") or payload.get("question") or ""
|
||||||
|
if question:
|
||||||
|
messages.append({"role": "user", "content": question})
|
||||||
|
return {
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": float(payload.get("temperature", 0.7)),
|
||||||
|
"top_p": float(payload.get("top_p", 0.95)),
|
||||||
|
"max_new_tokens": int(payload.get("max_tokens", 2048)),
|
||||||
|
"do_sample": bool(payload.get("do_sample", True)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _stream_chat_proxy(payload: dict[str, Any]) -> StreamingResponse:
|
||||||
|
"""Common SSE streaming proxy: convert payload → forward to compute node → stream back."""
|
||||||
|
store = get_platform_store()
|
||||||
|
node = _select_first_online_node(store)
|
||||||
|
if not node:
|
||||||
|
return StreamingResponse(
|
||||||
|
iter(['data: {"error": "no online compute node available for inference"}\n\n']),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
)
|
||||||
|
client = ComputeNodeClient(node["api_base_url"])
|
||||||
|
compute_payload = _build_messages_payload(payload)
|
||||||
|
|
||||||
|
async def stream_proxy():
|
||||||
|
async with httpx.AsyncClient(timeout=300) as http:
|
||||||
|
url = f"{node['api_base_url'].rstrip('/')}{client.route_prefix}/inference/chat/stream"
|
||||||
|
try:
|
||||||
|
async with http.stream("POST", url, json=compute_payload, headers=client.headers()) as resp:
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
yield f'data: {{"error": "compute node returned {resp.status_code}"}}\n\n'.encode()
|
||||||
|
return
|
||||||
|
async for chunk in resp.aiter_bytes():
|
||||||
|
yield chunk
|
||||||
|
except Exception as exc:
|
||||||
|
yield f'data: {{"error": "stream proxy failed: {exc}"}}\n\n'.encode()
|
||||||
|
|
||||||
|
return StreamingResponse(stream_proxy(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
def fail(status_code: int, message: str) -> HTTPException:
|
def fail(status_code: int, message: str) -> HTTPException:
|
||||||
return HTTPException(status_code=status_code, detail={"code": status_code, "message": message, "data": None})
|
return HTTPException(status_code=status_code, detail={"code": status_code, "message": message, "data": None})
|
||||||
|
|
||||||
@@ -859,6 +915,7 @@ async def upload_dataset_files(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
created: list[dict[str, Any]] = []
|
created: list[dict[str, Any]] = []
|
||||||
compute_sync: list[dict[str, Any]] = []
|
compute_sync: list[dict[str, Any]] = []
|
||||||
|
pending_sync: list[tuple[str, str, bytes]] = []
|
||||||
store = get_platform_store()
|
store = get_platform_store()
|
||||||
try:
|
try:
|
||||||
store.dataset(dataset_id)
|
store.dataset(dataset_id)
|
||||||
@@ -870,16 +927,18 @@ async def upload_dataset_files(
|
|||||||
content = raw.decode("utf-8", errors="replace")
|
content = raw.decode("utf-8", errors="replace")
|
||||||
created_file = store.add_dataset_file(conn, dataset_id, file.filename or "upload.jsonl", content)
|
created_file = store.add_dataset_file(conn, dataset_id, file.filename or "upload.jsonl", content)
|
||||||
created.append(created_file)
|
created.append(created_file)
|
||||||
if sync_to_compute:
|
pending_sync.append((created_file["id"], created_file["name"], raw))
|
||||||
compute_sync.extend(
|
if sync_to_compute:
|
||||||
await _sync_dataset_file_to_compute_nodes(
|
for file_id, file_name, raw in pending_sync:
|
||||||
store,
|
compute_sync.extend(
|
||||||
dataset_id,
|
await _sync_dataset_file_to_compute_nodes(
|
||||||
created_file["id"],
|
store,
|
||||||
created_file["name"],
|
dataset_id,
|
||||||
raw,
|
file_id,
|
||||||
)
|
file_name,
|
||||||
|
raw,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
return ok({"files": created, "compute_sync": compute_sync})
|
return ok({"files": created, "compute_sync": compute_sync})
|
||||||
|
|
||||||
|
|
||||||
@@ -1215,7 +1274,23 @@ async def model_eval_list(current_user: dict = Depends(get_current_user)) -> dic
|
|||||||
@router.get("/model-eval/{task_id}")
|
@router.get("/model-eval/{task_id}")
|
||||||
async def model_eval_detail(task_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
async def model_eval_detail(task_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
task = get_platform_store().eval_task(task_id)
|
store = get_platform_store()
|
||||||
|
task = store.eval_task(task_id)
|
||||||
|
if task.get("compute_job_id") and task.get("compute_node_id") and task.get("status") in {"queued", "running", "completed"}:
|
||||||
|
node = next(
|
||||||
|
(n for n in store.compute_nodes() if n["id"] == task.get("compute_node_id")),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if node:
|
||||||
|
try:
|
||||||
|
client = ComputeNodeClient(node["api_base_url"])
|
||||||
|
job = await client.get_job(task["compute_job_id"])
|
||||||
|
result_content = None
|
||||||
|
if job.get("status") == "completed" and not task.get("samples"):
|
||||||
|
result_content = await fetch_eval_result_content(client, node, job)
|
||||||
|
task = store.apply_eval_job_result(task_id, job, result_content)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise fail(404, "eval task not found")
|
raise fail(404, "eval task not found")
|
||||||
if not has_resource_access("eval", task_id, current_user, "read"):
|
if not has_resource_access("eval", task_id, current_user, "read"):
|
||||||
@@ -1225,8 +1300,149 @@ async def model_eval_detail(task_id: str, current_user: dict = Depends(get_curre
|
|||||||
|
|
||||||
@router.post("/model-eval/start")
|
@router.post("/model-eval/start")
|
||||||
async def model_eval_start(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
async def model_eval_start(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||||
task = get_platform_store().create_eval_task(payload)
|
"""Start an evaluation task: submit eval job to compute node."""
|
||||||
return ok({"task_id": task["id"], **task})
|
store = get_platform_store()
|
||||||
|
# 1. Create eval task record
|
||||||
|
task = store.create_eval_task({**payload, "status": "pending"})
|
||||||
|
|
||||||
|
# 2. Resolve model path (supports both regular models and trained models)
|
||||||
|
model_id = str(payload.get("model_id", ""))
|
||||||
|
model_path = ""
|
||||||
|
adapter_path = payload.get("adapter_path", "")
|
||||||
|
try:
|
||||||
|
db_model = store.model(model_id)
|
||||||
|
model_path = db_model.get("path", "")
|
||||||
|
except KeyError:
|
||||||
|
# Try trained_models table (IDs prefixed with tm_)
|
||||||
|
trained = next((m for m in store.trained_models() if m["id"] == model_id), None)
|
||||||
|
if trained:
|
||||||
|
merged_path = trained.get("merged_path", "")
|
||||||
|
base_path = trained.get("base_model_path", "")
|
||||||
|
if trained.get("merged") and merged_path:
|
||||||
|
# Merged model: use merged_path as model, no adapter needed
|
||||||
|
model_path = merged_path
|
||||||
|
elif base_path:
|
||||||
|
# Unmerged: use base model + adapter checkpoint
|
||||||
|
model_path = base_path
|
||||||
|
if merged_path:
|
||||||
|
adapter_path = merged_path
|
||||||
|
else:
|
||||||
|
model_path = merged_path or base_path
|
||||||
|
if not model_path:
|
||||||
|
store.update_eval_task(task["id"], {"status": "failed", "error": "model not found or no path"})
|
||||||
|
return ok({"task_id": task["id"], "status": "failed", "error": "model not found or no path"})
|
||||||
|
|
||||||
|
# 3. Resolve dataset file
|
||||||
|
dataset_id = str(payload.get("dataset_id", ""))
|
||||||
|
dataset_path = ""
|
||||||
|
try:
|
||||||
|
ds_files = store.training_dataset_files(dataset_id)
|
||||||
|
if ds_files:
|
||||||
|
dataset_path = ds_files[0].get("local_path") or ds_files[0].get("name", "")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not dataset_path:
|
||||||
|
# Try to get file content and sync to compute
|
||||||
|
try:
|
||||||
|
ds = store.dataset(dataset_id)
|
||||||
|
for f in ds.get("files", []):
|
||||||
|
if f.get("content"):
|
||||||
|
dataset_path = f.get("name", f"dataset_{dataset_id}.jsonl")
|
||||||
|
break
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
if not dataset_path:
|
||||||
|
store.update_eval_task(task["id"], {"status": "failed", "error": "dataset not found or no files"})
|
||||||
|
return ok({"task_id": task["id"], "status": "failed", "error": "dataset not found or no files"})
|
||||||
|
|
||||||
|
# 4. Resolve dimension config
|
||||||
|
dimension_id = str(payload.get("dimension_id", ""))
|
||||||
|
dimension_cfg: dict[str, Any] = {}
|
||||||
|
if dimension_id:
|
||||||
|
try:
|
||||||
|
dim = store.dimension(dimension_id)
|
||||||
|
# Resolve eval model API config
|
||||||
|
eval_model_name = dim.get("eval_model", "")
|
||||||
|
api_url = ""
|
||||||
|
api_key = ""
|
||||||
|
if eval_model_name:
|
||||||
|
try:
|
||||||
|
eval_model = store.model(eval_model_name) if eval_model_name.startswith("m_") else store.model_by_name(eval_model_name)
|
||||||
|
api_url = eval_model.get("api_url", "")
|
||||||
|
api_key = eval_model.get("api_key", "")
|
||||||
|
except (KeyError, Exception):
|
||||||
|
pass
|
||||||
|
dimension_cfg = {
|
||||||
|
"type": dim.get("type", ""),
|
||||||
|
"eval_model": eval_model_name,
|
||||||
|
"eval_method": dim.get("eval_method", ""),
|
||||||
|
"eval_prompt": dim.get("eval_prompt", ""),
|
||||||
|
"api_url": api_url,
|
||||||
|
"api_key": api_key,
|
||||||
|
"score_min": dim.get("score_min", 0),
|
||||||
|
"score_max": dim.get("score_max", 5),
|
||||||
|
"pass_threshold": dim.get("pass_threshold", 3),
|
||||||
|
}
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 5. Select compute node
|
||||||
|
node = _select_first_online_node(store)
|
||||||
|
if not node:
|
||||||
|
store.update_eval_task(task["id"], {"status": "failed", "error": "no online compute node"})
|
||||||
|
return ok({"task_id": task["id"], "status": "failed", "error": "no online compute node"})
|
||||||
|
|
||||||
|
# 6. Build eval job payload
|
||||||
|
output_dir = f"/data/yg-ft/outputs/{task['id']}"
|
||||||
|
job_payload = {
|
||||||
|
"id": f"eval_{task['id']}",
|
||||||
|
"name": task.get("eval_task_name", task["id"]),
|
||||||
|
"engine": "eval",
|
||||||
|
"model_name_or_path": model_path,
|
||||||
|
"adapter_name_or_path": adapter_path,
|
||||||
|
"template": payload.get("template", "qwen"),
|
||||||
|
"dataset_path": dataset_path,
|
||||||
|
"output_dir": output_dir,
|
||||||
|
"basic_metrics": payload.get("basic_metrics", {}),
|
||||||
|
"dimension": dimension_cfg,
|
||||||
|
"gpus": [int(payload.get("gpu_id", 0))],
|
||||||
|
"temperature": payload.get("temperature", 0.1),
|
||||||
|
"max_new_tokens": payload.get("max_new_tokens", 512),
|
||||||
|
"compute_node_id": node["id"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# 7. Submit to compute node via create_job (uses engine="eval" path)
|
||||||
|
try:
|
||||||
|
client = ComputeNodeClient(node["api_base_url"])
|
||||||
|
# Sync dataset file to compute node if needed
|
||||||
|
if not dataset_path.startswith("/"):
|
||||||
|
try:
|
||||||
|
ds_files = store.training_dataset_files(dataset_id)
|
||||||
|
if ds_files and ds_files[0].get("content"):
|
||||||
|
upload_result = await client.upload_file(
|
||||||
|
ds_files[0].get("name", "eval_data.jsonl"),
|
||||||
|
ds_files[0]["content"].encode("utf-8"),
|
||||||
|
f"datasets/{dataset_id}/{ds_files[0].get('name', 'eval_data.jsonl')}",
|
||||||
|
resource_type="dataset",
|
||||||
|
resource_id=dataset_id,
|
||||||
|
)
|
||||||
|
job_payload["dataset_path"] = upload_result.get("local_path", dataset_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
job = await client.create_job(job_payload)
|
||||||
|
store.update_eval_task(task["id"], {
|
||||||
|
"status": "running",
|
||||||
|
"compute_job_id": job.get("id"),
|
||||||
|
"compute_node_id": node["id"],
|
||||||
|
"output_dir": output_dir,
|
||||||
|
})
|
||||||
|
if job.get("status") in {"queued", "running"}:
|
||||||
|
store.mark_inference_loaded(node["id"])
|
||||||
|
return ok({"task_id": task["id"], "status": "running", "job": job})
|
||||||
|
except Exception as exc:
|
||||||
|
store.update_eval_task(task["id"], {"status": "failed", "error": str(exc)})
|
||||||
|
return ok({"task_id": task["id"], "status": "failed", "error": str(exc)})
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/model-eval/{task_id}")
|
@router.delete("/model-eval/{task_id}")
|
||||||
@@ -1301,8 +1517,27 @@ async def model_compare_detail(task_id: str) -> dict[str, Any]:
|
|||||||
raise fail(404, "compare task not found")
|
raise fail(404, "compare task not found")
|
||||||
|
|
||||||
|
|
||||||
|
async def _unload_from_compute_node() -> dict[str, Any]:
|
||||||
|
"""Best-effort unload the inference model from the first online compute node."""
|
||||||
|
store = get_platform_store()
|
||||||
|
# Clear all inference tracking — only one model can be loaded at a time
|
||||||
|
for node in store.compute_nodes():
|
||||||
|
store.mark_inference_unloaded(node["id"])
|
||||||
|
node = _select_first_online_node(store)
|
||||||
|
if not node:
|
||||||
|
return {"unloaded": False, "error": "no online compute node"}
|
||||||
|
try:
|
||||||
|
client = ComputeNodeClient(node["api_base_url"])
|
||||||
|
result = await client._request("POST", "/inference/unload", json_data={})
|
||||||
|
return result
|
||||||
|
except Exception as exc:
|
||||||
|
return {"unloaded": False, "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/model-compare/{task_id}")
|
@router.delete("/model-compare/{task_id}")
|
||||||
async def model_compare_delete(task_id: str) -> dict[str, Any]:
|
async def model_compare_delete(task_id: str) -> dict[str, Any]:
|
||||||
|
# 删除前先释放算力节点上的模型
|
||||||
|
await _unload_from_compute_node()
|
||||||
get_platform_store().delete_compare_task(task_id)
|
get_platform_store().delete_compare_task(task_id)
|
||||||
return ok({"deleted": task_id})
|
return ok({"deleted": task_id})
|
||||||
|
|
||||||
@@ -1332,26 +1567,56 @@ async def model_compare_update_load_status(task_id: str, payload: dict[str, Any]
|
|||||||
|
|
||||||
@router.post("/model-compare/{task_id}/load")
|
@router.post("/model-compare/{task_id}/load")
|
||||||
async def model_compare_load(task_id: str) -> dict[str, Any]:
|
async def model_compare_load(task_id: str) -> dict[str, Any]:
|
||||||
|
"""真正加载模型到算力节点(不再使用假 PID/端口)。"""
|
||||||
try:
|
try:
|
||||||
task = get_platform_store().compare_task(task_id)
|
store = get_platform_store()
|
||||||
|
task = store.compare_task(task_id)
|
||||||
models = task.get("models") or []
|
models = task.get("models") or []
|
||||||
if isinstance(models, str):
|
if isinstance(models, str):
|
||||||
try:
|
try:
|
||||||
models = json.loads(models)
|
models = json.loads(models)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
models = []
|
models = []
|
||||||
loaded_models = [
|
# 选取在线算力节点
|
||||||
{
|
node = _select_first_online_node(store)
|
||||||
"model_id": item.get("model_id"),
|
if not node:
|
||||||
"model_name": item.get("model_name"),
|
return ok({"status": "failed", "error": "no online compute node"})
|
||||||
"status": "ready",
|
client = ComputeNodeClient(node["api_base_url"])
|
||||||
"pid": 45000 + index,
|
loaded_models = []
|
||||||
"port": item.get("port") or 18000 + index,
|
for item in models:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
model_path = item.get("model_path", "")
|
||||||
|
if not model_path:
|
||||||
|
# 尝试从模型库获取路径
|
||||||
|
model_id = item.get("model_id", "")
|
||||||
|
try:
|
||||||
|
db_model = store.model(model_id)
|
||||||
|
model_path = db_model.get("path", "")
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
if not model_path:
|
||||||
|
loaded_models.append({**item, "status": "error", "error": "model_path not found"})
|
||||||
|
continue
|
||||||
|
# 真正调用算力节点加载模型
|
||||||
|
load_payload = {
|
||||||
|
"model_name_or_path": model_path,
|
||||||
|
"template": item.get("template", "qwen"),
|
||||||
}
|
}
|
||||||
for index, item in enumerate(models)
|
if item.get("adapter_path"):
|
||||||
if isinstance(item, dict)
|
load_payload["adapter_name_or_path"] = item["adapter_path"]
|
||||||
]
|
try:
|
||||||
return ok(get_platform_store().update_compare_task(task_id, {"status": "loaded", "load_status": {"loaded_models": loaded_models}}))
|
result = await client._request("POST", "/inference/load", json_data=load_payload)
|
||||||
|
if result.get("loaded"):
|
||||||
|
store.mark_inference_loaded(node["id"])
|
||||||
|
loaded_models.append({**item, "status": "ready"})
|
||||||
|
else:
|
||||||
|
loaded_models.append({**item, "status": "error", "error": result.get("error", "load failed")})
|
||||||
|
except Exception as exc:
|
||||||
|
loaded_models.append({**item, "status": "error", "error": str(exc)})
|
||||||
|
status = "loaded" if any(m.get("status") == "ready" for m in loaded_models) else "failed"
|
||||||
|
updated = store.update_compare_task(task_id, {"status": status, "load_status": {"loaded_models": loaded_models}})
|
||||||
|
return ok(updated)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise fail(404, "compare task not found")
|
raise fail(404, "compare task not found")
|
||||||
|
|
||||||
@@ -1359,7 +1624,11 @@ async def model_compare_load(task_id: str) -> dict[str, Any]:
|
|||||||
@router.post("/model-compare/{task_id}/unload")
|
@router.post("/model-compare/{task_id}/unload")
|
||||||
async def model_compare_unload(task_id: str) -> dict[str, Any]:
|
async def model_compare_unload(task_id: str) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return ok(get_platform_store().update_compare_task(task_id, {"status": "pending", "load_status": {"loaded_models": []}}))
|
store = get_platform_store()
|
||||||
|
# 真正释放算力节点上的模型资源
|
||||||
|
unload_result = await _unload_from_compute_node()
|
||||||
|
updated = store.update_compare_task(task_id, {"status": "pending", "load_status": {"loaded_models": []}})
|
||||||
|
return ok({"task": updated, "unload": unload_result})
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise fail(404, "compare task not found")
|
raise fail(404, "compare task not found")
|
||||||
|
|
||||||
@@ -1371,18 +1640,23 @@ async def model_compare_start_model(task_id: str, payload: dict[str, Any] = Body
|
|||||||
|
|
||||||
@router.post("/model-compare/chat-with-port")
|
@router.post("/model-compare/chat-with-port")
|
||||||
async def model_compare_chat_with_port(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
async def model_compare_chat_with_port(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||||
question = ""
|
"""Proxy non-streaming chat to the compute node running the inference model."""
|
||||||
for message in payload.get("messages") or []:
|
store = get_platform_store()
|
||||||
if message.get("role") == "user":
|
node = _select_first_online_node(store)
|
||||||
question = str(message.get("content") or "")
|
if not node:
|
||||||
content = f"当前后端已收到推理请求:{question[:120]}"
|
return ok({"response": "no online compute node available for inference", "request": payload})
|
||||||
return ok({"response": content, "content": content})
|
try:
|
||||||
|
client = ComputeNodeClient(node["api_base_url"])
|
||||||
|
result = await client._request("POST", "/inference/chat", json_data=_build_messages_payload(payload))
|
||||||
|
return ok(result)
|
||||||
|
except Exception as exc:
|
||||||
|
return ok({"response": f"inference failed: {exc}", "request": payload})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/model-compare/stream-chat")
|
@router.post("/model-compare/stream-chat")
|
||||||
async def model_compare_stream_chat(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
async def model_compare_stream_chat(payload: dict[str, Any] = Body(...)) -> StreamingResponse:
|
||||||
question = payload.get("user_question") or payload.get("question") or ""
|
"""Stream chat from the compute node (SSE proxy)."""
|
||||||
return ok({"response": f"当前后端已收到流式推理请求:{str(question)[:120]}"})
|
return await _stream_chat_proxy(payload)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/model-chat/batch")
|
@router.post("/model-chat/batch")
|
||||||
@@ -1399,7 +1673,7 @@ async def model_chat_local(payload: dict[str, Any] = Body(...)) -> dict[str, Any
|
|||||||
return ok({"response": "no online compute node available for inference", "request": payload})
|
return ok({"response": "no online compute node available for inference", "request": payload})
|
||||||
try:
|
try:
|
||||||
client = ComputeNodeClient(node["api_base_url"])
|
client = ComputeNodeClient(node["api_base_url"])
|
||||||
result = await client._request("POST", "/inference/chat", json_data=payload)
|
result = await client._request("POST", "/inference/chat", json_data=_build_messages_payload(payload))
|
||||||
return ok(result)
|
return ok(result)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return ok({"response": f"inference failed: {exc}", "request": payload})
|
return ok({"response": f"inference failed: {exc}", "request": payload})
|
||||||
@@ -1408,28 +1682,15 @@ async def model_chat_local(payload: dict[str, Any] = Body(...)) -> dict[str, Any
|
|||||||
@router.post("/model-chat/local/chat/stream")
|
@router.post("/model-chat/local/chat/stream")
|
||||||
async def model_chat_local_stream(payload: dict[str, Any] = Body(...)) -> StreamingResponse:
|
async def model_chat_local_stream(payload: dict[str, Any] = Body(...)) -> StreamingResponse:
|
||||||
"""Stream chat from the compute node."""
|
"""Stream chat from the compute node."""
|
||||||
store = get_platform_store()
|
return await _stream_chat_proxy(payload)
|
||||||
node = _select_first_online_node(store)
|
|
||||||
if not node:
|
|
||||||
return StreamingResponse(
|
|
||||||
iter(['data: {"error": "no online compute node"}\n\n']),
|
|
||||||
media_type="text/event-stream",
|
|
||||||
)
|
|
||||||
client = ComputeNodeClient(node["api_base_url"])
|
|
||||||
|
|
||||||
async def stream_proxy():
|
|
||||||
async with httpx.AsyncClient(timeout=300) as http:
|
|
||||||
url = f"{node['api_base_url'].rstrip('/')}/modelTF/inference/chat/stream"
|
|
||||||
async with http.stream("POST", url, json=payload, headers=client.headers()) as resp:
|
|
||||||
async for chunk in resp.aiter_bytes():
|
|
||||||
yield chunk
|
|
||||||
|
|
||||||
return StreamingResponse(stream_proxy(), media_type="text/event-stream")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/model-chat/local/preload")
|
@router.post("/model-chat/local/preload")
|
||||||
async def model_chat_local_preload(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
async def model_chat_local_preload(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||||
"""Load a model on the compute node for inference."""
|
"""Load a model on the compute node for inference."""
|
||||||
|
model_path = (payload.get("model_name_or_path") or "").strip()
|
||||||
|
if not model_path:
|
||||||
|
return ok({"loaded": False, "error": "model_name_or_path is required"})
|
||||||
store = get_platform_store()
|
store = get_platform_store()
|
||||||
node = _select_first_online_node(store)
|
node = _select_first_online_node(store)
|
||||||
if not node:
|
if not node:
|
||||||
@@ -1437,6 +1698,8 @@ async def model_chat_local_preload(payload: dict[str, Any] = Body(...)) -> dict[
|
|||||||
try:
|
try:
|
||||||
client = ComputeNodeClient(node["api_base_url"])
|
client = ComputeNodeClient(node["api_base_url"])
|
||||||
result = await client._request("POST", "/inference/load", json_data=payload)
|
result = await client._request("POST", "/inference/load", json_data=payload)
|
||||||
|
if result.get("loaded"):
|
||||||
|
store.mark_inference_loaded(node["id"])
|
||||||
return ok(result)
|
return ok(result)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return ok({"loaded": False, "error": str(exc)})
|
return ok({"loaded": False, "error": str(exc)})
|
||||||
@@ -1446,6 +1709,9 @@ async def model_chat_local_preload(payload: dict[str, Any] = Body(...)) -> dict[
|
|||||||
async def model_chat_local_unload() -> dict[str, Any]:
|
async def model_chat_local_unload() -> dict[str, Any]:
|
||||||
"""Unload the inference model from the compute node."""
|
"""Unload the inference model from the compute node."""
|
||||||
store = get_platform_store()
|
store = get_platform_store()
|
||||||
|
# Clear all inference tracking
|
||||||
|
for n in store.compute_nodes():
|
||||||
|
store.mark_inference_unloaded(n["id"])
|
||||||
node = _select_first_online_node(store)
|
node = _select_first_online_node(store)
|
||||||
if not node:
|
if not node:
|
||||||
return ok({"unloaded": False, "error": "no online compute node"})
|
return ok({"unloaded": False, "error": "no online compute node"})
|
||||||
@@ -1475,6 +1741,9 @@ async def model_chat_local_status() -> dict[str, Any]:
|
|||||||
@router.post("/model-chat/trained/preload")
|
@router.post("/model-chat/trained/preload")
|
||||||
async def model_chat_trained_preload(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
async def model_chat_trained_preload(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||||
"""Load a trained model (base + adapter) on the compute node for inference."""
|
"""Load a trained model (base + adapter) on the compute node for inference."""
|
||||||
|
model_path = (payload.get("model_name_or_path") or "").strip()
|
||||||
|
if not model_path:
|
||||||
|
return ok({"loaded": False, "error": "model_name_or_path is required"})
|
||||||
store = get_platform_store()
|
store = get_platform_store()
|
||||||
node = _select_first_online_node(store)
|
node = _select_first_online_node(store)
|
||||||
if not node:
|
if not node:
|
||||||
@@ -1482,6 +1751,8 @@ async def model_chat_trained_preload(payload: dict[str, Any] = Body(...)) -> dic
|
|||||||
try:
|
try:
|
||||||
client = ComputeNodeClient(node["api_base_url"])
|
client = ComputeNodeClient(node["api_base_url"])
|
||||||
result = await client._request("POST", "/inference/load", json_data=payload)
|
result = await client._request("POST", "/inference/load", json_data=payload)
|
||||||
|
if result.get("loaded"):
|
||||||
|
store.mark_inference_loaded(node["id"])
|
||||||
return ok(result)
|
return ok(result)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return ok({"loaded": False, "error": str(exc)})
|
return ok({"loaded": False, "error": str(exc)})
|
||||||
@@ -1520,6 +1791,16 @@ async def update_compute_node(node_id: str, payload: dict[str, Any] = Body(...))
|
|||||||
raise fail(400, str(exc))
|
raise fail(400, str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/compute/nodes/{node_id}")
|
||||||
|
async def delete_compute_node(node_id: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ok(get_platform_store().delete_compute_node(node_id))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "compute node not found")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise fail(400, str(exc))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/compute/nodes/{node_id}/test-connection")
|
@router.post("/compute/nodes/{node_id}/test-connection")
|
||||||
async def test_compute_node(node_id: str) -> dict[str, Any]:
|
async def test_compute_node(node_id: str) -> dict[str, Any]:
|
||||||
store = get_platform_store()
|
store = get_platform_store()
|
||||||
|
|||||||
@@ -88,6 +88,22 @@ def parse_size_bytes(value: Any) -> int:
|
|||||||
return max(0, round(amount * _SIZE_UNIT_BYTES[unit]))
|
return max(0, round(amount * _SIZE_UNIT_BYTES[unit]))
|
||||||
|
|
||||||
|
|
||||||
|
def count_dataset_records(content: str) -> int:
|
||||||
|
text = (content or "").strip()
|
||||||
|
if not text:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
value = json.loads(text)
|
||||||
|
if isinstance(value, list):
|
||||||
|
return len(value)
|
||||||
|
return 1
|
||||||
|
except (TypeError, ValueError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return len([line for line in text.splitlines() if line.strip()])
|
||||||
|
|
||||||
|
|
||||||
def version_number(value: Any, default: int = 0) -> int:
|
def version_number(value: Any, default: int = 0) -> int:
|
||||||
try:
|
try:
|
||||||
number = int(value)
|
number = int(value)
|
||||||
@@ -296,6 +312,7 @@ class PlatformStore:
|
|||||||
# request (notably expensive against the remote PostgreSQL instance).
|
# request (notably expensive against the remote PostgreSQL instance).
|
||||||
# TCP keepalive 让操作系统持续保活连接,抵抗远程库空闲静默断连。
|
# TCP keepalive 让操作系统持续保活连接,抵抗远程库空闲静默断连。
|
||||||
pool_kwargs = {
|
pool_kwargs = {
|
||||||
|
"connect_timeout": 5,
|
||||||
"keepalives": 1,
|
"keepalives": 1,
|
||||||
"keepalives_idle": 30,
|
"keepalives_idle": 30,
|
||||||
"keepalives_interval": 10,
|
"keepalives_interval": 10,
|
||||||
@@ -320,6 +337,19 @@ class PlatformStore:
|
|||||||
self._pool.open()
|
self._pool.open()
|
||||||
self.ensure_schema()
|
self.ensure_schema()
|
||||||
self.ensure_seed_data()
|
self.ensure_seed_data()
|
||||||
|
# Track which compute nodes have an active inference model loaded
|
||||||
|
self._inference_nodes: set[str] = set()
|
||||||
|
|
||||||
|
# ── inference node tracking ────────────────────────────────────
|
||||||
|
|
||||||
|
def mark_inference_loaded(self, node_id: str) -> None:
|
||||||
|
self._inference_nodes.add(node_id)
|
||||||
|
|
||||||
|
def mark_inference_unloaded(self, node_id: str) -> None:
|
||||||
|
self._inference_nodes.discard(node_id)
|
||||||
|
|
||||||
|
def is_inference_loaded(self, node_id: str) -> bool:
|
||||||
|
return node_id in self._inference_nodes
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def connect(self) -> Iterator["PgConnection"]:
|
def connect(self) -> Iterator["PgConnection"]:
|
||||||
@@ -1298,6 +1328,7 @@ class PlatformStore:
|
|||||||
file_size_bytes = int(file_row.get("size_bytes") or 0)
|
file_size_bytes = int(file_row.get("size_bytes") or 0)
|
||||||
if file_size_bytes <= 0:
|
if file_size_bytes <= 0:
|
||||||
file_size_bytes = parse_size_bytes(file_row.get("size"))
|
file_size_bytes = parse_size_bytes(file_row.get("size"))
|
||||||
|
file_record_count = int(file_row.get("record_count") or 0)
|
||||||
decoded_files.append(
|
decoded_files.append(
|
||||||
{
|
{
|
||||||
"id": file_row["id"],
|
"id": file_row["id"],
|
||||||
@@ -1306,7 +1337,7 @@ class PlatformStore:
|
|||||||
"size_bytes": file_size_bytes,
|
"size_bytes": file_size_bytes,
|
||||||
**dataset_file_version_summary(file_row),
|
**dataset_file_version_summary(file_row),
|
||||||
"create_time": file_row["create_time"],
|
"create_time": file_row["create_time"],
|
||||||
"record_count": int(file_row.get("record_count") or 0),
|
"record_count": file_record_count,
|
||||||
"split": metadata.get("file_split"),
|
"split": metadata.get("file_split"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1324,6 +1355,9 @@ class PlatformStore:
|
|||||||
total_size_bytes = int(row.get("size_bytes") or 0)
|
total_size_bytes = int(row.get("size_bytes") or 0)
|
||||||
if total_size_bytes <= 0:
|
if total_size_bytes <= 0:
|
||||||
total_size_bytes = parse_size_bytes(row.get("size"))
|
total_size_bytes = parse_size_bytes(row.get("size"))
|
||||||
|
total_record_count = sum(int(item.get("record_count") or 0) for item in decoded_files)
|
||||||
|
if not decoded_files:
|
||||||
|
total_record_count = int(row.get("record_count") or row.get("count") or 0)
|
||||||
current_version_nos = sorted(
|
current_version_nos = sorted(
|
||||||
{
|
{
|
||||||
int(item["current_version_no"])
|
int(item["current_version_no"])
|
||||||
@@ -1333,6 +1367,8 @@ class PlatformStore:
|
|||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
**dict(row),
|
**dict(row),
|
||||||
|
"count": total_record_count,
|
||||||
|
"record_count": total_record_count,
|
||||||
"size_bytes": total_size_bytes,
|
"size_bytes": total_size_bytes,
|
||||||
"current_version_no": (
|
"current_version_no": (
|
||||||
current_version_nos[0] if len(current_version_nos) == 1 else None
|
current_version_nos[0] if len(current_version_nos) == 1 else None
|
||||||
@@ -1407,7 +1443,7 @@ class PlatformStore:
|
|||||||
version_id = f"{file_id}_v1"
|
version_id = f"{file_id}_v1"
|
||||||
size_bytes = len(content.encode("utf-8"))
|
size_bytes = len(content.encode("utf-8"))
|
||||||
size = f"{size_bytes} B"
|
size = f"{size_bytes} B"
|
||||||
record_count = len([line for line in content.splitlines() if line.strip()])
|
record_count = count_dataset_records(content)
|
||||||
version = {
|
version = {
|
||||||
"id": version_id,
|
"id": version_id,
|
||||||
"version": 1,
|
"version": 1,
|
||||||
@@ -1440,10 +1476,18 @@ class PlatformStore:
|
|||||||
)
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""UPDATE datasets
|
"""UPDATE datasets
|
||||||
SET count=count+?, record_count=record_count+?,
|
SET count=stats.record_count,
|
||||||
size_bytes=size_bytes+?, size=((size_bytes+?)::text || ' B')
|
record_count=stats.record_count,
|
||||||
|
size_bytes=stats.size_bytes,
|
||||||
|
size=(stats.size_bytes::text || ' B')
|
||||||
|
FROM (
|
||||||
|
SELECT COALESCE(SUM(record_count), 0) AS record_count,
|
||||||
|
COALESCE(SUM(size_bytes), 0) AS size_bytes
|
||||||
|
FROM dataset_files
|
||||||
|
WHERE dataset_id=?
|
||||||
|
) stats
|
||||||
WHERE id=?""",
|
WHERE id=?""",
|
||||||
(record_count, record_count, size_bytes, size_bytes, dataset_id),
|
(dataset_id, dataset_id),
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"id": file_id,
|
"id": file_id,
|
||||||
@@ -1548,19 +1592,57 @@ class PlatformStore:
|
|||||||
row = conn.execute("SELECT * FROM dataset_files WHERE id=?", (file_id,)).fetchone()
|
row = conn.execute("SELECT * FROM dataset_files WHERE id=?", (file_id,)).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise KeyError(file_id)
|
raise KeyError(file_id)
|
||||||
|
content = payload.get("content", "")
|
||||||
|
size_bytes = len(content.encode("utf-8"))
|
||||||
|
record_count = count_dataset_records(content)
|
||||||
versions = json_loads(row["versions"], [])
|
versions = json_loads(row["versions"], [])
|
||||||
version = {
|
version = {
|
||||||
"id": f"{file_id}_v{len(versions) + 1}",
|
"id": f"{file_id}_v{len(versions) + 1}",
|
||||||
"version": len(versions) + 1,
|
"version": len(versions) + 1,
|
||||||
|
"version_no": len(versions) + 1,
|
||||||
"create_time": utcnow(),
|
"create_time": utcnow(),
|
||||||
"description": payload.get("description", "online edit"),
|
"description": payload.get("description", "online edit"),
|
||||||
|
"size_bytes": size_bytes,
|
||||||
|
"record_count": record_count,
|
||||||
}
|
}
|
||||||
versions.append(version)
|
versions.append(version)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE dataset_files SET content=?, active_version_id=?, versions=? WHERE id=?",
|
"""
|
||||||
(payload.get("content", ""), version["id"], json_dumps(versions), file_id),
|
UPDATE dataset_files
|
||||||
|
SET content=?, active_version_id=?, current_version_id=?, versions=?,
|
||||||
|
size_bytes=?, size=?, record_count=?, version_no=?
|
||||||
|
WHERE id=?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
content,
|
||||||
|
version["id"],
|
||||||
|
version["id"],
|
||||||
|
json_dumps(versions),
|
||||||
|
size_bytes,
|
||||||
|
f"{size_bytes} B",
|
||||||
|
record_count,
|
||||||
|
version["version_no"],
|
||||||
|
file_id,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return {"version": version, "content": payload.get("content", "")}
|
conn.execute(
|
||||||
|
"""UPDATE datasets
|
||||||
|
SET count=stats.record_count,
|
||||||
|
record_count=stats.record_count,
|
||||||
|
size_bytes=stats.size_bytes,
|
||||||
|
size=(stats.size_bytes::text || ' B')
|
||||||
|
FROM (
|
||||||
|
SELECT dataset_id,
|
||||||
|
COALESCE(SUM(record_count), 0) AS record_count,
|
||||||
|
COALESCE(SUM(size_bytes), 0) AS size_bytes
|
||||||
|
FROM dataset_files
|
||||||
|
WHERE dataset_id=(SELECT dataset_id FROM dataset_files WHERE id=?)
|
||||||
|
GROUP BY dataset_id
|
||||||
|
) stats
|
||||||
|
WHERE datasets.id=stats.dataset_id""",
|
||||||
|
(file_id,),
|
||||||
|
)
|
||||||
|
return {"version": version, "content": content}
|
||||||
|
|
||||||
def activate_file_version(self, file_id: str, version_id: str) -> dict[str, Any]:
|
def activate_file_version(self, file_id: str, version_id: str) -> dict[str, Any]:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
@@ -2114,10 +2196,63 @@ class PlatformStore:
|
|||||||
)
|
)
|
||||||
return self.eval_task(task_id)
|
return self.eval_task(task_id)
|
||||||
|
|
||||||
|
def update_eval_task(self, task_id: str, updates: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Update fields in an eval task's payload without replacing the whole record."""
|
||||||
|
task = self.eval_task(task_id)
|
||||||
|
merged = {**task, **updates}
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE eval_tasks SET payload=?, status=? WHERE id=?",
|
||||||
|
(json_dumps(merged), merged.get("status", task.get("status", "pending")), task_id),
|
||||||
|
)
|
||||||
|
return self.eval_task(task_id)
|
||||||
|
|
||||||
def delete_eval_task(self, task_id: str) -> None:
|
def delete_eval_task(self, task_id: str) -> None:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.execute("DELETE FROM eval_tasks WHERE id=?", (task_id,))
|
conn.execute("DELETE FROM eval_tasks WHERE id=?", (task_id,))
|
||||||
|
|
||||||
|
def running_eval_tasks(self) -> list[dict[str, Any]]:
|
||||||
|
"""Return eval tasks that have been submitted to a compute node and are still running."""
|
||||||
|
return [
|
||||||
|
task for task in self.eval_tasks()
|
||||||
|
if task.get("compute_job_id") and task.get("status") in {"queued", "running"}
|
||||||
|
]
|
||||||
|
|
||||||
|
def apply_eval_job_result(self, task_id: str, job: dict[str, Any], result_content: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
|
"""Sync a compute job status/result back to an eval task."""
|
||||||
|
task = self.eval_task(task_id)
|
||||||
|
job_status = str(job.get("status", ""))
|
||||||
|
status_map = {"queued": "running", "running": "running", "completed": "completed",
|
||||||
|
"failed": "failed", "stopped": "stopped"}
|
||||||
|
new_status = status_map.get(job_status, job_status or task.get("status", "pending"))
|
||||||
|
updates: dict[str, Any] = {
|
||||||
|
"status": new_status,
|
||||||
|
"progress": int(job.get("progress", 0)),
|
||||||
|
"output_dir": job.get("output_dir", task.get("output_dir", "")),
|
||||||
|
}
|
||||||
|
# On completion, populate results from eval_results.json content
|
||||||
|
if new_status == "completed" and result_content:
|
||||||
|
updates.update({
|
||||||
|
"overall_score": result_content.get("overall_score", 0),
|
||||||
|
"overall_score_max": result_content.get("overall_score_max", 100),
|
||||||
|
"overall_evaluation": result_content.get("overall_evaluation", ""),
|
||||||
|
"improvement_suggestions": result_content.get("improvement_suggestions", []),
|
||||||
|
"dimension_summary": result_content.get("dimension_summary", []),
|
||||||
|
"samples": result_content.get("samples", []),
|
||||||
|
"sample_count": result_content.get("sample_count", 0),
|
||||||
|
"completed_count": result_content.get("completed_count", 0),
|
||||||
|
"passed_count": result_content.get("passed_count", 0),
|
||||||
|
"basic_metrics": result_content.get("basic_metrics", {}),
|
||||||
|
"score": result_content.get("overall_score", 0),
|
||||||
|
"completed_time": utcnow(),
|
||||||
|
})
|
||||||
|
elif new_status in {"failed", "stopped"}:
|
||||||
|
updates.update({
|
||||||
|
"error": job.get("error") or task.get("error") or "",
|
||||||
|
"completed_time": utcnow(),
|
||||||
|
})
|
||||||
|
return self.update_eval_task(task_id, updates)
|
||||||
|
|
||||||
def dimensions(self) -> list[dict[str, Any]]:
|
def dimensions(self) -> list[dict[str, Any]]:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
rows = conn.execute("SELECT * FROM eval_dimensions ORDER BY create_time DESC").fetchall()
|
rows = conn.execute("SELECT * FROM eval_dimensions ORDER BY create_time DESC").fetchall()
|
||||||
@@ -2284,6 +2419,15 @@ class PlatformStore:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
return {int(row["gpu_index"]) for row in rows}
|
return {int(row["gpu_index"]) for row in rows}
|
||||||
|
|
||||||
|
def _node_gpu_indexes(self, conn: PgConnection, node: dict[str, Any]) -> set[int]:
|
||||||
|
rows = conn.execute("SELECT gpu_index FROM gpus WHERE node_id=?", (node["id"],)).fetchall()
|
||||||
|
if rows:
|
||||||
|
return {int(row["gpu_index"]) for row in rows}
|
||||||
|
return set(range(max(0, int(node.get("gpu_count") or 0))))
|
||||||
|
|
||||||
|
def _node_capacity(self, node: dict[str, Any]) -> int:
|
||||||
|
return max(1, int(node.get("max_parallel_jobs") or 1), int(node.get("gpu_count") or 0))
|
||||||
|
|
||||||
def _schedule_node_locked(self, conn: PgConnection, payload: dict[str, Any]) -> dict[str, Any]:
|
def _schedule_node_locked(self, conn: PgConnection, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
requested = payload.get("requested_node_id") or payload.get("compute_node_id")
|
requested = payload.get("requested_node_id") or payload.get("compute_node_id")
|
||||||
requested_gpus = [int(item) for item in payload.get("gpus") or []]
|
requested_gpus = [int(item) for item in payload.get("gpus") or []]
|
||||||
@@ -2291,13 +2435,15 @@ class PlatformStore:
|
|||||||
candidates = [
|
candidates = [
|
||||||
n
|
n
|
||||||
for n in nodes
|
for n in nodes
|
||||||
if n["enabled"] and n["scheduler_status"] == "online" and n["current_running_jobs"] < n["max_parallel_jobs"]
|
if n["enabled"] and n["scheduler_status"] == "online" and n["current_running_jobs"] < self._node_capacity(n)
|
||||||
]
|
]
|
||||||
if requested_gpus:
|
if requested_gpus:
|
||||||
|
requested_gpu_set = set(requested_gpus)
|
||||||
candidates = [
|
candidates = [
|
||||||
node
|
node
|
||||||
for node in candidates
|
for node in candidates
|
||||||
if not set(requested_gpus).intersection(self._active_gpu_indexes(conn, node["id"]))
|
if requested_gpu_set.issubset(self._node_gpu_indexes(conn, node))
|
||||||
|
and not requested_gpu_set.intersection(self._active_gpu_indexes(conn, node["id"]))
|
||||||
]
|
]
|
||||||
if requested:
|
if requested:
|
||||||
selected = next((n for n in candidates if n["id"] == requested), None)
|
selected = next((n for n in candidates if n["id"] == requested), None)
|
||||||
@@ -2312,8 +2458,8 @@ class PlatformStore:
|
|||||||
reason = "disabled"
|
reason = "disabled"
|
||||||
elif node["scheduler_status"] != "online":
|
elif node["scheduler_status"] != "online":
|
||||||
reason = f"status={node['scheduler_status']}"
|
reason = f"status={node['scheduler_status']}"
|
||||||
elif node["current_running_jobs"] >= node["max_parallel_jobs"]:
|
elif node["current_running_jobs"] >= self._node_capacity(node):
|
||||||
reason = f"capacity full {node['current_running_jobs']}/{node['max_parallel_jobs']}"
|
reason = f"capacity full {node['current_running_jobs']}/{self._node_capacity(node)}"
|
||||||
else:
|
else:
|
||||||
reason = "not selected"
|
reason = "not selected"
|
||||||
reasons.append(f"{node['code']}({reason})")
|
reasons.append(f"{node['code']}({reason})")
|
||||||
@@ -2664,6 +2810,24 @@ class PlatformStore:
|
|||||||
)
|
)
|
||||||
return next(node for node in self.compute_nodes() if node["id"] == node_id)
|
return next(node for node in self.compute_nodes() if node["id"] == node_id)
|
||||||
|
|
||||||
|
def delete_compute_node(self, node_id: str) -> dict[str, Any]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
node = conn.execute("SELECT * FROM compute_nodes WHERE id=?", (node_id,)).fetchone()
|
||||||
|
if not node:
|
||||||
|
raise KeyError(node_id)
|
||||||
|
active = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS cnt
|
||||||
|
FROM fine_tune_tasks
|
||||||
|
WHERE compute_node_id=? AND status IN ('syncing','queued','running')
|
||||||
|
""",
|
||||||
|
(node_id,),
|
||||||
|
).fetchone()
|
||||||
|
if active and int(active["cnt"] or 0) > 0:
|
||||||
|
raise ValueError("compute node has active training tasks")
|
||||||
|
conn.execute("DELETE FROM compute_nodes WHERE id=?", (node_id,))
|
||||||
|
return {"deleted": node_id}
|
||||||
|
|
||||||
def update_compute_node_health(self, node_id: str, health: dict[str, Any], success: bool, error: str | None = None) -> dict[str, Any]:
|
def update_compute_node_health(self, node_id: str, health: dict[str, Any], success: bool, error: str | None = None) -> dict[str, Any]:
|
||||||
current = next((n for n in self.compute_nodes() if n["id"] == node_id), None)
|
current = next((n for n in self.compute_nodes() if n["id"] == node_id), None)
|
||||||
if not current:
|
if not current:
|
||||||
@@ -2749,6 +2913,11 @@ class PlatformStore:
|
|||||||
)
|
)
|
||||||
busy = task is not None and task.get("status") == "running"
|
busy = task is not None and task.get("status") == "running"
|
||||||
reserved = task is not None and task.get("status") in {"syncing", "queued"}
|
reserved = task is not None and task.get("status") in {"syncing", "queued"}
|
||||||
|
# Also mark GPU as busy if an inference model is loaded on this node
|
||||||
|
inference_busy = self.is_inference_loaded(row["node_id"])
|
||||||
|
if inference_busy and not busy:
|
||||||
|
busy = True
|
||||||
|
reserved = False
|
||||||
memory_used = round(row["memory_total_gb"] * (0.72 if busy else 0.18 if reserved else 0.04), 1)
|
memory_used = round(row["memory_total_gb"] * (0.72 if busy else 0.18 if reserved else 0.04), 1)
|
||||||
gpu_percent = 86 if busy else 22 if reserved else 3
|
gpu_percent = 86 if busy else 22 if reserved else 3
|
||||||
memory_total = float(row["memory_total_gb"] or 0)
|
memory_total = float(row["memory_total_gb"] or 0)
|
||||||
@@ -2827,11 +2996,12 @@ class PlatformStore:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def health_metrics(self) -> dict[str, float]:
|
def health_metrics(self) -> dict[str, float]:
|
||||||
info = self.system_info()
|
# Health checks must stay lightweight. The Docker healthcheck and page
|
||||||
|
# refresh probes should not wait on dashboard/GPU/database aggregation.
|
||||||
return {
|
return {
|
||||||
"cpu_percent": info["cpu"]["percent"],
|
"cpu_percent": 0.0,
|
||||||
"memory_percent": info["memory"]["percent"],
|
"memory_percent": 0.0,
|
||||||
"disk_percent": info["disk"]["percent"],
|
"disk_percent": 0.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
def queue(self) -> list[dict[str, Any]]:
|
def queue(self) -> list[dict[str, Any]]:
|
||||||
|
|||||||
@@ -207,7 +207,8 @@ class ComputeNodeClient:
|
|||||||
"resource_id": resource_id or "",
|
"resource_id": resource_id or "",
|
||||||
}
|
}
|
||||||
files = {"file": (filename, content)}
|
files = {"file": (filename, content)}
|
||||||
async with httpx.AsyncClient(timeout=max(self.timeout, 60), headers=self.headers()) as client:
|
timeout = httpx.Timeout(max(self.timeout, 60), connect=self.timeout)
|
||||||
|
async with httpx.AsyncClient(timeout=timeout, headers=self.headers()) as client:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
_join_url(self.api_base_url, f"{self.route_prefix}/compute/files/upload"),
|
_join_url(self.api_base_url, f"{self.route_prefix}/compute/files/upload"),
|
||||||
data=data,
|
data=data,
|
||||||
|
|||||||
@@ -10,6 +10,24 @@ def _node_for_task(task: dict[str, Any]) -> dict[str, Any] | None:
|
|||||||
return next((node for node in get_platform_store().compute_nodes() if node["id"] == task.get("compute_node_id")), None)
|
return next((node for node in get_platform_store().compute_nodes() if node["id"] == task.get("compute_node_id")), None)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_eval_result_content(client: ComputeNodeClient, node: dict[str, Any], job: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
output_dir = job.get("output_dir")
|
||||||
|
if not output_dir:
|
||||||
|
return None
|
||||||
|
full_path = f"{str(output_dir).rstrip('/')}/eval_results.json"
|
||||||
|
data_root = "/data/yg-ft/"
|
||||||
|
if full_path.startswith(data_root):
|
||||||
|
full_path = full_path[len(data_root):]
|
||||||
|
rel_path = full_path.lstrip("/")
|
||||||
|
import httpx
|
||||||
|
url = f"{node['api_base_url'].rstrip('/')}/modelTF/compute/files/read"
|
||||||
|
async with httpx.AsyncClient(timeout=30, headers=client.headers()) as http:
|
||||||
|
response = await http.get(url, params={"path": rel_path})
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
return payload if isinstance(payload, dict) else None
|
||||||
|
|
||||||
|
|
||||||
async def poll_compute_jobs_once() -> dict[str, Any]:
|
async def poll_compute_jobs_once() -> dict[str, Any]:
|
||||||
store = get_platform_store()
|
store = get_platform_store()
|
||||||
synced: list[dict[str, Any]] = []
|
synced: list[dict[str, Any]] = []
|
||||||
@@ -48,4 +66,34 @@ async def poll_compute_jobs_once() -> dict[str, Any]:
|
|||||||
standalone_synced.append(store.sync_model_merge_job(record["id"], job))
|
standalone_synced.append(store.sync_model_merge_job(record["id"], job))
|
||||||
except Exception as exc: # noqa: BLE001 - keep polling other jobs
|
except Exception as exc: # noqa: BLE001 - keep polling other jobs
|
||||||
failed.append({"job_id": record["id"], "error": str(exc)})
|
failed.append({"job_id": record["id"], "error": str(exc)})
|
||||||
return {"synced": len(synced) + len(standalone_synced), "failed": failed, "items": synced, "standalone": standalone_synced}
|
|
||||||
|
# ── Eval job sync ────────────────────────────────────────────────
|
||||||
|
eval_synced = 0
|
||||||
|
for eval_task in store.running_eval_tasks():
|
||||||
|
node = next(
|
||||||
|
(item for item in store.compute_nodes() if item["id"] == eval_task.get("compute_node_id")),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not node:
|
||||||
|
failed.append({"eval_task_id": eval_task["id"], "error": "compute node not found"})
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
client = ComputeNodeClient(node["api_base_url"])
|
||||||
|
job = await client.get_job(eval_task["compute_job_id"])
|
||||||
|
result_content = None
|
||||||
|
# Try to read eval_results.json from the job output directory
|
||||||
|
if job.get("status") == "completed" and job.get("output_dir"):
|
||||||
|
try:
|
||||||
|
result_content = await fetch_eval_result_content(client, node, job)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
store.apply_eval_job_result(eval_task["id"], job, result_content)
|
||||||
|
# If job completed, un-mark inference loaded
|
||||||
|
if job.get("status") in {"completed", "failed", "stopped"}:
|
||||||
|
store.mark_inference_unloaded(node["id"])
|
||||||
|
eval_synced += 1
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
failed.append({"eval_task_id": eval_task["id"], "error": str(exc)})
|
||||||
|
|
||||||
|
return {"synced": len(synced) + len(standalone_synced) + eval_synced, "failed": failed,
|
||||||
|
"items": synced, "standalone": standalone_synced, "eval_synced": eval_synced}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ dependencies = [
|
|||||||
"pydantic>=2.7.0",
|
"pydantic>=2.7.0",
|
||||||
"sqlalchemy>=2.0.30",
|
"sqlalchemy>=2.0.30",
|
||||||
"psycopg[binary]>=3.2.1",
|
"psycopg[binary]>=3.2.1",
|
||||||
|
"psycopg-pool>=3.2.1",
|
||||||
"alembic>=1.13.1",
|
"alembic>=1.13.1",
|
||||||
"redis>=5.0.4",
|
"redis>=5.0.4",
|
||||||
"httpx>=0.27.0",
|
"httpx>=0.27.0",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ python-multipart>=0.0.9
|
|||||||
pydantic>=2.7.0
|
pydantic>=2.7.0
|
||||||
sqlalchemy>=2.0.30
|
sqlalchemy>=2.0.30
|
||||||
psycopg[binary]>=3.2.1
|
psycopg[binary]>=3.2.1
|
||||||
|
psycopg-pool>=3.2.1
|
||||||
alembic>=1.13.1
|
alembic>=1.13.1
|
||||||
redis>=5.0.4
|
redis>=5.0.4
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import math
|
import math
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -449,6 +450,29 @@ def create_app() -> FastAPI:
|
|||||||
accelerator_errors, accelerator_warnings, accelerator = _validate_training_accelerator(payload)
|
accelerator_errors, accelerator_warnings, accelerator = _validate_training_accelerator(payload)
|
||||||
errors.extend(accelerator_errors)
|
errors.extend(accelerator_errors)
|
||||||
warnings.extend(accelerator_warnings)
|
warnings.extend(accelerator_warnings)
|
||||||
|
elif engine == "eval":
|
||||||
|
# Eval engine: validate model path and dataset path
|
||||||
|
if not payload.get("model_name_or_path"):
|
||||||
|
errors.append("model_name_or_path is required for eval")
|
||||||
|
else:
|
||||||
|
path_checks.append(_check_path_item({
|
||||||
|
"name": "model_name_or_path",
|
||||||
|
"path": payload.get("model_name_or_path", ""),
|
||||||
|
"type": "any",
|
||||||
|
"required": True,
|
||||||
|
}))
|
||||||
|
if payload.get("dataset_path"):
|
||||||
|
path_checks.append(_check_path_item({
|
||||||
|
"name": "dataset_path",
|
||||||
|
"path": payload.get("dataset_path", ""),
|
||||||
|
"type": "file",
|
||||||
|
"required": True,
|
||||||
|
}))
|
||||||
|
else:
|
||||||
|
errors.append("dataset_path is required for eval")
|
||||||
|
if shutil.which("python") is None:
|
||||||
|
errors.append("python runtime not found")
|
||||||
|
|
||||||
elif engine == "smoke":
|
elif engine == "smoke":
|
||||||
warnings.append("smoke engine skips model and dataset path checks")
|
warnings.append("smoke engine skips model and dataset path checks")
|
||||||
|
|
||||||
@@ -688,8 +712,6 @@ def create_app() -> FastAPI:
|
|||||||
infer_backend=payload.get("infer_backend", "huggingface"),
|
infer_backend=payload.get("infer_backend", "huggingface"),
|
||||||
infer_dtype=payload.get("infer_dtype", "auto"),
|
infer_dtype=payload.get("infer_dtype", "auto"),
|
||||||
)
|
)
|
||||||
if not result.get("loaded"):
|
|
||||||
raise HTTPException(status_code=500, detail=result.get("error", "model load failed"))
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@app.post(f"{route_prefix}/inference/unload")
|
@app.post(f"{route_prefix}/inference/unload")
|
||||||
@@ -813,6 +835,22 @@ def create_app() -> FastAPI:
|
|||||||
"checksum_sha256": checksum,
|
"checksum_sha256": checksum,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@app.get(f"{route_prefix}/compute/files/read")
|
||||||
|
async def read_file(path: str = Query(...)) -> JSONResponse:
|
||||||
|
"""Read a text file from within YG_FT_DATA_ROOT. Used by the backend
|
||||||
|
to fetch eval results and other job outputs."""
|
||||||
|
data_root = Path(os.getenv("YG_FT_DATA_ROOT", "/data/yg-ft"))
|
||||||
|
target = (data_root / path.lstrip("/\\")).resolve()
|
||||||
|
if not _path_inside(data_root, target):
|
||||||
|
raise HTTPException(status_code=400, detail="path must stay inside YG_FT_DATA_ROOT")
|
||||||
|
if not target.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="file not found")
|
||||||
|
try:
|
||||||
|
content = target.read_text(encoding="utf-8")
|
||||||
|
return JSONResponse(json.loads(content) if content.strip().startswith("{") else {"content": content})
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc))
|
||||||
|
|
||||||
@app.get(f"{route_prefix}/compute/files/{{file_id}}/download")
|
@app.get(f"{route_prefix}/compute/files/{{file_id}}/download")
|
||||||
async def download_file(file_id: str) -> FileResponse:
|
async def download_file(file_id: str) -> FileResponse:
|
||||||
upload_root = Path(os.getenv("YG_FT_DATA_ROOT", "/data/yg-ft")) / "uploads"
|
upload_root = Path(os.getenv("YG_FT_DATA_ROOT", "/data/yg-ft")) / "uploads"
|
||||||
|
|||||||
@@ -204,6 +204,31 @@ def build_command(config: dict[str, Any], llama_factory_home: str = "/app/LLaMA-
|
|||||||
command.extend(["--quantization_bit", str(quantization_bit)])
|
command.extend(["--quantization_bit", str(quantization_bit)])
|
||||||
return LlamaFactoryCommand(command=command, work_dir=str(Path(llama_factory_home)), env={})
|
return LlamaFactoryCommand(command=command, work_dir=str(Path(llama_factory_home)), env={})
|
||||||
|
|
||||||
|
if engine == "eval":
|
||||||
|
output_dir = config.get("output_dir") or f"/data/yg-ft/outputs/{config.get('name', 'eval-job')}"
|
||||||
|
eval_config_path = str(Path(output_dir) / "eval_config.json")
|
||||||
|
eval_config = {
|
||||||
|
"model_name_or_path": config.get("model_name_or_path", ""),
|
||||||
|
"adapter_name_or_path": config.get("adapter_name_or_path", ""),
|
||||||
|
"template": config.get("template", "qwen"),
|
||||||
|
"dataset_path": config.get("dataset_path", ""),
|
||||||
|
"output_dir": output_dir,
|
||||||
|
"basic_metrics": config.get("basic_metrics", {}),
|
||||||
|
"dimension": config.get("dimension", {}),
|
||||||
|
"temperature": config.get("temperature", 0.1),
|
||||||
|
"top_p": config.get("top_p", 0.95),
|
||||||
|
"max_new_tokens": config.get("max_new_tokens", 512),
|
||||||
|
"infer_backend": config.get("infer_backend", "huggingface"),
|
||||||
|
"infer_dtype": config.get("infer_dtype", "auto"),
|
||||||
|
}
|
||||||
|
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||||
|
Path(eval_config_path).write_text(json.dumps(eval_config, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
return LlamaFactoryCommand(
|
||||||
|
command=["python", "-u", "-m", "compute.engines.llama_factory.eval_runner", "--config", eval_config_path],
|
||||||
|
work_dir="/app",
|
||||||
|
env={},
|
||||||
|
)
|
||||||
|
|
||||||
errors = validate_config(config)
|
errors = validate_config(config)
|
||||||
if errors:
|
if errors:
|
||||||
raise ValueError("; ".join(errors))
|
raise ValueError("; ".join(errors))
|
||||||
|
|||||||
480
compute/engines/llama_factory/eval_runner.py
Normal file
480
compute/engines/llama_factory/eval_runner.py
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
"""
|
||||||
|
Evaluation runner — executes model evaluation as a subprocess job.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m compute.engines.llama_factory.eval_runner --config <config_json_path>
|
||||||
|
|
||||||
|
The config JSON is written by the compute API before spawning this subprocess.
|
||||||
|
Results are written to ``output_dir/eval_results.json`` and progress is printed
|
||||||
|
to stdout (captured as job logs).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _load_dataset(path: str) -> list[dict[str, Any]]:
|
||||||
|
"""Load a JSON or JSONL dataset file.
|
||||||
|
|
||||||
|
Supports common field names used across the platform:
|
||||||
|
* ``instruction`` + ``input`` + ``output`` (Alpaca-style)
|
||||||
|
* ``question`` + ``answer``
|
||||||
|
* ``messages`` (ShareGPT-style – the last assistant message is treated as reference)
|
||||||
|
"""
|
||||||
|
file_path = Path(path)
|
||||||
|
text = file_path.read_text(encoding="utf-8", errors="replace").strip()
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
if file_path.suffix.lower() == ".json":
|
||||||
|
value = json.loads(text)
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [item for item in value if isinstance(item, dict)]
|
||||||
|
return [value] if isinstance(value, dict) else []
|
||||||
|
|
||||||
|
samples: list[dict[str, Any]] = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
obj = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
samples.append(obj)
|
||||||
|
return samples
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_question(sample: dict[str, Any]) -> str:
|
||||||
|
"""Extract the user-facing question / instruction from a sample."""
|
||||||
|
if sample.get("instruction"):
|
||||||
|
text = sample["instruction"]
|
||||||
|
if sample.get("input"):
|
||||||
|
text += "\n" + sample["input"]
|
||||||
|
return text
|
||||||
|
if sample.get("question"):
|
||||||
|
return sample["question"]
|
||||||
|
# ShareGPT-style: use the last user message as question
|
||||||
|
messages = sample.get("messages") or []
|
||||||
|
user_msgs = [m["content"] for m in messages if m.get("role") == "user"]
|
||||||
|
return user_msgs[-1] if user_msgs else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_reference(sample: dict[str, Any]) -> str:
|
||||||
|
"""Extract the reference answer from a sample."""
|
||||||
|
if sample.get("output"):
|
||||||
|
return sample["output"]
|
||||||
|
if sample.get("answer"):
|
||||||
|
return sample["answer"]
|
||||||
|
messages = sample.get("messages") or []
|
||||||
|
assistant_msgs = [m["content"] for m in messages if m.get("role") == "assistant"]
|
||||||
|
return assistant_msgs[-1] if assistant_msgs else ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Basic metrics
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _compute_bleu(references: list[str], predictions: list[str], ngram: int = 4) -> dict[str, Any]:
|
||||||
|
"""Compute BLEU score via sacrebleu (corpus-level)."""
|
||||||
|
try:
|
||||||
|
from sacrebleu.metrics import BLEU
|
||||||
|
except ImportError:
|
||||||
|
return {"enabled": False, "error": "sacrebleu not installed", "score": 0}
|
||||||
|
bleu = BLEU(max_ngram_order=ngram)
|
||||||
|
# sacrebleu expects list-of-strings; we have one reference per prediction
|
||||||
|
score = bleu.corpus_score(predictions, [references])
|
||||||
|
return {
|
||||||
|
"enabled": True,
|
||||||
|
"score": round(score.score, 2),
|
||||||
|
"bleu": round(score.score, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_rouge(references: list[str], predictions: list[str], methods: list[str] | None = None) -> dict[str, Any]:
|
||||||
|
"""Compute ROUGE scores via rouge-score."""
|
||||||
|
try:
|
||||||
|
from rouge_score import rouge_scorer
|
||||||
|
except ImportError:
|
||||||
|
return {"enabled": False, "error": "rouge-score not installed", "score": 0}
|
||||||
|
methods = methods or ["rouge1", "rouge2", "rougeL"]
|
||||||
|
# Normalize: map "rouge_1"/"rouge1" → "rouge1", "rouge_l"/"rougeL" → "rougeL"
|
||||||
|
_rouge_aliases = {"rouge_1": "rouge1", "rouge_2": "rouge2", "rouge_l": "rougeL"}
|
||||||
|
methods = [_rouge_aliases.get(m, m.replace("_", "")) for m in methods]
|
||||||
|
scorer = rouge_scorer.RougeScorer(methods, use_stemmer=True)
|
||||||
|
totals: dict[str, float] = {}
|
||||||
|
n = max(len(predictions), 1)
|
||||||
|
for ref, pred in zip(references, predictions):
|
||||||
|
result = scorer.score(ref, pred)
|
||||||
|
for key in methods:
|
||||||
|
totals[key] = totals.get(key, 0) + result[key].fmeasure
|
||||||
|
avg = {k: round(v / n, 4) for k, v in totals.items()}
|
||||||
|
return {"enabled": True, "score": round(avg.get("rougeL", avg.get("rouge1", 0)) * 100, 2), **avg}
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_cosine(references: list[str], predictions: list[str]) -> dict[str, Any]:
|
||||||
|
"""Compute average cosine similarity via sklearn."""
|
||||||
|
try:
|
||||||
|
from sklearn.feature_extraction.text import TfidfVectorizer
|
||||||
|
from sklearn.metrics.pairwise import cosine_similarity
|
||||||
|
except ImportError:
|
||||||
|
return {"enabled": False, "error": "scikit-learn not installed", "score": 0}
|
||||||
|
try:
|
||||||
|
vectorizer = TfidfVectorizer()
|
||||||
|
tfidf = vectorizer.fit_transform(references + predictions)
|
||||||
|
n = len(references)
|
||||||
|
ref_vec = tfidf[:n]
|
||||||
|
pred_vec = tfidf[n:]
|
||||||
|
sims = cosine_similarity(ref_vec, pred_vec).diagonal()
|
||||||
|
return {"enabled": True, "score": round(float(sims.mean()) * 100, 2)}
|
||||||
|
except ValueError:
|
||||||
|
return {"enabled": True, "score": 0, "error": "insufficient text for vectorization"}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_text(value: str) -> str:
|
||||||
|
return re.sub(r"\s+", " ", str(value or "").strip().lower())
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_exact_match(references: list[str], predictions: list[str]) -> dict[str, Any]:
|
||||||
|
total = len(predictions)
|
||||||
|
if not total:
|
||||||
|
return {"enabled": True, "score": 0, "matched": 0, "total": 0}
|
||||||
|
matched = sum(
|
||||||
|
1
|
||||||
|
for ref, pred in zip(references, predictions)
|
||||||
|
if _normalize_text(ref) == _normalize_text(pred)
|
||||||
|
)
|
||||||
|
return {"enabled": True, "score": round(matched / total * 100, 2), "matched": matched, "total": total}
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_text_similarity(references: list[str], predictions: list[str]) -> dict[str, Any]:
|
||||||
|
if not predictions:
|
||||||
|
return {"enabled": True, "score": 0}
|
||||||
|
scores = [
|
||||||
|
SequenceMatcher(None, _normalize_text(ref), _normalize_text(pred)).ratio()
|
||||||
|
for ref, pred in zip(references, predictions)
|
||||||
|
]
|
||||||
|
return {"enabled": True, "score": round(sum(scores) / max(len(scores), 1) * 100, 2)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# LLM Judge
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _judge_sample(
|
||||||
|
question: str,
|
||||||
|
reference: str,
|
||||||
|
prediction: str,
|
||||||
|
config: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Call an OpenAI-compatible LLM to judge a single sample.
|
||||||
|
|
||||||
|
Returns a dict with keys:
|
||||||
|
score, max_score, passed, judgement, evaluation_reason, error_type
|
||||||
|
"""
|
||||||
|
api_url = (config.get("api_url") or "").strip().rstrip("/")
|
||||||
|
api_key = (config.get("api_key") or "").strip()
|
||||||
|
eval_model = (config.get("eval_model") or "").strip()
|
||||||
|
eval_prompt = (config.get("eval_prompt") or "").strip()
|
||||||
|
score_min = float(config.get("score_min", 0))
|
||||||
|
score_max = float(config.get("score_max", 5))
|
||||||
|
pass_threshold = float(config.get("pass_threshold", 3))
|
||||||
|
|
||||||
|
if not api_url or not eval_model:
|
||||||
|
return {"score": 0, "max_score": score_max, "passed": False, "judgement": "未配置",
|
||||||
|
"evaluation_reason": "未配置评测模型", "error_type": "其他"}
|
||||||
|
|
||||||
|
system_msg = (
|
||||||
|
eval_prompt
|
||||||
|
or "你是一个专业的评测专家。请根据参考答-案对被测模型的输出进行评分。"
|
||||||
|
)
|
||||||
|
user_msg = (
|
||||||
|
f"## 问题\n{question}\n\n"
|
||||||
|
f"## 参考答案\n{reference}\n\n"
|
||||||
|
f"## 模型输出\n{prediction}\n\n"
|
||||||
|
f"请给出 {score_min}-{score_max} 分的评分,并说明理由。"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
body = json.dumps({
|
||||||
|
"model": eval_model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_msg},
|
||||||
|
{"role": "user", "content": user_msg},
|
||||||
|
],
|
||||||
|
"temperature": 0.3,
|
||||||
|
"max_tokens": 512,
|
||||||
|
}).encode("utf-8")
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{api_url}/v1/chat/completions",
|
||||||
|
data=body,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp = urllib.request.urlopen(req, timeout=120)
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
reply = data["choices"][0]["message"]["content"]
|
||||||
|
except Exception as exc:
|
||||||
|
return {"score": 0, "max_score": score_max, "passed": False,
|
||||||
|
"judgement": "错误", "evaluation_reason": f"评测模型调用失败: {exc}",
|
||||||
|
"error_type": "其他"}
|
||||||
|
|
||||||
|
# Parse score from reply — look for patterns like "4分" or "Score: 4"
|
||||||
|
score = 0
|
||||||
|
import re
|
||||||
|
score_patterns = [
|
||||||
|
r'(?:得分|分数|评分|score)[^\d]*(\d+(?:\.\d+)?)',
|
||||||
|
r'(\d+(?:\.\d+)?)\s*分',
|
||||||
|
r'(\d+(?:\.\d+)?)\s*/\s*\d+',
|
||||||
|
]
|
||||||
|
for pat in score_patterns:
|
||||||
|
m = re.search(pat, reply, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
score = float(m.group(1))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
score = max(score_min, min(score_max, score))
|
||||||
|
passed = score >= pass_threshold
|
||||||
|
|
||||||
|
# Determine judgement label
|
||||||
|
if score >= pass_threshold + 1:
|
||||||
|
judgement = "正确"
|
||||||
|
elif score >= pass_threshold:
|
||||||
|
judgement = "部分正确"
|
||||||
|
else:
|
||||||
|
judgement = "错误"
|
||||||
|
|
||||||
|
# Guess error type from reply
|
||||||
|
reply_lower = reply.lower()
|
||||||
|
if any(w in reply_lower for w in ["幻觉", "hallucination", "编造"]):
|
||||||
|
error_type = "幻觉"
|
||||||
|
elif any(w in reply_lower for w in ["不完整", "incomplete", "遗漏"]):
|
||||||
|
error_type = "不完整"
|
||||||
|
elif any(w in reply_lower for w in ["格式", "format"]):
|
||||||
|
error_type = "格式偏差"
|
||||||
|
elif any(w in reply_lower for w in ["混淆", "confusion", "错误"]):
|
||||||
|
error_type = "混淆"
|
||||||
|
else:
|
||||||
|
error_type = "其他"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"score": score,
|
||||||
|
"max_score": score_max,
|
||||||
|
"passed": passed,
|
||||||
|
"judgement": judgement,
|
||||||
|
"evaluation_reason": reply[:2000],
|
||||||
|
"error_type": error_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def run_eval(config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Execute a full evaluation run. Returns the result dict (also written to file)."""
|
||||||
|
model_path = config["model_name_or_path"]
|
||||||
|
adapter_path = config.get("adapter_name_or_path", "")
|
||||||
|
template = config.get("template", "qwen")
|
||||||
|
dataset_path = config["dataset_path"]
|
||||||
|
output_dir = Path(config["output_dir"])
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
basic_cfg = config.get("basic_metrics", {})
|
||||||
|
dimension_cfg = config.get("dimension", {}) or {}
|
||||||
|
output_precision = int(basic_cfg.get("output_precision", 2))
|
||||||
|
|
||||||
|
# ---- 1. Load dataset ----
|
||||||
|
print(f"[eval] loading dataset: {dataset_path}")
|
||||||
|
raw_samples = _load_dataset(dataset_path)
|
||||||
|
print(f"[eval] loaded {len(raw_samples)} samples")
|
||||||
|
|
||||||
|
# ---- 2. Load model ----
|
||||||
|
print(f"[eval] loading model: {model_path}")
|
||||||
|
from compute.engines.llama_factory.inference import InferenceSession
|
||||||
|
session = InferenceSession()
|
||||||
|
load_result = session.load(
|
||||||
|
model_name_or_path=model_path,
|
||||||
|
adapter_name_or_path=adapter_path,
|
||||||
|
template=template,
|
||||||
|
infer_backend=config.get("infer_backend", "huggingface"),
|
||||||
|
infer_dtype=config.get("infer_dtype", "auto"),
|
||||||
|
)
|
||||||
|
if not load_result.get("loaded"):
|
||||||
|
raise RuntimeError(f"model load failed: {load_result.get('error', 'unknown')}")
|
||||||
|
print(f"[eval] model loaded OK")
|
||||||
|
|
||||||
|
# ---- 3. Run inference on each sample ----
|
||||||
|
samples: list[dict[str, Any]] = []
|
||||||
|
predictions: list[str] = []
|
||||||
|
references: list[str] = []
|
||||||
|
questions: list[str] = []
|
||||||
|
|
||||||
|
total = len(raw_samples)
|
||||||
|
judge_enabled = bool(dimension_cfg.get("eval_model") and dimension_cfg.get("api_url"))
|
||||||
|
print(f"[eval] starting inference on {total} samples, judge={'enabled' if judge_enabled else 'disabled'}")
|
||||||
|
|
||||||
|
for idx, raw in enumerate(raw_samples, start=1):
|
||||||
|
question = _sample_question(raw)
|
||||||
|
reference = _sample_reference(raw)
|
||||||
|
if not question:
|
||||||
|
print(f"[eval] sample {idx}/{total}: skipped (no question)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Inference
|
||||||
|
chat_msgs = [{"role": "user", "content": question}]
|
||||||
|
result = session.chat(
|
||||||
|
chat_msgs,
|
||||||
|
temperature=float(config.get("temperature", 0.1)),
|
||||||
|
top_p=float(config.get("top_p", 0.95)),
|
||||||
|
max_new_tokens=int(config.get("max_new_tokens", 512)),
|
||||||
|
do_sample=False,
|
||||||
|
)
|
||||||
|
prediction = result.get("response", "") if not result.get("error") else f"[ERROR] {result['error']}"
|
||||||
|
|
||||||
|
predictions.append(prediction)
|
||||||
|
references.append(reference)
|
||||||
|
questions.append(question)
|
||||||
|
|
||||||
|
# LLM Judge
|
||||||
|
judge_result: dict[str, Any] = {}
|
||||||
|
if judge_enabled:
|
||||||
|
judge_result = _judge_sample(question, reference, prediction, dimension_cfg)
|
||||||
|
|
||||||
|
samples.append({
|
||||||
|
"index": idx,
|
||||||
|
"input": question,
|
||||||
|
"reference_answer": reference,
|
||||||
|
"model_output": prediction,
|
||||||
|
"score": judge_result.get("score"),
|
||||||
|
"max_score": judge_result.get("max_score", dimension_cfg.get("score_max", 5)),
|
||||||
|
"passed": judge_result.get("passed"),
|
||||||
|
"judgement": judge_result.get("judgement"),
|
||||||
|
"evaluation_reason": judge_result.get("evaluation_reason", ""),
|
||||||
|
"error_type": judge_result.get("error_type"),
|
||||||
|
"dimension_scores": [
|
||||||
|
{"name": "judge_score", "score": judge_result.get("score", 0),
|
||||||
|
"max_score": judge_result.get("max_score", dimension_cfg.get("score_max", 5))},
|
||||||
|
] if judge_result else [],
|
||||||
|
"status": "completed",
|
||||||
|
})
|
||||||
|
|
||||||
|
progress_pct = int(idx / max(total, 1) * 100)
|
||||||
|
print(f"[eval] sample {idx}/{total} ({progress_pct}%) done")
|
||||||
|
|
||||||
|
# ---- 4. Compute basic metrics ----
|
||||||
|
print(f"[eval] computing basic metrics on {len(predictions)} predictions")
|
||||||
|
metrics_result: dict[str, Any] = {}
|
||||||
|
|
||||||
|
bleu_cfg = basic_cfg.get("bleu", {})
|
||||||
|
if bleu_cfg.get("enabled"):
|
||||||
|
metrics_result["bleu"] = _compute_bleu(references, predictions, int(bleu_cfg.get("ngram", 4)))
|
||||||
|
|
||||||
|
rouge_cfg = basic_cfg.get("rouge", {})
|
||||||
|
if rouge_cfg.get("enabled"):
|
||||||
|
metrics_result["rouge"] = _compute_rouge(references, predictions, rouge_cfg.get("methods"))
|
||||||
|
|
||||||
|
cosine_cfg = basic_cfg.get("cosine", {})
|
||||||
|
if cosine_cfg.get("enabled"):
|
||||||
|
metrics_result["cosine"] = _compute_cosine(references, predictions)
|
||||||
|
metrics_result["exact_match"] = _compute_exact_match(references, predictions)
|
||||||
|
metrics_result["text_similarity"] = _compute_text_similarity(references, predictions)
|
||||||
|
|
||||||
|
# ---- 5. Summarise ----
|
||||||
|
completed = len(samples)
|
||||||
|
if judge_enabled:
|
||||||
|
scored = [s for s in samples if s.get("score") is not None]
|
||||||
|
passed_count = len([s for s in scored if s.get("passed")])
|
||||||
|
avg_score = round(sum(s["score"] for s in scored) / max(len(scored), 1), output_precision)
|
||||||
|
max_score = dimension_cfg.get("score_max", 5)
|
||||||
|
overall_score = round(avg_score / max_score * 100, output_precision)
|
||||||
|
overall_score_max = 100
|
||||||
|
dimension_summary = [{
|
||||||
|
"name": "综合评分",
|
||||||
|
"score": overall_score,
|
||||||
|
"max_score": 100,
|
||||||
|
"pass_rate": round(passed_count / max(completed, 1) * 100, 1),
|
||||||
|
}]
|
||||||
|
overall_evaluation = f"评测完成:{completed} 样本,{passed_count} 通过,平均 {avg_score}/{max_score} 分"
|
||||||
|
else:
|
||||||
|
passed_count = 0
|
||||||
|
enabled_scores = [
|
||||||
|
float(item.get("score") or 0)
|
||||||
|
for item in metrics_result.values()
|
||||||
|
if isinstance(item, dict) and item.get("enabled", True) and item.get("score") is not None
|
||||||
|
]
|
||||||
|
overall_score = round(sum(enabled_scores) / len(enabled_scores), output_precision) if enabled_scores else 0
|
||||||
|
overall_score_max = 100
|
||||||
|
dimension_summary = [
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"score": float(item.get("score") or 0),
|
||||||
|
"max_score": 100,
|
||||||
|
"pass_rate": float(item.get("score") or 0),
|
||||||
|
}
|
||||||
|
for name, item in metrics_result.items()
|
||||||
|
if isinstance(item, dict) and item.get("enabled", True) and item.get("score") is not None
|
||||||
|
]
|
||||||
|
overall_evaluation = f"评测完成:{completed} 样本(未配置 LLM 评委)"
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"overall_score": overall_score,
|
||||||
|
"overall_score_max": overall_score_max,
|
||||||
|
"overall_evaluation": overall_evaluation,
|
||||||
|
"improvement_suggestions": [],
|
||||||
|
"dimension_summary": dimension_summary,
|
||||||
|
"samples": samples,
|
||||||
|
"sample_count": total,
|
||||||
|
"completed_count": completed,
|
||||||
|
"passed_count": passed_count,
|
||||||
|
"basic_metrics": metrics_result,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- 6. Write results ----
|
||||||
|
result_path = output_dir / "eval_results.json"
|
||||||
|
result_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
print(f"[eval] results written to {result_path}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
import argparse
|
||||||
|
parser = argparse.ArgumentParser(description="YG-FT Evaluation Runner")
|
||||||
|
parser.add_argument("--config", required=True, help="Path to eval config JSON file")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
config_path = Path(args.config)
|
||||||
|
if not config_path.exists():
|
||||||
|
print(f"FATAL: config file not found: {args.config}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
start = time.time()
|
||||||
|
try:
|
||||||
|
run_eval(config)
|
||||||
|
elapsed = time.time() - start
|
||||||
|
print(f"[eval] DONE in {elapsed:.1f}s")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[eval] FAILED: {exc}", file=sys.stderr)
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -59,10 +59,18 @@ class InferenceSession:
|
|||||||
if adapter_name_or_path:
|
if adapter_name_or_path:
|
||||||
args["adapter_name_or_path"] = adapter_name_or_path
|
args["adapter_name_or_path"] = adapter_name_or_path
|
||||||
args.update(kwargs)
|
args.update(kwargs)
|
||||||
model_args, generating_args = get_infer_args(args)
|
infer_result = get_infer_args(args)
|
||||||
self._model = ChatModel(model_args)
|
# ChatModel internally re-parses the args dict via get_infer_args,
|
||||||
self._tokenizer = self._model.tokenizer
|
# so pass the original args (not the parsed dataclass objects).
|
||||||
self._generating_args = generating_args
|
self._model = ChatModel(args)
|
||||||
|
self._tokenizer = getattr(self._model, 'tokenizer', None) or self._model.engine.tokenizer
|
||||||
|
# Extract generating_args (last element) for later use in chat()
|
||||||
|
generating_args = infer_result[-1]
|
||||||
|
if hasattr(generating_args, '__dataclass_fields__'):
|
||||||
|
self._generating_args = {k: v for k, v in vars(generating_args).items()
|
||||||
|
if not k.startswith('_')}
|
||||||
|
else:
|
||||||
|
self._generating_args = dict(generating_args)
|
||||||
self._loaded_at = time.time()
|
self._loaded_at = time.time()
|
||||||
self._status = "ready"
|
self._status = "ready"
|
||||||
return {"loaded": True, "status": "ready"}
|
return {"loaded": True, "status": "ready"}
|
||||||
@@ -80,6 +88,16 @@ class InferenceSession:
|
|||||||
pass
|
pass
|
||||||
self._model = None
|
self._model = None
|
||||||
self._tokenizer = None
|
self._tokenizer = None
|
||||||
|
# 强制释放 PyTorch CUDA 缓存,真正归还 GPU 显存
|
||||||
|
try:
|
||||||
|
import gc
|
||||||
|
gc.collect()
|
||||||
|
import torch
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
torch.cuda.synchronize()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self._status = "idle"
|
self._status = "idle"
|
||||||
self._model_name = ""
|
self._model_name = ""
|
||||||
self._adapter_path = ""
|
self._adapter_path = ""
|
||||||
@@ -91,11 +109,12 @@ class InferenceSession:
|
|||||||
if self._status != "ready" or self._model is None:
|
if self._status != "ready" or self._model is None:
|
||||||
return {"error": "model not loaded", "response": ""}
|
return {"error": "model not loaded", "response": ""}
|
||||||
try:
|
try:
|
||||||
generate_kwargs = {**self._generating_args, "temperature": temperature, "top_p": top_p, "max_new_tokens": max_new_tokens, "do_sample": do_sample}
|
generate_kwargs = {"temperature": temperature, "top_p": top_p, "max_new_tokens": max_new_tokens, "do_sample": do_sample}
|
||||||
generate_kwargs.update(kwargs)
|
generate_kwargs.update(kwargs)
|
||||||
formatted = self._model.tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
|
system = next((m["content"] for m in messages if m["role"] == "system"), None)
|
||||||
|
user_messages = [m for m in messages if m["role"] != "system"]
|
||||||
responses = []
|
responses = []
|
||||||
for response in self._model.stream_chat(formatted, generate_kwargs):
|
for response in self._model.stream_chat(user_messages, system=system, **generate_kwargs):
|
||||||
responses.append(response)
|
responses.append(response)
|
||||||
full_response = "".join(str(r) for r in responses)
|
full_response = "".join(str(r) for r in responses)
|
||||||
return {"response": full_response}
|
return {"response": full_response}
|
||||||
@@ -108,9 +127,10 @@ class InferenceSession:
|
|||||||
yield 'data: {"error": "model not loaded"}\n\n'
|
yield 'data: {"error": "model not loaded"}\n\n'
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
generate_kwargs = {**self._generating_args, **kwargs}
|
generate_kwargs = {**kwargs}
|
||||||
formatted = self._model.tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
|
system = next((m["content"] for m in messages if m["role"] == "system"), None)
|
||||||
for new_text in self._model.stream_chat(formatted, generate_kwargs):
|
user_messages = [m for m in messages if m["role"] != "system"]
|
||||||
|
for new_text in self._model.stream_chat(user_messages, system=system, **generate_kwargs):
|
||||||
yield new_text
|
yield new_text
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
yield 'data: {"error": "' + str(exc) + '"}\n\n'
|
yield 'data: {"error": "' + str(exc) + '"}\n\n'
|
||||||
|
|||||||
@@ -4,4 +4,9 @@ python-multipart>=0.0.9
|
|||||||
pydantic>=2.7.0
|
pydantic>=2.7.0
|
||||||
python-dotenv>=1.0.1
|
python-dotenv>=1.0.1
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
|
# 模型评测指标
|
||||||
|
sacrebleu>=2.4.0
|
||||||
|
rouge-score>=0.1.2
|
||||||
|
scikit-learn>=1.3.0
|
||||||
|
# LLaMA-Factory 训练引擎
|
||||||
llamafactory
|
llamafactory
|
||||||
@@ -11,7 +11,7 @@ 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 \
|
&& pip install -r /tmp/requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||||
&& rm -f /tmp/requirements.txt
|
&& rm -f /tmp/requirements.txt
|
||||||
|
|
||||||
RUN python -c "import fastapi, uvicorn, psycopg, sqlalchemy, redis, jwt, passlib, httpx, alembic; print('backend dependency check ok')"
|
RUN python -c "import fastapi, uvicorn, psycopg, psycopg_pool, sqlalchemy, redis, jwt, passlib, httpx, alembic; print('backend dependency check ok')"
|
||||||
|
|
||||||
RUN mkdir -p /opt/yg-ft/logs/backend /data/yg-ft \
|
RUN mkdir -p /opt/yg-ft/logs/backend /data/yg-ft \
|
||||||
&& chmod -R 0775 /opt/yg-ft /data/yg-ft
|
&& chmod -R 0775 /opt/yg-ft /data/yg-ft
|
||||||
|
|||||||
121
docs/模型评测功能总结.md
Normal file
121
docs/模型评测功能总结.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# 模型评测功能总结
|
||||||
|
|
||||||
|
本项目(基于 LLaMA-Factory 的微调训练平台)包含 **4 套相对独立** 的模型评测能力,分别面向不同的使用场景:
|
||||||
|
|
||||||
|
| 能力 | 入口/目录 | 评测类型 | 打分方式 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1. 学术 Benchmark 评测 | `llamafactory/eval/` | 选择题式基准(类 MMLU/C-Eval) | 选项匹配 + few-shot |
|
||||||
|
| 2. 评估工作台 | `backend/app/api/v1/eval/` | 生成式问答(指令跟随) | BLEU / ROUGE / ExactMatch + 可选 LLM 评审 |
|
||||||
|
| 3. 平台评估系统 | `backend/app/api/v1/evaluation/` | 基于评估数据集的问答 | 判卷模型(judge model)打分(0–5 分) |
|
||||||
|
| 4. 训练时验证评估 | `backend/app/services/task_runner.py` | 训练验证集 | loss 指标 |
|
||||||
|
|
||||||
|
下面分别说明。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 学术 Benchmark 评测(LLaMA-Factory 原生)
|
||||||
|
|
||||||
|
面向标准学术选择题基准(如 MMLU、C-Eval 等),复用 LLaMA-Factory 原生的评测框架。
|
||||||
|
|
||||||
|
**核心文件**
|
||||||
|
- `llamafactory/eval/evaluator.py`:`Evaluator` 类 + `run_eval()` 入口
|
||||||
|
- `llamafactory/eval/template.py`:评测 prompt 模板(中/英,含 few-shot 示例构建)
|
||||||
|
- `llamafactory/hparams/evaluation_args.py`:`EvaluationArguments` 配置类
|
||||||
|
|
||||||
|
**工作流程**
|
||||||
|
1. 按 `task`(benchmark 名称)加载数据集,按科目(subject)拆分。
|
||||||
|
2. 每个样本构造 few-shot 提示词(`n_shot` 控制示例数,由 `lang` 决定中/英模板),将题干与候选选项拼入 prompt。
|
||||||
|
3. 调用模型推理得到预测,与标准答案比对,统计每个科目及整体的 `accuracy`。
|
||||||
|
4. 结果写入 `save_dir`,打印各科目与平均准确率。
|
||||||
|
|
||||||
|
**关键参数(`EvaluationArguments`)**
|
||||||
|
- `task`:基准数据集名
|
||||||
|
- `batch_size` / `n_shot` / `lang` / `save_dir` / `seed`
|
||||||
|
- `model_name_or_path`、`template`、`trust_remote_code` 等模型相关参数
|
||||||
|
|
||||||
|
> 该能力属于框架底层,本平台前端未直接提供操作入口,主要通过配置文件/脚本调用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 评估工作台(生成式评测 + 指标计算)
|
||||||
|
|
||||||
|
后端路由位于 `backend/app/api/v1/eval/__init__.py`,前端称为「评估工作台」。**适用于评测模型的指令跟随与生成质量**,并支持 LLM 作为裁判(LLM-as-a-Judge)。
|
||||||
|
|
||||||
|
**API 端点**
|
||||||
|
- `GET /evaluation/tasks`:列出评测任务(`frontend/src/api/evaluation.ts:listTasks`)
|
||||||
|
- `POST /evaluation/run`:提交一次评测(`runEval`)
|
||||||
|
- `GET /evaluation/report/{task_id}`:拉取评测报告(`getReport`)
|
||||||
|
- `DELETE /evaluation/tasks/{task_id}`:删除任务(`deleteTask`)
|
||||||
|
|
||||||
|
**评测流程(`run_eval`)**
|
||||||
|
1. 通过 **LLaMA-Factory 数据管道**(`get_dataset`) 加载数据集,支持 `subset` 与抽样(`eval_sample`)。
|
||||||
|
2. 用 **原生 transformers** 加载模型在本地做生成推理(单进程顺序生成,便于展示样本)。
|
||||||
|
3. 计算客观指标(`compute_score`):
|
||||||
|
- `BLEU`(sacrebleu)
|
||||||
|
- `ROUGE-1 / ROUGE-2 / ROUGE-L`(rouge-score)
|
||||||
|
- `Exact Match`
|
||||||
|
4. **可选 LLM 评审**(judge):当配置了 `judge_model` / `judge_api_base` / `judge_api_key` 时,调用 OpenAI 兼容接口对每条样本打分(10 分制),并输出 4 个维度与理由:
|
||||||
|
- 核心事实正确性 `factual`
|
||||||
|
- 信息完整性 `completeness`
|
||||||
|
- 无幻觉 `no_hallucination`
|
||||||
|
- 格式合规性 `format`
|
||||||
|
- 综合分 `score` + `reason`
|
||||||
|
5. 任务状态持久化在后端 `eval_tasks.json`(支持 running/completed/failed/stopped),前端轮询进度。
|
||||||
|
|
||||||
|
**前端页面**
|
||||||
|
- `frontend/src/views/evaluation/EvaluateTask.vue`:任务列表、创建评测对话框(选模型、数据集、指标、可选 judge 配置)
|
||||||
|
- `frontend/src/views/evaluation/EvaluateReport.vue`:报告页,展示综合得分、BLEU、ROUGE-L、各维度指标及「参考答案 vs 模型预测 vs LLM 评审」对比样例
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 平台评估系统(基于评估数据集 + 判卷模型)
|
||||||
|
|
||||||
|
后端路由位于 `backend/app/api/v1/evaluation/__init__.py`,是平台业务层自研的评测体系。通过「评估数据集」组织题目,可一次性对 **多个被测模型 + 指定判卷模型** 进行批量评分。
|
||||||
|
|
||||||
|
**核心概念(数据模型 `backend/app/models/models.py`)**
|
||||||
|
- `EvalDataset`(`models.py:131`):评估数据集,从项目问答对(`Question`/`Chunk`)中按 `question_type`(mixed/fact/reasoning)选题构建,状态 `pending/running/completed/failed`。
|
||||||
|
- `EvalResult`(`models.py:147`):单条评测结果,含 `judge_score`(0–5 分)、`is_correct`(true/false/partial)、`feedback`、`expected_answer` 等。
|
||||||
|
- `Task`(`models.py:184`):后台任务,`task_type="model-evaluation"`,记录进度与 `model_info`(存放平均分等汇总)。
|
||||||
|
|
||||||
|
**评测流程(`process_evaluation_task`,`backend/app/services/task_processor.py:336` 起)**
|
||||||
|
1. 加载评估数据集关联的题目,可选带入 `chunk` 上下文(RAG 场景)。
|
||||||
|
2. 对每道题,先用 `build_eval_prompt` 组合「上下文 + 题目 + 参考答案」,调用 **判卷模型**(`call_model`,temperature=0.3)生成评分。
|
||||||
|
3. `parse_eval_result` 解析出 `score`(0–5)、`is_correct`、`feedback`,写入 `EvalResult`。
|
||||||
|
4. 逐题提交进度(`completed_count` / `progress`),支持中途 `stopped`。
|
||||||
|
5. 汇总:`avg_score = 总分/有效数 × 20`(换算百分制),`avg_score_5 = 总分/有效数`(5 分制),存入 `task.model_info`。判定规则:得分 **≥3 视为正确**。
|
||||||
|
|
||||||
|
**特点**
|
||||||
|
- 判卷与被测模型解耦:被测模型给出答案,判卷模型(judge)独立评分,降低自评偏差。
|
||||||
|
- 支持失败隔离:单题异常写入 `evaluation_status: failed` 记录而不中断整体任务。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 训练时验证评估
|
||||||
|
|
||||||
|
在微调训练任务执行期间,由 `backend/app/services/task_runner.py` 的 `do_eval` 触发:
|
||||||
|
|
||||||
|
- 在训练过程中对验证集(validation set)计算 `eval_loss`,用于监控过拟合。
|
||||||
|
- 结果回填到 `Task` 的 `loss_info` / `detail`,前端绘制 loss 曲线。
|
||||||
|
- 属于训练配套的轻量评估,不参与上述 1–3 的业务评测。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附属:前端评测相关页面
|
||||||
|
|
||||||
|
| 文件 | 作用 |
|
||||||
|
| --- | --- |
|
||||||
|
| `frontend/src/views/evaluation/EvaluateTask.vue` | 评估工作台:任务列表 + 创建评测 |
|
||||||
|
| `frontend/src/views/evaluation/EvaluateReport.vue` | 评估报告:指标卡 + 维度标签 + 对比样例 |
|
||||||
|
| `frontend/src/api/evaluation.ts` | 评估工作台接口封装 |
|
||||||
|
| 平台评估系统入口 | 评估数据集管理 + 评估任务(model-evaluation)创建与结果查看 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 小结
|
||||||
|
|
||||||
|
- **想要学术榜单式准确率** → 用能力 1(LLaMA-Factory `eval/`)。
|
||||||
|
- **想要开放式生成质量(BLEU/ROUGE + LLM 评审)** → 用能力 2(评估工作台 `/evaluation/run`)。
|
||||||
|
- **想要基于自有问答数据、用判卷模型批量打分** → 用能力 3(平台评估系统 `model-evaluation` 任务)。
|
||||||
|
- **训练过程监控** → 能力 4(`do_eval` 验证集 loss)。
|
||||||
|
|
||||||
|
三种业务评测(1/2/3)相互独立,可并存于同一平台;数据模型(`EvalDataset`/`EvalResult`/`Task`)主要服务于能力 3,而能力 2 使用独立的 `eval_tasks.json` 文件持久化。
|
||||||
@@ -64,6 +64,27 @@ export const streamChat = async (data: any): Promise<any> => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 真实流式对话 — 使用 fetch 调用后端 SSE 端点,返回 Response 供 ReadableStream 消费 */
|
||||||
|
export const streamChatReal = (data: any): Promise<Response> => {
|
||||||
|
const messages = data.messages || []
|
||||||
|
if (!messages.length && data.user_question) {
|
||||||
|
if (data.system_prompt) {
|
||||||
|
messages.push({ role: 'system', content: data.system_prompt })
|
||||||
|
}
|
||||||
|
messages.push({ role: 'user', content: data.user_question })
|
||||||
|
}
|
||||||
|
return fetch('/modelTF/model-compare/stream-chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
messages,
|
||||||
|
temperature: data.temperature ?? 0.7,
|
||||||
|
top_p: data.top_p ?? 0.95,
|
||||||
|
max_tokens: data.max_tokens ?? 2048,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** 非流式对话(按端口代理) */
|
/** 非流式对话(按端口代理) */
|
||||||
export const chatWithPort = (data: any) => post('/model-compare/chat-with-port', data)
|
export const chatWithPort = (data: any) => post('/model-compare/chat-with-port', data)
|
||||||
|
|
||||||
@@ -73,8 +94,8 @@ export const batchChat = (data: any) => post('/model-chat/batch', data)
|
|||||||
/** 本地 transformers 模型对话 */
|
/** 本地 transformers 模型对话 */
|
||||||
export const localChat = (data: any) => post('/model-chat/local/chat', data)
|
export const localChat = (data: any) => post('/model-chat/local/chat', data)
|
||||||
|
|
||||||
/** 预加载本地模型 */
|
/** 预加载本地模型(模型加载耗时长,超时 5 分钟) */
|
||||||
export const preloadLocalModel = (data: any) => post('/model-chat/local/preload', data)
|
export const preloadLocalModel = (data: any) => post('/model-chat/local/preload', data, { timeout: 300000 })
|
||||||
|
|
||||||
/** 预加载已训练模型 */
|
/** 预加载已训练模型(超时 5 分钟) */
|
||||||
export const preloadTrainedModel = (data: any) => post('/model-chat/trained/preload', data)
|
export const preloadTrainedModel = (data: any) => post('/model-chat/trained/preload', data, { timeout: 300000 })
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { get, post, put } from '../request'
|
import { del, get, post, put } from '../request'
|
||||||
|
|
||||||
export interface ComputeNode {
|
export interface ComputeNode {
|
||||||
id: string
|
id: string
|
||||||
@@ -95,6 +95,9 @@ export const createComputeNode = (data: ComputeNodePayload) =>
|
|||||||
export const updateComputeNode = (id: string, data: Partial<ComputeNode>) =>
|
export const updateComputeNode = (id: string, data: Partial<ComputeNode>) =>
|
||||||
put<ComputeNode>(`/compute/nodes/${id}`, data)
|
put<ComputeNode>(`/compute/nodes/${id}`, data)
|
||||||
|
|
||||||
|
export const deleteComputeNode = (id: string) =>
|
||||||
|
del<{ deleted: string }>(`/compute/nodes/${id}`)
|
||||||
|
|
||||||
export const testComputeNode = (id: string) =>
|
export const testComputeNode = (id: string) =>
|
||||||
post<{ node_id: string; success: boolean; latency_ms: number; gpu_count: number; error?: string }>(`/compute/nodes/${id}/test-connection`)
|
post<{ node_id: string; success: boolean; latency_ms: number; gpu_count: number; error?: string }>(`/compute/nodes/${id}/test-connection`)
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const uploadDatasetFiles = (datasetId: string | number, files: File[]) =>
|
|||||||
files.forEach((f) => formData.append('files', f))
|
files.forEach((f) => formData.append('files', f))
|
||||||
return post(`/dataset-manage/upload/${datasetId}`, formData, {
|
return post(`/dataset-manage/upload/${datasetId}`, formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
timeout: 120000,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { streamChat } from '@/api/modules/compare'
|
import { streamChat, streamChatReal } from '@/api/modules/compare'
|
||||||
|
|
||||||
export interface StreamMessage {
|
export interface StreamMessage {
|
||||||
/** 用户问题 */
|
/** 用户问题 */
|
||||||
@@ -20,6 +20,11 @@ export interface StreamMessage {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SendOptions {
|
||||||
|
/** 是否使用 mock 模式(默认 true,向后兼容) */
|
||||||
|
useMock?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 流式对话 composable
|
* 流式对话 composable
|
||||||
* 移植自原 model-chat.html:
|
* 移植自原 model-chat.html:
|
||||||
@@ -65,8 +70,10 @@ export function useStreamChat() {
|
|||||||
/**
|
/**
|
||||||
* 发起流式对话
|
* 发起流式对话
|
||||||
* @param payload 后端请求体 { port, model_name, model_path, system_prompt, user_question, ... }
|
* @param payload 后端请求体 { port, model_name, model_path, system_prompt, user_question, ... }
|
||||||
|
* @param options 可选配置 { useMock?: boolean }
|
||||||
*/
|
*/
|
||||||
async function send(payload: any) {
|
async function send(payload: any, options?: SendOptions) {
|
||||||
|
const useMock = options?.useMock ?? true
|
||||||
loading.value = true
|
loading.value = true
|
||||||
message.value = {
|
message.value = {
|
||||||
question: payload.user_question || '',
|
question: payload.user_question || '',
|
||||||
@@ -82,7 +89,10 @@ export function useStreamChat() {
|
|||||||
const UPDATE_INTERVAL = 50 // 50ms 节流
|
const UPDATE_INTERVAL = 50 // 50ms 节流
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await streamChat(payload)
|
const response = useMock
|
||||||
|
? await streamChat(payload)
|
||||||
|
: await streamChatReal(payload)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP ${response.status}`)
|
throw new Error(`HTTP ${response.status}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ export interface FineTuneTask {
|
|||||||
train_dataset_id?: number | string
|
train_dataset_id?: number | string
|
||||||
auto_merge?: boolean
|
auto_merge?: boolean
|
||||||
output_model_name?: string
|
output_model_name?: string
|
||||||
|
compute_node_id?: string
|
||||||
gpus?: number[]
|
gpus?: number[]
|
||||||
batch_size?: number
|
batch_size?: number
|
||||||
learning_rate?: number
|
learning_rate?: number
|
||||||
@@ -355,16 +356,16 @@ export interface GpuInfo {
|
|||||||
power_w: number
|
power_w: number
|
||||||
id?: number
|
id?: number
|
||||||
uuid?: string
|
uuid?: string
|
||||||
status?: 'idle' | 'busy' | 'warning' | 'offline'
|
status?: 'idle' | 'busy' | 'reserved' | 'warning' | 'offline'
|
||||||
memory_percent?: number
|
memory_percent?: number
|
||||||
power_limit_w?: number
|
power_limit_w?: number
|
||||||
processes?: GpuProcess[]
|
processes?: GpuProcess[]
|
||||||
fan_speed?: number
|
fan_speed?: number
|
||||||
clock_mhz?: number
|
clock_mhz?: number
|
||||||
driver_version?: string
|
|
||||||
node_id?: string
|
node_id?: string
|
||||||
node_code?: string
|
node_code?: string
|
||||||
node_name?: string
|
node_name?: string
|
||||||
|
driver_version?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemInfo {
|
export interface SystemInfo {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { onMounted, reactive, ref } from 'vue'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import DataTablePage from '@/components/DataTablePage.vue'
|
import DataTablePage from '@/components/DataTablePage.vue'
|
||||||
import { getApprovalInstances, decideApproval, type ApprovalInstance } from '@/api/modules/approval'
|
import { getApprovalInstances, decideApproval, type ApprovalInstance } from '@/api/modules/approval'
|
||||||
import { getUsers, type SystemUser } from '@/api/modules/system'
|
import { getUsers } from '@/api/modules/system'
|
||||||
|
import type { SystemUser } from '@/types'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const instances = ref<ApprovalInstance[]>([])
|
const instances = ref<ApprovalInstance[]>([])
|
||||||
@@ -48,6 +49,10 @@ function openDecide(inst: ApprovalInstance) {
|
|||||||
showDecide.value = true
|
showDecide.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asApprovalInstance(row: unknown): ApprovalInstance {
|
||||||
|
return row as ApprovalInstance
|
||||||
|
}
|
||||||
|
|
||||||
async function submitDecision() {
|
async function submitDecision() {
|
||||||
if (!current.value) return
|
if (!current.value) return
|
||||||
if (!decision.value.approver_id) {
|
if (!decision.value.approver_id) {
|
||||||
@@ -72,7 +77,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<DataTablePage title="审批实例" :data="instances" :loading="loading" searchable search-fields="resource_type,resource_id">
|
<DataTablePage title="审批实例" :data="instances" :loading="loading" searchable :search-fields="['resource_type', 'resource_id']">
|
||||||
<template #toolbar-extra>
|
<template #toolbar-extra>
|
||||||
<el-select v-model="statusFilter" placeholder="状态" clearable style="width: 140px" @change="load">
|
<el-select v-model="statusFilter" placeholder="状态" clearable style="width: 140px" @change="load">
|
||||||
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
|
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
|
||||||
@@ -82,14 +87,14 @@ onMounted(() => {
|
|||||||
<el-table-column prop="resource_type" label="资源类型" min-width="120" />
|
<el-table-column prop="resource_type" label="资源类型" min-width="120" />
|
||||||
<el-table-column prop="resource_id" label="资源 ID" min-width="160" show-overflow-tooltip />
|
<el-table-column prop="resource_id" label="资源 ID" min-width="160" show-overflow-tooltip />
|
||||||
<el-table-column prop="applicant_id" label="申请人" min-width="120">
|
<el-table-column prop="applicant_id" label="申请人" min-width="120">
|
||||||
<template #default="{ row }">{{ userName(row.applicant_id) }}</template>
|
<template #default="{ row }">{{ userName(asApprovalInstance(row).applicant_id) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="status" label="状态" min-width="100" />
|
<el-table-column prop="status" label="状态" min-width="100" />
|
||||||
<el-table-column prop="current_step" label="当前步骤" min-width="100" />
|
<el-table-column prop="current_step" label="当前步骤" min-width="100" />
|
||||||
<el-table-column prop="create_time" label="创建时间" min-width="180" />
|
<el-table-column prop="create_time" label="创建时间" min-width="180" />
|
||||||
</template>
|
</template>
|
||||||
<template #actions="{ row }">
|
<template #actions="{ row }">
|
||||||
<el-button v-if="row.status === 'pending'" link type="primary" @click="openDecide(row)">审批</el-button>
|
<el-button v-if="asApprovalInstance(row).status === 'pending'" link type="primary" @click="openDecide(asApprovalInstance(row))">审批</el-button>
|
||||||
</template>
|
</template>
|
||||||
</DataTablePage>
|
</DataTablePage>
|
||||||
<el-dialog v-model="showDecide" title="审批决策" width="480px">
|
<el-dialog v-model="showDecide" title="审批决策" width="480px">
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
checkNodeReplicaDrift,
|
checkNodeReplicaDrift,
|
||||||
createComputeNode,
|
createComputeNode,
|
||||||
|
deleteComputeNode,
|
||||||
disableComputeNode,
|
disableComputeNode,
|
||||||
drainComputeNode,
|
|
||||||
enableComputeNode,
|
enableComputeNode,
|
||||||
getComputeGpus,
|
getComputeGpus,
|
||||||
getComputeNodes,
|
getComputeNodes,
|
||||||
@@ -129,11 +129,10 @@ async function changeTab(name: string | number) {
|
|||||||
await router.replace({ path: '/compute', query: { tab: String(name) } })
|
await router.replace({ path: '/compute', query: { tab: String(name) } })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleNodeAction(action: 'enable' | 'disable' | 'drain' | 'test', node: ComputeNode) {
|
async function handleNodeAction(action: 'enable' | 'disable' | 'test', node: ComputeNode) {
|
||||||
const nodeId = String(node.id)
|
const nodeId = String(node.id)
|
||||||
if (action === 'enable') await enableComputeNode(nodeId)
|
if (action === 'enable') await enableComputeNode(nodeId)
|
||||||
if (action === 'disable') await disableComputeNode(nodeId)
|
if (action === 'disable') await disableComputeNode(nodeId)
|
||||||
if (action === 'drain') await drainComputeNode(nodeId)
|
|
||||||
if (action === 'test') {
|
if (action === 'test') {
|
||||||
const result = await testComputeNode(nodeId)
|
const result = await testComputeNode(nodeId)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -145,6 +144,27 @@ async function handleNodeAction(action: 'enable' | 'disable' | 'drain' | 'test',
|
|||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDeleteNode(node: ComputeNode) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定删除算力节点「${node.name || node.code}」吗?节点删除后,其 GPU 设备和资源副本记录也会一并移除。`,
|
||||||
|
'删除算力节点',
|
||||||
|
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await deleteComputeNode(String(node.id))
|
||||||
|
ElMessage.success('算力节点已删除')
|
||||||
|
if (selectedNodeId.value === node.id) selectedNodeId.value = ''
|
||||||
|
await load({ showButtonLoading: true })
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err?.response?.data?.detail?.message || err?.response?.data?.message || '删除算力节点失败'
|
||||||
|
ElMessage.error(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleReplicaDriftCheck() {
|
async function handleReplicaDriftCheck() {
|
||||||
if (!selectedNodeId.value) return
|
if (!selectedNodeId.value) return
|
||||||
checkingReplicas.value = true
|
checkingReplicas.value = true
|
||||||
@@ -355,7 +375,7 @@ onUnmounted(() => {
|
|||||||
<el-button size="small" @click="handleNodeAction('test', asComputeNode(row))">测试</el-button>
|
<el-button size="small" @click="handleNodeAction('test', asComputeNode(row))">测试</el-button>
|
||||||
<el-button v-if="row.enabled" size="small" @click="handleNodeAction('disable', asComputeNode(row))">停用</el-button>
|
<el-button v-if="row.enabled" size="small" @click="handleNodeAction('disable', asComputeNode(row))">停用</el-button>
|
||||||
<el-button v-else size="small" type="primary" @click="handleNodeAction('enable', asComputeNode(row))">启用</el-button>
|
<el-button v-else size="small" type="primary" @click="handleNodeAction('enable', asComputeNode(row))">启用</el-button>
|
||||||
<el-button size="small" type="warning" plain @click="handleNodeAction('drain', asComputeNode(row))">维护</el-button>
|
<el-button size="small" type="danger" plain @click="handleDeleteNode(asComputeNode(row))">删除</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|||||||
@@ -50,6 +50,20 @@ const rules: FormRules = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 处理文件选择(替换模式:新文件覆盖旧文件) */
|
/** 处理文件选择(替换模式:新文件覆盖旧文件) */
|
||||||
|
function parseDatasetRecordValues(text: string, fileName: string): unknown[] {
|
||||||
|
const content = text.trim()
|
||||||
|
if (!content) return []
|
||||||
|
if (fileName.toLowerCase().endsWith('.json')) {
|
||||||
|
const parsed = JSON.parse(content)
|
||||||
|
return Array.isArray(parsed) ? parsed : [parsed]
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => JSON.parse(line))
|
||||||
|
}
|
||||||
|
|
||||||
async function handleFileChange(uploadFile: UploadFile) {
|
async function handleFileChange(uploadFile: UploadFile) {
|
||||||
const raw = uploadFile.raw
|
const raw = uploadFile.raw
|
||||||
if (!raw) return
|
if (!raw) return
|
||||||
@@ -70,25 +84,19 @@ async function handleFileChange(uploadFile: UploadFile) {
|
|||||||
async function analyzeFile(file: File) {
|
async function analyzeFile(file: File) {
|
||||||
try {
|
try {
|
||||||
const text = await file.text()
|
const text = await file.text()
|
||||||
const lines = text.trim().split('\n').filter(Boolean)
|
const records = parseDatasetRecordValues(text, file.name)
|
||||||
fileCount.value = lines.length
|
fileCount.value = records.length
|
||||||
|
|
||||||
// Alpaca 格式校验:每行 JSON 须含 instruction 字段
|
// Alpaca 格式校验:每行 JSON 须含 instruction 字段
|
||||||
let validCount = 0
|
const validCount = records.filter(
|
||||||
for (const line of lines) {
|
(obj) => obj && typeof obj === 'object' && 'instruction' in obj,
|
||||||
try {
|
).length
|
||||||
const obj = JSON.parse(line)
|
if (validCount > 0 && validCount === records.length) {
|
||||||
if (obj.instruction !== undefined) validCount++
|
|
||||||
} catch {
|
|
||||||
// 非 JSON 行(如纯 JSONL 多行结构)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (validCount > 0 && validCount === lines.length) {
|
|
||||||
formatValid.value = true
|
formatValid.value = true
|
||||||
formatMessage.value = `符合 Alpaca 格式(含 instruction 字段)`
|
formatMessage.value = `符合 Alpaca 格式(含 instruction 字段)`
|
||||||
} else if (validCount > 0) {
|
} else if (validCount > 0) {
|
||||||
formatValid.value = true
|
formatValid.value = true
|
||||||
formatMessage.value = `部分符合 Alpaca 格式(${validCount}/${lines.length})`
|
formatMessage.value = `部分符合 Alpaca 格式(${validCount}/${records.length})`
|
||||||
} else {
|
} else {
|
||||||
formatValid.value = false
|
formatValid.value = false
|
||||||
formatMessage.value = '未检测到标准 Alpaca 格式(缺少 instruction 字段),仍可上传'
|
formatMessage.value = '未检测到标准 Alpaca 格式(缺少 instruction 字段),仍可上传'
|
||||||
|
|||||||
@@ -79,7 +79,9 @@ async function loadEditData() {
|
|||||||
async function loadModels() {
|
async function loadModels() {
|
||||||
try {
|
try {
|
||||||
const all = (await getModelList()) || []
|
const all = (await getModelList()) || []
|
||||||
evalModels.value = all.filter((m) => m.purpose === 'evaluation')
|
evalModels.value = all.filter(
|
||||||
|
(m) => m.purpose === 'evaluation' || (m.model_source === 'api' && !!m.api_url),
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
evalModels.value = []
|
evalModels.value = []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { createDimension, startEval } from '@/api/modules/eval'
|
|||||||
import { getTrainedModels, getModelList } from '@/api/modules/model'
|
import { getTrainedModels, getModelList } from '@/api/modules/model'
|
||||||
import { getDatasetList } from '@/api/modules/dataset'
|
import { getDatasetList } from '@/api/modules/dataset'
|
||||||
import { getSystemInfo } from '@/api/modules/system'
|
import { getSystemInfo } from '@/api/modules/system'
|
||||||
|
import { getComputeNodes, type ComputeNode } from '@/api/modules/compute'
|
||||||
import type { DatasetItem, Dimension, GpuInfo, ModelItem, TrainedModel } from '@/types'
|
import type { DatasetItem, Dimension, GpuInfo, ModelItem, TrainedModel } from '@/types'
|
||||||
|
|
||||||
type StepExposed = { validate: () => Promise<boolean> }
|
type StepExposed = { validate: () => Promise<boolean> }
|
||||||
@@ -84,15 +85,26 @@ async function loadData() {
|
|||||||
getDatasetList(),
|
getDatasetList(),
|
||||||
getSystemInfo(),
|
getSystemInfo(),
|
||||||
getModelList(),
|
getModelList(),
|
||||||
|
getComputeNodes(),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (results[0].status === 'fulfilled') trainedModels.value = results[0].value?.models || []
|
if (results[0].status === 'fulfilled') trainedModels.value = results[0].value?.models || []
|
||||||
if (results[1].status === 'fulfilled') {
|
if (results[1].status === 'fulfilled') {
|
||||||
evalDatasets.value = (results[1].value || []).filter((dataset) => dataset.type === 'eval')
|
evalDatasets.value = (results[1].value || []).filter((dataset) => dataset.type === 'eval')
|
||||||
}
|
}
|
||||||
if (results[2].status === 'fulfilled') gpus.value = results[2].value?.gpu || []
|
if (results[2].status === 'fulfilled') {
|
||||||
|
const allGpus: GpuInfo[] = results[2].value?.gpu || []
|
||||||
|
const nodes: ComputeNode[] = (results[4].status === 'fulfilled' ? results[4].value : []) || []
|
||||||
|
const onlineIds = new Set(nodes.filter((n) => n.enabled && n.scheduler_status === 'online').map((n) => n.id))
|
||||||
|
// Only show idle GPUs from online compute nodes
|
||||||
|
gpus.value = allGpus.filter(
|
||||||
|
(g) => g.status === 'idle' && (!g.node_id || onlineIds.has(g.node_id)),
|
||||||
|
)
|
||||||
|
}
|
||||||
if (results[3].status === 'fulfilled') {
|
if (results[3].status === 'fulfilled') {
|
||||||
evalModels.value = (results[3].value || []).filter((model) => model.purpose === 'evaluation')
|
evalModels.value = (results[3].value || []).filter(
|
||||||
|
(model) => model.purpose === 'evaluation' || (model.model_source === 'api' && !!model.api_url),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const failedCount = results.filter((result) => result.status === 'rejected').length
|
const failedCount = results.filter((result) => result.status === 'rejected').length
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import PageCard from '@/components/PageCard.vue'
|
import PageCard from '@/components/PageCard.vue'
|
||||||
import ModelStatusTag from '@/components/ModelStatusTag.vue'
|
import ModelStatusTag from '@/components/ModelStatusTag.vue'
|
||||||
import { getEvalDetail } from '@/api/modules/eval'
|
import { getEvalDetail } from '@/api/modules/eval'
|
||||||
|
import { usePolling } from '@/composables/usePolling'
|
||||||
import type { EvalSampleResult, EvalTaskDetail } from '@/types'
|
import type { EvalSampleResult, EvalTaskDetail } from '@/types'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -16,6 +17,7 @@ const keyword = ref('')
|
|||||||
const judgementFilter = ref('')
|
const judgementFilter = ref('')
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const pageSize = ref(10)
|
const pageSize = ref(10)
|
||||||
|
const ACTIVE_STATUSES = new Set(['pending', 'queued', 'running'])
|
||||||
|
|
||||||
const filteredSamples = computed(() => {
|
const filteredSamples = computed(() => {
|
||||||
const normalizedKeyword = keyword.value.trim().toLowerCase()
|
const normalizedKeyword = keyword.value.trim().toLowerCase()
|
||||||
@@ -74,8 +76,8 @@ function resetPage() {
|
|||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDetail() {
|
async function loadDetail(options: { silent?: boolean } = {}) {
|
||||||
loading.value = true
|
if (!options.silent) loading.value = true
|
||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
try {
|
try {
|
||||||
detail.value = await getEvalDetail(taskId)
|
detail.value = await getEvalDetail(taskId)
|
||||||
@@ -83,11 +85,29 @@ async function loadDetail() {
|
|||||||
detail.value = null
|
detail.value = null
|
||||||
loadError.value = '评测详情加载失败,请稍后重试。'
|
loadError.value = '评测详情加载失败,请稍后重试。'
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (!options.silent) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadDetail)
|
const { start: startPolling, stop: stopPolling } = usePolling(
|
||||||
|
async () => {
|
||||||
|
await loadDetail({ silent: true })
|
||||||
|
if (!ACTIVE_STATUSES.has(String(detail.value?.status || ''))) {
|
||||||
|
stopPolling()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
{ immediate: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadDetail()
|
||||||
|
if (ACTIVE_STATUSES.has(String(detail.value?.status || ''))) {
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(stopPolling)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -113,7 +133,7 @@ onMounted(loadDetail)
|
|||||||
<i class="fa fa-exclamation-circle" aria-hidden="true" />
|
<i class="fa fa-exclamation-circle" aria-hidden="true" />
|
||||||
<h2>无法加载评测详情</h2>
|
<h2>无法加载评测详情</h2>
|
||||||
<p>{{ loadError }}</p>
|
<p>{{ loadError }}</p>
|
||||||
<el-button type="primary" @click="loadDetail">重新加载</el-button>
|
<el-button type="primary" @click="() => loadDetail()">重新加载</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else-if="detail">
|
<template v-else-if="detail">
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import DataTablePage from '@/components/DataTablePage.vue'
|
import DataTablePage from '@/components/DataTablePage.vue'
|
||||||
import ModelStatusTag from '@/components/ModelStatusTag.vue'
|
import ModelStatusTag from '@/components/ModelStatusTag.vue'
|
||||||
|
import { usePolling } from '@/composables/usePolling'
|
||||||
import {
|
import {
|
||||||
getEvalList,
|
getEvalList,
|
||||||
deleteEval,
|
deleteEval,
|
||||||
@@ -23,14 +24,16 @@ const leaderboard = ref([
|
|||||||
{ rank: 3, name: 'Qwen-Max', score: 85.3 },
|
{ rank: 3, name: 'Qwen-Max', score: 85.3 },
|
||||||
])
|
])
|
||||||
|
|
||||||
async function loadEvalList() {
|
const ACTIVE_STATUSES = new Set(['pending', 'queued', 'running'])
|
||||||
evalLoading.value = true
|
|
||||||
|
async function loadEvalList(options: { silent?: boolean } = {}) {
|
||||||
|
if (!options.silent) evalLoading.value = true
|
||||||
try {
|
try {
|
||||||
evalList.value = (await getEvalList()) || []
|
evalList.value = (await getEvalList()) || []
|
||||||
} catch {
|
} catch {
|
||||||
evalList.value = []
|
evalList.value = []
|
||||||
} finally {
|
} finally {
|
||||||
evalLoading.value = false
|
if (!options.silent) evalLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,8 +57,26 @@ function handleViewDetail(row: any) {
|
|||||||
router.push({ name: 'model-eval-detail', params: { id: row.id } })
|
router.push({ name: 'model-eval-detail', params: { id: row.id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
const { start: startPolling, stop: stopPolling } = usePolling(
|
||||||
loadEvalList()
|
async () => {
|
||||||
|
await loadEvalList({ silent: true })
|
||||||
|
if (!evalList.value.some((item) => ACTIVE_STATUSES.has(String(item.status || '')))) {
|
||||||
|
stopPolling()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
{ immediate: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadEvalList()
|
||||||
|
if (evalList.value.some((item) => ACTIVE_STATUSES.has(String(item.status || '')))) {
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopPolling()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -56,9 +56,9 @@ defineExpose({ validate })
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="form.rouge_enabled" label="ROUGE methods">
|
<el-form-item v-if="form.rouge_enabled" label="ROUGE methods">
|
||||||
<el-checkbox-group v-model="form.rouge_methods">
|
<el-checkbox-group v-model="form.rouge_methods">
|
||||||
<el-checkbox value="rouge_1">ROUGE-1</el-checkbox>
|
<el-checkbox value="rouge1">ROUGE-1</el-checkbox>
|
||||||
<el-checkbox value="rouge_2">ROUGE-2</el-checkbox>
|
<el-checkbox value="rouge2">ROUGE-2</el-checkbox>
|
||||||
<el-checkbox value="rouge_l">ROUGE-L</el-checkbox>
|
<el-checkbox value="rougeL">ROUGE-L</el-checkbox>
|
||||||
</el-checkbox-group>
|
</el-checkbox-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
|
|||||||
@@ -103,10 +103,10 @@ defineExpose({ validate })
|
|||||||
<el-form-item label="选择 GPU" prop="gpu_id">
|
<el-form-item label="选择 GPU" prop="gpu_id">
|
||||||
<el-select v-model="form.gpu_id" placeholder="请选择 GPU" style="width: 100%" :loading="loading">
|
<el-select v-model="form.gpu_id" placeholder="请选择 GPU" style="width: 100%" :loading="loading">
|
||||||
<el-option
|
<el-option
|
||||||
v-for="(gpu, index) in gpus"
|
v-for="gpu in gpus"
|
||||||
:key="index"
|
:key="gpu.id"
|
||||||
:label="`${gpu.name} (GPU ${index})`"
|
:label="`${gpu.name} (GPU ${gpu.id})`"
|
||||||
:value="index"
|
:value="gpu.id ?? 0"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const models = ref<ModelItem[]>([])
|
|||||||
const datasets = ref<DatasetItem[]>([])
|
const datasets = ref<DatasetItem[]>([])
|
||||||
const gpus = ref<GpuInfo[]>([])
|
const gpus = ref<GpuInfo[]>([])
|
||||||
const computeNodes = ref<Array<{ id: string; scheduler_status?: string }>>([])
|
const computeNodes = ref<Array<{ id: string; scheduler_status?: string }>>([])
|
||||||
const selectedGpuId = ref<number | null>(null)
|
const selectedGpuKeys = ref<string[]>([])
|
||||||
|
|
||||||
/** Only show GPUs from nodes that are online or draining */
|
/** Only show GPUs from nodes that are online or draining */
|
||||||
const availableGpus = computed(() => {
|
const availableGpus = computed(() => {
|
||||||
@@ -74,7 +74,13 @@ const selectedModel = computed(() => models.value.find((model) => model.id === f
|
|||||||
const modelDialogTitle = computed(() => selectedModel.value?.name || '')
|
const modelDialogTitle = computed(() => selectedModel.value?.name || '')
|
||||||
|
|
||||||
/** 训练命令与提交载荷共用同一份表单模型。 */
|
/** 训练命令与提交载荷共用同一份表单模型。 */
|
||||||
const selectedGpuIds = computed(() => (selectedGpuId.value != null ? [selectedGpuId.value] : []))
|
const selectedGpus = computed(() =>
|
||||||
|
selectedGpuKeys.value
|
||||||
|
.map((key) => availableGpus.value.find((gpu) => gpuKey(gpu) === key))
|
||||||
|
.filter((gpu): gpu is GpuInfo => Boolean(gpu)),
|
||||||
|
)
|
||||||
|
const selectedComputeNodeId = computed(() => selectedGpus.value[0]?.node_id)
|
||||||
|
const selectedGpuIds = computed(() => selectedGpus.value.map((gpu) => Number(gpu.id)))
|
||||||
const commandPreview = computed(() => buildFineTuneCommand(form, selectedGpuIds.value))
|
const commandPreview = computed(() => buildFineTuneCommand(form, selectedGpuIds.value))
|
||||||
|
|
||||||
const remoteCommandPreview = computed(() => {
|
const remoteCommandPreview = computed(() => {
|
||||||
@@ -83,9 +89,32 @@ const remoteCommandPreview = computed(() => {
|
|||||||
return preflightResult.value?.preview?.command_text || ''
|
return preflightResult.value?.preview?.command_text || ''
|
||||||
})
|
})
|
||||||
|
|
||||||
/** GPU 单选切换(每次只选中一张 GPU) */
|
function gpuKey(gpu: GpuInfo) {
|
||||||
function toggleGpu(gpuId: number) {
|
return `${gpu.node_id || 'local'}:${gpu.id ?? gpu.uuid ?? gpu.name}`
|
||||||
selectedGpuId.value = selectedGpuId.value === gpuId ? null : gpuId
|
}
|
||||||
|
|
||||||
|
function isGpuUnavailable(gpu: GpuInfo) {
|
||||||
|
return gpu.status === 'busy' || gpu.status === 'reserved' || gpu.status === 'offline'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGpuSelected(gpu: GpuInfo) {
|
||||||
|
return selectedGpuKeys.value.includes(gpuKey(gpu))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GPU 多选切换:单个任务只允许选择同一算力节点内的空闲卡。 */
|
||||||
|
function toggleGpu(gpu: GpuInfo) {
|
||||||
|
if (isGpuUnavailable(gpu) || gpu.id == null) return
|
||||||
|
const key = gpuKey(gpu)
|
||||||
|
if (isGpuSelected(gpu)) {
|
||||||
|
selectedGpuKeys.value = selectedGpuKeys.value.filter((item) => item !== key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectedComputeNodeId.value && gpu.node_id && selectedComputeNodeId.value !== gpu.node_id) {
|
||||||
|
selectedGpuKeys.value = [key]
|
||||||
|
ElMessage.info('已切换到新的算力节点,之前选择的 GPU 已清空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectedGpuKeys.value = [...selectedGpuKeys.value, key]
|
||||||
}
|
}
|
||||||
|
|
||||||
function gpuUsageWidth(percent: number) {
|
function gpuUsageWidth(percent: number) {
|
||||||
@@ -178,8 +207,8 @@ async function loadGpus() {
|
|||||||
const [sys, nodes] = await Promise.all([getSystemInfo(), getComputeNodes().catch(() => [])])
|
const [sys, nodes] = await Promise.all([getSystemInfo(), getComputeNodes().catch(() => [])])
|
||||||
gpus.value = sys?.gpu || []
|
gpus.value = sys?.gpu || []
|
||||||
computeNodes.value = nodes || []
|
computeNodes.value = nodes || []
|
||||||
// Default select first available GPU
|
const firstIdle = availableGpus.value.find((gpu) => !isGpuUnavailable(gpu) && gpu.id != null)
|
||||||
if (availableGpus.value.length > 0) selectedGpuId.value = availableGpus.value[0].id ?? null
|
if (firstIdle) selectedGpuKeys.value = [gpuKey(firstIdle)]
|
||||||
} catch {
|
} catch {
|
||||||
gpus.value = []
|
gpus.value = []
|
||||||
}
|
}
|
||||||
@@ -189,8 +218,8 @@ async function handleSubmit() {
|
|||||||
if (!formRef.value) return
|
if (!formRef.value) return
|
||||||
await formRef.value.validate(async (valid) => {
|
await formRef.value.validate(async (valid) => {
|
||||||
if (!valid) return
|
if (!valid) return
|
||||||
if (selectedGpuId.value == null) {
|
if (!selectedGpuIds.value.length) {
|
||||||
ElMessage.warning('请选择一个 GPU')
|
ElMessage.warning('请至少选择一张空闲 GPU')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
@@ -207,7 +236,7 @@ async function handleSubmit() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = buildFineTunePayload(form, selectedGpuIds.value)
|
const payload = buildFineTunePayload(form, selectedGpuIds.value, selectedComputeNodeId.value)
|
||||||
const preflight = await runPreflight(payload)
|
const preflight = await runPreflight(payload)
|
||||||
if (!preflight?.valid) {
|
if (!preflight?.valid) {
|
||||||
ElMessage.error('训练预检未通过,请先处理预检问题')
|
ElMessage.error('训练预检未通过,请先处理预检问题')
|
||||||
@@ -232,7 +261,7 @@ async function handleSubmit() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runPreflight(payload = buildFineTunePayload(form, selectedGpuIds.value)) {
|
async function runPreflight(payload = buildFineTunePayload(form, selectedGpuIds.value, selectedComputeNodeId.value)) {
|
||||||
preflightLoading.value = true
|
preflightLoading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await preflightFineTune(payload)
|
const result = await preflightFineTune(payload)
|
||||||
@@ -261,8 +290,8 @@ async function handlePreflightClick() {
|
|||||||
if (!formRef.value) return
|
if (!formRef.value) return
|
||||||
await formRef.value.validate(async (valid) => {
|
await formRef.value.validate(async (valid) => {
|
||||||
if (!valid) return
|
if (!valid) return
|
||||||
if (selectedGpuId.value == null) {
|
if (!selectedGpuIds.value.length) {
|
||||||
ElMessage.warning('请选择一个 GPU')
|
ElMessage.warning('请至少选择一张空闲 GPU')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await runPreflight()
|
await runPreflight()
|
||||||
@@ -297,12 +326,16 @@ onMounted(() => {
|
|||||||
<el-divider content-position="left">训练配置</el-divider>
|
<el-divider content-position="left">训练配置</el-divider>
|
||||||
<el-form-item label="GPU 硬件">
|
<el-form-item label="GPU 硬件">
|
||||||
<div class="gpu-list">
|
<div class="gpu-list">
|
||||||
|
<div class="gpu-selection-summary">
|
||||||
|
已选择 {{ selectedGpuIds.length }} 张 GPU
|
||||||
|
<template v-if="selectedGpus[0]?.node_code"> · {{ selectedGpus[0].node_code }}</template>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="gpu in availableGpus"
|
v-for="gpu in availableGpus"
|
||||||
:key="gpu.id"
|
:key="gpuKey(gpu)"
|
||||||
class="gpu-card"
|
class="gpu-card"
|
||||||
:class="{ active: selectedGpuId === gpu.id, 'is-busy': gpu.gpu_percent > 80 }"
|
:class="{ active: isGpuSelected(gpu), 'is-busy': isGpuUnavailable(gpu), 'is-disabled': isGpuUnavailable(gpu) }"
|
||||||
@click="toggleGpu(gpu.id!)"
|
@click="toggleGpu(gpu)"
|
||||||
>
|
>
|
||||||
<div class="gpu-card-top">
|
<div class="gpu-card-top">
|
||||||
<div class="gpu-title">
|
<div class="gpu-title">
|
||||||
@@ -312,7 +345,7 @@ onMounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
<span class="gpu-name">{{ gpu.name }}</span>
|
<span class="gpu-name">{{ gpu.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="gpu-usage">{{ gpu.gpu_percent }}%</span>
|
<span class="gpu-usage">{{ isGpuUnavailable(gpu) ? gpu.status : `${gpu.gpu_percent}%` }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="gpu-usage-bar">
|
<div class="gpu-usage-bar">
|
||||||
<span :style="{ width: gpuUsageWidth(gpu.gpu_percent) }" />
|
<span :style="{ width: gpuUsageWidth(gpu.gpu_percent) }" />
|
||||||
@@ -578,6 +611,13 @@ onMounted(() => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gpu-selection-summary {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.gpu-card {
|
.gpu-card {
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -627,6 +667,11 @@ onMounted(() => {
|
|||||||
background: #dc2626;
|
background: #dc2626;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.is-disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.gpu-card-top {
|
.gpu-card-top {
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export function createDefaultFineTuneForm(): FineTuneFormModel {
|
|||||||
export function buildFineTunePayload(
|
export function buildFineTunePayload(
|
||||||
form: FineTuneFormModel,
|
form: FineTuneFormModel,
|
||||||
gpus: number[],
|
gpus: number[],
|
||||||
|
computeNodeId?: string,
|
||||||
): Omit<FineTuneStartPayload, 'task_id'> {
|
): Omit<FineTuneStartPayload, 'task_id'> {
|
||||||
return {
|
return {
|
||||||
name: form.name,
|
name: form.name,
|
||||||
@@ -77,6 +78,7 @@ export function buildFineTunePayload(
|
|||||||
train_dataset_id: form.train_dataset_id,
|
train_dataset_id: form.train_dataset_id,
|
||||||
auto_merge: form.train_type === 'SFT' && form.auto_merge,
|
auto_merge: form.train_type === 'SFT' && form.auto_merge,
|
||||||
output_model_name: form.name,
|
output_model_name: form.name,
|
||||||
|
compute_node_id: computeNodeId,
|
||||||
batch_size: form.batch_size,
|
batch_size: form.batch_size,
|
||||||
learning_rate: form.learning_rate,
|
learning_rate: form.learning_rate,
|
||||||
n_epochs: form.n_epochs,
|
n_epochs: form.n_epochs,
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import type { CompareTask, LoadedModel } from '@/types'
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const taskId = route.params.id as string
|
const taskId = route.params.id as string
|
||||||
/** 是否为 mock 直通模式(新建推理假数据进入,不走真实任务接口) */
|
/** 是否为 mock 模式(新建推理无真实 taskId 或明确为 mock 时进入 mock 模式) */
|
||||||
const isMock = taskId === 'mock'
|
const isMock = taskId === 'mock' || !taskId || taskId === 'unknown'
|
||||||
/** 当前对话使用的模型名 */
|
/** 当前对话使用的模型名 */
|
||||||
const modelName = ref(route.query.model as string || '')
|
const modelName = ref(route.query.model as string || '')
|
||||||
|
|
||||||
@@ -88,30 +88,20 @@ async function handleSend() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 真实模式:获取已启动模型的端口/路径
|
// 真实模式:通过后端 SSE 流式代理到算力节点进行推理
|
||||||
const models = parseLoadedModels(task.value)
|
|
||||||
const target = models[0]
|
|
||||||
if (!target) {
|
|
||||||
ElMessage.error('未找到已启动的模型')
|
|
||||||
assistantMsg.content = '未找到已启动的模型,请先返回列表加载模型'
|
|
||||||
assistantMsg.done = true
|
|
||||||
assistantMsg.isStreaming = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 流式状态变化时只同步当前回复,避免固定定时器空转。
|
|
||||||
activeAssistant = assistantMsg
|
activeAssistant = assistantMsg
|
||||||
|
|
||||||
await send({
|
await send(
|
||||||
port: target.port,
|
{
|
||||||
model_name: target.model_name,
|
model_path: route.query.model_path as string || '',
|
||||||
model_path: '',
|
system_prompt: systemPrompt.value,
|
||||||
system_prompt: systemPrompt.value,
|
user_question: question,
|
||||||
user_question: question,
|
temperature: temperature.value,
|
||||||
temperature: temperature.value,
|
top_p: top_p.value,
|
||||||
top_p: top_p.value,
|
max_tokens: maxTokens.value,
|
||||||
max_tokens: maxTokens.value,
|
},
|
||||||
})
|
{ useMock: false },
|
||||||
|
)
|
||||||
|
|
||||||
// 完成后同步最终内容
|
// 完成后同步最终内容
|
||||||
assistantMsg.content = message.value.displayContent || message.value.error || '(无回复)'
|
assistantMsg.content = message.value.displayContent || message.value.error || '(无回复)'
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
|||||||
import PageCard from '@/components/PageCard.vue'
|
import PageCard from '@/components/PageCard.vue'
|
||||||
import { getModelList, getTrainedModels } from '@/api/modules/model'
|
import { getModelList, getTrainedModels } from '@/api/modules/model'
|
||||||
import { getSystemInfo } from '@/api/modules/system'
|
import { getSystemInfo } from '@/api/modules/system'
|
||||||
|
import { getComputeNodes, type ComputeNode } from '@/api/modules/compute'
|
||||||
|
import { createCompare, preloadLocalModel, preloadTrainedModel } from '@/api/modules/compare'
|
||||||
import type { ModelItem, TrainedModel, GpuInfo } from '@/types'
|
import type { ModelItem, TrainedModel, GpuInfo } from '@/types'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -15,6 +17,7 @@ const startupStatus = ref('')
|
|||||||
const dbModels = ref<ModelItem[]>([])
|
const dbModels = ref<ModelItem[]>([])
|
||||||
const trainedModels = ref<TrainedModel[]>([])
|
const trainedModels = ref<TrainedModel[]>([])
|
||||||
const gpus = ref<GpuInfo[]>([])
|
const gpus = ref<GpuInfo[]>([])
|
||||||
|
const computeNodes = ref<ComputeNode[]>([])
|
||||||
|
|
||||||
/** 可选模型(下拉用,区分本地/已训练两类) */
|
/** 可选模型(下拉用,区分本地/已训练两类) */
|
||||||
interface SelectableModel {
|
interface SelectableModel {
|
||||||
@@ -54,7 +57,17 @@ const trainedOptions = computed<SelectableModel[]>(() =>
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** key → 模型映射,便于取选中项 */
|
/** 仅显示在线算力节点上的空闲 GPU */
|
||||||
|
const onlineNodeIds = computed(() => new Set(
|
||||||
|
computeNodes.value
|
||||||
|
.filter((n) => n.enabled && n.scheduler_status === 'online')
|
||||||
|
.map((n) => n.id),
|
||||||
|
))
|
||||||
|
const idleGpus = computed(() =>
|
||||||
|
gpus.value.filter(
|
||||||
|
(g) => g.status === 'idle' && (!g.node_id || onlineNodeIds.value.has(g.node_id)),
|
||||||
|
),
|
||||||
|
)
|
||||||
const modelMap = computed<Record<string, SelectableModel>>(() => {
|
const modelMap = computed<Record<string, SelectableModel>>(() => {
|
||||||
const map: Record<string, SelectableModel> = {}
|
const map: Record<string, SelectableModel> = {}
|
||||||
for (const m of [...dbOptions.value, ...trainedOptions.value]) map[m.key] = m
|
for (const m of [...dbOptions.value, ...trainedOptions.value]) map[m.key] = m
|
||||||
@@ -90,12 +103,56 @@ async function handleSubmit() {
|
|||||||
submitting.value = true
|
submitting.value = true
|
||||||
startupStatus.value = '正在启动模型服务...'
|
startupStatus.value = '正在启动模型服务...'
|
||||||
try {
|
try {
|
||||||
// 当前为 mock 环境:不创建任务、不启动后端服务,
|
// Step 1: 将模型加载到算力节点
|
||||||
// 用假数据直通进入对话界面(模型名通过 query 传递)。
|
const preloadPayload = {
|
||||||
// 接入真实后端后,可在此恢复 createCompare / startModelsInBackground / monitorStartup 流程。
|
model_name_or_path: m.model_path,
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1200))
|
model_name: m.name,
|
||||||
|
template: 'qwen',
|
||||||
|
}
|
||||||
|
let preloadResult: any
|
||||||
|
if (m.source === 'trained') {
|
||||||
|
preloadResult = await preloadTrainedModel(preloadPayload)
|
||||||
|
} else {
|
||||||
|
preloadResult = await preloadLocalModel(preloadPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preloadResult && (preloadResult as any).error) {
|
||||||
|
ElMessage.warning(`模型加载失败:${(preloadResult as any).error}`)
|
||||||
|
submitting.value = false
|
||||||
|
startupStatus.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: 创建推理任务记录
|
||||||
|
const taskResult = await createCompare({
|
||||||
|
name: form.name || m.name,
|
||||||
|
description: form.description,
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
model_id: String(m.id),
|
||||||
|
model_name: m.name,
|
||||||
|
model_path: m.model_path,
|
||||||
|
source: m.source,
|
||||||
|
gpu_id: form.gpu_id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const taskId = taskResult?.id || 'unknown'
|
||||||
|
|
||||||
ElMessage.success('模型已启动')
|
ElMessage.success('模型已启动')
|
||||||
|
router.push({
|
||||||
|
path: `/model-inference/chat/${taskId}`,
|
||||||
|
query: {
|
||||||
|
model: m.name,
|
||||||
|
source: m.source,
|
||||||
|
model_path: m.model_path,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
// 真实 API 失败时回退到 mock 模式(方便无算力节点的开发调试)
|
||||||
|
const m = selectedModel.value!
|
||||||
|
const reason = e?.message || e?.toString() || '未知错误'
|
||||||
|
ElMessage.warning(`推理服务启动失败:${reason},进入 mock 演示模式`)
|
||||||
router.push({
|
router.push({
|
||||||
path: '/model-inference/chat/mock',
|
path: '/model-inference/chat/mock',
|
||||||
query: { model: m.name },
|
query: { model: m.name },
|
||||||
@@ -113,16 +170,18 @@ function handleCancel() {
|
|||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
try {
|
try {
|
||||||
const [db, trained, sys] = await Promise.all([
|
const [db, trained, sys, nodes] = await Promise.all([
|
||||||
getModelList(),
|
getModelList(),
|
||||||
getTrainedModels(),
|
getTrainedModels(),
|
||||||
getSystemInfo(),
|
getSystemInfo(),
|
||||||
|
getComputeNodes(),
|
||||||
])
|
])
|
||||||
dbModels.value = db || []
|
dbModels.value = db || []
|
||||||
trainedModels.value = trained?.models || []
|
trainedModels.value = trained?.models || []
|
||||||
gpus.value = sys?.gpu || []
|
gpus.value = sys?.gpu || []
|
||||||
// 默认选中第一个 GPU
|
computeNodes.value = nodes || []
|
||||||
if (gpus.value.length > 0) form.gpu_id = 0
|
// 默认选中第一个空闲 GPU
|
||||||
|
if (idleGpus.value.length > 0) form.gpu_id = idleGpus.value[0].id ?? 0
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -172,10 +231,10 @@ onMounted(loadData)
|
|||||||
<el-form-item label="GPU">
|
<el-form-item label="GPU">
|
||||||
<el-select v-model="form.gpu_id" style="width: 400px">
|
<el-select v-model="form.gpu_id" style="width: 400px">
|
||||||
<el-option
|
<el-option
|
||||||
v-for="(g, idx) in gpus"
|
v-for="g in idleGpus"
|
||||||
:key="idx"
|
:key="g.id ?? 0"
|
||||||
:label="`${g.name} (GPU${idx})`"
|
:label="`${g.name} (GPU${g.id ?? 0}) [空闲]`"
|
||||||
:value="idx"
|
:value="g.id ?? 0"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ import { usePolling } from '@/composables/usePolling'
|
|||||||
import {
|
import {
|
||||||
getCompareList,
|
getCompareList,
|
||||||
deleteCompare,
|
deleteCompare,
|
||||||
getCompare,
|
|
||||||
loadCompare,
|
loadCompare,
|
||||||
unloadCompare,
|
unloadCompare,
|
||||||
stopModelByPid,
|
|
||||||
} from '@/api/modules/compare'
|
} from '@/api/modules/compare'
|
||||||
import type { CompareTask, LoadedModel } from '@/types'
|
import type { CompareTask, LoadedModel } from '@/types'
|
||||||
import { statusLabel, statusTagType } from '@/utils/status'
|
import { statusLabel, statusTagType } from '@/utils/status'
|
||||||
@@ -86,26 +84,19 @@ async function handleLoad(row: any) {
|
|||||||
delayedRefreshTimer = setTimeout(loadData, 1000)
|
delayedRefreshTimer = setTimeout(loadData, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 卸载推理任务 */
|
/** 释放推理任务(停止模型服务,释放算力节点 GPU 显存) */
|
||||||
async function handleUnload(row: any) {
|
async function handleUnload(row: any) {
|
||||||
await ElMessageBox.confirm('确定要停止模型服务吗?', '确认停止', { type: 'warning' })
|
await ElMessageBox.confirm('确定要释放模型服务吗?将停止模型进程并释放 GPU 显存。', '确认释放', { type: 'warning' })
|
||||||
await unloadCompare(row.id)
|
await unloadCompare(row.id)
|
||||||
ElMessage.success('已停止模型服务')
|
ElMessage.success('已释放模型服务')
|
||||||
loadData()
|
loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除(先停止进程) */
|
/** 删除(先释放算力节点再删除记录) */
|
||||||
async function handleDelete(row: any) {
|
async function handleDelete(row: any) {
|
||||||
// 先尝试停止已加载的模型进程
|
await ElMessageBox.confirm('确定要删除该推理记录吗?将先释放算力节点再删除。', '确认删除', { type: 'warning' })
|
||||||
const task = await getCompare(row.id).catch(() => null)
|
// 先释放算力节点上的模型
|
||||||
if (task?.load_status) {
|
await unloadCompare(row.id).catch(() => {})
|
||||||
const models = parseLoadedModels(task as CompareTask)
|
|
||||||
for (const m of models) {
|
|
||||||
if (m.pid) {
|
|
||||||
await stopModelByPid(m.pid).catch(() => {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await deleteCompare(row.id)
|
await deleteCompare(row.id)
|
||||||
dataList.value = dataList.value.filter((item) => item.id !== row.id)
|
dataList.value = dataList.value.filter((item) => item.id !== row.id)
|
||||||
await loadData(true)
|
await loadData(true)
|
||||||
@@ -180,7 +171,7 @@ onUnmounted(() => {
|
|||||||
<i class="fa fa-comments-o" style="margin-right: 4px" />对话
|
<i class="fa fa-comments-o" style="margin-right: 4px" />对话
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button type="warning" link size="small" @click="handleUnload(row)">
|
<el-button type="warning" link size="small" @click="handleUnload(row)">
|
||||||
<i class="fa fa-stop-circle-o" style="margin-right: 4px" />停止
|
<i class="fa fa-stop-circle-o" style="margin-right: 4px" />释放
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { ElMessage } from 'element-plus'
|
|||||||
import DataTablePage from '@/components/DataTablePage.vue'
|
import DataTablePage from '@/components/DataTablePage.vue'
|
||||||
import AclDialog from '@/components/AclDialog.vue'
|
import AclDialog from '@/components/AclDialog.vue'
|
||||||
import { getProject, getProjectMembers, addProjectMember, removeProjectMember, type Project, type ProjectMember } from '@/api/modules/project'
|
import { getProject, getProjectMembers, addProjectMember, removeProjectMember, type Project, type ProjectMember } from '@/api/modules/project'
|
||||||
import { getUsers, type SystemUser } from '@/api/modules/system'
|
import { getUsers } from '@/api/modules/system'
|
||||||
|
import type { SystemUser } from '@/types'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -56,6 +57,10 @@ async function removeMember(userId: string) {
|
|||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asProjectMember(row: unknown): ProjectMember {
|
||||||
|
return row as ProjectMember
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadUsers()
|
loadUsers()
|
||||||
load()
|
load()
|
||||||
@@ -94,7 +99,7 @@ onMounted(() => {
|
|||||||
<el-table-column prop="create_time" label="加入时间" min-width="180" />
|
<el-table-column prop="create_time" label="加入时间" min-width="180" />
|
||||||
</template>
|
</template>
|
||||||
<template #actions="{ row }">
|
<template #actions="{ row }">
|
||||||
<el-button link type="danger" @click="removeMember(row.user_id)">移除</el-button>
|
<el-button link type="danger" @click="removeMember(asProjectMember(row).user_id)">移除</el-button>
|
||||||
</template>
|
</template>
|
||||||
</DataTablePage>
|
</DataTablePage>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ function openDetail(id: string) {
|
|||||||
router.push(`/projects/${id}`)
|
router.push(`/projects/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asProject(row: unknown): Project {
|
||||||
|
return row as Project
|
||||||
|
}
|
||||||
|
|
||||||
async function submitCreate() {
|
async function submitCreate() {
|
||||||
if (!form.value.name || !form.value.code) {
|
if (!form.value.name || !form.value.code) {
|
||||||
ElMessage.warning('请填写项目名与编码')
|
ElMessage.warning('请填写项目名与编码')
|
||||||
@@ -61,7 +65,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<DataTablePage title="项目空间" :data="projects" :loading="loading" searchable search-fields="name,code">
|
<DataTablePage title="项目空间" :data="projects" :loading="loading" searchable :search-fields="['name', 'code']">
|
||||||
<template #toolbar-extra>
|
<template #toolbar-extra>
|
||||||
<el-select v-model="tenantId" placeholder="租户" style="width: 160px" @change="load">
|
<el-select v-model="tenantId" placeholder="租户" style="width: 160px" @change="load">
|
||||||
<el-option v-for="t in tenantOptions" :key="t.value" :label="t.label" :value="t.value" />
|
<el-option v-for="t in tenantOptions" :key="t.value" :label="t.label" :value="t.value" />
|
||||||
@@ -76,7 +80,7 @@ onMounted(() => {
|
|||||||
<el-table-column prop="create_time" label="创建时间" min-width="180" />
|
<el-table-column prop="create_time" label="创建时间" min-width="180" />
|
||||||
</template>
|
</template>
|
||||||
<template #actions="{ row }">
|
<template #actions="{ row }">
|
||||||
<el-button link type="primary" @click="openDetail(row.id)">详情</el-button>
|
<el-button link type="primary" @click="openDetail(asProject(row).id)">详情</el-button>
|
||||||
</template>
|
</template>
|
||||||
</DataTablePage>
|
</DataTablePage>
|
||||||
<el-dialog v-model="showCreate" title="新建项目" width="520px">
|
<el-dialog v-model="showCreate" title="新建项目" width="520px">
|
||||||
|
|||||||
@@ -54,6 +54,18 @@ function isSelf(row: SystemUser) {
|
|||||||
return row.username === currentUsername.value
|
return row.username === currentUsername.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asSystemUser(row: unknown): SystemUser {
|
||||||
|
return row as SystemUser
|
||||||
|
}
|
||||||
|
|
||||||
|
function userPermissions(row: unknown): PermissionCode[] {
|
||||||
|
return (asSystemUser(row).permissions || []) as PermissionCode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function permissionLabel(code: PermissionCode): string {
|
||||||
|
return PERMISSION_LABELS[code] || code
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 启停 ----------
|
// ---------- 启停 ----------
|
||||||
async function toggleStatus(row: SystemUser, next: boolean) {
|
async function toggleStatus(row: SystemUser, next: boolean) {
|
||||||
const nextStatus: UserStatus = next ? 'active' : 'disabled'
|
const nextStatus: UserStatus = next ? 'active' : 'disabled'
|
||||||
@@ -162,31 +174,31 @@ async function removeUser(row: SystemUser) {
|
|||||||
<el-table-column prop="role" label="角色" width="120" />
|
<el-table-column prop="role" label="角色" width="120" />
|
||||||
<el-table-column label="状态" width="130">
|
<el-table-column label="状态" width="130">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
<el-tag :type="statusTagType(asSystemUser(row).status)" size="small">{{ statusLabel(asSystemUser(row).status) }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="页面权限" min-width="160">
|
<el-table-column label="页面权限" min-width="160">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag
|
<el-tag
|
||||||
v-for="p in (row.permissions || []).slice(0, 3)"
|
v-for="p in userPermissions(row).slice(0, 3)"
|
||||||
:key="p"
|
:key="p"
|
||||||
size="small"
|
size="small"
|
||||||
type="info"
|
type="info"
|
||||||
class="perm-tag"
|
class="perm-tag"
|
||||||
>{{ PERMISSION_LABELS[p] || p }}</el-tag>
|
>{{ permissionLabel(p) }}</el-tag>
|
||||||
<span v-if="(row.permissions || []).length > 3" class="perm-more">
|
<span v-if="userPermissions(row).length > 3" class="perm-more">
|
||||||
+{{ (row.permissions || []).length - 3 }}
|
+{{ userPermissions(row).length - 3 }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="!(row.permissions || []).length" class="perm-more">无</span>
|
<span v-if="!userPermissions(row).length" class="perm-more">无</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="create_time" label="创建时间" min-width="180" />
|
<el-table-column prop="create_time" label="创建时间" min-width="180" />
|
||||||
<el-table-column label="操作" width="260" fixed="right">
|
<el-table-column label="操作" width="260" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-switch
|
<el-switch
|
||||||
:model-value="row.status === 'active'"
|
:model-value="asSystemUser(row).status === 'active'"
|
||||||
:disabled="row.protected || isSelf(row)"
|
:disabled="asSystemUser(row).protected || isSelf(asSystemUser(row))"
|
||||||
@change="(v: any) => toggleStatus(row, v)"
|
@change="(v: any) => toggleStatus(asSystemUser(row), v)"
|
||||||
inline-prompt
|
inline-prompt
|
||||||
active-text="启用"
|
active-text="启用"
|
||||||
inactive-text="停用"
|
inactive-text="停用"
|
||||||
@@ -194,19 +206,19 @@ async function removeUser(row: SystemUser) {
|
|||||||
<el-button
|
<el-button
|
||||||
link
|
link
|
||||||
type="primary"
|
type="primary"
|
||||||
:disabled="row.protected"
|
:disabled="asSystemUser(row).protected"
|
||||||
@click="openResetPwd(row)"
|
@click="openResetPwd(asSystemUser(row))"
|
||||||
>重置密码</el-button>
|
>重置密码</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
link
|
link
|
||||||
type="primary"
|
type="primary"
|
||||||
@click="openPerms(row)"
|
@click="openPerms(asSystemUser(row))"
|
||||||
>页面权限</el-button>
|
>页面权限</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
link
|
link
|
||||||
type="danger"
|
type="danger"
|
||||||
:disabled="row.protected || isSelf(row)"
|
:disabled="asSystemUser(row).protected || isSelf(asSystemUser(row))"
|
||||||
@click="removeUser(row)"
|
@click="removeUser(asSystemUser(row))"
|
||||||
>删除</el-button>
|
>删除</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ function openDetail(id: string) {
|
|||||||
router.push(`/tenants/${id}`)
|
router.push(`/tenants/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asTenant(row: unknown): Tenant {
|
||||||
|
return row as Tenant
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotaText(row: unknown): string {
|
||||||
|
const quota = asTenant(row).quota || {}
|
||||||
|
return Object.keys(quota).length ? JSON.stringify(quota) : '—'
|
||||||
|
}
|
||||||
|
|
||||||
async function submitCreate() {
|
async function submitCreate() {
|
||||||
if (!form.value.name) {
|
if (!form.value.name) {
|
||||||
ElMessage.warning('请填写租户名称')
|
ElMessage.warning('请填写租户名称')
|
||||||
@@ -75,15 +84,15 @@ onMounted(load)
|
|||||||
<el-table-column prop="code" label="编码" min-width="100" />
|
<el-table-column prop="code" label="编码" min-width="100" />
|
||||||
<el-table-column label="配额" min-width="160">
|
<el-table-column label="配额" min-width="160">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ Object.keys(row.quota || {}).length ? JSON.stringify(row.quota) : '—' }}
|
{{ quotaText(row) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="status" label="状态" min-width="100" />
|
<el-table-column prop="status" label="状态" min-width="100" />
|
||||||
<el-table-column prop="create_time" label="创建时间" min-width="180" />
|
<el-table-column prop="create_time" label="创建时间" min-width="180" />
|
||||||
</template>
|
</template>
|
||||||
<template #actions="{ row }">
|
<template #actions="{ row }">
|
||||||
<el-button link type="primary" @click="openDetail(row.id)">详情</el-button>
|
<el-button link type="primary" @click="openDetail(asTenant(row).id)">详情</el-button>
|
||||||
<el-button link type="primary" @click="setQuota(row)">配额</el-button>
|
<el-button link type="primary" @click="setQuota(asTenant(row))">配额</el-button>
|
||||||
</template>
|
</template>
|
||||||
</DataTablePage>
|
</DataTablePage>
|
||||||
<el-dialog v-model="showCreate" title="新建租户" width="520px">
|
<el-dialog v-model="showCreate" title="新建租户" width="520px">
|
||||||
|
|||||||
Reference in New Issue
Block a user