重构 改为标准化输出
This commit is contained in:
+82
-22
@@ -123,7 +123,7 @@ async def upload_conversation_attachment(
|
||||
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
|
||||
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))
|
||||
@@ -144,25 +144,63 @@ async def upload_conversation_attachment(
|
||||
@app.get("/v1/tasks/{task_id}")
|
||||
async def read_task(task_id: str) -> JSONResponse:
|
||||
try:
|
||||
task = store.read_task(safe_task_id(task_id))
|
||||
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")
|
||||
# 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 "")
|
||||
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
|
||||
modeling_plan = store.read_modeling_plan(task["task_id"])
|
||||
task["modeling_plan_markdown"] = modeling_plan or None
|
||||
task["modeling_plan_review"] = store.read_modeling_plan_review(task["task_id"])
|
||||
task["agent_state"] = store.read_agent_state(task["task_id"])
|
||||
state = agent.v3.repository.get_state(safe_id)
|
||||
draft = agent.v3.artifacts.read_requirements_draft(safe_id, state.requirements_draft_path) if state is not None else None
|
||||
task["requirements_draft"] = draft
|
||||
task["requirements_review"] = agent.v3.artifacts.read_requirements_review(safe_id, state.requirements_review_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
|
||||
task["completion_markdown"] = (agent.v3.artifacts.task_dir(safe_id) / "completion.md").read_text(encoding="utf-8") if (agent.v3.artifacts.task_dir(safe_id) / "completion.md").is_file() else None
|
||||
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:
|
||||
@@ -175,34 +213,56 @@ async def cancel_task(task_id: str) -> JSONResponse:
|
||||
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 = store.artifact_path(safe_id, artifact_path)
|
||||
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 = store.read_task(safe_id) or {}
|
||||
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 ""
|
||||
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 "")
|
||||
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 isinstance(revision, dict):
|
||||
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 = {
|
||||
str(revision.get("cdsl_path") or ""),
|
||||
str(revision.get("step_path") or ""),
|
||||
str(revision.get("glb_path") or ""),
|
||||
str(revision.get("report_path") or ""),
|
||||
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")
|
||||
@@ -213,7 +273,7 @@ async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse:
|
||||
if (
|
||||
str(task.get("lifecycle") or "") != "running"
|
||||
or revision_id != active_revision
|
||||
or artifact_path != str(revision.get("glb_path") or "")
|
||||
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"})
|
||||
|
||||
Reference in New Issue
Block a user