更新功能
This commit is contained in:
+181
-7
@@ -1,17 +1,22 @@
|
||||
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 apply_parameter_updates, build_revision
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@@ -22,6 +27,99 @@ 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 {
|
||||
@@ -50,6 +148,12 @@ async def config() -> dict[str, Any]:
|
||||
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,
|
||||
@@ -58,6 +162,9 @@ async def config() -> dict[str, Any]:
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
@@ -104,8 +211,13 @@ async def upload_conversation_attachment(
|
||||
filename = file.filename or "attachment"
|
||||
try:
|
||||
conversation = safe_conversation_id(conversation_id)
|
||||
if store.read_conversation(conversation) is None:
|
||||
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 = ""
|
||||
@@ -113,7 +225,8 @@ async def upload_conversation_attachment(
|
||||
extracted_path = relative_path + ".txt"
|
||||
extracted = extract_document_text(data)
|
||||
store.conversation_attachment_path(conversation, extracted_path).write_text(extracted, encoding="utf-8")
|
||||
record = attachment_record(conversation, filename, file.content_type or "", relative_path, data, kind, extracted_path)
|
||||
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:
|
||||
@@ -128,6 +241,11 @@ async def read_task(task_id: str) -> JSONResponse:
|
||||
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)
|
||||
|
||||
|
||||
@@ -142,6 +260,19 @@ async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse:
|
||||
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)
|
||||
|
||||
|
||||
@@ -152,6 +283,8 @@ async def read_parameters(task_id: str) -> JSONResponse:
|
||||
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 "")
|
||||
@@ -170,6 +303,8 @@ async def read_quality(task_id: str) -> JSONResponse:
|
||||
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)
|
||||
@@ -200,11 +335,16 @@ async def update_parameters(task_id: str, payload: ParameterUpdate) -> JSONRespo
|
||||
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,
|
||||
@@ -217,9 +357,21 @@ async def update_parameters(task_id: str, payload: ParameterUpdate) -> JSONRespo
|
||||
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 as error:
|
||||
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
|
||||
|
||||
|
||||
@@ -228,7 +380,29 @@ async def modify_task(task_id: str, payload: ModifyRequest) -> JSONResponse:
|
||||
from app.services.editing import apply_direct_edit
|
||||
|
||||
try:
|
||||
result = apply_direct_edit(settings, store, safe_task_id(task_id), payload.operation, payload.selection, payload.parameters)
|
||||
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 as error:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user