282 lines
13 KiB
Python
282 lines
13 KiB
Python
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["requirements_markdown"] = (agent.v3.artifacts.task_dir(safe_id) / "requirements.md").read_text(encoding="utf-8") if (agent.v3.artifacts.task_dir(safe_id) / "requirements.md").is_file() else None
|
|
target_path = agent.v3.artifacts.task_dir(safe_id) / "completion-target.md"
|
|
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.is_file() else None
|
|
task["completion_target_path"] = "completion-target.md" if target_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
|
|
|
|
|
|
@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"})
|