Files

235 lines
10 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.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
],
})
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,
}
@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.cad.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.cad.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 "")
state = agent.cad.repository.get_state(safe_id)
task["requirements_analysis"] = agent.cad.artifacts.read_json(safe_id, state.requirements_path) if state and state.requirements_path else None
task["authoring_cdsl"] = agent.cad.artifacts.read_json(safe_id, state.authoring_path) if state and state.authoring_path else None
task["runtime_cdsl"] = agent.cad.artifacts.read_json(safe_id, state.runtime_cdsl_path) if state and state.runtime_cdsl_path else None
task["compile_audit"] = agent.cad.artifacts.read_json(safe_id, state.compile_audit_path) if state and state.compile_audit_path else None
task["diagnostics"] = agent.cad.artifacts.read_json(safe_id, state.diagnostics_path) if state and state.diagnostics_path else None
task["claim_report"] = agent.cad.artifacts.read_json(safe_id, "documents/claim-report.json")
result_path = agent.cad.artifacts.artifact_path(safe_id, state.completion_path) if state and state.completion_path else None
task["completion_result_markdown"] = result_path.read_text(encoding="utf-8") if result_path and result_path.is_file() else None
task["usage"] = agent.cad.repository.usage_summary(safe_id)
return JSONResponse(task)
@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.cad.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.cad.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("published_revision") or "")
active_revision = str(task.get("active_revision") or task.get("current_revision") or "")
if not revision_id:
if artifact_path == "completion-result.md" and str(task.get("completion_path") or "") == artifact_path:
return FileResponse(path, filename=path.name)
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",
f"revisions/{revision_id}/build-diagnostics.json",
f"revisions/{revision_id}/renders/render-manifest.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 can render its active executable prefix, but its staging
# inputs and diagnostics remain private until publication.
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"})