Files
cdsl-cad/backend/app/services/engine_service.py
T
2026-08-25 17:41:24 +08:00

609 lines
29 KiB
Python

from __future__ import annotations
import copy
import json
import math
import re
import shutil
import sys
from pathlib import Path
from typing import Any
from jsonschema import Draft202012Validator
from jsonschema.exceptions import SchemaError
from vendor.cdsl_preview_runtime import step_to_glb
from app.services.quality import evaluate_quality, validate_verification
from app.services.storage import WorkspaceStore, now_iso, write_json
from app.settings import Settings
class QualityVerificationError(RuntimeError):
"""A built CDSL document missed a blocking generic verification rule."""
def __init__(self, quality_report: dict[str, Any]) -> None:
super().__init__("Blocking CDSL verification checks failed")
self.quality_report = quality_report
self.task_id = ""
self.revision_id = ""
def load_engine(settings: Settings) -> Any:
parent = str(settings.engine_root.parent)
if parent not in sys.path:
sys.path.insert(0, parent)
import cdsl_engine
return cdsl_engine
def _walk(value: Any) -> list[tuple[str, Any]]:
result: list[tuple[str, Any]] = []
if isinstance(value, dict):
for key, child in value.items():
result.append((str(key), child))
result.extend(_walk(child))
elif isinstance(value, list):
for child in value:
result.extend(_walk(child))
return result
def _engine_schema(engine: Any) -> dict[str, Any]:
schema_path = Path(str(engine.__file__)).with_name("profile_schema.json")
try:
schema = json.loads(schema_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise RuntimeError("The local engine schema document is unavailable or invalid") from error
if not isinstance(schema, dict) or not isinstance(schema.get("feature_atomic_ids"), dict):
raise RuntimeError("The local engine schema has no feature_atomic_ids contract")
return schema
def load_cdsl_json_schema(engine: Any) -> dict[str, Any]:
document = _engine_schema(engine)
schema_name = str(document.get("cdsl_json_schema_file") or "")
if not schema_name or Path(schema_name).name != schema_name:
raise RuntimeError("The local engine schema has an invalid CDSL JSON Schema path")
schema_path = Path(str(engine.__file__)).with_name(schema_name)
try:
schema = json.loads(schema_path.read_text(encoding="utf-8"))
Draft202012Validator.check_schema(schema)
except (OSError, json.JSONDecodeError, SchemaError) as error:
raise RuntimeError("The local CDSL JSON Schema is unavailable or invalid") from error
return schema
def _validate_cdsl_json_schema(cdsl: dict[str, Any], engine: Any) -> None:
validator = Draft202012Validator(load_cdsl_json_schema(engine))
errors = sorted(validator.iter_errors(cdsl), key=lambda error: (list(error.absolute_path), error.message))
if not errors:
return
error = errors[0]
location = "$" + "".join(
f"[{item}]" if isinstance(item, int) else f".{item}"
for item in error.absolute_path
)
raise ValueError(f"CDSL schema violation at {location}: {error.message}")
def _legacy_workplane(plane: str, offset: Any) -> dict[str, list[float]] | None:
if isinstance(offset, bool) or not isinstance(offset, (int, float)):
return None
distance = float(offset)
definitions = {
"XY": ([0.0, 0.0, distance], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]),
"XZ": ([0.0, distance, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
"YZ": ([distance, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]),
}
definition = definitions.get(plane.upper())
if definition is None:
return None
origin, x_dir, normal = definition
return {"origin_mm": origin, "x_dir": x_dir, "normal": normal}
def normalize_cdsl_for_engine(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
"""Convert unambiguous legacy LLM aliases into the current CDSL dialect.
This intentionally does not infer dimensions, selectors, or feature
dependencies. Any non-mechanical error remains visible to the validator.
"""
normalized = copy.deepcopy(cdsl)
repairs: list[str] = []
geometry = normalized.get("geometry")
sketches = geometry.get("sketches") if isinstance(geometry, dict) else None
if isinstance(sketches, list):
for index, sketch in enumerate(sketches):
if not isinstance(sketch, dict):
continue
if "id" not in sketch and isinstance(sketch.get("sketch_id"), str):
sketch["id"] = sketch.pop("sketch_id")
repairs.append(f"geometry.sketches[{index}]: sketch_id -> id")
legacy_plane: Any = sketch.get("plane")
legacy_offset: Any = sketch.get("offset_mm", 0)
workplane_value = sketch.get("workplane")
if isinstance(workplane_value, dict) and "origin_mm" not in workplane_value:
legacy_plane = workplane_value.get("plane")
legacy_offset = workplane_value.get("offset_mm", 0)
elif "workplane" in sketch:
continue
workplane = _legacy_workplane(legacy_plane, legacy_offset) if isinstance(legacy_plane, str) else None
if workplane is None:
continue
sketch["workplane"] = workplane
sketch.pop("plane", None)
sketch.pop("offset_mm", None)
repairs.append(f"geometry.sketches[{index}]: legacy plane/offset_mm -> workplane")
features = normalized.get("features")
if isinstance(features, list):
for index, feature in enumerate(features):
if not isinstance(feature, dict):
continue
if "sketch_id" not in feature and isinstance(feature.get("sketch"), str):
feature["sketch_id"] = feature.pop("sketch")
repairs.append(f"features[{index}]: sketch -> sketch_id")
if "depends_on" not in feature:
feature["depends_on"] = []
repairs.append(f"features[{index}]: added empty depends_on")
params = feature.get("params")
axis = params.get("axis") if isinstance(params, dict) else None
if isinstance(axis, dict) and "origin_mm" not in axis and "point_mm" in axis:
axis["origin_mm"] = axis.pop("point_mm")
repairs.append(f"features[{index}].params.axis: point_mm -> origin_mm")
return normalized, repairs
def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
if not isinstance(cdsl, dict):
raise ValueError("CDSL must be a JSON object")
if cdsl.get("schema") != "cad.cdsl.llm.v1":
raise ValueError("Unsupported CDSL schema")
_validate_cdsl_json_schema(cdsl, engine)
part_id = str(cdsl.get("part_id") or "")
if not re.fullmatch(r"[a-zA-Z0-9_-]{3,80}", part_id):
raise ValueError("part_id must use letters, numbers, underscores, or hyphens")
forbidden = {"compiler_context", "unknown_shape", "complex_arc_shape", "contour_edges_mm", "contour_regions_mm", "entities"}
for key, value in _walk(cdsl):
if key in forbidden or (isinstance(value, str) and value in {"unknown_shape", "complex_arc_shape"}):
raise ValueError(f"Training-unsafe CDSL field: {key}")
features = cdsl.get("features")
sketches = cdsl.get("geometry", {}).get("sketches")
if not isinstance(features, list) or not features or not isinstance(sketches, list) or not sketches:
raise ValueError("CDSL requires features and parameterized sketches")
sketch_ids = {str(sketch.get("id")) for sketch in sketches}
semantic_contract = _engine_schema(engine)
atomic_contracts = semantic_contract["feature_atomic_ids"]
declared_atomic_ids = {
str(atomic_id)
for atomic_id in semantic_contract.get("runtime_supported_atomic_ids", atomic_contracts)
}
registered_atomic_ids = {str(atomic_id) for atomic_id in getattr(engine, "SUPPORTED_ATOMIC_IDS", ())}
supported_atomic_ids = sorted(declared_atomic_ids & registered_atomic_ids)
feature_ids: set[str] = set()
for feature in features:
fid = str(feature.get("id") or "")
if not fid or fid in feature_ids:
raise ValueError("Feature ids must be unique")
feature_ids.add(fid)
atomic_id = str(feature.get("atomic_id") or "")
if not atomic_id:
raise ValueError(f"Feature {fid} has no atomic_id")
contract = atomic_contracts.get(atomic_id)
if atomic_id not in supported_atomic_ids or not isinstance(contract, dict):
if feature.get("execution_status") == "deferred":
raise ValueError(f"Feature {fid} is deferred and cannot be rebuilt by the current engine")
raise ValueError(
f"Unsupported CDSL atomic_id: {atomic_id}. Supported: {', '.join(supported_atomic_ids)}"
)
params = feature.get("params")
if not isinstance(params, dict):
raise ValueError(f"Feature {fid} params must be an object")
for parameter_name in contract.get("required_params") or []:
if params.get(parameter_name) is None:
raise ValueError(f"Feature {fid} ({atomic_id}) is missing required parameter: {parameter_name}")
if atomic_id.startswith("extrude_") and float(params.get("distance_mm") or 0) <= 0:
raise ValueError(f"Feature {fid} ({atomic_id}) requires distance_mm > 0 for runtime rebuild")
if atomic_id.startswith("revolve_") and float(params.get("angle_deg") or 0) <= 0:
raise ValueError(f"Feature {fid} ({atomic_id}) requires angle_deg > 0 for runtime rebuild")
if contract.get("requires_sketch") and str(feature.get("sketch_id") or "") not in sketch_ids:
raise ValueError(f"Feature {fid} ({atomic_id}) requires a valid sketch_id")
for dependency in feature.get("depends_on") or []:
if dependency not in feature_ids:
raise ValueError(f"Feature {fid} has a forward or missing dependency")
for sketch in sketches:
profile = sketch.get("profile")
if sketch.get("profile_from"):
continue
if not isinstance(profile, dict):
raise ValueError(f"Sketch {sketch.get('id')} has no self-contained profile")
profile_type = str(profile.get("type") or "")
if profile_type == "polygon":
if not profile.get("vertices"):
raise ValueError("Polygon profiles require vertices")
elif profile_type not in engine.SHAPE_GENERATORS:
raise ValueError(f"Unsupported CDSL profile: {profile_type}")
try:
analysis = engine.analyze_cdsl(copy.deepcopy(cdsl))
except Exception as error:
raise ValueError(f"CDSL engine runtime preflight failed: {error}") from error
if not analysis.runtime_eligible:
first = next((result for result in analysis.feature_results if not result.executable), None)
if first is None:
raise ValueError(f"CDSL engine runtime preflight failed: {analysis.document_blockers[0].code}")
blockers = ", ".join(blocker.code for blocker in first.blockers)
raise ValueError(f"CDSL engine runtime preflight failed: feature {first.feature_id}: {blockers}")
def _parameter_id(path: list[str]) -> str:
return "param_" + "_".join(re.sub(r"[^a-zA-Z0-9]+", "_", item).strip("_") for item in path)
def _parameter_label(path: list[str]) -> str:
return " / ".join(path[-2:]).replace("_mm", " (mm)").replace("_", " ")
def _derived_parameters(cdsl: dict[str, Any]) -> list[dict[str, Any]]:
parameters: list[dict[str, Any]] = []
def add(path: list[str], value: Any, group: str) -> None:
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)):
return
number = float(value)
magnitude = max(abs(number), 1.0)
parameters.append({
"id": _parameter_id(path),
"name": ".".join(path),
"display_name": _parameter_label(path),
"path": path,
"value": number,
"default_value": number,
"minimum": 0.01 if number >= 0 else -magnitude * 10,
"maximum": magnitude * 10,
"step": 0.1 if abs(number) < 100 else 1.0,
"precision": 2,
"unit": "mm" if path[-1].endswith("_mm") else "",
"group": group,
"editable": True,
})
for feature_index, feature in enumerate(cdsl.get("features") or []):
for key, value in (feature.get("params") or {}).items():
add(["features", str(feature_index), "params", str(key)], value, "Features")
for sketch_index, sketch in enumerate(cdsl.get("geometry", {}).get("sketches") or []):
profile = sketch.get("profile") or {}
def walk_profile(value: Any, path: list[str]) -> None:
if isinstance(value, dict):
for key, child in value.items():
walk_profile(child, [*path, str(key)])
elif isinstance(value, list):
# Coordinates are topology anchors, not user-facing dimensions.
return
else:
add(path, value, "Sketches")
walk_profile(profile, ["geometry", "sketches", str(sketch_index), "profile"])
return parameters
def parameter_contract(cdsl: dict[str, Any]) -> dict[str, Any]:
declared = cdsl.get("meta", {}).get("editable_parameters")
if isinstance(declared, list) and declared:
values = [item for item in declared if isinstance(item, dict) and isinstance(item.get("path"), list)]
if values:
return {"schema_version": "1.0", "parameters": values, "source": "declared"}
return {"schema_version": "1.0", "parameters": _derived_parameters(cdsl), "source": "derived"}
def topology_sidecars(engine_result: dict[str, Any], preview: dict[str, Any] | None = None) -> tuple[dict[str, Any], dict[str, Any]]:
topology_faces = (preview or {}).get("topology_faces")
if isinstance(topology_faces, list) and topology_faces:
references = []
for face in topology_faces:
if not isinstance(face, dict):
continue
frame = face.get("frame")
center = face.get("center")
normal = face.get("normal")
if not isinstance(frame, dict) or not isinstance(center, list) or not isinstance(normal, list):
continue
references.append({
"id": str(face.get("id") or f"face_{len(references):03d}"),
"selectorType": "face",
"label": str(face.get("surface_type") or "face"),
"center": center,
"normal": normal,
"frame": frame,
"bbox": face.get("bbox") or {},
"surface_type": str(face.get("surface_type") or "unknown"),
"triangle_start": int(face.get("triangle_start") or 0),
"triangle_count": int(face.get("triangle_count") or 0),
})
if references:
return ({"schema_version": "1.1", "references": references}, {"schema_version": "1.0", "edges": []})
bbox = engine_result.get("bbox_mm") or {}
minimum = [float(value) for value in bbox.get("min") or [0, 0, 0]]
maximum = [float(value) for value in bbox.get("max") or [0, 0, 0]]
if len(minimum) != 3 or len(maximum) != 3:
raise ValueError("Engine result is missing a valid bounding box")
center = [(minimum[index] + maximum[index]) / 2 for index in range(3)]
definitions = [
("top", [center[0], center[1], maximum[2]], [0, 0, 1], [1, 0, 0], [0, 1, 0]),
("bottom", [center[0], center[1], minimum[2]], [0, 0, -1], [1, 0, 0], [0, -1, 0]),
("right", [maximum[0], center[1], center[2]], [1, 0, 0], [0, 1, 0], [0, 0, 1]),
("left", [minimum[0], center[1], center[2]], [-1, 0, 0], [0, 1, 0], [0, 0, -1]),
("front", [center[0], maximum[1], center[2]], [0, 1, 0], [1, 0, 0], [0, 0, -1]),
("back", [center[0], minimum[1], center[2]], [0, -1, 0], [1, 0, 0], [0, 0, 1]),
]
references = [
{
"id": f"face_{name}", "selectorType": "face", "label": name,
"center": point, "normal": normal,
"frame": {"origin_mm": point, "normal": normal, "x_dir": x_dir, "y_dir": y_dir},
"bbox": {"min": minimum, "max": maximum},
}
for name, point, normal, x_dir, y_dir in definitions
]
return ({"schema_version": "1.0", "references": references}, {"schema_version": "1.0", "edges": []})
def _set_parameter_value(document: dict[str, Any], path: list[str], value: float) -> None:
target: Any = document
for index, key in enumerate(path):
final = index == len(path) - 1
if isinstance(target, list):
item_index = int(key)
if item_index < 0 or item_index >= len(target):
raise ValueError("Parameter path is no longer valid")
if final:
target[item_index] = value
else:
target = target[item_index]
elif isinstance(target, dict):
if key not in target:
raise ValueError("Parameter path is no longer valid")
if final:
target[key] = value
else:
target = target[key]
else:
raise ValueError("Parameter path is no longer valid")
def apply_parameter_updates(cdsl: dict[str, Any], values: dict[str, float]) -> tuple[dict[str, Any], dict[str, Any]]:
contract = parameter_contract(cdsl)
entries = {str(item.get("id")): item for item in contract["parameters"]}
updated = copy.deepcopy(cdsl)
for parameter_id, raw_value in values.items():
entry = entries.get(parameter_id)
value = float(raw_value)
if entry is None or not entry.get("editable", False):
raise ValueError(f"Unknown editable parameter: {parameter_id}")
if not math.isfinite(value):
raise ValueError("Parameter values must be finite")
minimum, maximum = entry.get("minimum"), entry.get("maximum")
if isinstance(minimum, (int, float)) and value < float(minimum):
raise ValueError(f"{parameter_id} is below its minimum")
if isinstance(maximum, (int, float)) and value > float(maximum):
raise ValueError(f"{parameter_id} is above its maximum")
path = entry.get("path")
if not isinstance(path, list) or not all(isinstance(item, str) for item in path):
raise ValueError(f"{parameter_id} has an invalid path")
_set_parameter_value(updated, path, value)
declared = updated.get("meta", {}).get("editable_parameters")
if isinstance(declared, list):
for declared_entry in declared:
if isinstance(declared_entry, dict) and str(declared_entry.get("id")) == parameter_id:
declared_entry["value"] = value
return updated, parameter_contract(updated)
def _part_skill_audit(
part_skills: dict[str, Any] | None,
request: str,
generation_assumptions: list[str] | None,
) -> dict[str, Any]:
"""Normalize the planning audit persisted beside a product revision."""
audit = copy.deepcopy(part_skills) if isinstance(part_skills, dict) else {}
skills = [item for item in audit.get("skills") or [] if isinstance(item, dict)]
skill_ids = [str(item) for item in audit.get("skill_ids") or [] if str(item)]
if not skill_ids:
skill_ids = [str(item.get("id")) for item in skills if item.get("id")]
audit.update({
"schema_version": str(audit.get("schema_version") or "1.0"),
"request": str(audit.get("request") or request),
"structural_intent": str(audit.get("structural_intent") or request),
"skill_ids": skill_ids,
"skills": skills,
"inherited_skill_ids": [str(item) for item in audit.get("inherited_skill_ids") or [] if str(item)],
"assumptions": [str(item) for item in generation_assumptions or []],
})
return audit
def _generation_context(
reference_ids: list[str],
part_skill_audit: dict[str, Any],
) -> dict[str, Any]:
skills = [item for item in part_skill_audit.get("skills") or [] if isinstance(item, dict)]
return {
"cdsl_reference_ids": list(reference_ids),
"part_skill_ids": list(part_skill_audit.get("skill_ids") or []),
"part_skill_paths": [
{
"id": str(item.get("id") or ""),
"bridge": str(item.get("bridge") or ""),
"source": str(item.get("source") or ""),
}
for item in skills
],
"generation_assumptions": list(part_skill_audit.get("assumptions") or []),
}
def build_revision(
*,
settings: Settings,
store: WorkspaceStore,
task_id: str | None,
request: str,
cdsl: dict[str, Any],
reference_ids: list[str],
summary: str,
parent_revision_id: str | None = None,
operation: dict[str, Any] | None = None,
input_attachments: list[dict[str, Any]] | None = None,
part_skills: dict[str, Any] | None = None,
generation_assumptions: list[str] | None = None,
repair_attempts: int = 0,
verification: dict[str, Any] | None = None,
reference_records: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
engine = load_engine(settings)
task = store.ensure_task(task_id, request)
revision_id, revision_dir = store.next_revision(task["task_id"])
cdsl_path = revision_dir / "model.cdsl.json"
step_path = revision_dir / "model.step"
glb_path = revision_dir / "model.glb"
report_path = revision_dir / "rebuild-report.json"
request_path = revision_dir / "request.json"
references_path = revision_dir / "references.json"
parameters_path = revision_dir / "parameters.json"
selector_path = revision_dir / "model.selector.json"
edges_path = revision_dir / "model.edges.json"
part_skills_path = revision_dir / "part-skills.json"
quality_path = revision_dir / "quality-report.json"
snapshot_manifest_path = revision_dir / "snapshot-manifest.json"
part_skill_audit = _part_skill_audit(part_skills, request, generation_assumptions)
generation_context = _generation_context(reference_ids, part_skill_audit)
write_json(request_path, {"request": request, "created_at": now_iso()})
write_json(references_path, {"reference_ids": reference_ids, "records": [item for item in reference_records or [] if isinstance(item, dict)]})
write_json(part_skills_path, part_skill_audit)
write_json(snapshot_manifest_path, {
"schema_version": "1.0",
"status": "unavailable",
"reason": "Snapshot runner is not attached to this backend build",
"snapshots": [],
})
def revision_record(status: str, *, error: str = "", engine_name: str = "") -> dict[str, Any]:
record = {
"revision_id": revision_id,
"status": status,
"created_at": now_iso(),
"request_path": request_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"cdsl_path": cdsl_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"report_path": report_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"parameters_path": parameters_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"part_skills_path": part_skills_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"quality_path": quality_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"snapshot_manifest_path": snapshot_manifest_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"snapshot_status": "unavailable",
"snapshot_paths": [snapshot_manifest_path.relative_to(store.task_dir(task["task_id"])).as_posix()],
"part_skill_ids": list(part_skill_audit.get("skill_ids") or []),
"generation_assumptions": list(part_skill_audit.get("assumptions") or []),
"reference_ids": reference_ids,
"summary": summary,
"parent_revision_id": parent_revision_id or "",
"operation": operation or {},
"input_attachments": input_attachments or [],
"repair_attempts": max(0, int(repair_attempts)),
}
if status == "success":
record.update({
"step_path": step_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"glb_path": glb_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"selector_path": selector_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"edges_path": edges_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"engine": engine_name,
})
else:
record["error"] = error
return record
quality_report: dict[str, Any] | None = None
quality_status = ""
try:
cdsl_copy = copy.deepcopy(cdsl)
if not isinstance(cdsl_copy, dict):
raise ValueError("CDSL must be a JSON object")
cdsl_copy["part_id"] = task["task_id"]
meta = cdsl_copy.setdefault("meta", {})
if not isinstance(meta, dict):
raise ValueError("CDSL meta must be an object when present")
if not isinstance(meta.get("editable_parameters"), list) or not meta["editable_parameters"]:
meta["editable_parameters"] = _derived_parameters(cdsl_copy)
write_json(cdsl_path, cdsl_copy)
write_json(parameters_path, parameter_contract(cdsl_copy))
validate_cdsl(cdsl_copy, engine)
# Product revisions are semantic CDSL artifacts. Do not route them
# through the legacy rebuild entry point, which is allowed to use
# compiler_context/translator compatibility fallbacks.
engine_result = engine.run_cdsl_only(cdsl_copy, step_path)
if engine_result.get("engine") != "cdsl_only" or not step_path.is_file() or step_path.stat().st_size == 0:
raise RuntimeError("Engine did not produce a CDSL-only STEP artifact")
preview = step_to_glb(step_path, glb_path)
selector, edges = topology_sidecars(engine_result, preview)
write_json(selector_path, selector)
write_json(edges_path, edges)
rules = validate_verification(verification, cdsl_copy)
quality_report = evaluate_quality(rules, cdsl_copy, engine_result)
quality_report["evaluated_at"] = now_iso()
write_json(quality_path, quality_report)
quality_status = (
"accepted" if rules and quality_report["status"] == "passed"
else "built_with_warnings" if quality_report["status"] == "passed"
else "needs_repair"
)
if quality_report["status"] != "passed":
raise QualityVerificationError(quality_report)
report = {
"engine_result": engine_result,
"preview": preview,
"generation_context": generation_context,
"validated_at": now_iso(),
}
write_json(report_path, report)
revision = revision_record("success", engine_name=str(engine_result["engine"]))
revision["quality_status"] = quality_status or "accepted"
revision["verification_summary"] = {
"requested": bool(rules),
"blocking_failures": len(quality_report.get("blocking_failures") or []),
"warnings": len(quality_report.get("warnings") or []),
}
except Exception as error:
if not isinstance(error, QualityVerificationError):
# The failed candidate is retained in the conversation diagnostics,
# not as a task revision. Runtime/schema failures must not create
# an editable revision that looks like a model version.
shutil.rmtree(revision_dir, ignore_errors=True)
raise
if not cdsl_path.is_file() and isinstance(cdsl, dict):
write_json(cdsl_path, copy.deepcopy(cdsl))
if quality_report is not None and not quality_path.is_file():
write_json(quality_path, quality_report)
write_json(report_path, {
"error": str(error),
"generation_context": generation_context,
"quality": quality_report,
"validated_at": now_iso(),
})
revision = revision_record("needs_repair" if quality_report is not None else "failed", error=str(error))
revision["quality_status"] = quality_status or ("needs_repair" if quality_report else "failed")
if quality_report is not None:
revision["verification_summary"] = {
"requested": bool(quality_report.get("verification_requested")),
"blocking_failures": len(quality_report.get("blocking_failures") or []),
"warnings": len(quality_report.get("warnings") or []),
}
store.update_task(task["task_id"], revision)
error.task_id = task["task_id"]
error.revision_id = revision_id
raise
store.update_task(task["task_id"], revision)
return {"task_id": task["task_id"], **revision}