Files
cdsl-cad/backend/app/cad_agent/evals/usable_smoke.py
T
2026-09-02 13:51:35 +08:00

165 lines
7.9 KiB
Python

"""Run real-provider CAD tasks with a usable-model success criterion.
This evaluator is intentionally narrower than the release gate in ``live.py``.
It answers one operational question: can the current workflow reliably finish
ordinary prompts with a downloadable/previewable checkpoint, even if some
acceptance claims remain best-effort warnings.
"""
from __future__ import annotations
import argparse
import asyncio
from dataclasses import replace
from datetime import datetime, timezone
import json
from pathlib import Path
import secrets
import sys
from typing import Any
from app.cad_agent.application.workflow import ModelIdentity
from app.cad_agent.composition import compose_v3
from app.settings import BACKEND_ROOT, get_settings
DEFAULT_PROMPTS = (
"生成一个 80 mm x 50 mm x 8 mm 的简单矩形板,使用毫米,输出一个单一实体。",
"生成一个简单法兰,外径 100 mm,厚度 10 mm,中间有 30 mm 通孔,使用毫米。",
"生成一个圆柱垫块,直径 60 mm,高度 20 mm,中间有 20 mm 通孔,使用毫米。",
)
def _arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run real CAD generation prompts and require usable model artifacts.")
parser.add_argument("--author-provider")
parser.add_argument("--author-model")
parser.add_argument("--review-provider")
parser.add_argument("--review-model")
parser.add_argument("--prompt", action="append", help="Prompt to run. Repeat for multiple prompts. Defaults to three simple CAD prompts.")
parser.add_argument("--max-wall-seconds", type=int, default=1200)
parser.add_argument("--max-model-calls", type=int, default=60)
return parser.parse_args()
def _result_code(events: list[dict[str, Any]], projection: dict[str, Any]) -> str:
terminal = next((item["payload"] for item in reversed(events) if item.get("name") == "task_terminal"), {})
for source in (terminal, projection):
code = source.get("code") or source.get("last_error") if isinstance(source, dict) else ""
if isinstance(code, str) and code:
return code
return ""
def _artifact_ok(artifact_root: Path, revision_id: str) -> bool:
if not revision_id:
return False
revision_root = artifact_root / "revisions" / revision_id
return all((revision_root / name).is_file() for name in ("model.step", "model.glb", "model.cdsl.json", "rebuild-report.json"))
def _classify_failure(code: str, projection: dict[str, Any]) -> str:
if str(projection.get("lifecycle") or "") == "waiting_retry":
return "configuration_or_service"
if code in {"FAILED_INTERNAL", "RUNTIME_CONTRACT_INVALID", "REQUIREMENTS_SPEC_INVALID", "STORAGE_FAILURE", "RENDER_SERVICE_UNAVAILABLE"}:
return "code_or_flow"
if code in {"AUTHOR_TRANSPORT_UNAVAILABLE", "REVIEW_SERVICE_UNAVAILABLE", "MODEL_PROTOCOL_CHECK_PENDING"}:
return "configuration_or_service"
if code in {"AUTHOR_FORMAT_INVALID", "AUTHOR_DECISION_REJECTED", "CANDIDATE_BUILD_FAILED", "CLAIM_VERIFICATION_FAILED", "CANDIDATE_REVIEW_REJECTED", "NO_PROGRESS_LIMIT", "BEST_EFFORT_COMPLETED"}:
return "model_output_or_best_effort"
return "unknown"
async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, Any]:
settings = get_settings()
try:
author_provider, author_model = settings.resolve_model(arguments.author_provider, arguments.author_model)
if arguments.review_provider or arguments.review_model:
review_provider, review_model = settings.resolve_model(arguments.review_provider, arguments.review_model)
else:
review_provider, review_model = settings.resolve_independent_review_model(author_provider, author_model)
except ValueError as error:
return {"status": "blocked", "error": str(error), "results": []}
isolated = replace(settings, task_root=report_root / "artifacts", conversation_root=report_root / "conversations")
services = compose_v3(isolated)
services.workflow.config = replace(
services.workflow.config,
max_turns=max(8, arguments.max_model_calls * 3),
max_model_calls=arguments.max_model_calls,
)
prompts = tuple(arguments.prompt or DEFAULT_PROMPTS)
results: list[dict[str, Any]] = []
for index, prompt in enumerate(prompts, start=1):
task_id = f"cad_{secrets.token_hex(6)}"
services.workflow.create_task(task_id, prompt)
events: list[dict[str, Any]] = []
started = datetime.now(timezone.utc)
try:
async with asyncio.timeout(arguments.max_wall_seconds):
async for name, payload in services.workflow.run(
task_id=task_id,
author=ModelIdentity(author_provider.id, author_model.id),
reviewer=ModelIdentity(review_provider.id, review_model.id),
):
events.append({"name": name, "payload": payload})
except TimeoutError:
events.append({"name": "timeout", "payload": {"code": "LIVE_EVAL_TIMEOUT", "message": "Task timed out."}})
projection = services.repository.get_task_projection(task_id) or {}
artifact_root = (isolated.task_root / task_id).resolve()
revision_id = str(projection.get("active_revision") or projection.get("current_revision") or "")
code = _result_code(events, projection)
usable = str(projection.get("lifecycle") or "") == "completed" and _artifact_ok(artifact_root, revision_id)
results.append({
"index": index,
"task_id": task_id,
"prompt": prompt,
"success": usable,
"failure_layer": "" if usable else _classify_failure(code, projection),
"code": code,
"phase": projection.get("phase"),
"lifecycle": projection.get("lifecycle"),
"active_revision": revision_id,
"verification_status": projection.get("verification_status"),
"artifact_root": str(artifact_root),
"duration_ms": round((datetime.now(timezone.utc) - started).total_seconds() * 1000),
"usage": services.repository.usage_summary(task_id),
"event_audit": [
{
"name": item.get("name"),
"status": (item.get("payload") or {}).get("status"),
"lifecycle": (item.get("payload") or {}).get("lifecycle"),
"code": ((item.get("payload") or {}).get("result") or {}).get("code") if isinstance((item.get("payload") or {}).get("result"), dict) else (item.get("payload") or {}).get("code"),
"tool": (item.get("payload") or {}).get("tool"),
}
for item in events
],
})
return {
"status": "passed" if results and all(item["success"] for item in results) else "failed",
"schema_version": "cad.usable-smoke.v1",
"author": {"provider": author_provider.id, "model": author_model.id},
"reviewer": {"provider": review_provider.id, "model": review_model.id},
"results": results,
}
def main() -> int:
arguments = _arguments()
report_root = BACKEND_ROOT / "live-evals" / datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
report_root.mkdir(parents=True, exist_ok=True)
try:
result = asyncio.run(_run(arguments, report_root))
except KeyboardInterrupt:
raise
except BaseException as error:
result = {"status": "blocked", "error": f"UNEXPECTED_USABLE_SMOKE_ERROR: {type(error).__name__}: {str(error)[:1000]}", "results": []}
result.update({"report_root": str(report_root.resolve())})
report_path = report_root / "usable-smoke-report.json"
report_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({"status": result["status"], "report": str(report_path.resolve())}, ensure_ascii=False))
return 0 if result["status"] == "passed" else 2
if __name__ == "__main__":
sys.exit(main())