from __future__ import annotations import asyncio from typing import Any from fastapi import FastAPI, File, HTTPException, UploadFile from fastapi.responses import JSONResponse, StreamingResponse from app.models.contracts import ChatRequest, ConversationPatch 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 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 renderer_status 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_autonomous_generation() -> None: """Prewarm model protocols without delaying API readiness.""" asyncio.create_task(agent.resume_running_tasks(), name="cad-model-protocol-prewarm") @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, } 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(), "autonomous_generation": settings.autonomous_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 = agent.v3.repository.get_task_projection(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: safe_id = safe_task_id(task_id) task = agent.v3.repository.get_task_projection(safe_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") task["preview_revision"] = str(task.get("active_revision") or task.get("current_revision") or "") state = agent.v3.repository.get_state(safe_id) task["requirements_spec"] = agent.v3.artifacts.read_requirements_spec(safe_id, state.requirements_spec_path) if state is not None else None task["requirements_contract"] = agent.v3.artifacts.read_requirements_contract(safe_id, state.requirements_contract_path) if state is not None else None task["claim_summary"] = _claim_summary(task["requirements_contract"], task.get("action_ledger_summary")) task["checklist_progress"] = _checklist_progress(task["requirements_contract"], task["claim_summary"]) requirements_path = agent.v3.artifacts.artifact_path(safe_id, state.requirements_document_path) if state is not None and state.requirements_document_path else None task["requirements_markdown"] = requirements_path.read_text(encoding="utf-8") if requirements_path and requirements_path.is_file() else None target_path = agent.v3.artifacts.artifact_path(safe_id, state.completion_target_path) if state is not None and state.completion_target_path else None plan_path = agent.v3.artifacts.artifact_path(safe_id, state.modeling_plan_path) if state is not None and state.modeling_plan_path else None result_path = agent.v3.artifacts.task_dir(safe_id) / "completion-result.md" task["completion_target_markdown"] = target_path.read_text(encoding="utf-8") if target_path and target_path.is_file() else None task["completion_target_path"] = state.completion_target_path if target_path and target_path.is_file() else "" task["modeling_plan_markdown"] = plan_path.read_text(encoding="utf-8") if plan_path and plan_path.is_file() else None task["modeling_plan_path"] = state.modeling_plan_path if plan_path and plan_path.is_file() else "" task["completion_result_markdown"] = result_path.read_text(encoding="utf-8") if result_path.is_file() else None task["completion_result_path"] = "completion-result.md" if result_path.is_file() else "" task["usage"] = agent.v3.repository.usage_summary(safe_id) return JSONResponse(task) def _claim_summary(contract: dict[str, Any] | None, ledger: Any) -> list[dict[str, Any]]: """Project frozen claims with the newest committed verification evidence. This is API-only data derived from SQLite-backed ledger entries and the state-referenced frozen contract. It never parses Markdown or exposes a staged candidate as a task checkpoint. """ latest: dict[str, dict[str, Any]] = {} for event in reversed(ledger if isinstance(ledger, list) else []): if not isinstance(event, dict) or event.get("event") not in {"accepted", "completed"}: continue values = event.get("claim_results") if not isinstance(values, list): values = event.get("coverage") if not isinstance(values, list): continue for value in values: if isinstance(value, dict) and isinstance(value.get("claim_id"), str) and value["claim_id"] not in latest: latest[value["claim_id"]] = value result: list[dict[str, Any]] = [] for requirement in (contract or {}).get("requirements") or (): if not isinstance(requirement, dict): continue for claim in requirement.get("acceptance_claims") or (): if not isinstance(claim, dict) or not isinstance(claim.get("claim_id"), str): continue evidence = latest.get(claim["claim_id"], {}) result.append({ "requirement_id": str(requirement.get("requirement_id") or ""), "claim_id": claim["claim_id"], "claim_kind": str(claim.get("claim_kind") or ""), "deterministic": claim.get("verification_mode") == "deterministic", "status": str(evidence.get("status") or "pending"), "evidence": evidence.get("evidence") if isinstance(evidence.get("evidence"), dict) else {}, }) return result def _checklist_progress(contract: dict[str, Any] | None, claims: list[dict[str, Any]]) -> list[dict[str, Any]]: """Expose server-evaluated checklist progress without parsing Markdown.""" by_claim = {str(item.get("claim_id") or ""): str(item.get("status") or "pending") for item in claims if isinstance(item, dict)} progress: list[dict[str, Any]] = [] for requirement in (contract or {}).get("requirements") or (): if not isinstance(requirement, dict): continue statuses = [by_claim.get(str(claim.get("claim_id") or ""), "pending") for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict)] progress.append({ "requirement_id": str(requirement.get("requirement_id") or ""), "statement": str(requirement.get("statement") or ""), "status": "pass" if statuses and all(status == "pass" for status in statuses) else "fail" if "fail" in statuses or "unavailable" in statuses else "pending", }) return progress @app.delete("/v1/tasks/{task_id}") async def cancel_task(task_id: str) -> JSONResponse: try: safe_id = safe_task_id(task_id) task = await agent.cancel(safe_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") return JSONResponse(task) @app.post("/v1/tasks/{task_id}/resume") async def resume_task(task_id: str) -> JSONResponse: """Explicit recovery for a bounded infrastructure retry. The workflow resumes its persisted source phase rather than inferring an action from requirements, and never treats ``WAITING_FOR_USER`` as a retryable service error. """ try: safe_id = safe_task_id(task_id) except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error try: task = await agent.resume_retry(safe_id) except ValueError as error: raise HTTPException(status_code=409, detail=str(error)) from error except Exception as error: raise HTTPException(status_code=503, detail=f"CAD retry recovery is temporarily unavailable: {str(error)[:500]}") from error if task is None: raise HTTPException(status_code=404, detail="Task not found") 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 = agent.v3.artifacts.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 = agent.v3.repository.get_task_projection(safe_id) or {} parts = artifact_path.split("/") revision_id = parts[1] if len(parts) >= 3 and parts[0] == "revisions" else "" published_revision = str(task.get("active_revision") or "") if str(task.get("lifecycle") or "") == "completed" else "" active_revision = str(task.get("active_revision") or task.get("current_revision") or "") if not revision_id: # The task directory also contains candidate staging, agent audit and # frozen-input files. None of those are a public artifact surface. raise HTTPException(status_code=403, detail="This task artifact is not public") if revision_id == published_revision: published_paths = { f"revisions/{revision_id}/model.cdsl.json", f"revisions/{revision_id}/model.step", f"revisions/{revision_id}/model.glb", f"revisions/{revision_id}/rebuild-report.json", } if artifact_path not in published_paths: raise HTTPException(status_code=403, detail="Only final delivery artifacts are downloadable") return FileResponse(path, filename=path.name) # A running task may render its current checkpoint in the browser, but # cannot expose any other checkpoint artifact or a failed-task preview. if ( str(task.get("lifecycle") or "") != "running" or revision_id != active_revision or artifact_path != f"revisions/{revision_id}/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"})