修改agent流程,分步构建cdsl

This commit is contained in:
2026-08-27 18:59:33 +08:00
parent 7fc1dfa84d
commit b26db86d78
54 changed files with 6835 additions and 9984 deletions
+38 -242
View File
@@ -1,22 +1,18 @@
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 QualityVerificationError, apply_parameter_updates, build_revision
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, write_json
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 ReviewRenderError, render_checkpoint, renderer_status
from app.services.visual_review import VisualReviewError, review_checkpoint
from app.services.review_renderer import renderer_status
from app.settings import get_settings
@@ -28,96 +24,10 @@ 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,
})
async def resume_autonomous_generation() -> None:
"""Restore durable autonomous agent tasks after a backend process restart."""
if settings.resume_running_tasks_on_startup:
await agent.resume_running_tasks()
@app.get("/health")
@@ -143,7 +53,6 @@ async def config() -> dict[str, Any]:
{
"id": model.id,
"vision": model.vision,
"strict_tool_schema": model.strict_tool_schema,
}
for model in provider.models
],
@@ -161,8 +70,7 @@ async def config() -> dict[str, Any]:
"model": settings.llm_model,
"configured": settings.llm_configured,
"library_samples": library.count(),
"max_repair_attempts": settings.max_repair_attempts,
"incremental_generation": settings.incremental_generation,
"autonomous_generation": settings.autonomous_generation,
"review_configured": not review_error,
"review_error": review_error,
}
@@ -241,11 +149,14 @@ 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.
# Keep the task endpoint self-contained for a reconnecting UI. Requirements
# are immutable during a run; the active revision remains preview-only.
task["preview_revision"] = str(task.get("active_revision") or task.get("current_revision") or "")
task["generation_plan"] = store.read_generation_spec(task["task_id"])
requirements = store.read_requirements_document(task["task_id"])
task["requirements_markdown"] = requirements or None
completion_checklist = store.read_completion_checklist(task["task_id"])
task["completion_checklist_markdown"] = completion_checklist or None
task["agent_state"] = store.read_agent_state(task["task_id"])
return JSONResponse(task)
@@ -266,143 +177,28 @@ async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse:
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)
if not isinstance(revision, dict):
# 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 = {
str(revision.get("cdsl_path") or ""),
str(revision.get("step_path") or ""),
str(revision.get("glb_path") or ""),
str(revision.get("report_path") or ""),
}
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)
@app.get("/v1/tasks/{task_id}/parameters")
async def read_parameters(task_id: str) -> JSONResponse:
try:
safe_id = safe_task_id(task_id)
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 "")
if not relative:
raise HTTPException(status_code=404, detail="No editable parameters exist for this task")
path = store.artifact_path(safe_id, relative)
if not path.is_file():
raise HTTPException(status_code=404, detail="Parameter contract not found")
return JSONResponse({"task_id": safe_id, "revision_id": revision_id, **json.loads(path.read_text(encoding="utf-8"))})
@app.get("/v1/tasks/{task_id}/quality")
async def read_quality(task_id: str) -> JSONResponse:
try:
safe_id = safe_task_id(task_id)
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)
if revisions and revisions[-1].get("revision_id") != revision_id:
revision = revisions[-1]
revision_id = str(revision.get("revision_id") or "")
payload: dict[str, Any] = {
"task_id": safe_id,
"revision_id": revision_id,
"quality_status": (revision or {}).get("quality_status", ""),
"snapshot_status": (revision or {}).get("snapshot_status", "unavailable"),
"snapshot_paths": (revision or {}).get("snapshot_paths", []),
"assumptions": (revision or {}).get("generation_assumptions", []),
"verification_summary": (revision or {}).get("verification_summary", {}),
}
relative = str((revision or {}).get("quality_path") or "")
if relative:
path = store.artifact_path(safe_id, relative)
if path.is_file():
payload["quality"] = json.loads(path.read_text(encoding="utf-8"))
if not revision:
raise HTTPException(status_code=404, detail="Task has no revision")
return JSONResponse(payload)
@app.post("/v1/tasks/{task_id}/parameters")
async def update_parameters(task_id: str, payload: ParameterUpdate) -> JSONResponse:
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,
task_id=safe_id,
request=f"Parameter update: {', '.join(payload.values)}",
cdsl=updated,
reference_ids=[],
summary="Updated CDSL parameters",
parent_revision_id=current_revision_id,
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, 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
@app.post("/v1/tasks/{task_id}/modify")
async def modify_task(task_id: str, payload: ModifyRequest) -> JSONResponse:
from app.services.editing import apply_direct_edit
try:
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, 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
# 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 != str(revision.get("glb_path") or "")
):
raise HTTPException(status_code=403, detail="Only the published revision is downloadable")
return FileResponse(path, media_type="model/gltf-binary", headers={"Content-Disposition": "inline"})