from __future__ import annotations import asyncio import json import secrets from typing import Any from fastapi import FastAPI, File, HTTPException, UploadFile from fastapi.responses import JSONResponse, StreamingResponse from app.models.contracts import ChatRequest, ConversationPatch, ModifyRequest, ParameterUpdate from app.services.engine_service import QualityVerificationError, apply_parameter_updates, build_revision from app.services.agent_service import AgentService from app.services.library import CdslLibrary from app.services.storage import WorkspaceStore, safe_conversation_id, safe_task_id, write_json from app.services.attachments import attachment_record, classify_upload, extract_document_text from app.services.image_processing import image_metadata from app.services.review_renderer import ReviewRenderError, render_checkpoint, renderer_status from app.services.visual_review import VisualReviewError, review_checkpoint from app.settings import get_settings settings = get_settings() store = WorkspaceStore(settings) library = CdslLibrary(settings) agent = AgentService(settings, store, library) app = FastAPI(title="CDSL CAD Agent API", version="0.1.0") @app.on_event("startup") async def resume_incremental_generation() -> None: """Restore durable generation tasks after a backend process restart.""" await agent.resume_running_tasks() async def _finalize_controlled_revision( *, task_id: str, previous_revision_id: str, node_id: str, built: dict[str, Any], ) -> dict[str, Any]: """Publish a deterministic post-completion edit only after vision review.""" revision_id = str(built["revision_id"]) try: generation_spec = store.read_generation_spec(task_id) or {} requirements = generation_spec.get("requirements") if isinstance(generation_spec.get("requirements"), list) else [] render_dir = store.revision_dir(task_id, revision_id) / "review" manifest = await asyncio.to_thread( render_checkpoint, settings, step_path=store.artifact_path(task_id, str(built["step_path"])), output_dir=render_dir, ) review = await review_checkpoint( settings, manifest=manifest, requirements=requirements, node_id=node_id, deterministic_report={ "quality_status": built.get("quality_status"), "verification": built.get("verification_summary", {}), }, final_checkpoint=True, ) manifest_path = (render_dir / "render-manifest.json").relative_to(store.task_dir(task_id)).as_posix() review_path = (render_dir / "visual-review.json").relative_to(store.task_dir(task_id)).as_posix() write_json(render_dir / "visual-review.json", review) store.update_revision_metadata(task_id, revision_id, { "render_manifest_path": manifest_path, "visual_review_path": review_path, }) if review["verdict"] == "repair" and float(review["confidence"]) >= 0.85: store.rollback_to_revision(task_id, previous_revision_id, branch_id=f"branch_{secrets.token_hex(4)}") store.finish_generation(task_id, lifecycle="failed", failure={ "schema_version": "cad.generation-failure.v1", "node_id": node_id, "stage": "visual_review", "error_code": "HIGH_CONFIDENCE_VISUAL_REPAIR", "message": "; ".join(review.get("evidence") or ["Visual review rejected the controlled edit"]), "recommended_rollback_revision": previous_revision_id, }) raise ValueError("Visual review rejected this edit; the model was rolled back to its previous revision") store.finish_generation(task_id, lifecycle="completed") return {**built, "visibility": "final", "lifecycle": "completed", "checkpoint": False} except (ReviewRenderError, VisualReviewError, ValueError): task = store.read_task(task_id) or {} if str(task.get("lifecycle") or "") == "running": store.rollback_to_revision(task_id, previous_revision_id, branch_id=f"branch_{secrets.token_hex(4)}") store.finish_generation(task_id, lifecycle="failed", failure={ "schema_version": "cad.generation-failure.v1", "node_id": node_id, "stage": "visual_review", "message": "Controlled edit could not complete its required review", "recommended_rollback_revision": previous_revision_id, }) raise def _require_controlled_review_configuration() -> None: """Fail before a post-completion edit creates an unreviewed checkpoint.""" settings.resolve_review_model() ready, detail = renderer_status() if not ready: raise ValueError(detail) def _fail_controlled_run(task_id: str, previous_revision_id: str, node_id: str, error: Exception) -> None: task = store.read_task(task_id) or {} if str(task.get("lifecycle") or "") != "running": return store.rollback_to_revision(task_id, previous_revision_id, branch_id=f"branch_{secrets.token_hex(4)}") store.finish_generation(task_id, lifecycle="failed", failure={ "schema_version": "cad.generation-failure.v1", "node_id": node_id, "stage": "controlled_build", "error_code": type(error).__name__.upper(), "message": str(error), "recommended_rollback_revision": previous_revision_id, }) @app.get("/health") async def health() -> dict[str, Any]: return { "ok": True, "service": "cdsl-cad-backend", "llm_configured": settings.llm_configured, "library_index": (settings.library_root / "index" / "catalog.json").is_file(), } @app.get("/v1/config") async def config() -> dict[str, Any]: providers = [] for provider in settings.providers: if not provider.configured: continue providers.append({ "id": provider.id, "label": provider.label, "models": [ { "id": model.id, "vision": model.vision, "strict_tool_schema": model.strict_tool_schema, } for model in provider.models ], }) try: settings.resolve_review_model() renderer_ready, renderer_detail = renderer_status() review_error = "" if renderer_ready else renderer_detail except ValueError as error: review_error = str(error) return { "default_provider": settings.default_provider_id, "default_model": settings.llm_model, "providers": providers, "model": settings.llm_model, "configured": settings.llm_configured, "library_samples": library.count(), "max_repair_attempts": settings.max_repair_attempts, "incremental_generation": settings.incremental_generation, "review_configured": not review_error, "review_error": review_error, } @app.post("/v1/chat/stream") async def chat_stream(payload: ChatRequest) -> StreamingResponse: return StreamingResponse( agent.stream(payload.messages, payload.conversation_id, payload.selected_task_id, payload.provider_id, payload.model_id, payload.viewer_context), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) @app.get("/v1/conversations/{conversation_id}") async def read_conversation(conversation_id: str) -> JSONResponse: try: record = store.read_conversation(safe_conversation_id(conversation_id)) except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error if record is None: raise HTTPException(status_code=404, detail="Conversation not found") return JSONResponse(record) @app.post("/v1/conversations") async def create_conversation() -> JSONResponse: return JSONResponse(store.ensure_conversation(None)) @app.patch("/v1/conversations/{conversation_id}") async def patch_conversation(conversation_id: str, payload: ConversationPatch) -> JSONResponse: try: record = store.ensure_conversation(safe_conversation_id(conversation_id), payload.current_task_id) except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error return JSONResponse(record) @app.post("/v1/conversations/{conversation_id}/attachments") async def upload_conversation_attachment( conversation_id: str, file: UploadFile = File(...), ) -> JSONResponse: data = await file.read() filename = file.filename or "attachment" try: conversation = safe_conversation_id(conversation_id) current = store.read_conversation(conversation) if current is None: raise HTTPException(status_code=404, detail="Conversation not found") active_task_id = str(current.get("current_task_id") or "") active_task = store.read_task(active_task_id) if active_task_id else None if str((active_task or {}).get("lifecycle") or "") == "running": raise HTTPException(status_code=409, detail="CAD task is running; attachments are locked until it reaches a terminal state") kind = classify_upload(filename, file.content_type or "", len(data)) relative_path, _ = store.write_conversation_upload(conversation, filename, data) extracted_path = "" if kind == "document": extracted_path = relative_path + ".txt" extracted = extract_document_text(data) store.conversation_attachment_path(conversation, extracted_path).write_text(extracted, encoding="utf-8") metadata = image_metadata(data) if kind == "image" else {} record = attachment_record(conversation, filename, file.content_type or "", relative_path, data, kind, extracted_path, metadata) store.add_conversation_attachment(conversation, record) return JSONResponse(record) except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error @app.get("/v1/tasks/{task_id}") async def read_task(task_id: str) -> JSONResponse: try: task = store.read_task(safe_task_id(task_id)) except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error if task is None: raise HTTPException(status_code=404, detail="Task not found") # Keep the task endpoint self-contained for a reconnecting UI. The plan is # immutable within a run and exposes node status, while previews always use # the active working revision rather than a downloadable artifact. task["preview_revision"] = str(task.get("active_revision") or task.get("current_revision") or "") task["generation_plan"] = store.read_generation_spec(task["task_id"]) return JSONResponse(task) @app.get("/v1/tasks/{task_id}/artifacts/{artifact_path:path}") async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse: from fastapi.responses import FileResponse try: safe_id = safe_task_id(task_id) path = store.artifact_path(safe_id, artifact_path) except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error if not path.is_file(): raise HTTPException(status_code=404, detail="Artifact not found") task = store.read_task(safe_id) or {} parts = artifact_path.split("/") revision_id = parts[1] if len(parts) >= 3 and parts[0] == "revisions" else "" revision = next((item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id), None) published_revision = str(task.get("published_revision") or "") active_revision = str(task.get("active_revision") or task.get("current_revision") or "") if isinstance(revision, dict) and revision_id != published_revision: # Revisions are private until publication. The currently active # checkpoint exposes only its GLB inline for the review viewer; an old # or superseded checkpoint has no public artifact surface at all. if revision_id != active_revision or path.name != "model.glb": raise HTTPException(status_code=403, detail="Only the published revision is downloadable") return FileResponse(path, media_type="model/gltf-binary", headers={"Content-Disposition": "inline"}) return FileResponse(path, filename=path.name) @app.get("/v1/tasks/{task_id}/parameters") async def read_parameters(task_id: str) -> JSONResponse: try: safe_id = safe_task_id(task_id) except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error task = store.read_task(safe_id) if str((task or {}).get("published_revision") or "") != str((task or {}).get("current_revision") or ""): raise HTTPException(status_code=403, detail="Checkpoint parameters are not available until publication") revision_id = str((task or {}).get("current_revision") or "") revision = next((item for item in (task or {}).get("revisions", []) if item.get("revision_id") == revision_id), None) relative = str((revision or {}).get("parameters_path") or "") if not relative: raise HTTPException(status_code=404, detail="No editable parameters exist for this task") path = store.artifact_path(safe_id, relative) if not path.is_file(): raise HTTPException(status_code=404, detail="Parameter contract not found") return JSONResponse({"task_id": safe_id, "revision_id": revision_id, **json.loads(path.read_text(encoding="utf-8"))}) @app.get("/v1/tasks/{task_id}/quality") async def read_quality(task_id: str) -> JSONResponse: try: safe_id = safe_task_id(task_id) except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error task = store.read_task(safe_id) if str((task or {}).get("published_revision") or "") != str((task or {}).get("current_revision") or ""): raise HTTPException(status_code=403, detail="Checkpoint reports are not available until publication") revision_id = str((task or {}).get("current_revision") or "") revisions = (task or {}).get("revisions", []) revision = next((item for item in revisions if item.get("revision_id") == revision_id), None) if revisions and revisions[-1].get("revision_id") != revision_id: revision = revisions[-1] revision_id = str(revision.get("revision_id") or "") payload: dict[str, Any] = { "task_id": safe_id, "revision_id": revision_id, "quality_status": (revision or {}).get("quality_status", ""), "snapshot_status": (revision or {}).get("snapshot_status", "unavailable"), "snapshot_paths": (revision or {}).get("snapshot_paths", []), "assumptions": (revision or {}).get("generation_assumptions", []), "verification_summary": (revision or {}).get("verification_summary", {}), } relative = str((revision or {}).get("quality_path") or "") if relative: path = store.artifact_path(safe_id, relative) if path.is_file(): payload["quality"] = json.loads(path.read_text(encoding="utf-8")) if not revision: raise HTTPException(status_code=404, detail="Task has no revision") return JSONResponse(payload) @app.post("/v1/tasks/{task_id}/parameters") async def update_parameters(task_id: str, payload: ParameterUpdate) -> JSONResponse: try: safe_id = safe_task_id(task_id) task = store.read_task(safe_id) if str((task or {}).get("lifecycle") or "") == "running": raise ValueError("CAD task is running; parameter changes are locked") current_revision_id = str((task or {}).get("current_revision") or "") current_path = store.current_cdsl_path(safe_id) if not task or not current_path or not current_revision_id: raise ValueError("Task has no successful CDSL revision") updated, _ = apply_parameter_updates(json.loads(current_path.read_text(encoding="utf-8")), payload.values) if settings.incremental_generation: _require_controlled_review_configuration() store.start_generation(safe_id, request=f"Parameter update: {', '.join(payload.values)}") result = build_revision( settings=settings, store=store, task_id=safe_id, request=f"Parameter update: {', '.join(payload.values)}", cdsl=updated, reference_ids=[], summary="Updated CDSL parameters", parent_revision_id=current_revision_id, operation={"type": "parameter_update", "values": payload.values}, part_skills=None, generation_assumptions=[], node_id="parameter_update" if settings.incremental_generation else "", branch_id=f"branch_{secrets.token_hex(4)}" if settings.incremental_generation else "main", visibility="checkpoint" if settings.incremental_generation else "final", ) if settings.incremental_generation: result = await _finalize_controlled_revision( task_id=safe_id, previous_revision_id=current_revision_id, node_id="parameter_update", built=result, ) return JSONResponse(result) except (ValueError, QualityVerificationError, ReviewRenderError, VisualReviewError, RuntimeError) as error: if settings.incremental_generation and "safe_id" in locals() and "current_revision_id" in locals(): _fail_controlled_run(safe_id, current_revision_id, "parameter_update", error) raise HTTPException(status_code=400, detail=str(error)) from error @app.post("/v1/tasks/{task_id}/modify") async def modify_task(task_id: str, payload: ModifyRequest) -> JSONResponse: from app.services.editing import apply_direct_edit try: safe_id = safe_task_id(task_id) task = store.read_task(safe_id) or {} if str(task.get("lifecycle") or "") == "running": raise ValueError("CAD task is running; topology edits are locked") previous_revision_id = str(task.get("current_revision") or "") if settings.incremental_generation: _require_controlled_review_configuration() store.start_generation(safe_id, request=f"Direct CDSL edit: {payload.operation}") result = apply_direct_edit( settings, store, safe_id, payload.operation, payload.selection, payload.parameters, node_id="topology_edit" if settings.incremental_generation else "", branch_id=f"branch_{secrets.token_hex(4)}" if settings.incremental_generation else "main", visibility="checkpoint" if settings.incremental_generation else "final", ) if settings.incremental_generation: result = await _finalize_controlled_revision( task_id=safe_id, previous_revision_id=previous_revision_id, node_id="topology_edit", built=result, ) return JSONResponse(result) except (ValueError, QualityVerificationError, ReviewRenderError, VisualReviewError, RuntimeError) as error: if settings.incremental_generation and "safe_id" in locals() and "previous_revision_id" in locals(): _fail_controlled_run(safe_id, previous_revision_id, "topology_edit", error) raise HTTPException(status_code=400, detail=str(error)) from error