更新功能

This commit is contained in:
2026-08-26 14:13:11 +08:00
parent beaa5dd9fe
commit a508e6f07e
47 changed files with 5616 additions and 520 deletions
+181 -7
View File
@@ -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