chore: save current work
This commit is contained in:
@@ -39,6 +39,7 @@ class FileArtifactStore:
|
|||||||
request: str,
|
request: str,
|
||||||
*,
|
*,
|
||||||
source_blocks: list[dict[str, Any]] | None = None,
|
source_blocks: list[dict[str, Any]] | None = None,
|
||||||
|
image_inputs: list[dict[str, str]] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
root = self.task_dir(task_id)
|
root = self.task_dir(task_id)
|
||||||
(root / "documents").mkdir(parents=True, exist_ok=True)
|
(root / "documents").mkdir(parents=True, exist_ok=True)
|
||||||
@@ -50,6 +51,21 @@ class FileArtifactStore:
|
|||||||
if not source.exists():
|
if not source.exists():
|
||||||
self._write_once(source, "\n\n".join(block["text"] for block in blocks) + "\n")
|
self._write_once(source, "\n\n".join(block["text"] for block in blocks) + "\n")
|
||||||
self.write_source_index(task_id, request, source_blocks=blocks)
|
self.write_source_index(task_id, request, source_blocks=blocks)
|
||||||
|
images: list[dict[str, str]] = []
|
||||||
|
for position, item in enumerate(image_inputs or (), 1):
|
||||||
|
source_path = Path(str(item.get("path") or "")).resolve()
|
||||||
|
digest = str(item.get("sha256") or "")
|
||||||
|
if not source_path.is_file() or not re.fullmatch(r"[a-f0-9]{64}", digest):
|
||||||
|
raise ValueError("Image input is unavailable or has no valid checksum")
|
||||||
|
data = source_path.read_bytes()
|
||||||
|
if sha256(data).hexdigest() != digest:
|
||||||
|
raise ValueError("Image input checksum mismatch")
|
||||||
|
suffix = source_path.suffix.lower() if source_path.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp"} else ".bin"
|
||||||
|
relative = f"inputs/reference-{position:03d}{suffix}"
|
||||||
|
self._write_bytes_once(self._path(task_id, relative), data)
|
||||||
|
images.append({"path": relative, "mime": str(item.get("mime") or "image/*"), "sha256": digest})
|
||||||
|
if images:
|
||||||
|
self.write_json_once(task_id, "documents/source-images.json", {"schema_version": "cad.source-images.v1", "images": images})
|
||||||
|
|
||||||
def sync_action_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str:
|
def sync_action_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str:
|
||||||
"""Mirror committed SQLite events into an append-only JSONL audit log.
|
"""Mirror committed SQLite events into an append-only JSONL audit log.
|
||||||
@@ -171,30 +187,16 @@ class FileArtifactStore:
|
|||||||
path = self.task_dir(task_id) / "source-requirements.md"
|
path = self.task_dir(task_id) / "source-requirements.md"
|
||||||
return path.read_text(encoding="utf-8") if path.is_file() else ""
|
return path.read_text(encoding="utf-8") if path.is_file() else ""
|
||||||
|
|
||||||
def read_requirements_draft(self, task_id: str, artifact_path: str = "") -> dict[str, Any]:
|
def source_image_paths(self, task_id: str) -> list[str]:
|
||||||
if artifact_path:
|
manifest = self.read_json(task_id, "documents/source-images.json") or {}
|
||||||
value = self.read_json(task_id, artifact_path)
|
return [
|
||||||
if value is None:
|
str(self._path(task_id, str(item.get("path") or "")))
|
||||||
raise ValueError("Committed requirements draft artifact is unavailable")
|
for item in manifest.get("images") or ()
|
||||||
return value
|
if isinstance(item, dict) and item.get("path") and self._path(task_id, str(item["path"])).is_file()
|
||||||
documents = self.task_dir(task_id) / "documents"
|
]
|
||||||
candidates = sorted(documents.glob("requirements-draft-v*.json"))
|
|
||||||
if not candidates:
|
|
||||||
return {"schema_version": "cad.requirements-draft.v1", "revision": 0, "items": []}
|
|
||||||
value = json.loads(candidates[-1].read_text(encoding="utf-8"))
|
|
||||||
return value if isinstance(value, dict) else {"schema_version": "cad.requirements-draft.v1", "revision": 0, "items": []}
|
|
||||||
|
|
||||||
def write_requirements_draft(self, task_id: str, payload: dict[str, Any], *, invocation_id: str) -> str:
|
def read_requirements_spec(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None:
|
||||||
revision = int(payload.get("revision") or 0)
|
return self.read_json(task_id, artifact_path or "documents/requirements-spec.json")
|
||||||
if revision < 1:
|
|
||||||
raise ValueError("Requirements draft revision is invalid")
|
|
||||||
return self._write_invocation_json(task_id, f"requirements-draft-v{revision}", payload, invocation_id)
|
|
||||||
|
|
||||||
def read_requirements_review(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None:
|
|
||||||
return self.read_json(task_id, artifact_path) if artifact_path else None
|
|
||||||
|
|
||||||
def write_requirements_review(self, task_id: str, payload: dict[str, Any], *, invocation_id: str) -> str:
|
|
||||||
return self._write_invocation_json(task_id, "requirements-review", payload, invocation_id)
|
|
||||||
|
|
||||||
def read_requirements_contract(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None:
|
def read_requirements_contract(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None:
|
||||||
# State points to the immutable artifact used as program input. The
|
# State points to the immutable artifact used as program input. The
|
||||||
@@ -338,6 +340,17 @@ class FileArtifactStore:
|
|||||||
temporary.write_bytes(encoded)
|
temporary.write_bytes(encoded)
|
||||||
os.replace(temporary, path)
|
os.replace(temporary, path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write_bytes_once(path: Path, data: bytes) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if path.exists():
|
||||||
|
if path.read_bytes() != data:
|
||||||
|
raise ValueError(f"Immutable artifact already exists: {path.name}")
|
||||||
|
return
|
||||||
|
temporary = path.with_name(path.name + ".tmp-" + secrets.token_hex(4))
|
||||||
|
temporary.write_bytes(data)
|
||||||
|
os.replace(temporary, path)
|
||||||
|
|
||||||
def _write_invocation_json(self, task_id: str, stem: str, payload: dict[str, Any], invocation_id: str) -> str:
|
def _write_invocation_json(self, task_id: str, stem: str, payload: dict[str, Any], invocation_id: str) -> str:
|
||||||
digest = sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()[:12]
|
digest = sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()[:12]
|
||||||
safe_invocation = re.sub(r"[^A-Za-z0-9_-]", "", invocation_id)[:32]
|
safe_invocation = re.sub(r"[^A-Za-z0-9_-]", "", invocation_id)[:32]
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ class RenderedReviewGateway:
|
|||||||
name = str((tool.get("function") or {}).get("name") or "")
|
name = str((tool.get("function") or {}).get("name") or "")
|
||||||
if not name:
|
if not name:
|
||||||
raise RuntimeError("Review tool is missing a name")
|
raise RuntimeError("Review tool is missing a name")
|
||||||
content: list[dict[str, Any]] = [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]
|
public_payload = {key: value for key, value in payload.items() if key != "reference_image_paths"}
|
||||||
|
content: list[dict[str, Any]] = [{"type": "text", "text": json.dumps(public_payload, ensure_ascii=False)}]
|
||||||
|
if kind in {"image_observation", "final"}:
|
||||||
|
for raw_path in payload.get("reference_image_paths") or ():
|
||||||
|
path = Path(str(raw_path))
|
||||||
|
if path.is_file():
|
||||||
|
content.append(self._image_part(path))
|
||||||
if kind in {"candidate", "final"}:
|
if kind in {"candidate", "final"}:
|
||||||
manifest = payload.get("render_manifest") if isinstance(payload.get("render_manifest"), dict) else {}
|
manifest = payload.get("render_manifest") if isinstance(payload.get("render_manifest"), dict) else {}
|
||||||
for path in self._evidence_paths(manifest):
|
for path in self._evidence_paths(manifest):
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ class SqliteTaskRepository:
|
|||||||
repair_required INTEGER NOT NULL DEFAULT 0,
|
repair_required INTEGER NOT NULL DEFAULT 0,
|
||||||
last_error TEXT,
|
last_error TEXT,
|
||||||
retry_from_phase TEXT NOT NULL DEFAULT '',
|
retry_from_phase TEXT NOT NULL DEFAULT '',
|
||||||
requirements_draft_path TEXT NOT NULL DEFAULT '',
|
requirements_spec_path TEXT NOT NULL DEFAULT '',
|
||||||
requirements_review_path TEXT NOT NULL DEFAULT '',
|
clarification_path TEXT NOT NULL DEFAULT '',
|
||||||
requirements_contract_path TEXT NOT NULL DEFAULT '',
|
requirements_contract_path TEXT NOT NULL DEFAULT '',
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
@@ -100,15 +100,6 @@ class SqliteTaskRepository:
|
|||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
columns = {str(row["name"]) for row in connection.execute("PRAGMA table_info(tasks)").fetchall()}
|
|
||||||
if "requirements_draft_path" not in columns:
|
|
||||||
connection.execute("ALTER TABLE tasks ADD COLUMN requirements_draft_path TEXT NOT NULL DEFAULT ''")
|
|
||||||
if "requirements_review_path" not in columns:
|
|
||||||
connection.execute("ALTER TABLE tasks ADD COLUMN requirements_review_path TEXT NOT NULL DEFAULT ''")
|
|
||||||
if "requirements_contract_path" not in columns:
|
|
||||||
connection.execute("ALTER TABLE tasks ADD COLUMN requirements_contract_path TEXT NOT NULL DEFAULT ''")
|
|
||||||
if "retry_from_phase" not in columns:
|
|
||||||
connection.execute("ALTER TABLE tasks ADD COLUMN retry_from_phase TEXT NOT NULL DEFAULT ''")
|
|
||||||
|
|
||||||
def create_task(self, task_id: str, request: str) -> TaskState:
|
def create_task(self, task_id: str, request: str) -> TaskState:
|
||||||
with self._lock, self._connection() as connection:
|
with self._lock, self._connection() as connection:
|
||||||
@@ -143,21 +134,17 @@ class SqliteTaskRepository:
|
|||||||
verification_warnings = [
|
verification_warnings = [
|
||||||
str(item) for item in frozen.get("verification_warnings") or () if str(item)
|
str(item) for item in frozen.get("verification_warnings") or () if str(item)
|
||||||
] if isinstance(frozen, dict) else []
|
] if isinstance(frozen, dict) else []
|
||||||
applied_normalizations = [
|
|
||||||
item for item in frozen.get("applied_normalizations") or () if isinstance(item, dict)
|
|
||||||
] if isinstance(frozen, dict) else []
|
|
||||||
status_event = next((
|
status_event = next((
|
||||||
item for item in reversed(events)
|
item for item in reversed(events)
|
||||||
if item.get("event") in {
|
if item.get("event") in {
|
||||||
"requirements_waiting_for_user", "waiting_retry", "call_budget_exhausted",
|
"requirements_waiting_for_user", "waiting_retry", "call_budget_exhausted",
|
||||||
"requirements_review_limit_reached", "no_progress_limit",
|
"no_progress_limit",
|
||||||
"candidate_runtime_execution_failure", "candidate_recovery_runtime_execution_failure",
|
"candidate_runtime_execution_failure", "candidate_recovery_runtime_execution_failure",
|
||||||
"failed_author_format", "runtime_contract_invalid",
|
"failed_author_format", "runtime_contract_invalid",
|
||||||
}
|
}
|
||||||
), {}) if state.phase in {TaskPhase.FAILED, TaskPhase.WAITING_RETRY, TaskPhase.WAITING_FOR_USER} else {}
|
), {}) if state.phase in {TaskPhase.FAILED, TaskPhase.WAITING_RETRY, TaskPhase.WAITING_FOR_USER} else {}
|
||||||
questions = [str(item) for item in status_event.get("questions") or () if str(item)] if isinstance(status_event, dict) else []
|
questions = [str(item) for item in status_event.get("questions") or () if str(item)] if isinstance(status_event, dict) else []
|
||||||
findings = [item for item in status_event.get("findings") or () if isinstance(item, dict)] if isinstance(status_event, dict) else []
|
issues = [str(item) for item in status_event.get("issues") or () if str(item)] if isinstance(status_event, dict) else []
|
||||||
issues = [str(item.get("description") or "") for item in findings if item.get("description")]
|
|
||||||
return {
|
return {
|
||||||
"schema_version": "3.0",
|
"schema_version": "3.0",
|
||||||
"task_id": state.task_id,
|
"task_id": state.task_id,
|
||||||
@@ -172,12 +159,15 @@ class SqliteTaskRepository:
|
|||||||
"repair_required": state.repair_required,
|
"repair_required": state.repair_required,
|
||||||
"last_error": state.last_error.value if state.last_error else "",
|
"last_error": state.last_error.value if state.last_error else "",
|
||||||
"retry_from_phase": state.retry_from_phase.value if state.retry_from_phase else "",
|
"retry_from_phase": state.retry_from_phase.value if state.retry_from_phase else "",
|
||||||
"requirements_draft_path": state.requirements_draft_path,
|
"requirements_spec_path": state.requirements_spec_path,
|
||||||
"requirements_review_path": state.requirements_review_path,
|
"clarification_path": state.clarification_path,
|
||||||
"requirements_contract_path": state.requirements_contract_path,
|
"requirements_contract_path": state.requirements_contract_path,
|
||||||
"verification_status": "completed_with_risks" if verification_warnings else "verified",
|
"verification_status": (
|
||||||
|
"completed_with_risks" if state.phase == TaskPhase.COMPLETED and verification_warnings
|
||||||
|
else "verified" if state.phase == TaskPhase.COMPLETED
|
||||||
|
else "pending"
|
||||||
|
),
|
||||||
"verification_warnings": verification_warnings,
|
"verification_warnings": verification_warnings,
|
||||||
"applied_normalizations": applied_normalizations,
|
|
||||||
"message": str(status_event.get("message") or "") if isinstance(status_event, dict) else "",
|
"message": str(status_event.get("message") or "") if isinstance(status_event, dict) else "",
|
||||||
"questions": questions,
|
"questions": questions,
|
||||||
"issues": issues,
|
"issues": issues,
|
||||||
@@ -228,16 +218,16 @@ class SqliteTaskRepository:
|
|||||||
try:
|
try:
|
||||||
cursor = connection.execute(
|
cursor = connection.execute(
|
||||||
"""UPDATE tasks SET phase = ?, state_version = ?, active_revision = ?, pending_action_json = ?,
|
"""UPDATE tasks SET phase = ?, state_version = ?, active_revision = ?, pending_action_json = ?,
|
||||||
candidate_id = ?, candidate_stage_id = ?, repair_required = ?, last_error = ?, retry_from_phase = ?, requirements_draft_path = ?,
|
candidate_id = ?, candidate_stage_id = ?, repair_required = ?, last_error = ?, retry_from_phase = ?, requirements_spec_path = ?,
|
||||||
requirements_review_path = ?, requirements_contract_path = ?, updated_at = CURRENT_TIMESTAMP
|
clarification_path = ?, requirements_contract_path = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE task_id = ? AND state_version = ?""",
|
WHERE task_id = ? AND state_version = ?""",
|
||||||
(
|
(
|
||||||
state.phase.value, state.version, state.active_revision,
|
state.phase.value, state.version, state.active_revision,
|
||||||
json.dumps(self._pending_payload(state.pending_action), ensure_ascii=True) if state.pending_action else None,
|
json.dumps(self._pending_payload(state.pending_action), ensure_ascii=True) if state.pending_action else None,
|
||||||
state.candidate_id, state.candidate_stage_id,
|
state.candidate_id, state.candidate_stage_id,
|
||||||
int(state.repair_required), state.last_error.value if state.last_error else None,
|
int(state.repair_required), state.last_error.value if state.last_error else None,
|
||||||
state.retry_from_phase.value if state.retry_from_phase else "", state.requirements_draft_path,
|
state.retry_from_phase.value if state.retry_from_phase else "", state.requirements_spec_path,
|
||||||
state.requirements_review_path, state.requirements_contract_path,
|
state.clarification_path, state.requirements_contract_path,
|
||||||
state.task_id, previous_version,
|
state.task_id, previous_version,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -386,8 +376,8 @@ class SqliteTaskRepository:
|
|||||||
repair_required=bool(row["repair_required"]),
|
repair_required=bool(row["repair_required"]),
|
||||||
last_error=ErrorCode(str(row["last_error"])) if row["last_error"] else None,
|
last_error=ErrorCode(str(row["last_error"])) if row["last_error"] else None,
|
||||||
retry_from_phase=TaskPhase(str(row["retry_from_phase"])) if row["retry_from_phase"] else None,
|
retry_from_phase=TaskPhase(str(row["retry_from_phase"])) if row["retry_from_phase"] else None,
|
||||||
requirements_draft_path=str(row["requirements_draft_path"] or ""),
|
requirements_spec_path=str(row["requirements_spec_path"] or ""),
|
||||||
requirements_review_path=str(row["requirements_review_path"] or ""),
|
clarification_path=str(row["clarification_path"] or ""),
|
||||||
requirements_contract_path=str(row["requirements_contract_path"] or ""),
|
requirements_contract_path=str(row["requirements_contract_path"] or ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -914,7 +914,7 @@ class ActionCommandHandler:
|
|||||||
return Accepted(result)
|
return Accepted(result)
|
||||||
next_state = transition(state, "final_accepted")
|
next_state = transition(state, "final_accepted")
|
||||||
result = {"status": "completed", "revision_id": state.active_revision}
|
result = {"status": "completed", "revision_id": state.active_revision}
|
||||||
if not self._commit_invocation(next_state, [{"event": "completed", "revision_id": state.active_revision, "final_review_path": final_review_path, "claim_results": claim_results}], invocation, result):
|
if not self._commit_invocation(next_state, [{"event": "completed", "revision_id": state.active_revision, "final_review_path": final_review_path, "completion_result_path": "completion-result.md", "claim_results": claim_results}], invocation, result):
|
||||||
return Rejected(self._stale())
|
return Rejected(self._stale())
|
||||||
return Accepted(result)
|
return Accepted(result)
|
||||||
|
|
||||||
|
|||||||
@@ -1,83 +1,98 @@
|
|||||||
"""Real provider structured-output conformance gate for protocol v3."""
|
"""Cached, role-scoped structured-output conformance checks."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
|
|
||||||
from app.cad_agent.application.llm_contracts import (
|
from app.cad_agent.application.llm_contracts import (
|
||||||
EmptyCommand,
|
EmptyCommand,
|
||||||
GeometryConclusion,
|
ImageObservation,
|
||||||
RollbackCheckpoint,
|
RequirementsAuthorOutput,
|
||||||
candidate_review_schema,
|
StatelessCandidateReview,
|
||||||
final_review_schema,
|
StatelessGeometryConclusion,
|
||||||
geometry_conclusion_schema,
|
StatelessRollbackCheckpoint,
|
||||||
next_action_schema,
|
StatelessTopologyRequest,
|
||||||
operation_contract_request_schema,
|
requirements_spec_schema,
|
||||||
requirements_draft_schema,
|
stateless_final_review_schema,
|
||||||
requirements_patch_schema,
|
stateless_next_action_schema,
|
||||||
requirements_review_schema,
|
|
||||||
rollback_checkpoint_schema,
|
|
||||||
topology_request_schema,
|
|
||||||
)
|
)
|
||||||
from app.cad_agent.domain.operation_contract import fragment_schema
|
from app.cad_agent.domain.operation_contract import fragment_schema
|
||||||
from app.cad_agent.domain.verifier_registry import default_registry
|
from app.cad_agent.domain.verifier_registry import default_registry
|
||||||
from app.cad_agent.ports import CadRuntime, ModelGateway
|
from app.cad_agent.ports import CadRuntime, ModelGateway
|
||||||
|
|
||||||
|
|
||||||
_CONFORMANCE_WORKING_HEAD = "cad_conformance:root:v1"
|
CapabilityRole = Literal["author", "reviewer"]
|
||||||
_CONFORMANCE_REQUIREMENT_IDS = ["req_001"]
|
|
||||||
_CONFORMANCE_CLAIM_IDS = ["claim_001"]
|
|
||||||
_CONFORMANCE_DRAFT_IDS = ["draft_001"]
|
|
||||||
_CONFORMANCE_SOURCE_IDS = ["src_001"]
|
|
||||||
|
|
||||||
|
|
||||||
def conformance_tools(runtime: CadRuntime) -> list[dict[str, Any]]:
|
def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict[str, Any]]:
|
||||||
"""Return the complete v3 structured-output surface.
|
if role == "reviewer":
|
||||||
|
return [
|
||||||
A provider is usable only when it can return valid arguments for every
|
_tool("observe_images", ImageObservation.model_json_schema()),
|
||||||
fixed schema and every currently registered runtime operation. This list
|
_tool("review_candidate", StatelessCandidateReview.model_json_schema()),
|
||||||
intentionally derives the operation portion from the runtime registry so
|
_tool("review_final", stateless_final_review_schema(1)),
|
||||||
a newly exposed operation cannot bypass the capability gate.
|
]
|
||||||
"""
|
|
||||||
registry = default_registry()
|
|
||||||
atomic_ids = list(runtime.supported_atomic_ids())
|
atomic_ids = list(runtime.supported_atomic_ids())
|
||||||
tools = [
|
|
||||||
_tool("submit_requirements_draft_batch", requirements_draft_schema(registry.expected_one_of_schema(), _CONFORMANCE_SOURCE_IDS)),
|
|
||||||
_tool("patch_requirements_draft", requirements_patch_schema(registry.expected_one_of_schema(), _CONFORMANCE_SOURCE_IDS, _CONFORMANCE_DRAFT_IDS)),
|
|
||||||
_tool("finalize_requirements_draft", EmptyCommand.model_json_schema()),
|
|
||||||
_tool("review_requirements", requirements_review_schema(_CONFORMANCE_SOURCE_IDS, _CONFORMANCE_DRAFT_IDS)),
|
|
||||||
_tool("propose_next_action", next_action_schema(_CONFORMANCE_WORKING_HEAD, _CONFORMANCE_REQUIREMENT_IDS, atomic_ids)),
|
|
||||||
_tool("inspect_topology", topology_request_schema(_CONFORMANCE_WORKING_HEAD)),
|
|
||||||
_tool("get_cdsl_operation_contract", operation_contract_request_schema(_CONFORMANCE_WORKING_HEAD, atomic_ids[0])),
|
|
||||||
_tool("review_candidate", candidate_review_schema("candidate_conformance", _CONFORMANCE_WORKING_HEAD, _CONFORMANCE_CLAIM_IDS)),
|
|
||||||
_tool("complete_task", EmptyCommand.model_json_schema()),
|
|
||||||
_tool("review_final", final_review_schema(_CONFORMANCE_WORKING_HEAD, _CONFORMANCE_CLAIM_IDS)),
|
|
||||||
_tool("record_geometry_conclusion", geometry_conclusion_schema(_CONFORMANCE_WORKING_HEAD, ["evidence_current_state"])),
|
|
||||||
_tool("rollback_checkpoint", rollback_checkpoint_schema(_CONFORMANCE_WORKING_HEAD, ["checkpoint_root"])),
|
|
||||||
]
|
|
||||||
if not atomic_ids:
|
if not atomic_ids:
|
||||||
raise RuntimeError("Runtime has no operations for conformance")
|
raise RuntimeError("Runtime has no operations for conformance")
|
||||||
|
tools = [
|
||||||
|
_tool("submit_requirements_spec", requirements_spec_schema(default_registry().expected_one_of_schema())),
|
||||||
|
_tool("propose_next_action", stateless_next_action_schema(atomic_ids)),
|
||||||
|
_tool("inspect_topology", StatelessTopologyRequest.model_json_schema()),
|
||||||
|
_tool("record_geometry_conclusion", StatelessGeometryConclusion.model_json_schema()),
|
||||||
|
_tool("rollback_checkpoint", StatelessRollbackCheckpoint.model_json_schema()),
|
||||||
|
_tool("complete_task", EmptyCommand.model_json_schema()),
|
||||||
|
]
|
||||||
for atomic_id in atomic_ids:
|
for atomic_id in atomic_ids:
|
||||||
contract = runtime.operation_contract(atomic_id)
|
contract = runtime.operation_contract(atomic_id)
|
||||||
tools.append(_tool(f"conformance_{atomic_id}", fragment_schema(contract, selector_tokens=["sel_conformance"], reference_tokens=["ref_conformance"])))
|
tools.append(_tool(
|
||||||
|
f"conformance_{atomic_id}",
|
||||||
|
fragment_schema(contract, selector_tokens=["sel_conformance"], reference_tokens=["ref_conformance"]),
|
||||||
|
))
|
||||||
return tools
|
return tools
|
||||||
|
|
||||||
|
|
||||||
def conformance_hash(tools: list[dict[str, Any]]) -> str:
|
def conformance_hash(tools: list[dict[str, Any]], *, role: CapabilityRole) -> str:
|
||||||
return sha256(json.dumps(tools, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
payload = {"protocol": "cad.v3.spec.v1", "role": role, "tools": tools}
|
||||||
|
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
async def verify_model_capability(repository: Any, runtime: CadRuntime, models: ModelGateway, *, provider_id: str, model_id: str, force: bool = False) -> dict[str, Any]:
|
def cached_model_capability(
|
||||||
tools = conformance_tools(runtime)
|
repository: Any,
|
||||||
schema_hash = conformance_hash(tools)
|
runtime: CadRuntime,
|
||||||
|
*,
|
||||||
|
provider_id: str,
|
||||||
|
model_id: str,
|
||||||
|
role: CapabilityRole,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
tools = conformance_tools(runtime, role=role)
|
||||||
|
schema_hash = conformance_hash(tools, role=role)
|
||||||
cached = repository.model_capability(provider_id, model_id, schema_hash)
|
cached = repository.model_capability(provider_id, model_id, schema_hash)
|
||||||
if cached is not None and cached["supported"] and not force:
|
if cached is None:
|
||||||
return {"cached": True, "schema_hash": schema_hash, **cached["report"]}
|
return None
|
||||||
|
return {"cached": True, "schema_hash": schema_hash, "role": role, **cached["report"]}
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_model_capability(
|
||||||
|
repository: Any,
|
||||||
|
runtime: CadRuntime,
|
||||||
|
models: ModelGateway,
|
||||||
|
*,
|
||||||
|
provider_id: str,
|
||||||
|
model_id: str,
|
||||||
|
role: CapabilityRole,
|
||||||
|
force: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
tools = conformance_tools(runtime, role=role)
|
||||||
|
schema_hash = conformance_hash(tools, role=role)
|
||||||
|
cached = repository.model_capability(provider_id, model_id, schema_hash)
|
||||||
|
if cached is not None and not force:
|
||||||
|
return {"cached": True, "schema_hash": schema_hash, "role": role, **cached["report"]}
|
||||||
report = await models.conformance(provider_id=provider_id, model_id=model_id, tools=tools)
|
report = await models.conformance(provider_id=provider_id, model_id=model_id, tools=tools)
|
||||||
report = {"schema_hash": schema_hash, "tool_count": len(tools), **report}
|
report = {"schema_hash": schema_hash, "role": role, "tool_count": len(tools), **report}
|
||||||
repository.record_model_capability(provider_id, model_id, schema_hash, report)
|
if not report.get("probe_unavailable"):
|
||||||
|
repository.record_model_capability(provider_id, model_id, schema_hash, report)
|
||||||
return report
|
return report
|
||||||
|
|
||||||
|
|
||||||
@@ -86,7 +101,7 @@ def _tool(name: str, parameters: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": name,
|
"name": name,
|
||||||
"description": "Structured output conformance probe. Return one schema-valid call with every required root and nested property.",
|
"description": "Structured output conformance probe. Return one schema-valid call.",
|
||||||
"parameters": parameters,
|
"parameters": parameters,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import math
|
|||||||
from typing import Annotated, Any, Literal, TypeVar
|
from typing import Annotated, Any, Literal, TypeVar
|
||||||
|
|
||||||
from jsonschema import Draft202012Validator
|
from jsonschema import Draft202012Validator
|
||||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue, ValidationError, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, JsonValue, RootModel, ValidationError, model_validator
|
||||||
|
|
||||||
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
|
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
|
||||||
|
|
||||||
@@ -31,84 +31,33 @@ class AcceptanceClaimInput(StrictDto):
|
|||||||
expected: dict[str, JsonValue] = Field(min_length=0, max_length=24)
|
expected: dict[str, JsonValue] = Field(min_length=0, max_length=24)
|
||||||
|
|
||||||
|
|
||||||
class RequirementInput(StrictDto):
|
class SpecRequirementInput(StrictDto):
|
||||||
source_ids: list[Identifier] = Field(min_length=1, max_length=32)
|
|
||||||
statement: Annotated[str, Field(min_length=1, max_length=1000)]
|
statement: Annotated[str, Field(min_length=1, max_length=1000)]
|
||||||
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||||
acceptance_claims: list[AcceptanceClaimInput] = Field(min_length=1, max_length=16)
|
acceptance_claims: list[AcceptanceClaimInput] = Field(min_length=1, max_length=16)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def _source_ids_are_unique(self) -> "RequirementInput":
|
class RequirementsSpec(StrictDto):
|
||||||
if len(self.source_ids) != len(set(self.source_ids)):
|
outcome: Literal["ready"]
|
||||||
raise ValueError("source_ids must not contain duplicates")
|
summary: Annotated[str, Field(min_length=1, max_length=2000)]
|
||||||
return self
|
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=32)
|
||||||
|
requirements: list[SpecRequirementInput] = Field(min_length=1, max_length=32)
|
||||||
|
|
||||||
|
|
||||||
class RequirementsDraftBatch(StrictDto):
|
class RequirementsClarification(StrictDto):
|
||||||
items: list[RequirementInput] = Field(min_length=1, max_length=8)
|
outcome: Literal["clarification"]
|
||||||
|
source_quotes: list[Annotated[str, Field(min_length=1, max_length=500)]] = Field(min_length=2, max_length=4)
|
||||||
|
question: Annotated[str, Field(min_length=1, max_length=500)]
|
||||||
|
|
||||||
|
|
||||||
class RequirementsPatch(StrictDto):
|
class RequirementsAuthorOutput(RootModel[Annotated[RequirementsSpec | RequirementsClarification, Field(discriminator="outcome")]]):
|
||||||
target_draft_id: Identifier = Field(description="Current server-assigned draft ID to patch. This field belongs inside one patches[] entry.")
|
pass
|
||||||
op: Literal["replace", "remove"] = Field(description="replace supplies a complete replacement item; remove supplies a reason instead.")
|
|
||||||
item: RequirementInput | None = Field(default=None, description="Complete replacement RequirementInput for op=replace. It must not include draft_id because the server preserves that ID.")
|
|
||||||
reason: str | None = Field(default=None, min_length=1, max_length=360, description="Required only for op=remove; explains why the current draft item is removed.")
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def _complete_patch(self) -> "RequirementsPatch":
|
|
||||||
if self.op == "replace" and self.item is None:
|
|
||||||
raise ValueError("replace requires a complete item")
|
|
||||||
if self.op == "remove" and (self.item is not None or self.reason is None):
|
|
||||||
raise ValueError("remove requires a reason and forbids item")
|
|
||||||
return self
|
|
||||||
|
|
||||||
|
|
||||||
class RequirementsPatchBatch(StrictDto):
|
|
||||||
patches: list[RequirementsPatch] = Field(min_length=1, max_length=8, description="Patch entries. Example shape: {\"patches\":[{\"target_draft_id\":\"draft_001\",\"op\":\"replace\",\"item\":{...}}]}.")
|
|
||||||
|
|
||||||
|
|
||||||
class EmptyCommand(StrictDto):
|
class EmptyCommand(StrictDto):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ReviewNormalization(StrictDto):
|
|
||||||
rule_id: Literal["full_circle_equal_spacing"]
|
|
||||||
count: int = Field(ge=2, le=1024)
|
|
||||||
declared_spacing_degrees: float = Field(gt=0, le=360)
|
|
||||||
full_circle: bool
|
|
||||||
|
|
||||||
|
|
||||||
class ReviewFinding(StrictDto):
|
|
||||||
draft_id: Identifier
|
|
||||||
source_ids: list[Identifier] = Field(min_length=1, max_length=32)
|
|
||||||
finding_type: Literal[
|
|
||||||
"missing_source_semantics",
|
|
||||||
"claim_mismatch",
|
|
||||||
"verification_gap",
|
|
||||||
"derivable_conflict",
|
|
||||||
"ambiguous_conflict",
|
|
||||||
]
|
|
||||||
description: Annotated[str, Field(min_length=1, max_length=1000)]
|
|
||||||
question: str | None = Field(default=None, min_length=1, max_length=360)
|
|
||||||
normalization: ReviewNormalization | None = None
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def _finding_payload_matches_type(self) -> "ReviewFinding":
|
|
||||||
if len(self.source_ids) != len(set(self.source_ids)):
|
|
||||||
raise ValueError("source_ids must not contain duplicates")
|
|
||||||
if self.finding_type == "ambiguous_conflict" and self.question is None:
|
|
||||||
raise ValueError("ambiguous_conflict requires an answerable question")
|
|
||||||
if self.finding_type == "derivable_conflict" and self.normalization is None:
|
|
||||||
raise ValueError("derivable_conflict requires structured normalization data")
|
|
||||||
if self.finding_type != "derivable_conflict" and self.normalization is not None:
|
|
||||||
raise ValueError("normalization is allowed only for derivable_conflict")
|
|
||||||
return self
|
|
||||||
|
|
||||||
|
|
||||||
class RequirementsReview(StrictDto):
|
|
||||||
findings: list[ReviewFinding] = Field(default_factory=list, max_length=128)
|
|
||||||
|
|
||||||
|
|
||||||
class NextAction(StrictDto):
|
class NextAction(StrictDto):
|
||||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
||||||
intent: ShortText
|
intent: ShortText
|
||||||
@@ -123,17 +72,18 @@ class NextAction(StrictDto):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class StatelessNextAction(StrictDto):
|
||||||
|
intent: ShortText
|
||||||
|
operation: Identifier
|
||||||
|
expected_change: ShortText
|
||||||
|
|
||||||
|
|
||||||
class TopologyRequest(StrictDto):
|
class TopologyRequest(StrictDto):
|
||||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
||||||
kind: Literal["face", "edge", "vertex", "plane", "axis", "body"] | None = None
|
kind: Literal["face", "edge", "vertex", "plane", "axis", "body"] | None = None
|
||||||
limit: int = Field(default=16, ge=1, le=64)
|
limit: int = Field(default=16, ge=1, le=64)
|
||||||
|
|
||||||
|
|
||||||
class OperationContractRequest(StrictDto):
|
|
||||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
|
||||||
atomic_id: Identifier
|
|
||||||
|
|
||||||
|
|
||||||
class GeometryConclusion(StrictDto):
|
class GeometryConclusion(StrictDto):
|
||||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
||||||
evidence_refs: list[Identifier] = Field(min_length=1, max_length=16)
|
evidence_refs: list[Identifier] = Field(min_length=1, max_length=16)
|
||||||
@@ -154,6 +104,22 @@ class RollbackCheckpoint(StrictDto):
|
|||||||
reason: Annotated[str, Field(min_length=1, max_length=360)]
|
reason: Annotated[str, Field(min_length=1, max_length=360)]
|
||||||
|
|
||||||
|
|
||||||
|
class StatelessTopologyRequest(StrictDto):
|
||||||
|
kind: Literal["face", "edge", "vertex", "plane", "axis", "body"] | None = None
|
||||||
|
limit: int = Field(default=16, ge=1, le=64)
|
||||||
|
|
||||||
|
|
||||||
|
class StatelessGeometryConclusion(StrictDto):
|
||||||
|
root_cause: Annotated[str, Field(min_length=1, max_length=360)]
|
||||||
|
decision: Literal["return_to_action_selection", "rollback"]
|
||||||
|
corrective_intent: str | None = Field(default=None, min_length=1, max_length=360)
|
||||||
|
|
||||||
|
|
||||||
|
class StatelessRollbackCheckpoint(StrictDto):
|
||||||
|
checkpoint_token: Identifier
|
||||||
|
reason: Annotated[str, Field(min_length=1, max_length=360)]
|
||||||
|
|
||||||
|
|
||||||
class ClaimCoverage(StrictDto):
|
class ClaimCoverage(StrictDto):
|
||||||
claim_id: Identifier
|
claim_id: Identifier
|
||||||
status: Literal["pass", "pending", "fail", "not_applicable"]
|
status: Literal["pass", "pending", "fail", "not_applicable"]
|
||||||
@@ -175,6 +141,41 @@ class CandidateReview(StrictDto):
|
|||||||
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||||
|
|
||||||
|
|
||||||
|
class StatelessCandidateReview(StrictDto):
|
||||||
|
verdict: Literal["accept", "reject"]
|
||||||
|
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||||
|
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||||
|
|
||||||
|
|
||||||
|
class VisualClaimDecision(StrictDto):
|
||||||
|
status: Literal["pass", "fail"]
|
||||||
|
evidence: Annotated[str, Field(min_length=1, max_length=360)]
|
||||||
|
|
||||||
|
|
||||||
|
class StatelessFinalReview(StrictDto):
|
||||||
|
verdict: Literal["pass", "repair"]
|
||||||
|
visual_claims: list[VisualClaimDecision] = Field(default_factory=list, max_length=128)
|
||||||
|
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||||
|
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||||
|
|
||||||
|
|
||||||
|
class ImageMeasurement(StrictDto):
|
||||||
|
name: Annotated[str, Field(min_length=1, max_length=160)]
|
||||||
|
value: float | None = None
|
||||||
|
unit: Literal["mm", "degree", "count", "unknown"] = "unknown"
|
||||||
|
evidence: Annotated[str, Field(min_length=1, max_length=360)]
|
||||||
|
confidence: float = Field(ge=0, le=1)
|
||||||
|
|
||||||
|
|
||||||
|
class ImageObservation(StrictDto):
|
||||||
|
summary: Annotated[str, Field(min_length=1, max_length=2000)]
|
||||||
|
visible_features: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64)
|
||||||
|
measurements: list[ImageMeasurement] = Field(default_factory=list, max_length=128)
|
||||||
|
view_directions: list[Annotated[str, Field(min_length=1, max_length=120)]] = Field(default_factory=list, max_length=16)
|
||||||
|
uncertainties: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64)
|
||||||
|
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
class FinalReview(StrictDto):
|
class FinalReview(StrictDto):
|
||||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$", description="Current server-issued working head from the final review facts.")]
|
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$", description="Current server-issued working head from the final review facts.")]
|
||||||
verdict: Literal["pass", "repair"] = Field(description="Required independent final decision. Set pass only when the supplied evidence supports every claim; otherwise set repair.")
|
verdict: Literal["pass", "repair"] = Field(description="Required independent final decision. Set pass only when the supplied evidence supports every claim; otherwise set repair.")
|
||||||
@@ -183,85 +184,39 @@ class FinalReview(StrictDto):
|
|||||||
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||||
|
|
||||||
|
|
||||||
def requirements_draft_schema(claim_one_of: dict[str, Any], source_ids: list[str]) -> dict[str, Any]:
|
def requirements_spec_schema(claim_one_of: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Bind claim and source enums for the current requirements snapshot."""
|
schema = RequirementsAuthorOutput.model_json_schema()
|
||||||
schema = RequirementsDraftBatch.model_json_schema()
|
requirement = schema.get("$defs", {}).get("SpecRequirementInput")
|
||||||
requirement = schema.get("$defs", {}).get("RequirementInput")
|
|
||||||
if isinstance(requirement, dict):
|
if isinstance(requirement, dict):
|
||||||
properties = requirement.get("properties", {})
|
|
||||||
source_items = properties.get("source_ids", {}).get("items") if isinstance(properties, dict) and isinstance(properties.get("source_ids"), dict) else None
|
|
||||||
if isinstance(source_items, dict):
|
|
||||||
source_items["enum"] = source_ids
|
|
||||||
claims = requirement.get("properties", {}).get("acceptance_claims")
|
claims = requirement.get("properties", {}).get("acceptance_claims")
|
||||||
if isinstance(claims, dict):
|
if isinstance(claims, dict):
|
||||||
claims["items"] = deepcopy(claim_one_of)
|
claims["items"] = deepcopy(claim_one_of)
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
|
|
||||||
def requirements_patch_schema(claim_one_of: dict[str, Any], source_ids: list[str], draft_ids: list[str]) -> dict[str, Any]:
|
def stateless_next_action_schema(atomic_ids: list[str]) -> dict[str, Any]:
|
||||||
schema = RequirementsPatchBatch.model_json_schema()
|
schema = StatelessNextAction.model_json_schema()
|
||||||
definitions = schema.get("$defs", {})
|
|
||||||
requirement = definitions.get("RequirementInput") if isinstance(definitions, dict) else None
|
|
||||||
if isinstance(requirement, dict):
|
|
||||||
properties = requirement.get("properties", {})
|
|
||||||
source_items = properties.get("source_ids", {}).get("items") if isinstance(properties, dict) and isinstance(properties.get("source_ids"), dict) else None
|
|
||||||
if isinstance(source_items, dict):
|
|
||||||
source_items["enum"] = source_ids
|
|
||||||
claims = requirement.get("properties", {}).get("acceptance_claims")
|
|
||||||
if isinstance(claims, dict):
|
|
||||||
claims["items"] = deepcopy(claim_one_of)
|
|
||||||
patch = definitions.get("RequirementsPatch") if isinstance(definitions, dict) else None
|
|
||||||
if isinstance(patch, dict):
|
|
||||||
target = patch.get("properties", {}).get("target_draft_id")
|
|
||||||
if isinstance(target, dict):
|
|
||||||
target["enum"] = draft_ids
|
|
||||||
return schema
|
|
||||||
|
|
||||||
|
|
||||||
def requirements_review_schema(source_ids: list[str], draft_ids: list[str]) -> dict[str, Any]:
|
|
||||||
schema = RequirementsReview.model_json_schema()
|
|
||||||
definitions = schema.get("$defs", {})
|
|
||||||
finding = definitions.get("ReviewFinding") if isinstance(definitions, dict) else None
|
|
||||||
if isinstance(finding, dict):
|
|
||||||
properties = finding.get("properties", {})
|
|
||||||
if isinstance(properties.get("draft_id"), dict):
|
|
||||||
properties["draft_id"] = {"enum": draft_ids}
|
|
||||||
source_items = properties.get("source_ids", {}).get("items") if isinstance(properties.get("source_ids"), dict) else None
|
|
||||||
if isinstance(source_items, dict):
|
|
||||||
source_items.clear()
|
|
||||||
source_items.update({"enum": source_ids})
|
|
||||||
return schema
|
|
||||||
|
|
||||||
|
|
||||||
def next_action_schema(working_head: str, requirement_ids: list[str], atomic_ids: list[str]) -> dict[str, Any]:
|
|
||||||
schema = NextAction.model_json_schema()
|
|
||||||
properties = schema.get("properties", {})
|
properties = schema.get("properties", {})
|
||||||
if isinstance(properties, dict):
|
if isinstance(properties, dict):
|
||||||
properties["working_head"] = {"const": working_head}
|
properties["operation"] = {"enum": atomic_ids}
|
||||||
if isinstance(properties.get("requirement_ids"), dict):
|
|
||||||
properties["requirement_ids"]["items"] = {"enum": requirement_ids}
|
|
||||||
properties["atomic_id"] = {"enum": atomic_ids}
|
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
|
|
||||||
def candidate_review_schema(candidate_id: str, working_head: str, claim_ids: list[str]) -> dict[str, Any]:
|
def stateless_final_review_schema(visual_claim_count: int) -> dict[str, Any]:
|
||||||
"""Bind an independent candidate review to immutable candidate facts."""
|
schema = StatelessFinalReview.model_json_schema()
|
||||||
schema = CandidateReview.model_json_schema()
|
|
||||||
properties = schema.get("properties", {})
|
properties = schema.get("properties", {})
|
||||||
if isinstance(properties, dict):
|
visual = properties.get("visual_claims") if isinstance(properties, dict) else None
|
||||||
properties["candidate_id"] = {"const": candidate_id}
|
if isinstance(visual, dict):
|
||||||
properties["working_head"] = {"const": working_head}
|
visual["minItems"] = visual_claim_count
|
||||||
_bind_claim_coverage_ids(schema, claim_ids)
|
visual["maxItems"] = visual_claim_count
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
|
|
||||||
def final_review_schema(working_head: str, claim_ids: list[str]) -> dict[str, Any]:
|
def stateless_rollback_checkpoint_schema(checkpoint_tokens: list[str]) -> dict[str, Any]:
|
||||||
"""Bind final review output to the currently reviewable revision."""
|
schema = StatelessRollbackCheckpoint.model_json_schema()
|
||||||
schema = FinalReview.model_json_schema()
|
|
||||||
properties = schema.get("properties", {})
|
properties = schema.get("properties", {})
|
||||||
if isinstance(properties, dict):
|
if isinstance(properties, dict):
|
||||||
properties["working_head"] = {"const": working_head}
|
properties["checkpoint_token"] = {"enum": checkpoint_tokens}
|
||||||
_bind_claim_coverage_ids(schema, claim_ids)
|
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
|
|
||||||
@@ -273,15 +228,6 @@ def topology_request_schema(working_head: str) -> dict[str, Any]:
|
|||||||
return schema
|
return schema
|
||||||
|
|
||||||
|
|
||||||
def operation_contract_request_schema(working_head: str, atomic_id: str) -> dict[str, Any]:
|
|
||||||
schema = OperationContractRequest.model_json_schema()
|
|
||||||
properties = schema.get("properties", {})
|
|
||||||
if isinstance(properties, dict):
|
|
||||||
properties["working_head"] = {"const": working_head}
|
|
||||||
properties["atomic_id"] = {"const": atomic_id}
|
|
||||||
return schema
|
|
||||||
|
|
||||||
|
|
||||||
def geometry_conclusion_schema(working_head: str, evidence_refs: list[str]) -> dict[str, Any]:
|
def geometry_conclusion_schema(working_head: str, evidence_refs: list[str]) -> dict[str, Any]:
|
||||||
"""Bind a diagnostic conclusion to evidence generated for this head."""
|
"""Bind a diagnostic conclusion to evidence generated for this head."""
|
||||||
schema = GeometryConclusion.model_json_schema()
|
schema = GeometryConclusion.model_json_schema()
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
"""Versioned, deterministic requirement-conflict resolutions."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import math
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
REGISTRY_VERSION = "cad.resolution-registry.v1"
|
|
||||||
FULL_CIRCLE_EQUAL_SPACING_VERSION = "full_circle_equal_spacing.v1"
|
|
||||||
|
|
||||||
|
|
||||||
class NormalizationError(ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_normalization(
|
|
||||||
*,
|
|
||||||
task_id: str,
|
|
||||||
draft_id: str,
|
|
||||||
source_ids: list[str],
|
|
||||||
description: str,
|
|
||||||
value: dict[str, Any],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if value.get("rule_id") != "full_circle_equal_spacing":
|
|
||||||
raise NormalizationError("Unknown deterministic normalization rule.")
|
|
||||||
count = value.get("count")
|
|
||||||
declared = value.get("declared_spacing_degrees")
|
|
||||||
if not isinstance(count, int) or isinstance(count, bool) or count < 2:
|
|
||||||
raise NormalizationError("Full-circle equal spacing requires an integer count of at least 2.")
|
|
||||||
if value.get("full_circle") is not True:
|
|
||||||
raise NormalizationError("Automatic equal-spacing normalization is allowed only for a full circle.")
|
|
||||||
if not isinstance(declared, (int, float)) or isinstance(declared, bool) or not math.isfinite(float(declared)):
|
|
||||||
raise NormalizationError("Declared angular spacing must be finite.")
|
|
||||||
adopted = 360.0 / count
|
|
||||||
if math.isclose(float(declared), adopted, rel_tol=0.0, abs_tol=1e-9):
|
|
||||||
raise NormalizationError("The declared spacing already matches 360/count; there is no derivable conflict.")
|
|
||||||
return {
|
|
||||||
"schema_version": "cad.requirements-normalization.v1",
|
|
||||||
"registry_version": REGISTRY_VERSION,
|
|
||||||
"rule_id": "full_circle_equal_spacing",
|
|
||||||
"rule_version": FULL_CIRCLE_EQUAL_SPACING_VERSION,
|
|
||||||
"task_id": task_id,
|
|
||||||
"draft_id": draft_id,
|
|
||||||
"source_ids": list(source_ids),
|
|
||||||
"original": {"count": count, "spacing_degrees": float(declared), "full_circle": True},
|
|
||||||
"adopted": {"count": count, "spacing_degrees": adopted, "full_circle": True},
|
|
||||||
"reason": description,
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
"""One-pass requirements specification and server-owned contract artifacts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
from hashlib import sha256
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.cad_agent.application.llm_contracts import (
|
||||||
|
RequirementsAuthorOutput,
|
||||||
|
RequirementsClarification,
|
||||||
|
RequirementsSpec,
|
||||||
|
requirements_spec_schema,
|
||||||
|
)
|
||||||
|
from app.cad_agent.application.results import Accepted, Rejected, Waiting
|
||||||
|
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
|
||||||
|
from app.cad_agent.domain.state import TaskPhase, TaskState, transition
|
||||||
|
from app.cad_agent.domain.verifier_registry import VerifierRegistry
|
||||||
|
from app.cad_agent.ports import ArtifactStore, TaskRepository
|
||||||
|
|
||||||
|
|
||||||
|
class RequirementsCommandHandler:
|
||||||
|
def __init__(self, repository: TaskRepository, artifacts: ArtifactStore, registry: VerifierRegistry) -> None:
|
||||||
|
self.repository = repository
|
||||||
|
self.artifacts = artifacts
|
||||||
|
self.registry = registry
|
||||||
|
self._evaluation_contract_oracles: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
self._evaluation_capability_gaps: dict[str, list[dict[str, str]]] = {}
|
||||||
|
|
||||||
|
def register_evaluation_contract_oracle(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
required_claims: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
validation_capability_gaps: list[dict[str, Any]] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Retain release-evaluation metadata without changing production decisions."""
|
||||||
|
self._evaluation_contract_oracles[task_id] = deepcopy(required_claims)
|
||||||
|
self._evaluation_capability_gaps[task_id] = [
|
||||||
|
{"id": str(item.get("id") or ""), "description": str(item.get("description") or "")}
|
||||||
|
for item in validation_capability_gaps or ()
|
||||||
|
if isinstance(item, dict)
|
||||||
|
]
|
||||||
|
|
||||||
|
def evaluation_review_context(self, task_id: str) -> dict[str, Any] | None:
|
||||||
|
claims = self._evaluation_contract_oracles.get(task_id)
|
||||||
|
if claims is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"evaluation_only": True,
|
||||||
|
"required_claims": deepcopy(claims),
|
||||||
|
"known_validation_capability_gaps": deepcopy(self._evaluation_capability_gaps.get(task_id, [])),
|
||||||
|
}
|
||||||
|
|
||||||
|
def spec_schema(self) -> dict[str, Any]:
|
||||||
|
return requirements_spec_schema(self.registry.expected_one_of_schema())
|
||||||
|
|
||||||
|
def submit_spec(self, task_id: str, output: RequirementsAuthorOutput, *, invocation_id: str) -> Accepted | Rejected | Waiting:
|
||||||
|
replay = self._replay(task_id, invocation_id)
|
||||||
|
if replay is not None:
|
||||||
|
return replay
|
||||||
|
state = self.repository.get_state(task_id)
|
||||||
|
if state is None or state.phase != TaskPhase.DRAFTING_REQUIREMENTS:
|
||||||
|
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements are not expected in the current workflow phase."))
|
||||||
|
value = output.root
|
||||||
|
if isinstance(value, RequirementsClarification):
|
||||||
|
return self._record_clarification(task_id, state, value, invocation_id=invocation_id)
|
||||||
|
if not isinstance(value, RequirementsSpec):
|
||||||
|
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements output is not a supported specification."))
|
||||||
|
|
||||||
|
field_errors: list[dict[str, str]] = []
|
||||||
|
for requirement_index, requirement in enumerate(value.requirements):
|
||||||
|
for claim_index, claim in enumerate(requirement.acceptance_claims):
|
||||||
|
try:
|
||||||
|
errors = self.registry.validate_expected(claim.claim_kind, claim.expected)
|
||||||
|
except ValueError:
|
||||||
|
errors = [{"path": "", "message": "VERIFIER_UNAVAILABLE"}]
|
||||||
|
field_errors.extend({
|
||||||
|
"path": f"/requirements/{requirement_index}/acceptance_claims/{claim_index}/expected{error['path']}",
|
||||||
|
"message": error["message"],
|
||||||
|
} for error in errors)
|
||||||
|
if field_errors:
|
||||||
|
return Rejected(WorkflowError(
|
||||||
|
ErrorCode.REQUIREMENTS_SPEC_INVALID,
|
||||||
|
"Requirements specification contains an unreadable or non-executable acceptance target.",
|
||||||
|
field_errors=tuple(field_errors),
|
||||||
|
))
|
||||||
|
|
||||||
|
invocation = self.repository.begin_invocation(
|
||||||
|
task_id,
|
||||||
|
invocation_id,
|
||||||
|
self._key(task_id, "requirements_spec", state.working_head, value.model_dump(mode="json")),
|
||||||
|
)
|
||||||
|
if invocation.status == "finished" and invocation.result is not None:
|
||||||
|
return self._restore(invocation.result)
|
||||||
|
|
||||||
|
source_ids = list(self.artifacts.read_source_index(task_id))
|
||||||
|
image_observation = self.artifacts.read_json(task_id, "documents/image-observation.json") or {}
|
||||||
|
warnings = [str(item) for item in image_observation.get("uncertainties") or () if str(item)]
|
||||||
|
requirements: list[dict[str, Any]] = []
|
||||||
|
claim_position = 1
|
||||||
|
for position, item in enumerate(value.requirements, 1):
|
||||||
|
claims: list[dict[str, Any]] = []
|
||||||
|
for claim in item.acceptance_claims:
|
||||||
|
deterministic = self.registry.definition(claim.claim_kind).deterministic
|
||||||
|
claims.append({
|
||||||
|
"claim_id": f"claim_{claim_position:03d}",
|
||||||
|
"claim_kind": claim.claim_kind,
|
||||||
|
"expected": claim.expected,
|
||||||
|
"verification_mode": "deterministic" if deterministic else "visual",
|
||||||
|
})
|
||||||
|
claim_position += 1
|
||||||
|
requirements.append({
|
||||||
|
"requirement_id": f"req_{position:03d}",
|
||||||
|
"source_ids": source_ids,
|
||||||
|
"statement": item.statement,
|
||||||
|
"assumptions": list(item.assumptions),
|
||||||
|
"acceptance_claims": claims,
|
||||||
|
})
|
||||||
|
spec_payload = {
|
||||||
|
"schema_version": "cad.requirements-spec.v1",
|
||||||
|
"summary": value.summary,
|
||||||
|
"assumptions": list(value.assumptions),
|
||||||
|
"requirements": [item.model_dump(mode="json") for item in value.requirements],
|
||||||
|
"image_observation_path": "documents/image-observation.json" if image_observation else "",
|
||||||
|
}
|
||||||
|
contract = {
|
||||||
|
"schema_version": "cad.requirements-contract.v3",
|
||||||
|
"task_id": task_id,
|
||||||
|
"summary": value.summary,
|
||||||
|
"assumptions": list(value.assumptions),
|
||||||
|
"requirements": requirements,
|
||||||
|
"verification_warnings": warnings,
|
||||||
|
}
|
||||||
|
contract["contract_hash"] = sha256(json.dumps(contract, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||||
|
try:
|
||||||
|
spec_path = self.artifacts.write_json_once(task_id, "documents/requirements-spec.json", spec_payload)
|
||||||
|
contract_path = self.artifacts.write_requirements_contract(task_id, contract, invocation_id=invocation_id)
|
||||||
|
except OSError as error:
|
||||||
|
return self._park_for_storage_retry(state, str(error))
|
||||||
|
next_state = transition(
|
||||||
|
state,
|
||||||
|
"requirements_approved",
|
||||||
|
requirements_spec_path=spec_path,
|
||||||
|
requirements_contract_path=contract_path,
|
||||||
|
clarification_path="",
|
||||||
|
)
|
||||||
|
result = Accepted({"phase": next_state.phase.value, "contract_path": contract_path})
|
||||||
|
if not self._commit(next_state, [{
|
||||||
|
"event": "requirements_contract_frozen",
|
||||||
|
"invocation_id": invocation_id,
|
||||||
|
"contract_hash": contract["contract_hash"],
|
||||||
|
"contract_path": contract_path,
|
||||||
|
"requirement_count": len(requirements),
|
||||||
|
"verification_warnings": warnings,
|
||||||
|
}], invocation, result):
|
||||||
|
return Rejected(self._stale())
|
||||||
|
self.ensure_rendered_contract_views(task_id, next_state)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def ensure_rendered_contract_views(self, task_id: str, state: TaskState) -> None:
|
||||||
|
if not state.requirements_contract_path:
|
||||||
|
return
|
||||||
|
contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path)
|
||||||
|
if not isinstance(contract, dict):
|
||||||
|
raise RuntimeError("Committed requirements contract is unavailable")
|
||||||
|
self.artifacts.write_requirements_contract(task_id, contract)
|
||||||
|
self.artifacts.write_text_once(task_id, "requirements.md", self._requirements_markdown(contract))
|
||||||
|
target = self._completion_target_markdown(contract)
|
||||||
|
self.artifacts.write_text_once(task_id, "completion-target.md", target)
|
||||||
|
|
||||||
|
def write_completion_result(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
state: TaskState,
|
||||||
|
*,
|
||||||
|
claim_results: list[dict[str, Any]],
|
||||||
|
review: dict[str, Any],
|
||||||
|
) -> str:
|
||||||
|
contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {}
|
||||||
|
by_id = {str(item.get("claim_id") or ""): item for item in claim_results if isinstance(item, dict)}
|
||||||
|
visual = iter(review.get("visual_claims") or ())
|
||||||
|
rows = ["# Completion Result", "", f"Status: {'completed with risks' if contract.get('verification_warnings') else 'verified'}", ""]
|
||||||
|
for requirement in contract.get("requirements") or ():
|
||||||
|
if not isinstance(requirement, dict):
|
||||||
|
continue
|
||||||
|
rows.append(f"## {requirement.get('statement')}")
|
||||||
|
for claim in requirement.get("acceptance_claims") or ():
|
||||||
|
if not isinstance(claim, dict):
|
||||||
|
continue
|
||||||
|
if claim.get("verification_mode") == "visual":
|
||||||
|
decision = next(visual, {})
|
||||||
|
status = str(decision.get("status") or "unknown")
|
||||||
|
evidence = str(decision.get("evidence") or "")
|
||||||
|
else:
|
||||||
|
result = by_id.get(str(claim.get("claim_id") or ""), {})
|
||||||
|
status = str(result.get("status") or "unknown")
|
||||||
|
evidence = json.dumps(result.get("evidence") or {}, ensure_ascii=False, sort_keys=True)
|
||||||
|
rows.append(f"- [{'x' if status == 'pass' else ' '}] {claim.get('claim_kind')}: {status}")
|
||||||
|
if evidence:
|
||||||
|
rows.append(f" - Evidence: {evidence}")
|
||||||
|
rows.append("")
|
||||||
|
warnings = [str(item) for item in contract.get("verification_warnings") or () if str(item)]
|
||||||
|
if warnings:
|
||||||
|
rows.extend(["## Verification Warnings", "", *[f"- {item}" for item in warnings], ""])
|
||||||
|
return self.artifacts.write_text_once(task_id, "completion-result.md", "\n".join(rows).rstrip() + "\n")
|
||||||
|
|
||||||
|
def _record_clarification(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
state: TaskState,
|
||||||
|
clarification: RequirementsClarification,
|
||||||
|
*,
|
||||||
|
invocation_id: str,
|
||||||
|
) -> Waiting | Rejected:
|
||||||
|
evidence = self.artifacts.read_source_requirements(task_id)
|
||||||
|
observation = self.artifacts.read_json(task_id, "documents/image-observation.json") or {}
|
||||||
|
evidence += "\n" + json.dumps(observation, ensure_ascii=False)
|
||||||
|
missing = [quote for quote in clarification.source_quotes if quote not in evidence]
|
||||||
|
if missing:
|
||||||
|
return Rejected(WorkflowError(
|
||||||
|
ErrorCode.REQUIREMENTS_SPEC_INVALID,
|
||||||
|
"Clarification quotes must be copied from the user request or image observation.",
|
||||||
|
field_errors=tuple({"path": "/source_quotes", "message": f"Unknown quote: {quote}"} for quote in missing),
|
||||||
|
))
|
||||||
|
invocation = self.repository.begin_invocation(
|
||||||
|
task_id,
|
||||||
|
invocation_id,
|
||||||
|
self._key(task_id, "requirements_clarification", state.working_head, clarification.model_dump(mode="json")),
|
||||||
|
)
|
||||||
|
payload = {"schema_version": "cad.requirements-clarification.v1", **clarification.model_dump(mode="json")}
|
||||||
|
try:
|
||||||
|
path = self.artifacts.write_json_once(task_id, f"documents/requirements-clarification-{sha256(clarification.question.encode()).hexdigest()[:12]}.json", payload)
|
||||||
|
except OSError as error:
|
||||||
|
return self._park_for_storage_retry(state, str(error))
|
||||||
|
next_state = transition(state, "waiting_for_user", error=ErrorCode.WAITING_FOR_USER, clarification_path=path)
|
||||||
|
result = Waiting(WorkflowError(
|
||||||
|
ErrorCode.WAITING_FOR_USER,
|
||||||
|
clarification.question,
|
||||||
|
details={"questions": [clarification.question], "source_quotes": list(clarification.source_quotes)},
|
||||||
|
))
|
||||||
|
if not self._commit(next_state, [{
|
||||||
|
"event": "requirements_waiting_for_user",
|
||||||
|
"invocation_id": invocation_id,
|
||||||
|
"review_path": path,
|
||||||
|
"message": clarification.question,
|
||||||
|
"questions": [clarification.question],
|
||||||
|
"source_quotes": list(clarification.source_quotes),
|
||||||
|
}], invocation, result):
|
||||||
|
return Rejected(self._stale())
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _replay(self, task_id: str, invocation_id: str) -> Accepted | Waiting | None:
|
||||||
|
invocation = self.repository.get_invocation(task_id, invocation_id)
|
||||||
|
if invocation is None or invocation.status != "finished" or invocation.result is None:
|
||||||
|
return None
|
||||||
|
return self._restore(invocation.result)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _restore(payload: dict[str, Any]) -> Accepted | Waiting:
|
||||||
|
if payload.get("result_type") == "waiting":
|
||||||
|
error = payload.get("error") if isinstance(payload.get("error"), dict) else {}
|
||||||
|
return Waiting(WorkflowError(
|
||||||
|
ErrorCode(str(error.get("code") or ErrorCode.WAITING_FOR_USER.value)),
|
||||||
|
str(error.get("message") or "Requirements need a user decision."),
|
||||||
|
tuple(error.get("field_errors") or ()),
|
||||||
|
bool(error.get("retryable")),
|
||||||
|
dict(error.get("details") or {}),
|
||||||
|
))
|
||||||
|
return Accepted(payload.get("payload") if isinstance(payload.get("payload"), dict) else payload)
|
||||||
|
|
||||||
|
def _commit(self, state: TaskState, events: list[dict[str, Any]], invocation: Any, result: Accepted | Waiting) -> bool:
|
||||||
|
payload = {"result_type": "waiting", "error": result.error.payload()} if isinstance(result, Waiting) else {"result_type": "accepted", "payload": result.payload}
|
||||||
|
return self.repository.compare_and_swap(state, events=events, invocation_id=invocation.invocation_id, invocation_result=payload)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _key(task_id: str, kind: str, head: str, value: dict[str, Any]) -> str:
|
||||||
|
encoded = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
||||||
|
return sha256(f"{task_id}|{kind}|{head}|{encoded}".encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _requirements_markdown(contract: dict[str, Any]) -> str:
|
||||||
|
rows = ["# Requirements", "", str(contract.get("summary") or ""), ""]
|
||||||
|
assumptions = [str(item) for item in contract.get("assumptions") or () if str(item)]
|
||||||
|
if assumptions:
|
||||||
|
rows.extend(["## Assumptions", "", *[f"- {item}" for item in assumptions], ""])
|
||||||
|
rows.extend(["## Requirements", ""])
|
||||||
|
for item in contract.get("requirements") or ():
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
rows.append(f"- {item.get('statement')}")
|
||||||
|
rows.extend(f" - Assumption: {value}" for value in item.get("assumptions") or ())
|
||||||
|
return "\n".join(rows).rstrip() + "\n"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _completion_target_markdown(contract: dict[str, Any]) -> str:
|
||||||
|
rows = ["# Completion Target", ""]
|
||||||
|
for requirement in contract.get("requirements") or ():
|
||||||
|
if not isinstance(requirement, dict):
|
||||||
|
continue
|
||||||
|
rows.append(f"## {requirement.get('statement')}")
|
||||||
|
for claim in requirement.get("acceptance_claims") or ():
|
||||||
|
if isinstance(claim, dict):
|
||||||
|
rows.append(f"- [ ] {claim.get('claim_kind')}: {json.dumps(claim.get('expected') or {}, ensure_ascii=False, sort_keys=True)}")
|
||||||
|
rows.append("")
|
||||||
|
return "\n".join(rows).rstrip() + "\n"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _stale() -> WorkflowError:
|
||||||
|
return WorkflowError(ErrorCode.STALE_WORKING_HEAD, "Task state changed before this command could commit.")
|
||||||
|
|
||||||
|
def _park_for_storage_retry(self, state: TaskState, message: str) -> Rejected:
|
||||||
|
waiting = transition(state, "waiting_retry", error=ErrorCode.STORAGE_FAILURE)
|
||||||
|
self.repository.compare_and_swap(waiting, events=[{
|
||||||
|
"event": "waiting_retry",
|
||||||
|
"code": ErrorCode.STORAGE_FAILURE.value,
|
||||||
|
"message": message[:1000],
|
||||||
|
}])
|
||||||
|
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Requirements artifact storage is temporarily unavailable.", retryable=True))
|
||||||
@@ -1,665 +0,0 @@
|
|||||||
"""Requirements drafting/review handlers; Markdown is rendered, never parsed."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from copy import deepcopy
|
|
||||||
from hashlib import sha256
|
|
||||||
import json
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from app.cad_agent.application.llm_contracts import (
|
|
||||||
RequirementsDraftBatch,
|
|
||||||
RequirementsPatchBatch,
|
|
||||||
RequirementsReview,
|
|
||||||
requirements_draft_schema,
|
|
||||||
requirements_patch_schema,
|
|
||||||
requirements_review_schema,
|
|
||||||
)
|
|
||||||
from app.cad_agent.application.normalization import NormalizationError, resolve_normalization
|
|
||||||
from app.cad_agent.application.results import Accepted, Rejected, Waiting
|
|
||||||
from app.cad_agent.domain.claim_matching import contains_expected
|
|
||||||
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
|
|
||||||
from app.cad_agent.domain.state import TaskPhase, TaskState, transition
|
|
||||||
from app.cad_agent.domain.verifier_registry import VerifierRegistry
|
|
||||||
from app.cad_agent.ports import ArtifactStore, TaskRepository
|
|
||||||
|
|
||||||
|
|
||||||
class RequirementsCommandHandler:
|
|
||||||
def __init__(self, repository: TaskRepository, artifacts: ArtifactStore, registry: VerifierRegistry) -> None:
|
|
||||||
self.repository = repository
|
|
||||||
self.artifacts = artifacts
|
|
||||||
self.registry = registry
|
|
||||||
# This intentionally has no production configuration path. The live
|
|
||||||
# evaluator registers its fixture oracle per task before authoring.
|
|
||||||
self._evaluation_contract_oracles: dict[str, list[dict[str, Any]]] = {}
|
|
||||||
self._evaluation_capability_gaps: dict[str, list[dict[str, str]]] = {}
|
|
||||||
|
|
||||||
def register_evaluation_contract_oracle(
|
|
||||||
self,
|
|
||||||
task_id: str,
|
|
||||||
required_claims: list[dict[str, Any]],
|
|
||||||
*,
|
|
||||||
validation_capability_gaps: list[dict[str, Any]] | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Register an external contract oracle for one live-evaluation task.
|
|
||||||
|
|
||||||
The source requirement remains evidence for normal production work;
|
|
||||||
this guarded fixture data exists only to keep an evaluation from
|
|
||||||
accepting a reviewer false positive before CAD execution starts.
|
|
||||||
"""
|
|
||||||
normalized: list[dict[str, Any]] = []
|
|
||||||
for item in required_claims:
|
|
||||||
claim_kind = item.get("claim_kind") if isinstance(item, dict) else None
|
|
||||||
expected = item.get("expected") if isinstance(item, dict) else None
|
|
||||||
if (
|
|
||||||
not isinstance(claim_kind, str)
|
|
||||||
or claim_kind not in self.registry.claim_kinds
|
|
||||||
or not isinstance(expected, dict)
|
|
||||||
):
|
|
||||||
raise ValueError("Evaluation contract oracle claims require claim_kind and expected object.")
|
|
||||||
# The frozen contract is production-valid and complete. Fixture
|
|
||||||
# expectations are intentionally partial so they can compare
|
|
||||||
# provider-chosen optional fields such as tolerances.
|
|
||||||
normalized.append({"claim_kind": claim_kind, "expected": deepcopy(expected)})
|
|
||||||
if not normalized:
|
|
||||||
raise ValueError("Evaluation contract oracle requires at least one claim.")
|
|
||||||
self._evaluation_contract_oracles[task_id] = normalized
|
|
||||||
gaps: list[dict[str, str]] = []
|
|
||||||
for gap in validation_capability_gaps or ():
|
|
||||||
gap_id = gap.get("id") if isinstance(gap, dict) else None
|
|
||||||
description = gap.get("description") if isinstance(gap, dict) else None
|
|
||||||
if not isinstance(gap_id, str) or not gap_id or not isinstance(description, str) or not description:
|
|
||||||
raise ValueError("Evaluation capability gaps require id and description.")
|
|
||||||
gaps.append({"id": gap_id, "description": description})
|
|
||||||
self._evaluation_capability_gaps[task_id] = gaps
|
|
||||||
|
|
||||||
def evaluation_review_context(self, task_id: str) -> dict[str, Any] | None:
|
|
||||||
"""Expose fixture-only known verifier gaps to the live reviewer."""
|
|
||||||
gaps = self._evaluation_capability_gaps.get(task_id)
|
|
||||||
if gaps is None:
|
|
||||||
return None
|
|
||||||
return {"evaluation_only": True, "known_validation_capability_gaps": deepcopy(gaps)}
|
|
||||||
|
|
||||||
def draft_schema(self, task_id: str) -> dict[str, Any]:
|
|
||||||
return requirements_draft_schema(self.registry.expected_one_of_schema(), list(self.artifacts.read_source_index(task_id)))
|
|
||||||
|
|
||||||
def patch_schema(self, task_id: str) -> dict[str, Any]:
|
|
||||||
state = self.repository.get_state(task_id)
|
|
||||||
draft = self._draft(state)
|
|
||||||
ids = [str(item.get("draft_id") or "") for item in draft.get("items") or () if isinstance(item, dict) and item.get("draft_id")]
|
|
||||||
return requirements_patch_schema(self.registry.expected_one_of_schema(), list(self.artifacts.read_source_index(task_id)), ids)
|
|
||||||
|
|
||||||
def review_schema(self, task_id: str) -> dict[str, Any]:
|
|
||||||
state = self.repository.get_state(task_id)
|
|
||||||
draft = self._draft(state)
|
|
||||||
draft_ids = [str(item.get("draft_id") or "") for item in draft.get("items") or () if isinstance(item, dict) and item.get("draft_id")]
|
|
||||||
source_ids = list(self.artifacts.read_source_index(task_id))
|
|
||||||
return requirements_review_schema(source_ids, draft_ids)
|
|
||||||
|
|
||||||
def submit_draft(self, task_id: str, batch: RequirementsDraftBatch, *, invocation_id: str) -> Accepted | Rejected:
|
|
||||||
replay = self._replay(task_id, invocation_id)
|
|
||||||
if replay is not None:
|
|
||||||
return replay
|
|
||||||
state = self.repository.get_state(task_id)
|
|
||||||
if state is None:
|
|
||||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Task does not exist."))
|
|
||||||
if state.phase != TaskPhase.DRAFTING_REQUIREMENTS:
|
|
||||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements cannot be drafted in the current workflow phase."))
|
|
||||||
draft = self._draft(state)
|
|
||||||
source_index = self.artifacts.read_source_index(task_id)
|
|
||||||
current = [deepcopy(item) for item in draft.get("items") or () if isinstance(item, dict)]
|
|
||||||
errors = self._validate_items(batch.items, source_index, current)
|
|
||||||
if errors:
|
|
||||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Requirements draft batch failed canonical validation.", field_errors=tuple(errors)))
|
|
||||||
invocation = self.repository.begin_invocation(
|
|
||||||
task_id,
|
|
||||||
invocation_id,
|
|
||||||
self._key(task_id, "requirements_draft", state.working_head, batch.model_dump(mode="json")),
|
|
||||||
)
|
|
||||||
if invocation.status == "finished" and invocation.result is not None:
|
|
||||||
return self._restore(invocation.result)
|
|
||||||
start = len(current) + 1
|
|
||||||
for position, item in enumerate(batch.items, start):
|
|
||||||
current.append({"draft_id": f"draft_{position:03d}", **item.model_dump(mode="json")})
|
|
||||||
payload = {"schema_version": "cad.requirements-draft.v1", "revision": int(draft.get("revision") or 0) + 1, "items": current}
|
|
||||||
try:
|
|
||||||
artifact_path = self.artifacts.write_requirements_draft(task_id, payload, invocation_id=invocation_id)
|
|
||||||
except OSError as error:
|
|
||||||
return self._park_for_storage_retry(
|
|
||||||
state,
|
|
||||||
event="requirements_draft_storage_failure",
|
|
||||||
message=str(error),
|
|
||||||
)
|
|
||||||
next_state = transition(state, "draft_updated", requirements_draft_path=artifact_path)
|
|
||||||
result = Accepted({"draft_revision": payload["revision"], "draft_ids": [item["draft_id"] for item in current[-len(batch.items):]]})
|
|
||||||
if not self._commit(next_state, [{"event": "requirements_draft_updated", "invocation_id": invocation_id, "draft_revision": payload["revision"], "draft_path": artifact_path, "item_count": len(current)}], invocation, result):
|
|
||||||
return Rejected(self._stale())
|
|
||||||
return result
|
|
||||||
|
|
||||||
def patch_draft(self, task_id: str, batch: RequirementsPatchBatch, *, invocation_id: str) -> Accepted | Rejected:
|
|
||||||
replay = self._replay(task_id, invocation_id)
|
|
||||||
if replay is not None:
|
|
||||||
return replay
|
|
||||||
state = self.repository.get_state(task_id)
|
|
||||||
if state is None or state.phase != TaskPhase.DRAFTING_REQUIREMENTS:
|
|
||||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements patches are not allowed in the current workflow phase."))
|
|
||||||
draft = self._draft(state)
|
|
||||||
items = [deepcopy(item) for item in draft.get("items") or () if isinstance(item, dict)]
|
|
||||||
by_id = {str(item.get("draft_id") or ""): item for item in items}
|
|
||||||
source_index = self.artifacts.read_source_index(task_id)
|
|
||||||
errors: list[dict[str, str]] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
for index, patch in enumerate(batch.patches):
|
|
||||||
if patch.target_draft_id in seen:
|
|
||||||
errors.append({"path": f"/patches/{index}/target_draft_id", "message": "A draft item may be patched only once per batch."})
|
|
||||||
seen.add(patch.target_draft_id)
|
|
||||||
if patch.target_draft_id not in by_id:
|
|
||||||
errors.append({"path": f"/patches/{index}/target_draft_id", "message": "Unknown current draft ID."})
|
|
||||||
if patch.item:
|
|
||||||
errors.extend(self._validate_items([patch.item], source_index, [item for item in items if item.get("draft_id") != patch.target_draft_id], prefix=f"/patches/{index}/item"))
|
|
||||||
if errors:
|
|
||||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Requirements patch batch failed canonical validation.", field_errors=tuple(errors)))
|
|
||||||
invocation = self.repository.begin_invocation(
|
|
||||||
task_id,
|
|
||||||
invocation_id,
|
|
||||||
self._key(task_id, "requirements_patch", state.working_head, batch.model_dump(mode="json")),
|
|
||||||
)
|
|
||||||
if invocation.status == "finished" and invocation.result is not None:
|
|
||||||
return self._restore(invocation.result)
|
|
||||||
remaining: list[dict[str, Any]] = []
|
|
||||||
patches = {patch.target_draft_id: patch for patch in batch.patches}
|
|
||||||
for item in items:
|
|
||||||
patch = patches.get(str(item["draft_id"]))
|
|
||||||
if patch is None:
|
|
||||||
remaining.append(item)
|
|
||||||
elif patch.op == "replace" and patch.item is not None:
|
|
||||||
remaining.append({"draft_id": item["draft_id"], **patch.item.model_dump(mode="json")})
|
|
||||||
payload = {"schema_version": "cad.requirements-draft.v1", "revision": int(draft.get("revision") or 0) + 1, "items": remaining}
|
|
||||||
try:
|
|
||||||
artifact_path = self.artifacts.write_requirements_draft(task_id, payload, invocation_id=invocation_id)
|
|
||||||
except OSError as error:
|
|
||||||
return self._park_for_storage_retry(
|
|
||||||
state,
|
|
||||||
event="requirements_patch_storage_failure",
|
|
||||||
message=str(error),
|
|
||||||
)
|
|
||||||
# A successful patch creates a new draft revision. The prior review
|
|
||||||
# describes the old revision and must not keep the author trapped in
|
|
||||||
# patch-only mode; it will be replaced after explicit re-finalization.
|
|
||||||
next_state = transition(
|
|
||||||
state,
|
|
||||||
"draft_updated",
|
|
||||||
requirements_draft_path=artifact_path,
|
|
||||||
requirements_review_path="",
|
|
||||||
)
|
|
||||||
result = Accepted({"draft_revision": payload["revision"], "item_count": len(remaining)})
|
|
||||||
if not self._commit(next_state, [{"event": "requirements_draft_patched", "invocation_id": invocation_id, "draft_revision": payload["revision"], "draft_path": artifact_path, "item_count": len(remaining)}], invocation, result):
|
|
||||||
return Rejected(self._stale())
|
|
||||||
return result
|
|
||||||
|
|
||||||
def finalize_draft(self, task_id: str, *, invocation_id: str) -> Accepted | Rejected:
|
|
||||||
replay = self._replay(task_id, invocation_id)
|
|
||||||
if replay is not None:
|
|
||||||
return replay
|
|
||||||
state = self.repository.get_state(task_id)
|
|
||||||
if state is None or state.phase != TaskPhase.DRAFTING_REQUIREMENTS:
|
|
||||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements cannot be finalized in the current workflow phase."))
|
|
||||||
draft = self._draft(state)
|
|
||||||
items = [item for item in draft.get("items") or () if isinstance(item, dict)]
|
|
||||||
source_index = self.artifacts.read_source_index(task_id)
|
|
||||||
covered = {str(source) for item in items for source in item.get("source_ids") or ()}
|
|
||||||
missing = sorted(set(source_index) - covered)
|
|
||||||
if not items or missing:
|
|
||||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "REQUIREMENTS_COVERAGE_INCOMPLETE", details={"missing_source_ids": missing}))
|
|
||||||
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, "requirements_finalize", state.working_head, {}))
|
|
||||||
if invocation.status == "finished" and invocation.result is not None:
|
|
||||||
return self._restore(invocation.result)
|
|
||||||
next_state = transition(state, "requirements_finalized")
|
|
||||||
result = Accepted({"phase": next_state.phase.value, "draft_revision": draft.get("revision", 0)})
|
|
||||||
if not self._commit(next_state, [{"event": "requirements_review_requested", "invocation_id": invocation_id, "draft_revision": draft.get("revision", 0)}], invocation, result):
|
|
||||||
return Rejected(self._stale())
|
|
||||||
return result
|
|
||||||
|
|
||||||
def record_review(self, task_id: str, review: RequirementsReview, *, invocation_id: str) -> Accepted | Rejected | Waiting:
|
|
||||||
replay = self._replay(task_id, invocation_id)
|
|
||||||
if replay is not None:
|
|
||||||
return replay
|
|
||||||
state = self.repository.get_state(task_id)
|
|
||||||
if state is None or state.phase != TaskPhase.REVIEWING_REQUIREMENTS:
|
|
||||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements review is not expected in the current workflow phase."))
|
|
||||||
draft = self._draft(state)
|
|
||||||
items = [item for item in draft.get("items") or () if isinstance(item, dict)]
|
|
||||||
source_ids = set(self.artifacts.read_source_index(task_id))
|
|
||||||
draft_ids = {str(item.get("draft_id") or "") for item in items}
|
|
||||||
errors = self._validate_review(review, source_ids, items, draft_ids)
|
|
||||||
if errors:
|
|
||||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Requirements review failed canonical validation.", field_errors=tuple(errors)))
|
|
||||||
invocation = self.repository.begin_invocation(
|
|
||||||
task_id,
|
|
||||||
invocation_id,
|
|
||||||
self._key(task_id, "requirements_review", state.working_head, review.model_dump(mode="json")),
|
|
||||||
)
|
|
||||||
if invocation.status == "finished" and invocation.result is not None:
|
|
||||||
return self._restore(invocation.result)
|
|
||||||
coverage = self._derive_coverage(source_ids, items)
|
|
||||||
normalizations: list[dict[str, Any]] = []
|
|
||||||
normalization_errors: list[dict[str, str]] = []
|
|
||||||
for index, finding in enumerate(review.findings):
|
|
||||||
if finding.finding_type != "derivable_conflict" or finding.normalization is None:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
normalizations.append(resolve_normalization(
|
|
||||||
task_id=task_id,
|
|
||||||
draft_id=finding.draft_id,
|
|
||||||
source_ids=list(finding.source_ids),
|
|
||||||
description=finding.description,
|
|
||||||
value=finding.normalization.model_dump(mode="json"),
|
|
||||||
))
|
|
||||||
except NormalizationError as error:
|
|
||||||
normalization_errors.append({"path": f"/findings/{index}/normalization", "message": str(error)})
|
|
||||||
if normalization_errors:
|
|
||||||
return Rejected(WorkflowError(
|
|
||||||
ErrorCode.AUTHOR_FORMAT_INVALID,
|
|
||||||
"Requirements review supplied an invalid deterministic normalization.",
|
|
||||||
field_errors=tuple(normalization_errors),
|
|
||||||
))
|
|
||||||
warnings = [
|
|
||||||
finding.description
|
|
||||||
for finding in review.findings
|
|
||||||
if finding.finding_type == "verification_gap"
|
|
||||||
]
|
|
||||||
author_findings = [
|
|
||||||
finding for finding in review.findings
|
|
||||||
if finding.finding_type in {"missing_source_semantics", "claim_mismatch"}
|
|
||||||
or (
|
|
||||||
finding.finding_type == "verification_gap"
|
|
||||||
and not self._draft_has_visual_claim(items, finding.draft_id)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
ambiguous = [finding for finding in review.findings if finding.finding_type == "ambiguous_conflict"]
|
|
||||||
decision = "waiting_for_user" if ambiguous else "revise" if author_findings else "freeze"
|
|
||||||
review_payload = {
|
|
||||||
"schema_version": "cad.requirements-review.v2",
|
|
||||||
"findings": [finding.model_dump(mode="json") for finding in review.findings],
|
|
||||||
"coverage": coverage,
|
|
||||||
"decision": decision,
|
|
||||||
"verification_warnings": warnings,
|
|
||||||
"applied_normalizations": normalizations,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
review_path = self.artifacts.write_requirements_review(task_id, review_payload, invocation_id=invocation_id)
|
|
||||||
except OSError as error:
|
|
||||||
return self._park_for_storage_retry(
|
|
||||||
state,
|
|
||||||
event="requirements_review_storage_failure",
|
|
||||||
message=str(error),
|
|
||||||
)
|
|
||||||
if ambiguous:
|
|
||||||
next_state = transition(state, "waiting_for_user", error=ErrorCode.WAITING_FOR_USER, requirements_review_path=review_path)
|
|
||||||
questions = [str(finding.question) for finding in ambiguous if finding.question]
|
|
||||||
message = "Requirements contain an ambiguity that cannot be resolved deterministically."
|
|
||||||
ambiguous_findings = [finding.model_dump(mode="json") for finding in ambiguous]
|
|
||||||
result = Waiting(WorkflowError(
|
|
||||||
ErrorCode.WAITING_FOR_USER,
|
|
||||||
message,
|
|
||||||
details={"questions": questions, "findings": ambiguous_findings},
|
|
||||||
))
|
|
||||||
if not self._commit(next_state, [{
|
|
||||||
"event": "requirements_waiting_for_user",
|
|
||||||
"invocation_id": invocation_id,
|
|
||||||
"review_path": review_path,
|
|
||||||
"message": message,
|
|
||||||
"questions": questions,
|
|
||||||
"findings": ambiguous_findings,
|
|
||||||
}], invocation, result):
|
|
||||||
return Rejected(self._stale())
|
|
||||||
return result
|
|
||||||
if author_findings:
|
|
||||||
next_state = transition(state, "requirements_revise", requirements_review_path=review_path)
|
|
||||||
result = Accepted({"phase": next_state.phase.value, "review": review_payload})
|
|
||||||
if not self._commit(next_state, [{"event": "requirements_review_revise", "invocation_id": invocation_id, "review_path": review_path}], invocation, result):
|
|
||||||
return Rejected(self._stale())
|
|
||||||
return result
|
|
||||||
if normalizations:
|
|
||||||
normalization_digest = sha256(
|
|
||||||
json.dumps(normalizations, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
||||||
).hexdigest()[:12]
|
|
||||||
try:
|
|
||||||
normalization_path = self.artifacts.write_json_once(
|
|
||||||
task_id,
|
|
||||||
f"documents/requirements-normalizations-{normalization_digest}.json",
|
|
||||||
{
|
|
||||||
"schema_version": "cad.requirements-normalizations.v1",
|
|
||||||
"task_id": task_id,
|
|
||||||
"items": normalizations,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except OSError as error:
|
|
||||||
return self._park_for_storage_retry(
|
|
||||||
state,
|
|
||||||
event="requirements_normalization_storage_failure",
|
|
||||||
message=str(error),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
normalization_path = ""
|
|
||||||
contract = self._freeze_contract(
|
|
||||||
task_id,
|
|
||||||
items,
|
|
||||||
verification_warnings=warnings,
|
|
||||||
applied_normalizations=normalizations,
|
|
||||||
)
|
|
||||||
missing_claims = self._missing_evaluation_oracle_claims(task_id, contract)
|
|
||||||
if missing_claims:
|
|
||||||
oracle_payload = self._evaluation_oracle_failure(
|
|
||||||
task_id,
|
|
||||||
draft_revision=int(draft.get("revision") or 0),
|
|
||||||
reviewer_review_path=review_path,
|
|
||||||
draft_ids=sorted(draft_ids),
|
|
||||||
missing_claims=missing_claims,
|
|
||||||
)
|
|
||||||
oracle_digest = sha256(
|
|
||||||
json.dumps(oracle_payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
||||||
).hexdigest()[:12]
|
|
||||||
try:
|
|
||||||
oracle_path = self.artifacts.write_json_once(
|
|
||||||
task_id,
|
|
||||||
f"documents/evaluation-contract-oracle-{oracle_digest}.json",
|
|
||||||
oracle_payload,
|
|
||||||
)
|
|
||||||
except OSError as error:
|
|
||||||
return self._park_for_storage_retry(
|
|
||||||
state,
|
|
||||||
event="evaluation_contract_oracle_storage_failure",
|
|
||||||
message=str(error),
|
|
||||||
)
|
|
||||||
next_state = transition(state, "requirements_revise", requirements_review_path=oracle_path)
|
|
||||||
result = Accepted({
|
|
||||||
"phase": next_state.phase.value,
|
|
||||||
"review": oracle_payload,
|
|
||||||
"reviewer_review_path": review_path,
|
|
||||||
"evaluation_oracle": {"missing_claims": missing_claims},
|
|
||||||
})
|
|
||||||
if not self._commit(next_state, [{
|
|
||||||
"event": "requirements_evaluation_oracle_revise",
|
|
||||||
"invocation_id": invocation_id,
|
|
||||||
"review_path": review_path,
|
|
||||||
"oracle_path": oracle_path,
|
|
||||||
"missing_claims": missing_claims,
|
|
||||||
}], invocation, result):
|
|
||||||
return Rejected(self._stale())
|
|
||||||
return result
|
|
||||||
try:
|
|
||||||
contract_path = self.artifacts.write_requirements_contract(task_id, contract, invocation_id=invocation_id)
|
|
||||||
except OSError as error:
|
|
||||||
return self._park_for_storage_retry(
|
|
||||||
state,
|
|
||||||
event="requirements_contract_storage_failure",
|
|
||||||
message=str(error),
|
|
||||||
)
|
|
||||||
next_state = transition(
|
|
||||||
state,
|
|
||||||
"requirements_approved",
|
|
||||||
requirements_review_path=review_path,
|
|
||||||
requirements_contract_path=contract_path,
|
|
||||||
)
|
|
||||||
result = Accepted({
|
|
||||||
"phase": next_state.phase.value,
|
|
||||||
"contract_hash": contract["contract_hash"],
|
|
||||||
"verification_warnings": warnings,
|
|
||||||
"applied_normalizations": normalizations,
|
|
||||||
})
|
|
||||||
events = []
|
|
||||||
if normalizations:
|
|
||||||
events.append({
|
|
||||||
"event": "requirements_normalized",
|
|
||||||
"normalization_path": normalization_path,
|
|
||||||
"applied_normalizations": normalizations,
|
|
||||||
})
|
|
||||||
events.append({"event": "requirements_contract_frozen", "invocation_id": invocation_id, "contract_hash": contract["contract_hash"], "contract_path": contract_path, "review_path": review_path, "requirement_count": len(contract["requirements"]), "verification_warnings": warnings, "applied_normalizations": normalizations})
|
|
||||||
if not self._commit(next_state, events, invocation, result):
|
|
||||||
return Rejected(self._stale())
|
|
||||||
return result
|
|
||||||
|
|
||||||
def ensure_rendered_contract_views(self, task_id: str, state: TaskState) -> None:
|
|
||||||
"""Rebuild read-only contract views from the committed immutable path.
|
|
||||||
|
|
||||||
The SQLite transition is authoritative. Rendering convenience files
|
|
||||||
after its commit must never reclassify a frozen contract as an
|
|
||||||
internal authoring failure, so this idempotent operation is replayed
|
|
||||||
by the workflow until every view exists.
|
|
||||||
"""
|
|
||||||
if not state.requirements_contract_path:
|
|
||||||
return
|
|
||||||
contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path)
|
|
||||||
if not isinstance(contract, dict):
|
|
||||||
raise RuntimeError("Committed requirements contract is unavailable")
|
|
||||||
self.artifacts.write_requirements_contract(task_id, contract)
|
|
||||||
self.artifacts.write_json_once(task_id, "requirements-index.json", {
|
|
||||||
"schema_version": "cad.requirements-index.v1",
|
|
||||||
"requirements": [
|
|
||||||
{
|
|
||||||
"requirement_id": item["requirement_id"],
|
|
||||||
"claim_ids": [claim["claim_id"] for claim in item["acceptance_claims"]],
|
|
||||||
}
|
|
||||||
for item in contract.get("requirements") or ()
|
|
||||||
if isinstance(item, dict)
|
|
||||||
],
|
|
||||||
})
|
|
||||||
self.artifacts.write_text_once(task_id, "requirements.md", self._requirements_markdown(contract))
|
|
||||||
self.artifacts.write_text_once(task_id, "completion.md", self._completion_markdown(contract))
|
|
||||||
|
|
||||||
def _replay(self, task_id: str, invocation_id: str) -> Accepted | Waiting | None:
|
|
||||||
invocation = self.repository.get_invocation(task_id, invocation_id)
|
|
||||||
if invocation is None or invocation.status != "finished" or invocation.result is None:
|
|
||||||
return None
|
|
||||||
return self._restore(invocation.result)
|
|
||||||
|
|
||||||
def _finish(self, invocation_id: str, result: Accepted | Waiting) -> None:
|
|
||||||
if isinstance(result, Waiting):
|
|
||||||
payload = {"result_type": "waiting", "error": result.error.payload()}
|
|
||||||
else:
|
|
||||||
payload = {"result_type": "accepted", "payload": result.payload}
|
|
||||||
self.repository.finish_invocation(invocation_id, payload)
|
|
||||||
|
|
||||||
def _commit(self, state: TaskState, events: list[dict[str, Any]], invocation: Any, result: Accepted | Waiting) -> bool:
|
|
||||||
if isinstance(result, Waiting):
|
|
||||||
payload = {"result_type": "waiting", "error": result.error.payload()}
|
|
||||||
else:
|
|
||||||
payload = {"result_type": "accepted", "payload": result.payload}
|
|
||||||
return self.repository.compare_and_swap(
|
|
||||||
state,
|
|
||||||
events=events,
|
|
||||||
invocation_id=invocation.invocation_id,
|
|
||||||
invocation_result=payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _restore(payload: dict[str, Any]) -> Accepted | Waiting:
|
|
||||||
if payload.get("result_type") == "waiting":
|
|
||||||
error = payload.get("error") if isinstance(payload.get("error"), dict) else {}
|
|
||||||
return Waiting(WorkflowError(
|
|
||||||
ErrorCode(str(error.get("code") or ErrorCode.WAITING_FOR_USER.value)),
|
|
||||||
str(error.get("message") or "Requirements need a user decision."),
|
|
||||||
tuple(error.get("field_errors") or ()),
|
|
||||||
bool(error.get("retryable")),
|
|
||||||
dict(error.get("details") or {}),
|
|
||||||
))
|
|
||||||
value = payload.get("payload") if isinstance(payload.get("payload"), dict) else payload
|
|
||||||
return Accepted(value)
|
|
||||||
|
|
||||||
def _draft(self, state: TaskState | None) -> dict[str, Any]:
|
|
||||||
return self.artifacts.read_requirements_draft(state.task_id, state.requirements_draft_path) if state is not None else {"schema_version": "cad.requirements-draft.v1", "revision": 0, "items": []}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _key(task_id: str, kind: str, head: str, value: dict[str, Any]) -> str:
|
|
||||||
encoded = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
|
||||||
return sha256(f"{task_id}|{kind}|{head}|{encoded}".encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
def _validate_items(self, inputs: list[Any], source_index: dict[str, str], existing: list[dict[str, Any]], *, prefix: str = "/items") -> list[dict[str, str]]:
|
|
||||||
errors: list[dict[str, str]] = []
|
|
||||||
seen = {(tuple(item.get("source_ids") or ()), str(item.get("statement") or "").casefold()) for item in existing}
|
|
||||||
for index, item in enumerate(inputs):
|
|
||||||
path = f"{prefix}/{index}"
|
|
||||||
if not set(item.source_ids).issubset(source_index):
|
|
||||||
errors.append({"path": f"{path}/source_ids", "message": "source_ids must come from the current source index."})
|
|
||||||
signature = (tuple(item.source_ids), item.statement.casefold())
|
|
||||||
if signature in seen:
|
|
||||||
errors.append({"path": f"{path}/statement", "message": "Duplicate requirement item."})
|
|
||||||
seen.add(signature)
|
|
||||||
for claim_index, claim in enumerate(item.acceptance_claims):
|
|
||||||
try:
|
|
||||||
claim_errors = self.registry.validate_expected(claim.claim_kind, claim.expected)
|
|
||||||
except ValueError:
|
|
||||||
errors.append({"path": f"{path}/acceptance_claims/{claim_index}/claim_kind", "message": "VERIFIER_UNAVAILABLE"})
|
|
||||||
continue
|
|
||||||
errors.extend({"path": f"{path}/acceptance_claims/{claim_index}/expected{error['path']}", "message": error["message"]} for error in claim_errors)
|
|
||||||
return errors
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _validate_review(
|
|
||||||
review: RequirementsReview,
|
|
||||||
source_ids: set[str],
|
|
||||||
items: list[dict[str, Any]],
|
|
||||||
draft_ids: set[str],
|
|
||||||
) -> list[dict[str, str]]:
|
|
||||||
errors: list[dict[str, str]] = []
|
|
||||||
by_draft = {str(item.get("draft_id") or ""): item for item in items}
|
|
||||||
for index, finding in enumerate(review.findings):
|
|
||||||
if finding.draft_id not in draft_ids:
|
|
||||||
errors.append({"path": f"/findings/{index}/draft_id", "message": "Finding must reference a current draft item."})
|
|
||||||
continue
|
|
||||||
if not set(finding.source_ids).issubset(source_ids):
|
|
||||||
errors.append({"path": f"/findings/{index}/source_ids", "message": "Finding source_ids must come from the current source index."})
|
|
||||||
continue
|
|
||||||
cited = set(by_draft[finding.draft_id].get("source_ids") or ())
|
|
||||||
if not set(finding.source_ids).issubset(cited):
|
|
||||||
errors.append({"path": f"/findings/{index}/source_ids", "message": "Finding source_ids must be cited by its draft item."})
|
|
||||||
return errors
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _derive_coverage(source_ids: set[str], items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
||||||
coverage = {source_id: [] for source_id in sorted(source_ids)}
|
|
||||||
for position, item in enumerate(items, 1):
|
|
||||||
requirement_id = f"req_{position:03d}"
|
|
||||||
for source_id in item.get("source_ids") or ():
|
|
||||||
if source_id in coverage:
|
|
||||||
coverage[source_id].append(requirement_id)
|
|
||||||
return [{"source_id": source_id, "requirement_ids": requirement_ids} for source_id, requirement_ids in coverage.items()]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _draft_has_visual_claim(items: list[dict[str, Any]], draft_id: str) -> bool:
|
|
||||||
return any(
|
|
||||||
claim.get("claim_kind") == "visual"
|
|
||||||
for item in items
|
|
||||||
if item.get("draft_id") == draft_id
|
|
||||||
for claim in item.get("acceptance_claims") or ()
|
|
||||||
if isinstance(claim, dict)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _freeze_contract(
|
|
||||||
self,
|
|
||||||
task_id: str,
|
|
||||||
items: list[dict[str, Any]],
|
|
||||||
*,
|
|
||||||
verification_warnings: list[str],
|
|
||||||
applied_normalizations: list[dict[str, Any]],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
requirements: list[dict[str, Any]] = []
|
|
||||||
claim_position = 1
|
|
||||||
for position, item in enumerate(items, 1):
|
|
||||||
claims: list[dict[str, Any]] = []
|
|
||||||
for claim in item.get("acceptance_claims") or ():
|
|
||||||
deterministic = self.registry.definition(str(claim["claim_kind"])).deterministic
|
|
||||||
claims.append({"claim_id": f"claim_{claim_position:03d}", "claim_kind": claim["claim_kind"], "expected": claim["expected"], "verification_mode": "deterministic" if deterministic else "visual"})
|
|
||||||
claim_position += 1
|
|
||||||
requirements.append({"requirement_id": f"req_{position:03d}", "draft_id": item["draft_id"], "source_ids": item["source_ids"], "statement": item["statement"], "assumptions": item["assumptions"], "acceptance_claims": claims})
|
|
||||||
payload = {
|
|
||||||
"schema_version": "cad.requirements-contract.v2",
|
|
||||||
"task_id": task_id,
|
|
||||||
"requirements": requirements,
|
|
||||||
"verification_warnings": list(verification_warnings),
|
|
||||||
"applied_normalizations": deepcopy(applied_normalizations),
|
|
||||||
}
|
|
||||||
payload["contract_hash"] = sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
|
||||||
return payload
|
|
||||||
|
|
||||||
def _missing_evaluation_oracle_claims(self, task_id: str, contract: dict[str, Any]) -> list[dict[str, Any]]:
|
|
||||||
required = self._evaluation_contract_oracles.get(task_id)
|
|
||||||
if not required:
|
|
||||||
return []
|
|
||||||
actual = [
|
|
||||||
{"claim_kind": str(claim.get("claim_kind") or ""), "expected": claim.get("expected")}
|
|
||||||
for requirement in contract.get("requirements") or ()
|
|
||||||
if isinstance(requirement, dict)
|
|
||||||
for claim in requirement.get("acceptance_claims") or ()
|
|
||||||
if isinstance(claim, dict) and isinstance(claim.get("expected"), dict)
|
|
||||||
]
|
|
||||||
return [
|
|
||||||
deepcopy(claim)
|
|
||||||
for claim in required
|
|
||||||
if not any(
|
|
||||||
actual_claim["claim_kind"] == claim["claim_kind"]
|
|
||||||
and contains_expected(actual_claim["expected"], claim["expected"])
|
|
||||||
for actual_claim in actual
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _evaluation_oracle_failure(
|
|
||||||
task_id: str,
|
|
||||||
*,
|
|
||||||
draft_revision: int,
|
|
||||||
reviewer_review_path: str,
|
|
||||||
draft_ids: list[str],
|
|
||||||
missing_claims: list[dict[str, Any]],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"schema_version": "cad.requirements-evaluation-oracle.v1",
|
|
||||||
"task_id": task_id,
|
|
||||||
"evaluation_only": True,
|
|
||||||
"decision": "revise",
|
|
||||||
"draft_revision": draft_revision,
|
|
||||||
"reviewer_decision": "freeze",
|
|
||||||
"reviewer_review_path": reviewer_review_path,
|
|
||||||
"findings": [
|
|
||||||
{
|
|
||||||
"draft_id": draft_id,
|
|
||||||
"source_ids": [],
|
|
||||||
"finding_type": "missing_source_semantics",
|
|
||||||
"description": "The external live-evaluation contract oracle found missing executable acceptance claims.",
|
|
||||||
}
|
|
||||||
for draft_id in draft_ids
|
|
||||||
],
|
|
||||||
"missing_claims": missing_claims,
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _requirements_markdown(contract: dict[str, Any]) -> str:
|
|
||||||
rows = ["# Requirements", ""]
|
|
||||||
for item in contract["requirements"]:
|
|
||||||
rows.append(f"- {item['requirement_id']}: {item['statement']}")
|
|
||||||
for assumption in item["assumptions"]:
|
|
||||||
rows.append(f" - Assumption: {assumption}")
|
|
||||||
return "\n".join(rows) + "\n"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _completion_markdown(contract: dict[str, Any]) -> str:
|
|
||||||
return "# Completion\n\n" + "\n".join(f"- [ ] {item['requirement_id']}: {item['statement']}" for item in contract["requirements"]) + "\n"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _stale() -> WorkflowError:
|
|
||||||
return WorkflowError(ErrorCode.STALE_WORKING_HEAD, "Task state changed before this command could commit.")
|
|
||||||
|
|
||||||
def _park_for_storage_retry(self, state: TaskState, *, event: str, message: str) -> Rejected:
|
|
||||||
waiting = transition(state, "waiting_retry", error=ErrorCode.STORAGE_FAILURE)
|
|
||||||
if not self.repository.compare_and_swap(waiting, events=[{
|
|
||||||
"event": event,
|
|
||||||
"code": ErrorCode.STORAGE_FAILURE.value,
|
|
||||||
"message": message[:1000],
|
|
||||||
}]):
|
|
||||||
return Rejected(self._stale())
|
|
||||||
return Rejected(WorkflowError(
|
|
||||||
ErrorCode.STORAGE_FAILURE,
|
|
||||||
"Requirements artifact storage is temporarily unavailable; the task can be resumed.",
|
|
||||||
retryable=True,
|
|
||||||
))
|
|
||||||
@@ -17,19 +17,20 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from app.cad_agent.application.action_handlers import ActionCommandHandler
|
from app.cad_agent.application.action_handlers import ActionCommandHandler
|
||||||
from app.cad_agent.application.llm_contracts import (
|
from app.cad_agent.application.llm_contracts import (
|
||||||
CandidateReview, EmptyCommand, FinalReview, GeometryConclusion, NextAction,
|
CandidateReview, EmptyCommand, FinalReview, GeometryConclusion, ImageObservation, NextAction,
|
||||||
OperationContractRequest, RequirementsDraftBatch, RequirementsPatchBatch,
|
RequirementsAuthorOutput, RollbackCheckpoint, StatelessCandidateReview,
|
||||||
RequirementsReview, RollbackCheckpoint, TopologyRequest,
|
StatelessFinalReview, StatelessGeometryConclusion, StatelessNextAction, StatelessRollbackCheckpoint,
|
||||||
candidate_review_schema, canonical_json_object, canonical_validate, canonical_validate_schema,
|
StatelessTopologyRequest, TopologyRequest,
|
||||||
final_review_schema, geometry_conclusion_schema, next_action_schema,
|
canonical_json_object, canonical_validate, canonical_validate_schema,
|
||||||
operation_contract_request_schema, rollback_checkpoint_schema, topology_request_schema,
|
stateless_final_review_schema,
|
||||||
|
stateless_next_action_schema, stateless_rollback_checkpoint_schema,
|
||||||
raw_arguments_hash, validate_one_tool_call,
|
raw_arguments_hash, validate_one_tool_call,
|
||||||
)
|
)
|
||||||
from app.cad_agent.application.requirements_review import RequirementsCommandHandler
|
from app.cad_agent.application.requirements import RequirementsCommandHandler
|
||||||
from app.cad_agent.application.results import Accepted, Rejected, Waiting
|
from app.cad_agent.application.results import Accepted, Rejected, Waiting
|
||||||
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
|
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
|
||||||
from app.cad_agent.domain.operation_contract import fragment_schema
|
from app.cad_agent.domain.operation_contract import fragment_schema
|
||||||
from app.cad_agent.domain.state import TaskPhase, TaskState, reject_stale_head, retry_resume_event, transition
|
from app.cad_agent.domain.state import TaskPhase, TaskState, retry_resume_event, transition
|
||||||
from app.cad_agent.ports import AdapterUnavailable, ArtifactStore, CadRuntime, ModelGateway, ReviewGateway, TaskRepository
|
from app.cad_agent.ports import AdapterUnavailable, ArtifactStore, CadRuntime, ModelGateway, ReviewGateway, TaskRepository
|
||||||
|
|
||||||
|
|
||||||
@@ -46,7 +47,6 @@ class ModelIdentity:
|
|||||||
class WorkflowConfig:
|
class WorkflowConfig:
|
||||||
max_turns: int
|
max_turns: int
|
||||||
format_error_limit: int
|
format_error_limit: int
|
||||||
requirements_review_limit: int = 3
|
|
||||||
author_fallbacks: tuple[ModelIdentity, ...] = ()
|
author_fallbacks: tuple[ModelIdentity, ...] = ()
|
||||||
max_author_turns: int | None = None
|
max_author_turns: int | None = None
|
||||||
max_reviewer_turns: int | None = None
|
max_reviewer_turns: int | None = None
|
||||||
@@ -129,11 +129,12 @@ class WorkflowCoordinator:
|
|||||||
request: str,
|
request: str,
|
||||||
*,
|
*,
|
||||||
source_blocks: list[dict[str, Any]] | None = None,
|
source_blocks: list[dict[str, Any]] | None = None,
|
||||||
|
image_inputs: list[dict[str, str]] | None = None,
|
||||||
) -> TaskState:
|
) -> TaskState:
|
||||||
# The immutable source artifact is safe to create before SQLite state:
|
# The immutable source artifact is safe to create before SQLite state:
|
||||||
# an interrupted creation leaves only an unreferenced directory, never
|
# an interrupted creation leaves only an unreferenced directory, never
|
||||||
# a runnable task without its source index.
|
# a runnable task without its source index.
|
||||||
self.artifacts.initialize_task(task_id, request, source_blocks=source_blocks)
|
self.artifacts.initialize_task(task_id, request, source_blocks=source_blocks, image_inputs=image_inputs)
|
||||||
return self.repository.create_task(task_id, request)
|
return self.repository.create_task(task_id, request)
|
||||||
|
|
||||||
def resume(self, task_id: str) -> bool:
|
def resume(self, task_id: str) -> bool:
|
||||||
@@ -158,15 +159,8 @@ class WorkflowCoordinator:
|
|||||||
state = self.repository.get_state(task_id)
|
state = self.repository.get_state(task_id)
|
||||||
if state is None or state.phase != TaskPhase.WAITING_FOR_USER:
|
if state is None or state.phase != TaskPhase.WAITING_FOR_USER:
|
||||||
return False
|
return False
|
||||||
review = self._requirements_review(task_id, state)
|
clarification_request = self.artifacts.read_json(task_id, state.clarification_path) if state.clarification_path else None
|
||||||
is_requirements_pause = isinstance(review, dict) and (
|
if not isinstance(clarification_request, dict) or not str(clarification_request.get("question") or "").strip():
|
||||||
review.get("decision") == "waiting_for_user"
|
|
||||||
and any(
|
|
||||||
item.get("finding_type") == "ambiguous_conflict" and item.get("question")
|
|
||||||
for item in review.get("findings") or () if isinstance(item, dict)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if not is_requirements_pause:
|
|
||||||
return False
|
return False
|
||||||
text = clarification.strip()
|
text = clarification.strip()
|
||||||
if not text:
|
if not text:
|
||||||
@@ -182,7 +176,7 @@ class WorkflowCoordinator:
|
|||||||
})
|
})
|
||||||
except OSError:
|
except OSError:
|
||||||
return False
|
return False
|
||||||
resumed = transition(state, "requirements_clarified")
|
resumed = transition(state, "requirements_clarified", clarification_path="")
|
||||||
return self.repository.compare_and_swap(resumed, events=[{
|
return self.repository.compare_and_swap(resumed, events=[{
|
||||||
"event": "user_clarification_received",
|
"event": "user_clarification_received",
|
||||||
"message_id": message_id,
|
"message_id": message_id,
|
||||||
@@ -271,9 +265,42 @@ class WorkflowCoordinator:
|
|||||||
return
|
return
|
||||||
continue
|
continue
|
||||||
if state.phase == TaskPhase.DRAFTING_REQUIREMENTS:
|
if state.phase == TaskPhase.DRAFTING_REQUIREMENTS:
|
||||||
draft_schema = self.requirements.draft_schema(task_id)
|
image_paths = self.artifacts.source_image_paths(task_id)
|
||||||
patch_schema = self.requirements.patch_schema(task_id)
|
if image_paths and self.artifacts.read_json(task_id, "documents/image-observation.json") is None:
|
||||||
tools = self._requirements_author_tools(task_id, draft_schema, patch_schema)
|
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="reviewer")
|
||||||
|
if terminal:
|
||||||
|
yield terminal
|
||||||
|
return
|
||||||
|
call_budget.record_attempt("reviewer")
|
||||||
|
observation = await self._observe_images(task_id, reviewer, image_paths)
|
||||||
|
if isinstance(observation, WorkflowError):
|
||||||
|
if observation.code == ErrorCode.AUTHOR_FORMAT_INVALID:
|
||||||
|
observation = WorkflowError(
|
||||||
|
ErrorCode.REVIEW_SERVICE_UNAVAILABLE,
|
||||||
|
"Image observation did not return the required structured format.",
|
||||||
|
field_errors=observation.field_errors,
|
||||||
|
retryable=True,
|
||||||
|
)
|
||||||
|
yield self._service_failure(task_id, state, observation)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.artifacts.write_json_once(task_id, "documents/image-observation.json", {
|
||||||
|
"schema_version": "cad.image-observation.v3",
|
||||||
|
**observation.model_dump(mode="json"),
|
||||||
|
})
|
||||||
|
except OSError as error:
|
||||||
|
yield self._storage_failure(task_id, str(error))
|
||||||
|
return
|
||||||
|
observed_state = transition(state, "image_observed")
|
||||||
|
self.repository.compare_and_swap(observed_state, events=[{
|
||||||
|
"event": "image_observation_ready",
|
||||||
|
"path": "documents/image-observation.json",
|
||||||
|
"image_count": len(image_paths),
|
||||||
|
}])
|
||||||
|
yield "image_observation", {"taskId": task_id, "status": "success", "path": "documents/image-observation.json"}
|
||||||
|
continue
|
||||||
|
spec_schema = self.requirements.spec_schema()
|
||||||
|
tools = [self._tool("submit_requirements_spec", spec_schema)]
|
||||||
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author")
|
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author")
|
||||||
if terminal:
|
if terminal:
|
||||||
yield terminal
|
yield terminal
|
||||||
@@ -294,98 +321,33 @@ class WorkflowCoordinator:
|
|||||||
return
|
return
|
||||||
continue
|
continue
|
||||||
name, raw, usage = result
|
name, raw, usage = result
|
||||||
model = RequirementsDraftBatch if name == "submit_requirements_draft_batch" else RequirementsPatchBatch if name == "patch_requirements_draft" else EmptyCommand
|
validation = canonical_validate(raw, RequirementsAuthorOutput)
|
||||||
validation = canonical_validate(raw, model)
|
dynamic_error = canonical_validate_schema(raw, spec_schema) if not isinstance(validation, WorkflowError) else None
|
||||||
dynamic_error = canonical_validate_schema(raw, draft_schema if name == "submit_requirements_draft_batch" else patch_schema) if not isinstance(validation, WorkflowError) and name != "finalize_requirements_draft" else None
|
|
||||||
if dynamic_error is not None:
|
if dynamic_error is not None:
|
||||||
validation = dynamic_error
|
validation = dynamic_error
|
||||||
if isinstance(validation, WorkflowError):
|
if isinstance(validation, WorkflowError):
|
||||||
terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback)
|
terminal = self._requirements_format_failure(task_id, state, validation, format_errors, feedback)
|
||||||
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
|
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
|
||||||
if terminal:
|
if terminal:
|
||||||
yield terminal
|
yield terminal
|
||||||
return
|
return
|
||||||
continue
|
continue
|
||||||
invocation_id = self._invocation_id(task_id)
|
invocation_id = self._invocation_id(task_id)
|
||||||
command = self.requirements.submit_draft(task_id, validation, invocation_id=invocation_id) if name == "submit_requirements_draft_batch" else self.requirements.patch_draft(task_id, validation, invocation_id=invocation_id) if name == "patch_requirements_draft" else self.requirements.finalize_draft(task_id, invocation_id=invocation_id)
|
command = self.requirements.submit_spec(task_id, validation, invocation_id=invocation_id)
|
||||||
if isinstance(command, Rejected):
|
if isinstance(command, Rejected):
|
||||||
terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback)
|
terminal = self._requirements_rejection(task_id, state, command.error, format_errors, feedback)
|
||||||
yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage)
|
yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage)
|
||||||
if terminal:
|
if terminal:
|
||||||
yield terminal
|
yield terminal
|
||||||
return
|
return
|
||||||
continue
|
continue
|
||||||
yield "tool_call", self._event(task_id, name, self._result_payload(command), "success", usage)
|
yield "requirements_ready", self._event(task_id, name, self._result_payload(command), "waiting" if isinstance(command, Waiting) else "success", usage)
|
||||||
feedback = []
|
feedback = []
|
||||||
continue
|
continue
|
||||||
if state.phase == TaskPhase.REVIEWING_REQUIREMENTS:
|
|
||||||
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="reviewer")
|
|
||||||
if terminal:
|
|
||||||
yield terminal
|
|
||||||
return
|
|
||||||
call_budget.record_attempt("reviewer")
|
|
||||||
command = await self._review_requirements(task_id, reviewer, feedback)
|
|
||||||
if isinstance(command, WorkflowError):
|
|
||||||
if command.code == ErrorCode.AUTHOR_FORMAT_INVALID:
|
|
||||||
terminal = self._format_failure(task_id, state, "review_requirements", command, format_errors, feedback, actor="reviewer")
|
|
||||||
yield "requirements_review", {"taskId": task_id, "status": "error", "result": command.payload()}
|
|
||||||
if terminal:
|
|
||||||
yield terminal
|
|
||||||
return
|
|
||||||
continue
|
|
||||||
terminal = self._service_failure(task_id, state, command)
|
|
||||||
yield terminal
|
|
||||||
return
|
|
||||||
review_result = self.requirements.record_review(task_id, command, invocation_id=self._invocation_id(task_id))
|
|
||||||
if isinstance(review_result, Rejected):
|
|
||||||
terminal = self._model_rejection_or_service_failure(task_id, state, "review_requirements", review_result.error, format_errors, feedback, actor="reviewer")
|
|
||||||
yield "requirements_review", {"taskId": task_id, "status": "error", "result": review_result.error.payload()}
|
|
||||||
if terminal:
|
|
||||||
yield terminal
|
|
||||||
return
|
|
||||||
continue
|
|
||||||
yield "requirements_review", {"taskId": task_id, "status": "success", "result": self._result_payload(review_result)}
|
|
||||||
if isinstance(review_result, Accepted) and review_result.payload.get("phase") == TaskPhase.DRAFTING_REQUIREMENTS.value:
|
|
||||||
draft_state = self.repository.get_state(task_id)
|
|
||||||
revision = int(self._requirements_draft(draft_state).get("revision") or 0)
|
|
||||||
# The initial draft is reviewed before any revision.
|
|
||||||
# ``requirements_review_limit`` bounds subsequent
|
|
||||||
# author corrections, so permit exactly that many
|
|
||||||
# patches and park only after they are exhausted.
|
|
||||||
if revision > self.config.requirements_review_limit:
|
|
||||||
if draft_state is not None:
|
|
||||||
failed = transition(draft_state, "failed", error=ErrorCode.REQUIREMENTS_REVIEW_NOT_CONVERGED)
|
|
||||||
review = self._requirements_review(task_id, draft_state) or {}
|
|
||||||
findings = [
|
|
||||||
item for item in review.get("findings") or ()
|
|
||||||
if isinstance(item, dict)
|
|
||||||
]
|
|
||||||
self.repository.compare_and_swap(failed, events=[{
|
|
||||||
"event": "requirements_review_limit_reached",
|
|
||||||
"code": ErrorCode.REQUIREMENTS_REVIEW_NOT_CONVERGED.value,
|
|
||||||
"draft_revision": revision,
|
|
||||||
"review_path": draft_state.requirements_review_path,
|
|
||||||
"findings": findings,
|
|
||||||
}])
|
|
||||||
yield "task_terminal", {
|
|
||||||
"taskId": task_id,
|
|
||||||
"lifecycle": "failed",
|
|
||||||
"code": ErrorCode.REQUIREMENTS_REVIEW_NOT_CONVERGED.value,
|
|
||||||
"message": "Requirements review did not converge within the configured author-revision limit.",
|
|
||||||
"issues": [str(item.get("description") or "") for item in findings if item.get("description")],
|
|
||||||
"blockerType": "requirements_review_not_converged",
|
|
||||||
"userActionRequired": False,
|
|
||||||
}
|
|
||||||
return
|
|
||||||
continue
|
|
||||||
if state.phase == TaskPhase.AWAITING_ACTION:
|
if state.phase == TaskPhase.AWAITING_ACTION:
|
||||||
contract = self._requirements_contract(task_id, state) or {}
|
contract = self._requirements_contract(task_id, state) or {}
|
||||||
requirement_ids = [str(item.get("requirement_id") or "") for item in contract.get("requirements") or () if isinstance(item, dict) and item.get("requirement_id")]
|
requirement_ids = [str(item.get("requirement_id") or "") for item in contract.get("requirements") or () if isinstance(item, dict) and item.get("requirement_id")]
|
||||||
action_schema = next_action_schema(
|
action_schema = stateless_next_action_schema(list(self.actions.available_atomic_ids(task_id, state)))
|
||||||
state.working_head,
|
|
||||||
requirement_ids,
|
|
||||||
list(self.actions.available_atomic_ids(task_id, state)),
|
|
||||||
)
|
|
||||||
tools = self._recovery_tools(task_id, state)
|
tools = self._recovery_tools(task_id, state)
|
||||||
if not tools:
|
if not tools:
|
||||||
if self._can_complete(task_id, state):
|
if self._can_complete(task_id, state):
|
||||||
@@ -423,11 +385,7 @@ class WorkflowCoordinator:
|
|||||||
continue
|
continue
|
||||||
command = self.actions.complete_task(task_id, invocation_id=self._invocation_id(task_id))
|
command = self.actions.complete_task(task_id, invocation_id=self._invocation_id(task_id))
|
||||||
elif name == "record_geometry_conclusion":
|
elif name == "record_geometry_conclusion":
|
||||||
diagnostic_schema = geometry_conclusion_schema(state.working_head, list(self.actions.diagnostic_evidence_refs(task_id, state)))
|
validation = canonical_validate(raw, StatelessGeometryConclusion)
|
||||||
validation = canonical_validate(raw, GeometryConclusion)
|
|
||||||
dynamic_error = canonical_validate_schema(raw, diagnostic_schema) if not isinstance(validation, WorkflowError) else None
|
|
||||||
if dynamic_error is not None:
|
|
||||||
validation = dynamic_error
|
|
||||||
if isinstance(validation, WorkflowError):
|
if isinstance(validation, WorkflowError):
|
||||||
terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback)
|
terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback)
|
||||||
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
|
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
|
||||||
@@ -435,10 +393,17 @@ class WorkflowCoordinator:
|
|||||||
yield terminal
|
yield terminal
|
||||||
return
|
return
|
||||||
continue
|
continue
|
||||||
|
validation = GeometryConclusion(
|
||||||
|
working_head=state.working_head,
|
||||||
|
evidence_refs=list(self.actions.diagnostic_evidence_refs(task_id, state)),
|
||||||
|
root_cause=validation.root_cause,
|
||||||
|
decision=validation.decision,
|
||||||
|
corrective_intent=validation.corrective_intent,
|
||||||
|
)
|
||||||
command = self.actions.record_geometry_conclusion(task_id, validation, invocation_id=self._invocation_id(task_id))
|
command = self.actions.record_geometry_conclusion(task_id, validation, invocation_id=self._invocation_id(task_id))
|
||||||
elif name == "rollback_checkpoint":
|
elif name == "rollback_checkpoint":
|
||||||
rollback_schema = rollback_checkpoint_schema(state.working_head, list(self.actions.checkpoint_tokens(task_id, state)))
|
rollback_schema = stateless_rollback_checkpoint_schema(list(self.actions.checkpoint_tokens(task_id, state)))
|
||||||
validation = canonical_validate(raw, RollbackCheckpoint)
|
validation = canonical_validate(raw, StatelessRollbackCheckpoint)
|
||||||
dynamic_error = canonical_validate_schema(raw, rollback_schema) if not isinstance(validation, WorkflowError) else None
|
dynamic_error = canonical_validate_schema(raw, rollback_schema) if not isinstance(validation, WorkflowError) else None
|
||||||
if dynamic_error is not None:
|
if dynamic_error is not None:
|
||||||
validation = dynamic_error
|
validation = dynamic_error
|
||||||
@@ -449,9 +414,10 @@ class WorkflowCoordinator:
|
|||||||
yield terminal
|
yield terminal
|
||||||
return
|
return
|
||||||
continue
|
continue
|
||||||
|
validation = RollbackCheckpoint(working_head=state.working_head, checkpoint_token=validation.checkpoint_token, reason=validation.reason)
|
||||||
command = self.actions.rollback_checkpoint(task_id, validation, invocation_id=self._invocation_id(task_id))
|
command = self.actions.rollback_checkpoint(task_id, validation, invocation_id=self._invocation_id(task_id))
|
||||||
else:
|
else:
|
||||||
validation = canonical_validate(raw, NextAction)
|
validation = canonical_validate(raw, StatelessNextAction)
|
||||||
dynamic_error = canonical_validate_schema(raw, action_schema) if not isinstance(validation, WorkflowError) else None
|
dynamic_error = canonical_validate_schema(raw, action_schema) if not isinstance(validation, WorkflowError) else None
|
||||||
if dynamic_error is not None:
|
if dynamic_error is not None:
|
||||||
validation = dynamic_error
|
validation = dynamic_error
|
||||||
@@ -462,6 +428,13 @@ class WorkflowCoordinator:
|
|||||||
yield terminal
|
yield terminal
|
||||||
return
|
return
|
||||||
continue
|
continue
|
||||||
|
validation = NextAction(
|
||||||
|
working_head=state.working_head,
|
||||||
|
intent=validation.intent,
|
||||||
|
requirement_ids=requirement_ids,
|
||||||
|
atomic_id=validation.operation,
|
||||||
|
expected_change=validation.expected_change,
|
||||||
|
)
|
||||||
command = self.actions.propose_next_action(task_id, validation, invocation_id=self._invocation_id(task_id))
|
command = self.actions.propose_next_action(task_id, validation, invocation_id=self._invocation_id(task_id))
|
||||||
if isinstance(command, Rejected):
|
if isinstance(command, Rejected):
|
||||||
terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback)
|
terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback)
|
||||||
@@ -523,10 +496,7 @@ class WorkflowCoordinator:
|
|||||||
feedback = []
|
feedback = []
|
||||||
continue
|
continue
|
||||||
if name == "inspect_topology":
|
if name == "inspect_topology":
|
||||||
validation = canonical_validate(raw, TopologyRequest)
|
validation = canonical_validate(raw, StatelessTopologyRequest)
|
||||||
dynamic_error = canonical_validate_schema(raw, topology_request_schema(state.working_head)) if not isinstance(validation, WorkflowError) else None
|
|
||||||
if dynamic_error is not None:
|
|
||||||
validation = dynamic_error
|
|
||||||
if isinstance(validation, WorkflowError):
|
if isinstance(validation, WorkflowError):
|
||||||
terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback)
|
terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback)
|
||||||
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
|
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
|
||||||
@@ -534,36 +504,9 @@ class WorkflowCoordinator:
|
|||||||
yield terminal
|
yield terminal
|
||||||
return
|
return
|
||||||
continue
|
continue
|
||||||
stale = reject_stale_head(state, validation.working_head)
|
payload = self._topology_payload(task_id, state, validation.kind, validation.limit)
|
||||||
payload = stale.payload() if stale else self._topology_payload(task_id, state, validation.kind, validation.limit)
|
observed.add("topology")
|
||||||
if stale is None:
|
yield "tool_call", self._event(task_id, name, payload, "success", usage)
|
||||||
observed.add("topology")
|
|
||||||
yield "tool_call", self._event(task_id, name, payload, "error" if stale else "success", usage)
|
|
||||||
feedback = [*feedback, {"role": "tool", "content": json.dumps({"tool": name, "result": payload}, ensure_ascii=False)}][-2:]
|
|
||||||
continue
|
|
||||||
if name == "get_cdsl_operation_contract":
|
|
||||||
validation = canonical_validate(raw, OperationContractRequest)
|
|
||||||
action = state.pending_action
|
|
||||||
dynamic_error = canonical_validate_schema(raw, operation_contract_request_schema(state.working_head, action.atomic_id)) if not isinstance(validation, WorkflowError) and action is not None else None
|
|
||||||
if dynamic_error is not None:
|
|
||||||
validation = dynamic_error
|
|
||||||
if isinstance(validation, WorkflowError):
|
|
||||||
terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback)
|
|
||||||
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
|
|
||||||
if terminal:
|
|
||||||
yield terminal
|
|
||||||
return
|
|
||||||
continue
|
|
||||||
action = state.pending_action
|
|
||||||
stale = reject_stale_head(state, validation.working_head)
|
|
||||||
if stale:
|
|
||||||
payload = stale.payload()
|
|
||||||
elif action is None or validation.atomic_id != action.atomic_id:
|
|
||||||
payload = WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Only the pending action's operation contract is available.").payload()
|
|
||||||
else:
|
|
||||||
payload = self._operation_payload(task_id, state)
|
|
||||||
observed.add("contract")
|
|
||||||
yield "tool_call", self._event(task_id, name, payload, "error" if "code" in payload else "success", usage)
|
|
||||||
feedback = [*feedback, {"role": "tool", "content": json.dumps({"tool": name, "result": payload}, ensure_ascii=False)}][-2:]
|
feedback = [*feedback, {"role": "tool", "content": json.dumps({"tool": name, "result": payload}, ensure_ascii=False)}][-2:]
|
||||||
continue
|
continue
|
||||||
fragment = canonical_json_object(raw)
|
fragment = canonical_json_object(raw)
|
||||||
@@ -618,6 +561,29 @@ class WorkflowCoordinator:
|
|||||||
continue
|
continue
|
||||||
yield self._service_failure(task_id, state, review)
|
yield self._service_failure(task_id, state, review)
|
||||||
return
|
return
|
||||||
|
candidate = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "candidate.json") or {}
|
||||||
|
action = state.pending_action
|
||||||
|
if action is None:
|
||||||
|
yield self._storage_failure(task_id, "Candidate action is unavailable during review.")
|
||||||
|
return
|
||||||
|
coverage = []
|
||||||
|
for item in candidate.get("claim_results") or ():
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
status = str(item.get("status") or "pending")
|
||||||
|
coverage.append({
|
||||||
|
"claim_id": str(item.get("claim_id") or ""),
|
||||||
|
"status": status if status in {"pass", "pending", "fail", "not_applicable"} else "fail",
|
||||||
|
"evidence_refs": [],
|
||||||
|
})
|
||||||
|
review = CandidateReview(
|
||||||
|
candidate_id=state.candidate_id,
|
||||||
|
working_head=action.working_head,
|
||||||
|
verdict=review.verdict,
|
||||||
|
claim_coverage=coverage,
|
||||||
|
evidence=review.evidence,
|
||||||
|
issues=review.issues,
|
||||||
|
)
|
||||||
command = self.actions.record_candidate_review(task_id, review, invocation_id=self._invocation_id(task_id))
|
command = self.actions.record_candidate_review(task_id, review, invocation_id=self._invocation_id(task_id))
|
||||||
if isinstance(command, Rejected):
|
if isinstance(command, Rejected):
|
||||||
terminal = self._model_rejection_or_service_failure(task_id, state, "review_candidate", command.error, format_errors, feedback, actor="reviewer")
|
terminal = self._model_rejection_or_service_failure(task_id, state, "review_candidate", command.error, format_errors, feedback, actor="reviewer")
|
||||||
@@ -631,6 +597,15 @@ class WorkflowCoordinator:
|
|||||||
if state.phase == TaskPhase.FINAL_VALIDATION:
|
if state.phase == TaskPhase.FINAL_VALIDATION:
|
||||||
recovered = self.actions.recover_final_review(task_id)
|
recovered = self.actions.recover_final_review(task_id)
|
||||||
if recovered is not None:
|
if recovered is not None:
|
||||||
|
if isinstance(recovered, Accepted) and recovered.payload.get("status") == "completed":
|
||||||
|
completed_state = self.repository.get_state(task_id)
|
||||||
|
if completed_state is not None:
|
||||||
|
try:
|
||||||
|
self._ensure_recovered_completion_result(task_id, completed_state)
|
||||||
|
except OSError as error:
|
||||||
|
yield self._storage_failure(task_id, str(error))
|
||||||
|
return
|
||||||
|
yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"}
|
||||||
yield "final_review", {"taskId": task_id, "status": "success" if isinstance(recovered, Accepted) else "error", "result": self._result_payload(recovered), "recovered": True}
|
yield "final_review", {"taskId": task_id, "status": "success" if isinstance(recovered, Accepted) else "error", "result": self._result_payload(recovered), "recovered": True}
|
||||||
if isinstance(recovered, Rejected):
|
if isinstance(recovered, Rejected):
|
||||||
yield self._service_failure(task_id, state, recovered.error)
|
yield self._service_failure(task_id, state, recovered.error)
|
||||||
@@ -652,6 +627,41 @@ class WorkflowCoordinator:
|
|||||||
continue
|
continue
|
||||||
yield self._service_failure(task_id, state, review)
|
yield self._service_failure(task_id, state, review)
|
||||||
return
|
return
|
||||||
|
facts = self.actions._facts(task_id, state.active_revision)
|
||||||
|
claim_results = self.actions._evaluate_claims(task_id, facts)
|
||||||
|
visual_decisions = iter(review.visual_claims)
|
||||||
|
coverage = []
|
||||||
|
for item in claim_results:
|
||||||
|
if item.get("deterministic"):
|
||||||
|
status = str(item.get("status") or "fail")
|
||||||
|
status = status if status in {"pass", "pending", "fail", "not_applicable"} else "fail"
|
||||||
|
else:
|
||||||
|
status = next(visual_decisions).status
|
||||||
|
coverage.append({"claim_id": str(item.get("claim_id") or ""), "status": status, "evidence_refs": []})
|
||||||
|
stateless_review = review
|
||||||
|
review = FinalReview(
|
||||||
|
working_head=state.working_head,
|
||||||
|
verdict=review.verdict,
|
||||||
|
claim_coverage=coverage,
|
||||||
|
evidence=review.evidence,
|
||||||
|
issues=review.issues,
|
||||||
|
)
|
||||||
|
will_complete = (
|
||||||
|
stateless_review.verdict == "pass"
|
||||||
|
and all(item.get("status") == "pass" for item in claim_results if item.get("deterministic"))
|
||||||
|
and all(item.status == "pass" for item in stateless_review.visual_claims)
|
||||||
|
)
|
||||||
|
if will_complete:
|
||||||
|
try:
|
||||||
|
self.requirements.write_completion_result(
|
||||||
|
task_id,
|
||||||
|
state,
|
||||||
|
claim_results=claim_results,
|
||||||
|
review=stateless_review.model_dump(mode="json"),
|
||||||
|
)
|
||||||
|
except OSError as error:
|
||||||
|
yield self._storage_failure(task_id, str(error))
|
||||||
|
return
|
||||||
command = self.actions.record_final_review(task_id, review, invocation_id=self._invocation_id(task_id))
|
command = self.actions.record_final_review(task_id, review, invocation_id=self._invocation_id(task_id))
|
||||||
if isinstance(command, Rejected):
|
if isinstance(command, Rejected):
|
||||||
terminal = self._model_rejection_or_service_failure(task_id, state, "review_final", command.error, format_errors, feedback, actor="reviewer")
|
terminal = self._model_rejection_or_service_failure(task_id, state, "review_final", command.error, format_errors, feedback, actor="reviewer")
|
||||||
@@ -660,6 +670,8 @@ class WorkflowCoordinator:
|
|||||||
yield terminal
|
yield terminal
|
||||||
return
|
return
|
||||||
continue
|
continue
|
||||||
|
if isinstance(command, Accepted) and command.payload.get("status") == "completed":
|
||||||
|
yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"}
|
||||||
yield "final_review", {"taskId": task_id, "status": "success", "result": self._result_payload(command)}
|
yield "final_review", {"taskId": task_id, "status": "success", "result": self._result_payload(command)}
|
||||||
continue
|
continue
|
||||||
state = self.repository.get_state(task_id)
|
state = self.repository.get_state(task_id)
|
||||||
@@ -798,50 +810,50 @@ class WorkflowCoordinator:
|
|||||||
self.repository.record_usage(task_id, usage)
|
self.repository.record_usage(task_id, usage)
|
||||||
return name, raw, usage
|
return name, raw, usage
|
||||||
|
|
||||||
async def _review_requirements(self, task_id: str, reviewer: ModelIdentity, feedback: list[dict[str, Any]] | None = None) -> RequirementsReview | WorkflowError:
|
async def _review_candidate(self, task_id: str, reviewer: ModelIdentity, state: TaskState, feedback: list[dict[str, Any]] | None = None) -> StatelessCandidateReview | WorkflowError:
|
||||||
state = self.repository.get_state(task_id)
|
|
||||||
evaluation_context = self.requirements.evaluation_review_context(task_id)
|
|
||||||
instruction = "Return only structured findings; the service derives coverage and the overall decision. Omit a draft item when it has no finding. Bind every finding to one current draft_id and only source_ids cited by that draft. Use missing_source_semantics for omitted source meaning, claim_mismatch for an incorrect deterministic claim, verification_gap only when a scoped visual claim covers a property unavailable to deterministic verifiers, derivable_conflict only with a registered normalization payload, and ambiguous_conflict only when multiple reasonable interpretations remain; ambiguous conflicts require one precise answerable question. For a full-circle equally spaced pattern, 360/count is the only registered automatic rule. Every independently measurable number, count, dimension, relationship, orientation, material/unit constraint, and single-body requirement must have executable or scoped visual coverage. Do not demand an invented deterministic claim for a verifier gap and do not invent requirements absent from the sources."
|
|
||||||
if evaluation_context is not None:
|
|
||||||
instruction += " The evaluation-only known_validation_capability_gaps are authoritative facts explicitly unavailable to the current deterministic verifier registry, not author errors. Do not request an invented executable claim for one of those gaps. When an otherwise covered draft item has a scoped visual acceptance claim for a declared gap, emit verification_gap once; the service will freeze it with an explicit risk and require final independent visual review."
|
|
||||||
return await self._review_tool(task_id, reviewer, "review_requirements", RequirementsReview, {
|
|
||||||
"source_index": self.artifacts.read_source_index(task_id), "draft": self._requirements_draft(state), "user_clarifications": self._user_clarifications(task_id),
|
|
||||||
"evaluation_context": evaluation_context,
|
|
||||||
"instruction": instruction,
|
|
||||||
}, schema=self.requirements.review_schema(task_id), feedback=feedback)
|
|
||||||
|
|
||||||
async def _review_candidate(self, task_id: str, reviewer: ModelIdentity, state: TaskState, feedback: list[dict[str, Any]] | None = None) -> CandidateReview | WorkflowError:
|
|
||||||
candidate = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "candidate.json")
|
candidate = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "candidate.json")
|
||||||
action = state.pending_action
|
action = state.pending_action
|
||||||
if not isinstance(candidate, dict) or action is None:
|
if not isinstance(candidate, dict) or action is None:
|
||||||
return WorkflowError(ErrorCode.STORAGE_FAILURE, "Candidate review facts are unavailable.", retryable=True)
|
return WorkflowError(ErrorCode.STORAGE_FAILURE, "Candidate review facts are unavailable.", retryable=True)
|
||||||
claim_ids = [str(item.get("claim_id") or "") for item in candidate.get("claim_results") or () if isinstance(item, dict) and item.get("claim_id")]
|
return await self._review_tool(task_id, reviewer, "review_candidate", StatelessCandidateReview, {
|
||||||
return await self._review_tool(task_id, reviewer, "review_candidate", CandidateReview, {
|
"requirements": self._public_requirements(self._requirements_contract(task_id, state)),
|
||||||
# Candidate review is checkpoint-scoped. The frozen contract is
|
"action": {"intent": action.intent, "expected_change": action.expected_change, "operation": action.atomic_id},
|
||||||
# enough for this decision; source text is retained only for the
|
"candidate_facts": self._public_candidate(candidate),
|
||||||
# final review where it guards against a frozen-contract omission.
|
"render_manifest": candidate.get("render_manifest") or {},
|
||||||
"requirements_contract": self._requirements_contract(task_id, state),
|
"instruction": "Review only whether the current checkpoint correctly performs the stated action. Deterministic facts are authoritative. Do not return task, action, candidate, requirement, claim, revision, head, or evidence identifiers.",
|
||||||
"candidate_id": state.candidate_id, "working_head": action.working_head,
|
}, feedback=feedback)
|
||||||
"action": {"action_id": action.action_id, "intent": action.intent, "expected_change": action.expected_change, "requirement_ids": list(action.requirement_ids)},
|
|
||||||
"candidate": candidate, "render_manifest": candidate.get("render_manifest") or {},
|
|
||||||
"instruction": "Independently review only this checkpoint against the supplied action, not as the finished model. Return every provided claim ID exactly once. A deterministic claim with status pending is explicitly deferred to a later action: cover it as pending and still return verdict accept unless this candidate contradicts the current action, violates a global invariant, or the renders show this action itself is wrong. In particular, when the action establishes a flange base, the absence of a later through bore is pending and MUST NOT cause rejection. Deterministic results are evidence and cannot be overridden.",
|
|
||||||
}, schema=candidate_review_schema(state.candidate_id, action.working_head, claim_ids), feedback=feedback)
|
|
||||||
|
|
||||||
async def _review_final(self, task_id: str, reviewer: ModelIdentity, state: TaskState, feedback: list[dict[str, Any]] | None = None) -> FinalReview | WorkflowError:
|
async def _observe_images(self, task_id: str, reviewer: ModelIdentity, image_paths: list[str]) -> ImageObservation | WorkflowError:
|
||||||
|
return await self._review_tool(task_id, reviewer, "observe_images", ImageObservation, {
|
||||||
|
"source_requirements": self.artifacts.read_source_requirements(task_id),
|
||||||
|
"reference_image_paths": image_paths,
|
||||||
|
"instruction": (
|
||||||
|
"Inspect every supplied reference image once. Describe visible part geometry, view directions, readable dimensions, holes and profiles, confidence, assumptions, and uncertainties. "
|
||||||
|
"Do not create CAD operations and do not return attachment or runtime identifiers."
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
async def _review_final(self, task_id: str, reviewer: ModelIdentity, state: TaskState, feedback: list[dict[str, Any]] | None = None) -> StatelessFinalReview | WorkflowError:
|
||||||
facts = self.actions._facts(task_id, state.active_revision)
|
facts = self.actions._facts(task_id, state.active_revision)
|
||||||
results = self.actions._evaluate_claims(task_id, facts)
|
results = self.actions._evaluate_claims(task_id, facts)
|
||||||
return await self._review_tool(task_id, reviewer, "review_final", FinalReview, {
|
visual_claims = [item for item in results if not item.get("deterministic")]
|
||||||
"source_requirements": self.artifacts.read_source_requirements(task_id), "requirements_contract": self._requirements_contract(task_id, state),
|
return await self._review_tool(task_id, reviewer, "review_final", StatelessFinalReview, {
|
||||||
"revision_id": state.active_revision, "working_head": state.working_head, "claim_results": results, "render_manifest": (facts.get("report") or {}).get("render_manifest") or {},
|
"source_requirements": self.artifacts.read_source_requirements(task_id),
|
||||||
"instruction": "Independently review final model evidence. Return every claim ID exactly once. Deterministic results are final gates and cannot be overridden.",
|
"requirements": self._public_requirements(self._requirements_contract(task_id, state)),
|
||||||
}, schema=final_review_schema(state.working_head, [str(item.get("claim_id") or "") for item in results if item.get("claim_id")]), feedback=feedback)
|
"deterministic_results": [self._public_claim_result(item) for item in results if item.get("deterministic")],
|
||||||
|
"visual_claims": [self._public_claim_result(item) for item in visual_claims],
|
||||||
|
"render_manifest": (facts.get("report") or {}).get("render_manifest") or {},
|
||||||
|
"reference_image_paths": self.artifacts.source_image_paths(task_id),
|
||||||
|
"instruction": "Review the final CAD renders against the original reference images and the ordered visual claims. Return exactly one visual_claims decision for each supplied visual claim, in the same order. Deterministic results are final. Do not return any runtime identifiers.",
|
||||||
|
}, schema=stateless_final_review_schema(len(visual_claims)), feedback=feedback)
|
||||||
|
|
||||||
async def _review_tool(self, task_id: str, reviewer: ModelIdentity, name: str, model_type: type[T], payload: dict[str, Any], *, schema: dict[str, Any] | None = None, feedback: list[dict[str, Any]] | None = None) -> T | WorkflowError:
|
async def _review_tool(self, task_id: str, reviewer: ModelIdentity, name: str, model_type: type[T], payload: dict[str, Any], *, schema: dict[str, Any] | None = None, feedback: list[dict[str, Any]] | None = None) -> T | WorkflowError:
|
||||||
if feedback:
|
if feedback:
|
||||||
payload = {**payload, "previous_schema_or_state_error": str(feedback[-1].get("content") or "")[:2_000]}
|
payload = {**payload, "previous_schema_or_state_error": str(feedback[-1].get("content") or "")[:2_000]}
|
||||||
try:
|
try:
|
||||||
tool = self._tool(name, schema or model_type)
|
tool = self._tool(name, schema or model_type)
|
||||||
response = await self.review_gateway.review(kind="requirements" if name == "review_requirements" else "candidate" if name == "review_candidate" else "final", payload=payload, tool=tool, provider_id=reviewer.provider_id, model_id=reviewer.model_id)
|
kind = "image_observation" if name == "observe_images" else "candidate" if name == "review_candidate" else "final"
|
||||||
|
response = await self.review_gateway.review(kind=kind, payload=payload, tool=tool, provider_id=reviewer.provider_id, model_id=reviewer.model_id)
|
||||||
except AdapterUnavailable as error:
|
except AdapterUnavailable as error:
|
||||||
error_code = (
|
error_code = (
|
||||||
ErrorCode.RENDER_SERVICE_UNAVAILABLE
|
ErrorCode.RENDER_SERVICE_UNAVAILABLE
|
||||||
@@ -1030,7 +1042,7 @@ class WorkflowCoordinator:
|
|||||||
tokens = self.runtime.selector_tokens(topology)
|
tokens = self.runtime.selector_tokens(topology)
|
||||||
eligible_tokens = self._selector_tokens_for_contract(contract, tokens)
|
eligible_tokens = self._selector_tokens_for_contract(contract, tokens)
|
||||||
if selector_shape == "required" and len(eligible_tokens) > 16 and "topology" not in seen:
|
if selector_shape == "required" and len(eligible_tokens) > 16 and "topology" not in seen:
|
||||||
return [self._tool("inspect_topology", topology_request_schema(state.working_head))]
|
return [self._tool("inspect_topology", StatelessTopologyRequest)]
|
||||||
references = self.runtime.reference_tokens(self.artifacts.read_active_cdsl(task_id, state.active_revision))
|
references = self.runtime.reference_tokens(self.artifacts.read_active_cdsl(task_id, state.active_revision))
|
||||||
description = "Submit exactly one CDSL feature for the pending action."
|
description = "Submit exactly one CDSL feature for the pending action."
|
||||||
if action.atomic_id.startswith("hole_"):
|
if action.atomic_id.startswith("hole_"):
|
||||||
@@ -1052,66 +1064,36 @@ class WorkflowCoordinator:
|
|||||||
if not state.repair_required:
|
if not state.repair_required:
|
||||||
return []
|
return []
|
||||||
if self.actions.rollback_available(task_id, state):
|
if self.actions.rollback_available(task_id, state):
|
||||||
return [self._tool("rollback_checkpoint", rollback_checkpoint_schema(state.working_head, list(self.actions.checkpoint_tokens(task_id, state))))]
|
return [self._tool("rollback_checkpoint", stateless_rollback_checkpoint_schema(list(self.actions.checkpoint_tokens(task_id, state))))]
|
||||||
if self.actions.repair_action_ready(task_id, state):
|
if self.actions.repair_action_ready(task_id, state):
|
||||||
return []
|
return []
|
||||||
evidence_refs = list(self.actions.diagnostic_evidence_refs(task_id, state))
|
evidence_refs = list(self.actions.diagnostic_evidence_refs(task_id, state))
|
||||||
if not evidence_refs:
|
if not evidence_refs:
|
||||||
return []
|
return []
|
||||||
return [self._tool("record_geometry_conclusion", geometry_conclusion_schema(state.working_head, evidence_refs))]
|
return [self._tool("record_geometry_conclusion", StatelessGeometryConclusion)]
|
||||||
|
|
||||||
def _requirements_author_tools(self, task_id: str, draft_schema: dict[str, Any], patch_schema: dict[str, Any]) -> list[dict[str, Any]]:
|
|
||||||
"""Expose exactly one requirement command from persisted draft facts."""
|
|
||||||
state = self.repository.get_state(task_id)
|
|
||||||
draft = self._requirements_draft(state)
|
|
||||||
review = self._requirements_review(task_id, state)
|
|
||||||
if isinstance(review, dict) and review.get("decision") == "revise":
|
|
||||||
return [self._tool("patch_requirements_draft", patch_schema)]
|
|
||||||
source_ids = set(self.artifacts.read_source_index(task_id))
|
|
||||||
covered = {
|
|
||||||
str(source_id)
|
|
||||||
for item in draft.get("items") or ()
|
|
||||||
if isinstance(item, dict)
|
|
||||||
for source_id in item.get("source_ids") or ()
|
|
||||||
}
|
|
||||||
if draft.get("items") and source_ids.issubset(covered):
|
|
||||||
return [self._tool("finalize_requirements_draft", EmptyCommand)]
|
|
||||||
return [self._tool("submit_requirements_draft_batch", draft_schema)]
|
|
||||||
|
|
||||||
def _author_context(self, task_id: str, feedback: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def _author_context(self, task_id: str, feedback: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
state = self.repository.get_state(task_id)
|
state = self.repository.get_state(task_id)
|
||||||
if state is None:
|
if state is None:
|
||||||
return []
|
return []
|
||||||
if state.phase == TaskPhase.DRAFTING_REQUIREMENTS:
|
if state.phase == TaskPhase.DRAFTING_REQUIREMENTS:
|
||||||
draft = self._requirements_draft(state)
|
content = {
|
||||||
review = self._requirements_review(task_id, state)
|
"protocol": "cad.v3.spec.v1",
|
||||||
if isinstance(review, dict) and review.get("decision") == "revise":
|
"source_requirements": self.artifacts.read_source_requirements(task_id),
|
||||||
current_ids = [str(item.get("draft_id") or "") for item in draft.get("items") or () if isinstance(item, dict) and item.get("draft_id")]
|
"image_observation": self.artifacts.read_json(task_id, "documents/image-observation.json"),
|
||||||
example_id = current_ids[0] if current_ids else "draft_001"
|
"user_clarifications": self._user_clarifications(task_id),
|
||||||
instruction = (
|
"instruction": (
|
||||||
"The independent reviewer requested revisions. Call patch_requirements_draft with exactly one outer patches array. "
|
"Return one complete bounded requirements specification. Use deterministic claims only when the supplied registry can execute them; otherwise use a scoped visual claim. "
|
||||||
f"Each patch nests target_draft_id (for example {example_id!r}) inside patches[], alongside op. "
|
"Unspecified design choices are assumptions and must not block generation. Return clarification only when two quoted source statements cannot both be followed and the user must choose; ask exactly one question. "
|
||||||
"For op=replace, item is the complete replacement RequirementInput and MUST NOT include draft_id; draft_id is server-owned. "
|
"Never return source, task, draft, requirement, claim, revision, candidate, action, head, or evidence identifiers. Do not solve semantic conflicts by changing a user value."
|
||||||
"Do not put target_draft_id or item at the top level. The root object must have this exact shape: "
|
),
|
||||||
f'{{"patches":[{{"target_draft_id":"{example_id}","op":"replace","item":{{...}}}}]}}. '
|
}
|
||||||
"Then call finalize_requirements_draft for another review. Do not write Markdown."
|
|
||||||
)
|
|
||||||
evaluation_context = self.requirements.evaluation_review_context(task_id)
|
|
||||||
if evaluation_context is not None:
|
|
||||||
instruction += " For a declared evaluation-only validation capability gap, an existing scoped visual claim is valid coverage. Preserve it and do not add a made-up deterministic substitute solely to address that gap."
|
|
||||||
else:
|
|
||||||
covered = {str(source_id) for item in draft.get("items") or () if isinstance(item, dict) for source_id in item.get("source_ids") or ()}
|
|
||||||
source_ids = set(self.artifacts.read_source_index(task_id))
|
|
||||||
instruction = "Every source is represented in the draft. Call finalize_requirements_draft now; do not write Markdown." if draft.get("items") and source_ids.issubset(covered) else "Create at most 8 structured requirement items that cover every source. Do not write Markdown."
|
|
||||||
content = {"protocol": "cad.v3", "phase": state.phase.value, "source_index": self.artifacts.read_source_index(task_id), "source_requirements": self.artifacts.read_source_requirements(task_id), "user_clarifications": self._user_clarifications(task_id), "draft": draft, "review": review, "evaluation_context": self.requirements.evaluation_review_context(task_id), "instruction": instruction}
|
|
||||||
else:
|
else:
|
||||||
contract = self._requirements_contract(task_id, state) or {}
|
contract = self._requirements_contract(task_id, state) or {}
|
||||||
compact = [{
|
compact = [{
|
||||||
"requirement_id": item.get("requirement_id"),
|
|
||||||
"statement": item.get("statement"),
|
"statement": item.get("statement"),
|
||||||
"acceptance_claims": [
|
"acceptance_claims": [
|
||||||
{
|
{
|
||||||
"claim_id": claim.get("claim_id"),
|
|
||||||
"claim_kind": claim.get("claim_kind"),
|
"claim_kind": claim.get("claim_kind"),
|
||||||
"expected": claim.get("expected"),
|
"expected": claim.get("expected"),
|
||||||
}
|
}
|
||||||
@@ -1135,18 +1117,10 @@ class WorkflowCoordinator:
|
|||||||
for token, value in tokens.items() if token in allowed
|
for token, value in tokens.items() if token in allowed
|
||||||
][:16]
|
][:16]
|
||||||
instruction = "The exact operation contract and eligible selector summary are attached. Submit one fragment; call inspect_topology only when the selector summary is marked truncated."
|
instruction = "The exact operation contract and eligible selector summary are attached. Submit one fragment; call inspect_topology only when the selector summary is marked truncated."
|
||||||
content = {"protocol": "cad.v3", "phase": state.phase.value, "working_head": state.working_head, "requirements": compact, "verification_warnings": contract.get("verification_warnings") or [], "applied_normalizations": contract.get("applied_normalizations") or [], "claim_coverage": self.actions.claim_summary(task_id, state), "model_summary": self.actions.model_summary(task_id, state), "active_revision": state.active_revision, "pending_action": self._pending_context(state), "operation_contract": operation_payload, "selector_summary": selector_summary, "selector_summary_truncated": bool(action is not None and selector_shape == "required" and len(self._selector_tokens_for_contract(operation or {}, self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision)))) > len(selector_summary)), "recent_failures": self._recent_failure_constraints(task_id, state), "recent_ledger": (self.repository.get_task_projection(task_id) or {}).get("action_ledger_summary", [])[-4:], "diagnostic_evidence_refs": list(self.actions.diagnostic_evidence_refs(task_id, state)) if state.repair_required else [], "repair_diagnostics": self.actions.repair_diagnostics(task_id, state), "rollback_checkpoints": list(self.actions.checkpoint_tokens(task_id, state)) if self.actions.rollback_available(task_id, state) else [], "instruction": instruction}
|
content = {"protocol": "cad.v3", "phase": state.phase.value, "requirements": compact, "verification_warnings": contract.get("verification_warnings") or [], "claim_coverage": [self._public_claim_result(item) for item in self.actions.claim_summary(task_id, state)], "model_summary": self.actions.model_summary(task_id, state), "pending_action": self._public_pending_context(state), "operation_contract": self._public_operation_payload(operation_payload), "selector_summary": selector_summary, "selector_summary_truncated": bool(action is not None and selector_shape == "required" and len(self._selector_tokens_for_contract(operation or {}, self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision)))) > len(selector_summary)), "recent_failures": self._recent_failure_constraints(task_id, state), "repair_diagnostics": self.actions.repair_diagnostics(task_id, state), "rollback_checkpoints": list(self.actions.checkpoint_tokens(task_id, state)) if self.actions.rollback_available(task_id, state) else [], "instruction": instruction}
|
||||||
messages: list[dict[str, Any]] = [{"role": "system", "content": "You are the autonomous CAD author. Use exactly one offered structured tool call. Never emit Markdown plans or free-form JSON."}, {"role": "user", "content": json.dumps(content, ensure_ascii=False)}]
|
messages: list[dict[str, Any]] = [{"role": "system", "content": "You are the autonomous CAD author. Use exactly one offered structured tool call. Never emit Markdown plans or free-form JSON."}, {"role": "user", "content": json.dumps(content, ensure_ascii=False)}]
|
||||||
return [*messages, *feedback[-2:]]
|
return [*messages, *feedback[-2:]]
|
||||||
|
|
||||||
def _requirements_draft(self, state: TaskState | None) -> dict[str, Any]:
|
|
||||||
if state is None:
|
|
||||||
return {"schema_version": "cad.requirements-draft.v1", "revision": 0, "items": []}
|
|
||||||
return self.artifacts.read_requirements_draft(state.task_id, state.requirements_draft_path)
|
|
||||||
|
|
||||||
def _requirements_review(self, task_id: str, state: TaskState | None) -> dict[str, Any] | None:
|
|
||||||
return self.artifacts.read_requirements_review(task_id, state.requirements_review_path if state is not None else "")
|
|
||||||
|
|
||||||
def _user_clarifications(self, task_id: str) -> list[dict[str, str]]:
|
def _user_clarifications(self, task_id: str) -> list[dict[str, str]]:
|
||||||
clarifications: list[dict[str, str]] = []
|
clarifications: list[dict[str, str]] = []
|
||||||
for event in self.repository.ledger_events(task_id):
|
for event in self.repository.ledger_events(task_id):
|
||||||
@@ -1162,32 +1136,12 @@ class WorkflowCoordinator:
|
|||||||
def waiting_for_user_terminal(self, task_id: str, state: TaskState) -> dict[str, Any]:
|
def waiting_for_user_terminal(self, task_id: str, state: TaskState) -> dict[str, Any]:
|
||||||
"""Expose the persisted requirement question when a task is parked.
|
"""Expose the persisted requirement question when a task is parked.
|
||||||
|
|
||||||
The review artifact is the durable source of a human decision. The
|
The clarification artifact is the durable source of the single human
|
||||||
terminal event deliberately carries only its explicit questions, not
|
decision needed to continue this task.
|
||||||
the full reviewer report, so it remains useful to both SSE clients and
|
|
||||||
conversation history without leaking unrelated review detail.
|
|
||||||
"""
|
"""
|
||||||
review = self._requirements_review(task_id, state) or {}
|
clarification = self.artifacts.read_json(task_id, state.clarification_path) if state.clarification_path else None
|
||||||
questions: list[str] = []
|
question = str((clarification or {}).get("question") or "").strip()
|
||||||
unresolved: list[dict[str, str]] = []
|
questions = [question] if question else []
|
||||||
for finding in review.get("findings") or ():
|
|
||||||
if not isinstance(finding, dict):
|
|
||||||
continue
|
|
||||||
question = str(finding.get("question") or "").strip()
|
|
||||||
finding_type = str(finding.get("finding_type") or "")
|
|
||||||
if finding_type == "ambiguous_conflict" and question and question not in questions:
|
|
||||||
questions.append(question)
|
|
||||||
if finding_type == "ambiguous_conflict":
|
|
||||||
unresolved.append({
|
|
||||||
"draftId": str(finding.get("draft_id") or ""),
|
|
||||||
"reasonCode": finding_type,
|
|
||||||
"question": question,
|
|
||||||
})
|
|
||||||
issues = [
|
|
||||||
str(finding.get("description") or "").strip()
|
|
||||||
for finding in review.get("findings") or ()
|
|
||||||
if isinstance(finding, dict) and str(finding.get("description") or "").strip()
|
|
||||||
]
|
|
||||||
if not questions:
|
if not questions:
|
||||||
raise RuntimeError("WAITING_FOR_USER requires at least one answerable requirements question")
|
raise RuntimeError("WAITING_FOR_USER requires at least one answerable requirements question")
|
||||||
message = f"Requirements need a user decision. {questions[0]}"
|
message = f"Requirements need a user decision. {questions[0]}"
|
||||||
@@ -1198,14 +1152,10 @@ class WorkflowCoordinator:
|
|||||||
"code": state.last_error.value if state.last_error else ErrorCode.WAITING_FOR_USER.value,
|
"code": state.last_error.value if state.last_error else ErrorCode.WAITING_FOR_USER.value,
|
||||||
"message": message,
|
"message": message,
|
||||||
"questions": questions,
|
"questions": questions,
|
||||||
"reviewPath": state.requirements_review_path,
|
"clarificationPath": state.clarification_path,
|
||||||
"blockerType": "requirements_ambiguity",
|
"blockerType": "requirements_ambiguity",
|
||||||
"userActionRequired": True,
|
"userActionRequired": True,
|
||||||
}
|
}
|
||||||
if issues:
|
|
||||||
payload["issues"] = issues
|
|
||||||
if unresolved:
|
|
||||||
payload["unresolved"] = unresolved
|
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
def _requirements_contract(self, task_id: str, state: TaskState | None) -> dict[str, Any] | None:
|
def _requirements_contract(self, task_id: str, state: TaskState | None) -> dict[str, Any] | None:
|
||||||
@@ -1213,6 +1163,85 @@ class WorkflowCoordinator:
|
|||||||
return None
|
return None
|
||||||
return self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path)
|
return self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path)
|
||||||
|
|
||||||
|
def _ensure_recovered_completion_result(self, task_id: str, state: TaskState) -> None:
|
||||||
|
result_path = self.artifacts.artifact_path(task_id, "completion-result.md")
|
||||||
|
if result_path.is_file():
|
||||||
|
return
|
||||||
|
facts = self.actions._facts(task_id, state.active_revision)
|
||||||
|
claim_results = self.actions._evaluate_claims(task_id, facts)
|
||||||
|
raw_review = self.artifacts.read_json(task_id, f"reviews/final/{state.active_revision}/final-review.json") or {}
|
||||||
|
coverage = {
|
||||||
|
str(item.get("claim_id") or ""): item
|
||||||
|
for item in raw_review.get("claim_coverage") or ()
|
||||||
|
if isinstance(item, dict)
|
||||||
|
}
|
||||||
|
visual_claims = [
|
||||||
|
{
|
||||||
|
"status": str(coverage.get(str(item.get("claim_id") or ""), {}).get("status") or "fail"),
|
||||||
|
"evidence": "; ".join(str(value) for value in raw_review.get("evidence") or ()) or "Recovered final review decision.",
|
||||||
|
}
|
||||||
|
for item in claim_results
|
||||||
|
if not item.get("deterministic")
|
||||||
|
]
|
||||||
|
self.requirements.write_completion_result(
|
||||||
|
task_id,
|
||||||
|
state,
|
||||||
|
claim_results=claim_results,
|
||||||
|
review={"visual_claims": visual_claims},
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _public_requirements(contract: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"statement": str(item.get("statement") or ""),
|
||||||
|
"assumptions": list(item.get("assumptions") or []),
|
||||||
|
"acceptance_claims": [
|
||||||
|
{
|
||||||
|
"claim_kind": str(claim.get("claim_kind") or ""),
|
||||||
|
"expected": claim.get("expected") or {},
|
||||||
|
"verification_mode": str(claim.get("verification_mode") or ""),
|
||||||
|
}
|
||||||
|
for claim in item.get("acceptance_claims") or ()
|
||||||
|
if isinstance(claim, dict)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
for item in (contract or {}).get("requirements") or ()
|
||||||
|
if isinstance(item, dict)
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _public_claim_result(item: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
key: value
|
||||||
|
for key, value in item.items()
|
||||||
|
if key not in {"claim_id", "requirement_id", "evidence_refs"}
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _public_candidate(cls, candidate: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"claim_results": [cls._public_claim_result(item) for item in candidate.get("claim_results") or () if isinstance(item, dict)],
|
||||||
|
"operation_verifier_results": [cls._public_claim_result(item) for item in candidate.get("operation_verifier_results") or () if isinstance(item, dict)],
|
||||||
|
"health": candidate.get("health") or {},
|
||||||
|
"model_summary": candidate.get("model_summary") or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _public_pending_context(state: TaskState) -> dict[str, Any] | None:
|
||||||
|
action = state.pending_action
|
||||||
|
return {"intent": action.intent, "operation": action.atomic_id, "expected_change": action.expected_change} if action else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _public_operation_payload(payload: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"operation": payload.get("atomic_id"),
|
||||||
|
"contract": payload.get("contract"),
|
||||||
|
"fragment_schema": payload.get("fragment_schema"),
|
||||||
|
}
|
||||||
|
|
||||||
def _projected_terminal(self, task_id: str, state: TaskState) -> dict[str, Any]:
|
def _projected_terminal(self, task_id: str, state: TaskState) -> dict[str, Any]:
|
||||||
projection = self.repository.get_task_projection(task_id) or {}
|
projection = self.repository.get_task_projection(task_id) or {}
|
||||||
return {
|
return {
|
||||||
@@ -1227,7 +1256,6 @@ class WorkflowCoordinator:
|
|||||||
"userActionRequired": bool(projection.get("user_action_required")),
|
"userActionRequired": bool(projection.get("user_action_required")),
|
||||||
"verificationStatus": str(projection.get("verification_status") or "verified"),
|
"verificationStatus": str(projection.get("verification_status") or "verified"),
|
||||||
"verificationWarnings": projection.get("verification_warnings") or [],
|
"verificationWarnings": projection.get("verification_warnings") or [],
|
||||||
"appliedNormalizations": projection.get("applied_normalizations") or [],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def _operation_payload(self, task_id: str, state: TaskState) -> dict[str, Any]:
|
def _operation_payload(self, task_id: str, state: TaskState) -> dict[str, Any]:
|
||||||
@@ -1429,6 +1457,48 @@ class WorkflowCoordinator:
|
|||||||
"field_errors": list(error.field_errors),
|
"field_errors": list(error.field_errors),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _requirements_format_failure(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
state: TaskState,
|
||||||
|
error: WorkflowError,
|
||||||
|
counters: dict[str, int],
|
||||||
|
feedback: list[dict[str, Any]],
|
||||||
|
) -> tuple[str, dict[str, Any]] | None:
|
||||||
|
key = "requirements_spec"
|
||||||
|
counters[key] = counters.get(key, 0) + 1
|
||||||
|
feedback[:] = [self._feedback(error)]
|
||||||
|
if counters[key] < 2:
|
||||||
|
return None
|
||||||
|
failed = transition(state, "failed", error=ErrorCode.REQUIREMENTS_SPEC_INVALID)
|
||||||
|
self.repository.compare_and_swap(failed, events=[{
|
||||||
|
"event": "requirements_spec_invalid",
|
||||||
|
"code": ErrorCode.REQUIREMENTS_SPEC_INVALID.value,
|
||||||
|
"message": error.message,
|
||||||
|
"field_errors": list(error.field_errors),
|
||||||
|
}])
|
||||||
|
return "task_terminal", {
|
||||||
|
"taskId": task_id,
|
||||||
|
"lifecycle": "failed",
|
||||||
|
"code": ErrorCode.REQUIREMENTS_SPEC_INVALID.value,
|
||||||
|
"message": "Requirements specification remained unreadable after one field-level correction.",
|
||||||
|
"tool": "submit_requirements_spec",
|
||||||
|
"field_errors": list(error.field_errors),
|
||||||
|
"userActionRequired": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _requirements_rejection(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
state: TaskState,
|
||||||
|
error: WorkflowError,
|
||||||
|
counters: dict[str, int],
|
||||||
|
feedback: list[dict[str, Any]],
|
||||||
|
) -> tuple[str, dict[str, Any]] | None:
|
||||||
|
if error.retryable or error.code == ErrorCode.STORAGE_FAILURE:
|
||||||
|
return self._service_failure(task_id, state, error)
|
||||||
|
return self._requirements_format_failure(task_id, state, error, counters, feedback)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _tool(name: str, model: type[BaseModel] | dict[str, Any]) -> dict[str, Any]:
|
def _tool(name: str, model: type[BaseModel] | dict[str, Any]) -> dict[str, Any]:
|
||||||
parameters = model if isinstance(model, dict) else model.model_json_schema()
|
parameters = model if isinstance(model, dict) else model.model_json_schema()
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from app.cad_agent.adapters.structured_llm import StructuredModelGateway
|
|||||||
from app.cad_agent.adapters.verifier import RegistryVerifierExecutor
|
from app.cad_agent.adapters.verifier import RegistryVerifierExecutor
|
||||||
from app.cad_agent.application.action_handlers import ActionCommandHandler
|
from app.cad_agent.application.action_handlers import ActionCommandHandler
|
||||||
from app.cad_agent.application.outbox import OutboxDispatcher
|
from app.cad_agent.application.outbox import OutboxDispatcher
|
||||||
from app.cad_agent.application.requirements_review import RequirementsCommandHandler
|
from app.cad_agent.application.requirements import RequirementsCommandHandler
|
||||||
from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator
|
from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator
|
||||||
from app.cad_agent.domain.verifier_registry import default_registry
|
from app.cad_agent.domain.verifier_registry import default_registry
|
||||||
from app.settings import Settings
|
from app.settings import Settings
|
||||||
@@ -48,7 +48,6 @@ def compose_v3(settings: Settings) -> V3Services:
|
|||||||
WorkflowConfig(
|
WorkflowConfig(
|
||||||
max_turns=max(8, settings.agent_tool_calls_per_cycle * 8),
|
max_turns=max(8, settings.agent_tool_calls_per_cycle * 8),
|
||||||
format_error_limit=settings.agent_format_error_repeat_limit,
|
format_error_limit=settings.agent_format_error_repeat_limit,
|
||||||
requirements_review_limit=3,
|
|
||||||
author_fallbacks=fallbacks,
|
author_fallbacks=fallbacks,
|
||||||
),
|
),
|
||||||
repository,
|
repository,
|
||||||
|
|||||||
@@ -20,12 +20,13 @@ class ErrorCode(StrEnum):
|
|||||||
VERIFIER_UNAVAILABLE = "VERIFIER_UNAVAILABLE"
|
VERIFIER_UNAVAILABLE = "VERIFIER_UNAVAILABLE"
|
||||||
CLAIM_VERIFICATION_FAILED = "CLAIM_VERIFICATION_FAILED"
|
CLAIM_VERIFICATION_FAILED = "CLAIM_VERIFICATION_FAILED"
|
||||||
MODEL_STRUCTURED_OUTPUT_UNSUPPORTED = "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED"
|
MODEL_STRUCTURED_OUTPUT_UNSUPPORTED = "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED"
|
||||||
|
MODEL_PROTOCOL_CHECK_PENDING = "MODEL_PROTOCOL_CHECK_PENDING"
|
||||||
AUTHOR_TRANSPORT_UNAVAILABLE = "AUTHOR_TRANSPORT_UNAVAILABLE"
|
AUTHOR_TRANSPORT_UNAVAILABLE = "AUTHOR_TRANSPORT_UNAVAILABLE"
|
||||||
REVIEW_SERVICE_UNAVAILABLE = "REVIEW_SERVICE_UNAVAILABLE"
|
REVIEW_SERVICE_UNAVAILABLE = "REVIEW_SERVICE_UNAVAILABLE"
|
||||||
RENDER_SERVICE_UNAVAILABLE = "RENDER_SERVICE_UNAVAILABLE"
|
RENDER_SERVICE_UNAVAILABLE = "RENDER_SERVICE_UNAVAILABLE"
|
||||||
STORAGE_FAILURE = "STORAGE_FAILURE"
|
STORAGE_FAILURE = "STORAGE_FAILURE"
|
||||||
CALL_BUDGET_EXHAUSTED = "CALL_BUDGET_EXHAUSTED"
|
CALL_BUDGET_EXHAUSTED = "CALL_BUDGET_EXHAUSTED"
|
||||||
REQUIREMENTS_REVIEW_NOT_CONVERGED = "REQUIREMENTS_REVIEW_NOT_CONVERGED"
|
REQUIREMENTS_SPEC_INVALID = "REQUIREMENTS_SPEC_INVALID"
|
||||||
NO_PROGRESS_LIMIT = "NO_PROGRESS_LIMIT"
|
NO_PROGRESS_LIMIT = "NO_PROGRESS_LIMIT"
|
||||||
RUNTIME_EXECUTION_FAILURE = "RUNTIME_EXECUTION_FAILURE"
|
RUNTIME_EXECUTION_FAILURE = "RUNTIME_EXECUTION_FAILURE"
|
||||||
FAILED_INTERNAL = "FAILED_INTERNAL"
|
FAILED_INTERNAL = "FAILED_INTERNAL"
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from .errors import ErrorCode, WorkflowError
|
|||||||
|
|
||||||
class TaskPhase(StrEnum):
|
class TaskPhase(StrEnum):
|
||||||
DRAFTING_REQUIREMENTS = "DRAFTING_REQUIREMENTS"
|
DRAFTING_REQUIREMENTS = "DRAFTING_REQUIREMENTS"
|
||||||
REVIEWING_REQUIREMENTS = "REVIEWING_REQUIREMENTS"
|
|
||||||
AWAITING_ACTION = "AWAITING_ACTION"
|
AWAITING_ACTION = "AWAITING_ACTION"
|
||||||
ACTION_PENDING = "ACTION_PENDING"
|
ACTION_PENDING = "ACTION_PENDING"
|
||||||
CANDIDATE_BUILDING = "CANDIDATE_BUILDING"
|
CANDIDATE_BUILDING = "CANDIDATE_BUILDING"
|
||||||
@@ -47,8 +46,8 @@ class TaskState:
|
|||||||
repair_required: bool = False
|
repair_required: bool = False
|
||||||
last_error: ErrorCode | None = None
|
last_error: ErrorCode | None = None
|
||||||
retry_from_phase: TaskPhase | None = None
|
retry_from_phase: TaskPhase | None = None
|
||||||
requirements_draft_path: str = ""
|
requirements_spec_path: str = ""
|
||||||
requirements_review_path: str = ""
|
clarification_path: str = ""
|
||||||
requirements_contract_path: str = ""
|
requirements_contract_path: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -59,14 +58,12 @@ class TaskState:
|
|||||||
# Legal state transitions. Events are intentionally terse persistence-neutral
|
# Legal state transitions. Events are intentionally terse persistence-neutral
|
||||||
# names used by command handlers and architecture tests.
|
# names used by command handlers and architecture tests.
|
||||||
_TRANSITIONS: dict[tuple[TaskPhase, str], TaskPhase] = {
|
_TRANSITIONS: dict[tuple[TaskPhase, str], TaskPhase] = {
|
||||||
(TaskPhase.DRAFTING_REQUIREMENTS, "draft_updated"): TaskPhase.DRAFTING_REQUIREMENTS,
|
(TaskPhase.DRAFTING_REQUIREMENTS, "image_observed"): TaskPhase.DRAFTING_REQUIREMENTS,
|
||||||
(TaskPhase.DRAFTING_REQUIREMENTS, "requirements_finalized"): TaskPhase.REVIEWING_REQUIREMENTS,
|
(TaskPhase.DRAFTING_REQUIREMENTS, "requirements_approved"): TaskPhase.AWAITING_ACTION,
|
||||||
(TaskPhase.REVIEWING_REQUIREMENTS, "requirements_approved"): TaskPhase.AWAITING_ACTION,
|
(TaskPhase.DRAFTING_REQUIREMENTS, "waiting_for_user"): TaskPhase.WAITING_FOR_USER,
|
||||||
(TaskPhase.REVIEWING_REQUIREMENTS, "requirements_revise"): TaskPhase.DRAFTING_REQUIREMENTS,
|
# User clarifications are durable task evidence. Resume on the same task
|
||||||
(TaskPhase.REVIEWING_REQUIREMENTS, "waiting_for_user"): TaskPhase.WAITING_FOR_USER,
|
# so its frozen request remains authoritative
|
||||||
# User clarifications are durable task evidence. Requirement-level pauses
|
# instead of turning a clarification into a new CAD request.
|
||||||
# resume on the same task so the frozen request and draft history remain
|
|
||||||
# reviewable instead of turning a short reply into a new CAD request.
|
|
||||||
(TaskPhase.WAITING_FOR_USER, "requirements_clarified"): TaskPhase.DRAFTING_REQUIREMENTS,
|
(TaskPhase.WAITING_FOR_USER, "requirements_clarified"): TaskPhase.DRAFTING_REQUIREMENTS,
|
||||||
(TaskPhase.AWAITING_ACTION, "action_proposed"): TaskPhase.ACTION_PENDING,
|
(TaskPhase.AWAITING_ACTION, "action_proposed"): TaskPhase.ACTION_PENDING,
|
||||||
(TaskPhase.AWAITING_ACTION, "diagnosis_recorded"): TaskPhase.AWAITING_ACTION,
|
(TaskPhase.AWAITING_ACTION, "diagnosis_recorded"): TaskPhase.AWAITING_ACTION,
|
||||||
@@ -98,7 +95,6 @@ _TRANSITIONS.update({
|
|||||||
})
|
})
|
||||||
_RETRY_RESUMABLE_PHASES = frozenset({
|
_RETRY_RESUMABLE_PHASES = frozenset({
|
||||||
TaskPhase.DRAFTING_REQUIREMENTS,
|
TaskPhase.DRAFTING_REQUIREMENTS,
|
||||||
TaskPhase.REVIEWING_REQUIREMENTS,
|
|
||||||
TaskPhase.AWAITING_ACTION,
|
TaskPhase.AWAITING_ACTION,
|
||||||
TaskPhase.ACTION_PENDING,
|
TaskPhase.ACTION_PENDING,
|
||||||
TaskPhase.CANDIDATE_BUILDING,
|
TaskPhase.CANDIDATE_BUILDING,
|
||||||
@@ -126,7 +122,7 @@ def retry_resume_event(state: TaskState) -> str | None:
|
|||||||
return f"resume_{state.retry_from_phase.value.lower()}"
|
return f"resume_{state.retry_from_phase.value.lower()}"
|
||||||
|
|
||||||
|
|
||||||
def transition(state: TaskState, event: str, *, pending_action: PendingAction | None | object = ..., active_revision: str | None = None, candidate_id: str | None = None, candidate_stage_id: str | None = None, repair_required: bool | None = None, error: ErrorCode | None = None, requirements_draft_path: str | None = None, requirements_review_path: str | None = None, requirements_contract_path: str | None = None) -> TaskState:
|
def transition(state: TaskState, event: str, *, pending_action: PendingAction | None | object = ..., active_revision: str | None = None, candidate_id: str | None = None, candidate_stage_id: str | None = None, repair_required: bool | None = None, error: ErrorCode | None = None, requirements_spec_path: str | None = None, clarification_path: str | None = None, requirements_contract_path: str | None = None) -> TaskState:
|
||||||
"""Apply one legal transition and advance optimistic-concurrency version."""
|
"""Apply one legal transition and advance optimistic-concurrency version."""
|
||||||
target = _TRANSITIONS.get((state.phase, event))
|
target = _TRANSITIONS.get((state.phase, event))
|
||||||
if target is None:
|
if target is None:
|
||||||
@@ -147,8 +143,8 @@ def transition(state: TaskState, event: str, *, pending_action: PendingAction |
|
|||||||
repair_required=state.repair_required if repair_required is None else repair_required,
|
repair_required=state.repair_required if repair_required is None else repair_required,
|
||||||
last_error=error,
|
last_error=error,
|
||||||
retry_from_phase=state.phase if target == TaskPhase.WAITING_RETRY else None,
|
retry_from_phase=state.phase if target == TaskPhase.WAITING_RETRY else None,
|
||||||
requirements_draft_path=state.requirements_draft_path if requirements_draft_path is None else requirements_draft_path,
|
requirements_spec_path=state.requirements_spec_path if requirements_spec_path is None else requirements_spec_path,
|
||||||
requirements_review_path=state.requirements_review_path if requirements_review_path is None else requirements_review_path,
|
clarification_path=state.clarification_path if clarification_path is None else clarification_path,
|
||||||
requirements_contract_path=state.requirements_contract_path if requirements_contract_path is None else requirements_contract_path,
|
requirements_contract_path=state.requirements_contract_path if requirements_contract_path is None else requirements_contract_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ def _acceptance_coverage(
|
|||||||
claim for claim in expected_claims
|
claim for claim in expected_claims
|
||||||
if not any(
|
if not any(
|
||||||
actual_claim["claim_kind"] == claim["claim_kind"]
|
actual_claim["claim_kind"] == claim["claim_kind"]
|
||||||
and contains_expected(actual_claim["expected"], claim["expected"])
|
and _contains_business_expected(actual_claim["expected"], claim["expected"])
|
||||||
for actual_claim in actual
|
for actual_claim in actual
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
@@ -186,8 +186,26 @@ def _acceptance_coverage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _contains_business_expected(actual: Any, required: Any) -> bool:
|
||||||
|
"""Match fixture business values without coupling to verifier tolerances.
|
||||||
|
|
||||||
|
Tolerances are executable verifier parameters chosen within the schema's
|
||||||
|
safe range. They are not a separate user requirement and should not make
|
||||||
|
a valid generated contract fail release evaluation merely because the
|
||||||
|
author used the registry default instead of the fixture's tighter value.
|
||||||
|
"""
|
||||||
|
if isinstance(actual, dict) and isinstance(required, dict):
|
||||||
|
return all(
|
||||||
|
key in actual
|
||||||
|
and _contains_business_expected(actual[key], value)
|
||||||
|
for key, value in required.items()
|
||||||
|
if key not in {"tolerance_mm", "tolerance"}
|
||||||
|
)
|
||||||
|
return contains_expected(actual, required)
|
||||||
|
|
||||||
|
|
||||||
def _contains_expected(actual: Any, required: Any) -> bool:
|
def _contains_expected(actual: Any, required: Any) -> bool:
|
||||||
"""Backward-compatible evaluation helper for canonical claim matching."""
|
"""Match canonical claim values in release evaluation."""
|
||||||
return contains_expected(actual, required)
|
return contains_expected(actual, required)
|
||||||
|
|
||||||
|
|
||||||
@@ -490,7 +508,12 @@ def _redacted_correlation_ids(task_id: str, invocations: list[dict[str, Any]]) -
|
|||||||
|
|
||||||
def _safe_artifact_manifest(artifact_root: Path) -> dict[str, Any]:
|
def _safe_artifact_manifest(artifact_root: Path) -> dict[str, Any]:
|
||||||
"""Hash reviewable CAD evidence without copying source or prompt content."""
|
"""Hash reviewable CAD evidence without copying source or prompt content."""
|
||||||
allowed_exact = {"requirements-contract.json", "requirements-index.json", "requirements.md", "completion.md"}
|
allowed_exact = {
|
||||||
|
"requirements-contract.json",
|
||||||
|
"requirements.md",
|
||||||
|
"completion-target.md",
|
||||||
|
"completion-result.md",
|
||||||
|
}
|
||||||
allowed_prefixes = ("actions/", "revisions/", "reviews/", "documents/requirements-")
|
allowed_prefixes = ("actions/", "revisions/", "reviews/", "documents/requirements-")
|
||||||
files: list[dict[str, str]] = []
|
files: list[dict[str, str]] = []
|
||||||
if artifact_root.is_dir():
|
if artifact_root.is_dir():
|
||||||
@@ -550,8 +573,8 @@ async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, An
|
|||||||
]
|
]
|
||||||
verifier_schema_hash = canonical_hash(default_registry().expected_one_of_schema())
|
verifier_schema_hash = canonical_hash(default_registry().expected_one_of_schema())
|
||||||
try:
|
try:
|
||||||
author_capability = await verify_model_capability(services.repository, services.workflow.runtime, services.models, provider_id=author_provider.id, model_id=author_model.id, force=True)
|
author_capability = await verify_model_capability(services.repository, services.workflow.runtime, services.models, provider_id=author_provider.id, model_id=author_model.id, role="author", force=True)
|
||||||
reviewer_capability = await verify_model_capability(services.repository, services.workflow.runtime, services.models, provider_id=review_provider.id, model_id=review_model.id, force=True)
|
reviewer_capability = await verify_model_capability(services.repository, services.workflow.runtime, services.models, provider_id=review_provider.id, model_id=review_model.id, role="reviewer", force=True)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)[:1000]}
|
return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)[:1000]}
|
||||||
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
|
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
|
||||||
|
|||||||
@@ -58,15 +58,13 @@ class TaskRepository(Protocol):
|
|||||||
|
|
||||||
|
|
||||||
class ArtifactStore(Protocol):
|
class ArtifactStore(Protocol):
|
||||||
def initialize_task(self, task_id: str, request: str, *, source_blocks: list[dict[str, Any]] | None = None) -> None: ...
|
def initialize_task(self, task_id: str, request: str, *, source_blocks: list[dict[str, Any]] | None = None, image_inputs: list[dict[str, str]] | None = None) -> None: ...
|
||||||
def sync_action_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str: ...
|
def sync_action_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str: ...
|
||||||
def write_source_index(self, task_id: str, request: str) -> dict[str, str]: ...
|
def write_source_index(self, task_id: str, request: str) -> dict[str, str]: ...
|
||||||
def read_source_index(self, task_id: str) -> dict[str, str]: ...
|
def read_source_index(self, task_id: str) -> dict[str, str]: ...
|
||||||
def read_source_requirements(self, task_id: str) -> str: ...
|
def read_source_requirements(self, task_id: str) -> str: ...
|
||||||
def read_requirements_draft(self, task_id: str, artifact_path: str = "") -> dict[str, Any]: ...
|
def source_image_paths(self, task_id: str) -> list[str]: ...
|
||||||
def write_requirements_draft(self, task_id: str, payload: dict[str, Any], *, invocation_id: str) -> str: ...
|
def read_requirements_spec(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: ...
|
||||||
def read_requirements_review(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: ...
|
|
||||||
def write_requirements_review(self, task_id: str, payload: dict[str, Any], *, invocation_id: str) -> str: ...
|
|
||||||
def read_requirements_contract(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: ...
|
def read_requirements_contract(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: ...
|
||||||
def write_requirements_contract(self, task_id: str, payload: dict[str, Any], *, invocation_id: str = "") -> str: ...
|
def write_requirements_contract(self, task_id: str, payload: dict[str, Any], *, invocation_id: str = "") -> str: ...
|
||||||
def read_json(self, task_id: str, relative_path: str) -> dict[str, Any] | None: ...
|
def read_json(self, task_id: str, relative_path: str) -> dict[str, Any] | None: ...
|
||||||
|
|||||||
+9
-7
@@ -25,9 +25,8 @@ app = FastAPI(title="CDSL CAD Agent API", version="0.1.0")
|
|||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def resume_autonomous_generation() -> None:
|
async def resume_autonomous_generation() -> None:
|
||||||
"""Restore durable autonomous agent tasks after a backend process restart."""
|
"""Prewarm model protocols without delaying API readiness."""
|
||||||
if settings.resume_running_tasks_on_startup:
|
asyncio.create_task(agent.resume_running_tasks(), name="cad-model-protocol-prewarm")
|
||||||
await agent.resume_running_tasks()
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
@@ -152,13 +151,16 @@ async def read_task(task_id: str) -> JSONResponse:
|
|||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
task["preview_revision"] = str(task.get("active_revision") or task.get("current_revision") or "")
|
task["preview_revision"] = str(task.get("active_revision") or task.get("current_revision") or "")
|
||||||
state = agent.v3.repository.get_state(safe_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_spec"] = agent.v3.artifacts.read_requirements_spec(safe_id, state.requirements_spec_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["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["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["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
|
target_path = agent.v3.artifacts.task_dir(safe_id) / "completion-target.md"
|
||||||
|
result_path = agent.v3.artifacts.task_dir(safe_id) / "completion-result.md"
|
||||||
|
task["completion_target_markdown"] = target_path.read_text(encoding="utf-8") if target_path.is_file() else None
|
||||||
|
task["completion_target_path"] = "completion-target.md" if target_path.is_file() else ""
|
||||||
|
task["completion_result_markdown"] = result_path.read_text(encoding="utf-8") if result_path.is_file() else None
|
||||||
|
task["completion_result_path"] = "completion-result.md" if result_path.is_file() else ""
|
||||||
task["usage"] = agent.v3.repository.usage_summary(safe_id)
|
task["usage"] = agent.v3.repository.usage_summary(safe_id)
|
||||||
return JSONResponse(task)
|
return JSONResponse(task)
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import secrets
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.cad_agent.application.workflow import ModelIdentity
|
from app.cad_agent.application.workflow import ModelIdentity
|
||||||
from app.cad_agent.application.capabilities import verify_model_capability
|
from app.cad_agent.application.capabilities import cached_model_capability, verify_model_capability
|
||||||
from app.cad_agent.composition import V3Services, compose_v3
|
from app.cad_agent.composition import V3Services, compose_v3
|
||||||
from app.cad_agent.domain.errors import ErrorCode
|
from app.cad_agent.domain.errors import ErrorCode
|
||||||
from app.cad_agent.domain.state import TaskPhase, transition
|
from app.cad_agent.domain.state import TaskPhase, transition
|
||||||
@@ -32,7 +32,10 @@ def _response_language(text: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
_EVENT_LABELS = {
|
_EVENT_LABELS = {
|
||||||
"requirements_review": "需求合同独立复核",
|
"image_observation": "参考图片观察",
|
||||||
|
"requirements_ready": "需求规格已就绪",
|
||||||
|
"completion_result_ready": "完成结果已就绪",
|
||||||
|
"model_protocol_check": "模型协议检查",
|
||||||
"action_selection": "动作选择",
|
"action_selection": "动作选择",
|
||||||
"tool_call": "建模工具",
|
"tool_call": "建模工具",
|
||||||
"candidate_result": "候选构建",
|
"candidate_result": "候选构建",
|
||||||
@@ -72,8 +75,6 @@ class AgentService:
|
|||||||
|
|
||||||
async def resume_running_tasks(self) -> None:
|
async def resume_running_tasks(self) -> None:
|
||||||
task_ids = self.v3.repository.running_task_ids()
|
task_ids = self.v3.repository.running_task_ids()
|
||||||
if not task_ids:
|
|
||||||
return
|
|
||||||
try:
|
try:
|
||||||
author_provider, author_model = self.settings.resolve_model(None, None)
|
author_provider, author_model = self.settings.resolve_model(None, None)
|
||||||
review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model)
|
review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model)
|
||||||
@@ -86,19 +87,23 @@ class AgentService:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
author_capability = await verify_model_capability(
|
author_capability, reviewer_capability = await asyncio.gather(
|
||||||
self.v3.repository,
|
verify_model_capability(
|
||||||
self.v3.workflow.runtime,
|
self.v3.repository,
|
||||||
self.v3.models,
|
self.v3.workflow.runtime,
|
||||||
provider_id=author_provider.id,
|
self.v3.models,
|
||||||
model_id=author_model.id,
|
provider_id=author_provider.id,
|
||||||
)
|
model_id=author_model.id,
|
||||||
reviewer_capability = await verify_model_capability(
|
role="author",
|
||||||
self.v3.repository,
|
),
|
||||||
self.v3.workflow.runtime,
|
verify_model_capability(
|
||||||
self.v3.models,
|
self.v3.repository,
|
||||||
provider_id=review_provider.id,
|
self.v3.workflow.runtime,
|
||||||
model_id=review_model.id,
|
self.v3.models,
|
||||||
|
provider_id=review_provider.id,
|
||||||
|
model_id=review_model.id,
|
||||||
|
role="reviewer",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
# Startup recovery must never bypass a production capability gate.
|
# Startup recovery must never bypass a production capability gate.
|
||||||
@@ -111,6 +116,14 @@ class AgentService:
|
|||||||
retryable=True,
|
retryable=True,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
if author_capability.get("probe_unavailable") or reviewer_capability.get("probe_unavailable"):
|
||||||
|
await self._park_startup_tasks(
|
||||||
|
task_ids,
|
||||||
|
ErrorCode.MODEL_PROTOCOL_CHECK_PENDING,
|
||||||
|
"Model protocol check is temporarily unavailable.",
|
||||||
|
retryable=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
|
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
|
||||||
await self._park_startup_tasks(
|
await self._park_startup_tasks(
|
||||||
task_ids,
|
task_ids,
|
||||||
@@ -119,6 +132,8 @@ class AgentService:
|
|||||||
retryable=False,
|
retryable=False,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
if not self.settings.resume_running_tasks_on_startup:
|
||||||
|
return
|
||||||
for task_id in task_ids:
|
for task_id in task_ids:
|
||||||
if task_id in self._autonomous_runs:
|
if task_id in self._autonomous_runs:
|
||||||
continue
|
continue
|
||||||
@@ -213,6 +228,7 @@ class AgentService:
|
|||||||
self.v3.models,
|
self.v3.models,
|
||||||
provider_id=author_provider.id,
|
provider_id=author_provider.id,
|
||||||
model_id=author_model.id,
|
model_id=author_model.id,
|
||||||
|
role="author",
|
||||||
)
|
)
|
||||||
reviewer_capability = await verify_model_capability(
|
reviewer_capability = await verify_model_capability(
|
||||||
self.v3.repository,
|
self.v3.repository,
|
||||||
@@ -220,6 +236,7 @@ class AgentService:
|
|||||||
self.v3.models,
|
self.v3.models,
|
||||||
provider_id=review_provider.id,
|
provider_id=review_provider.id,
|
||||||
model_id=review_model.id,
|
model_id=review_model.id,
|
||||||
|
role="reviewer",
|
||||||
)
|
)
|
||||||
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
|
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
|
||||||
raise ValueError("MODEL_STRUCTURED_OUTPUT_UNSUPPORTED: selected author or reviewer did not pass the v3 conformance suite")
|
raise ValueError("MODEL_STRUCTURED_OUTPUT_UNSUPPORTED: selected author or reviewer did not pass the v3 conformance suite")
|
||||||
@@ -260,14 +277,15 @@ class AgentService:
|
|||||||
if state is not None:
|
if state is not None:
|
||||||
terminal = self.v3.workflow.waiting_for_user_terminal(selected, state)
|
terminal = self.v3.workflow.waiting_for_user_terminal(selected, state)
|
||||||
fields = [
|
fields = [
|
||||||
{
|
{"path": "/requirements/clarification", "message": str(question)}
|
||||||
"path": f"/requirements/{str(item.get('draftId') or 'review')}",
|
for question in terminal.get("questions") or ()
|
||||||
"message": str(item.get("question") or item.get("reasonCode") or "Unresolved review item."),
|
if str(question).strip()
|
||||||
}
|
|
||||||
for item in terminal.get("unresolved") or ()
|
|
||||||
if isinstance(item, dict)
|
|
||||||
]
|
]
|
||||||
fields.extend({"path": "/requirements/review", "message": issue} for issue in terminal.get("issues") or ())
|
fields.extend(
|
||||||
|
{"path": "/requirements", "message": str(issue)}
|
||||||
|
for issue in terminal.get("issues") or ()
|
||||||
|
if str(issue).strip()
|
||||||
|
)
|
||||||
if not self.v3.workflow.resume_with_user_clarification(selected, request, message_id=latest_user.id):
|
if not self.v3.workflow.resume_with_user_clarification(selected, request, message_id=latest_user.id):
|
||||||
yield event("cad_error", {
|
yield event("cad_error", {
|
||||||
"stage": "request",
|
"stage": "request",
|
||||||
@@ -280,35 +298,16 @@ class AgentService:
|
|||||||
yield event("done", {})
|
yield event("done", {})
|
||||||
return
|
return
|
||||||
resumed_task_id = selected
|
resumed_task_id = selected
|
||||||
try:
|
|
||||||
author_provider, author_model = self.settings.resolve_model(provider_id, model_id)
|
|
||||||
review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model)
|
|
||||||
except ValueError as error:
|
|
||||||
yield event("cad_error", {"stage": "configuration", "message": str(error)})
|
|
||||||
yield event("done", {})
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
author_capability = await verify_model_capability(self.v3.repository, self.v3.workflow.runtime, self.v3.models, provider_id=author_provider.id, model_id=author_model.id)
|
|
||||||
reviewer_capability = await verify_model_capability(self.v3.repository, self.v3.workflow.runtime, self.v3.models, provider_id=review_provider.id, model_id=review_model.id)
|
|
||||||
except Exception as error:
|
|
||||||
yield event("cad_error", {"stage": "configuration", "message": f"MODEL_STRUCTURED_OUTPUT_UNSUPPORTED: {str(error)[:500]}"})
|
|
||||||
yield event("done", {})
|
|
||||||
return
|
|
||||||
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
|
|
||||||
yield event("cad_error", {"stage": "configuration", "message": "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED: selected author or reviewer did not pass the v3 conformance suite."})
|
|
||||||
yield event("done", {})
|
|
||||||
return
|
|
||||||
if current and str(current.get("lifecycle") or "") == "running":
|
if current and str(current.get("lifecycle") or "") == "running":
|
||||||
yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成或失败前不能继续对话。"})
|
yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成或失败前不能继续对话。"})
|
||||||
yield event("done", {})
|
yield event("done", {})
|
||||||
return
|
return
|
||||||
# A terminal task is immutable. A legacy task is never migrated: both
|
# A terminal task is immutable; follow-up text creates a new task.
|
||||||
# cases intentionally create a fresh v3 task.
|
|
||||||
task_id = resumed_task_id or f"cad_{secrets.token_hex(6)}"
|
task_id = resumed_task_id or f"cad_{secrets.token_hex(6)}"
|
||||||
if not resumed_task_id:
|
if not resumed_task_id:
|
||||||
try:
|
try:
|
||||||
source_blocks = self._task_source_blocks(conversation, request)
|
source_blocks, image_inputs = self._task_inputs(conversation, request)
|
||||||
self.v3.workflow.create_task(task_id, request, source_blocks=source_blocks)
|
self.v3.workflow.create_task(task_id, request, source_blocks=source_blocks, image_inputs=image_inputs)
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
yield event("cad_error", {"stage": "request", "message": str(error)})
|
yield event("cad_error", {"stage": "request", "message": str(error)})
|
||||||
yield event("done", {})
|
yield event("done", {})
|
||||||
@@ -316,6 +315,24 @@ class AgentService:
|
|||||||
conversation = self.store.append_conversation_message(conversation["conversation_id"], latest_user.model_dump(), task_id)
|
conversation = self.store.append_conversation_message(conversation["conversation_id"], latest_user.model_dump(), task_id)
|
||||||
yield event("progress", {"taskId": task_id, "step": "task_started", "label": "Agent", "status": "running", "message": "已应用补充说明并恢复 CAD 任务。" if resumed_task_id else "CAD 任务已启动。" if _response_language(request) == "Chinese" else "CAD task started."})
|
yield event("progress", {"taskId": task_id, "step": "task_started", "label": "Agent", "status": "running", "message": "已应用补充说明并恢复 CAD 任务。" if resumed_task_id else "CAD 任务已启动。" if _response_language(request) == "Chinese" else "CAD task started."})
|
||||||
|
|
||||||
|
try:
|
||||||
|
author_provider, author_model = self.settings.resolve_model(provider_id, model_id)
|
||||||
|
review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model)
|
||||||
|
except ValueError as error:
|
||||||
|
state = self.v3.repository.get_state(task_id)
|
||||||
|
if state is not None:
|
||||||
|
failed = transition(state, "failed", error=ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED)
|
||||||
|
self.v3.repository.compare_and_swap(failed, events=[{
|
||||||
|
"event": "model_configuration_invalid",
|
||||||
|
"message": str(error)[:1000],
|
||||||
|
"issues": [str(error)[:1000]],
|
||||||
|
}])
|
||||||
|
terminal = {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED.value, "message": str(error), "userActionRequired": False}
|
||||||
|
yield event("task_terminal", terminal)
|
||||||
|
yield event("cad_error", {"stage": "configuration", "message": str(error)})
|
||||||
|
yield event("done", {})
|
||||||
|
return
|
||||||
|
|
||||||
queue: asyncio.Queue[tuple[str, dict[str, Any]] | None] = asyncio.Queue()
|
queue: asyncio.Queue[tuple[str, dict[str, Any]] | None] = asyncio.Queue()
|
||||||
parts: list[dict[str, Any]] = []
|
parts: list[dict[str, Any]] = []
|
||||||
sequence = 0
|
sequence = 0
|
||||||
@@ -345,6 +362,18 @@ class AgentService:
|
|||||||
await queue.put(("progress", _visible_progress("state_changed", payload)))
|
await queue.put(("progress", _visible_progress("state_changed", payload)))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
capability_terminal = await self._ensure_task_capabilities(
|
||||||
|
task_id,
|
||||||
|
ModelIdentity(author_provider.id, author_model.id),
|
||||||
|
ModelIdentity(review_provider.id, review_model.id),
|
||||||
|
queue,
|
||||||
|
)
|
||||||
|
if capability_terminal is not None:
|
||||||
|
sequence += 1
|
||||||
|
capability_terminal = {**capability_terminal, "eventId": f"{task_id}_{sequence}_task_terminal", "sequence": sequence, "timestamp": now_iso()}
|
||||||
|
_upsert_part(parts, {"type": "data-cad-progress", "id": capability_terminal["eventId"], "data": _visible_progress("task_terminal", capability_terminal)})
|
||||||
|
await queue.put(("task_terminal", capability_terminal))
|
||||||
|
return
|
||||||
async for name, payload in self.v3.workflow.run(
|
async for name, payload in self.v3.workflow.run(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
author=ModelIdentity(author_provider.id, author_model.id),
|
author=ModelIdentity(author_provider.id, author_model.id),
|
||||||
@@ -393,9 +422,82 @@ class AgentService:
|
|||||||
yield event(name, payload)
|
yield event(name, payload)
|
||||||
yield event("done", {})
|
yield event("done", {})
|
||||||
|
|
||||||
def _task_source_blocks(self, conversation: dict[str, Any], request: str) -> list[dict[str, Any]]:
|
async def _ensure_task_capabilities(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
author: ModelIdentity,
|
||||||
|
reviewer: ModelIdentity,
|
||||||
|
queue: asyncio.Queue[tuple[str, dict[str, Any]] | None],
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
roles = (("author", author), ("reviewer", reviewer))
|
||||||
|
cached = {
|
||||||
|
role: cached_model_capability(
|
||||||
|
self.v3.repository,
|
||||||
|
self.v3.workflow.runtime,
|
||||||
|
provider_id=model.provider_id,
|
||||||
|
model_id=model.model_id,
|
||||||
|
role=role,
|
||||||
|
)
|
||||||
|
for role, model in roles
|
||||||
|
}
|
||||||
|
unsupported = [role for role, result in cached.items() if result is not None and not result.get("supported")]
|
||||||
|
if unsupported:
|
||||||
|
return self._fail_capability(task_id, f"Model protocol is unsupported for role(s): {', '.join(unsupported)}")
|
||||||
|
missing = [(role, model) for role, model in roles if cached[role] is None]
|
||||||
|
if not missing:
|
||||||
|
return None
|
||||||
|
|
||||||
|
state = self.v3.repository.get_state(task_id)
|
||||||
|
if state is None:
|
||||||
|
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STORAGE_FAILURE.value, "message": "Task state is unavailable.", "userActionRequired": False}
|
||||||
|
waiting = transition(state, "waiting_retry", error=ErrorCode.MODEL_PROTOCOL_CHECK_PENDING)
|
||||||
|
if not self.v3.repository.compare_and_swap(waiting, events=[{
|
||||||
|
"event": "model_protocol_check_pending",
|
||||||
|
"code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value,
|
||||||
|
"message": "模型协议检查中,完成后将自动继续。",
|
||||||
|
}]):
|
||||||
|
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STALE_WORKING_HEAD.value, "message": "Task state changed before the model protocol check started.", "userActionRequired": False}
|
||||||
|
await queue.put(("progress", {"taskId": task_id, "step": "model_protocol_check", "label": _EVENT_LABELS["model_protocol_check"], "status": "waiting", "lifecycle": "waiting_retry", "message": "模型协议检查中,完成后将自动继续。"}))
|
||||||
|
try:
|
||||||
|
results = await asyncio.gather(*(
|
||||||
|
verify_model_capability(
|
||||||
|
self.v3.repository,
|
||||||
|
self.v3.workflow.runtime,
|
||||||
|
self.v3.models,
|
||||||
|
provider_id=model.provider_id,
|
||||||
|
model_id=model.model_id,
|
||||||
|
role=role,
|
||||||
|
)
|
||||||
|
for role, model in missing
|
||||||
|
))
|
||||||
|
except Exception as error:
|
||||||
|
return {"taskId": task_id, "lifecycle": "waiting_retry", "code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value, "message": f"模型协议检查暂时不可用:{str(error)[:500]}", "userActionRequired": False}
|
||||||
|
unavailable = [role for (role, _model), result in zip(missing, results, strict=True) if result.get("probe_unavailable")]
|
||||||
|
if unavailable:
|
||||||
|
return {"taskId": task_id, "lifecycle": "waiting_retry", "code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value, "message": f"模型协议检查暂时不可用({', '.join(unavailable)}),可稍后重试。", "userActionRequired": False}
|
||||||
|
unsupported = [role for (role, _model), result in zip(missing, results, strict=True) if not result.get("supported")]
|
||||||
|
if unsupported:
|
||||||
|
return self._fail_capability(task_id, f"Model protocol is unsupported for role(s): {', '.join(unsupported)}")
|
||||||
|
if not self.v3.workflow.resume(task_id):
|
||||||
|
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.FAILED_INTERNAL.value, "message": "Model protocol check completed but the task could not resume.", "userActionRequired": False}
|
||||||
|
await queue.put(("progress", {"taskId": task_id, "step": "model_protocol_check", "label": _EVENT_LABELS["model_protocol_check"], "status": "success", "lifecycle": "running", "message": "模型协议检查完成,继续生成。"}))
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _fail_capability(self, task_id: str, message: str) -> dict[str, Any]:
|
||||||
|
state = self.v3.repository.get_state(task_id)
|
||||||
|
if state is not None and state.phase not in {TaskPhase.FAILED, TaskPhase.COMPLETED, TaskPhase.CANCELLED}:
|
||||||
|
failed = transition(state, "failed", error=ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED)
|
||||||
|
self.v3.repository.compare_and_swap(failed, events=[{
|
||||||
|
"event": "model_protocol_unsupported",
|
||||||
|
"message": message,
|
||||||
|
"issues": [message],
|
||||||
|
}])
|
||||||
|
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED.value, "message": message, "userActionRequired": False}
|
||||||
|
|
||||||
|
def _task_inputs(self, conversation: dict[str, Any], request: str) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
|
||||||
"""Freeze message paragraphs and attachment blocks before task creation."""
|
"""Freeze message paragraphs and attachment blocks before task creation."""
|
||||||
blocks = [{"text": paragraph} for paragraph in re.split(r"\n\s*\n", request) if paragraph.strip()]
|
blocks = [{"text": paragraph} for paragraph in re.split(r"\n\s*\n", request) if paragraph.strip()]
|
||||||
|
image_inputs: list[dict[str, str]] = []
|
||||||
conversation_id = str(conversation.get("conversation_id") or "")
|
conversation_id = str(conversation.get("conversation_id") or "")
|
||||||
if not conversation_id:
|
if not conversation_id:
|
||||||
raise ValueError("Conversation has no identifier")
|
raise ValueError("Conversation has no identifier")
|
||||||
@@ -432,6 +534,11 @@ class AgentService:
|
|||||||
f"(SHA-256 {expected_digest}, MIME {attachment.get('mime') or 'image/*'}). "
|
f"(SHA-256 {expected_digest}, MIME {attachment.get('mime') or 'image/*'}). "
|
||||||
"It is a visual reference and requires explicit visual verification."
|
"It is a visual reference and requires explicit visual verification."
|
||||||
)
|
)
|
||||||
|
image_inputs.append({
|
||||||
|
"path": str(binary_path),
|
||||||
|
"mime": str(attachment.get("mime") or "image/*"),
|
||||||
|
"sha256": expected_digest,
|
||||||
|
})
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported attachment kind: {kind or 'unknown'}")
|
raise ValueError(f"Unsupported attachment kind: {kind or 'unknown'}")
|
||||||
if not text:
|
if not text:
|
||||||
@@ -446,4 +553,4 @@ class AgentService:
|
|||||||
"sha256": expected_digest,
|
"sha256": expected_digest,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return blocks
|
return blocks, image_inputs
|
||||||
|
|||||||
+292
-3741
File diff suppressed because it is too large
Load Diff
@@ -18,8 +18,7 @@ export function CadProgressPart({ data }: { data: CadProgress }) {
|
|||||||
const statusLabel = isRunning ? "进行中" : isWaiting ? data.lifecycle === "waiting_retry" ? "等待重试" : "等待确认" : isError ? "失败" : status === "success" ? "完成" : data.status;
|
const statusLabel = isRunning ? "进行中" : isWaiting ? data.lifecycle === "waiting_retry" ? "等待重试" : "等待确认" : isError ? "失败" : status === "success" ? "完成" : data.status;
|
||||||
const Icon = data.step === "tool_call" ? Wrench : data.step.includes("review") || data.step === "final_review" ? Eye : data.step === "rollback" ? RotateCcw : data.step.includes("requirements") || data.step.includes("checklist") ? FileCheck : data.step.includes("diagnostic") ? Search : isError || isWaiting ? AlertTriangle : Check;
|
const Icon = data.step === "tool_call" ? Wrench : data.step.includes("review") || data.step === "final_review" ? Eye : data.step === "rollback" ? RotateCcw : data.step.includes("requirements") || data.step.includes("checklist") ? FileCheck : data.step.includes("diagnostic") ? Search : isError || isWaiting ? AlertTriangle : Check;
|
||||||
const evidence = data.evidence || (Array.isArray(data.review?.evidence) ? data.review.evidence.map(String) : []);
|
const evidence = data.evidence || (Array.isArray(data.review?.evidence) ? data.review.evidence.map(String) : []);
|
||||||
const documentTitle = data.step === "requirements_review" ? "需求合同复核" : "";
|
const documentMarkdown = data.markdown || "";
|
||||||
const documentMarkdown = data.step === "requirements_document" ? withoutRequirementsFilename(data.markdown || "") : data.markdown || "";
|
|
||||||
return (
|
return (
|
||||||
<div className={`cad-message cad-progress${isError ? " is-error" : ""}${isWaiting ? " is-waiting" : ""}`} role="status" aria-live="polite">
|
<div className={`cad-message cad-progress${isError ? " is-error" : ""}${isWaiting ? " is-waiting" : ""}`} role="status" aria-live="polite">
|
||||||
<div className="cad-message-heading">
|
<div className="cad-message-heading">
|
||||||
@@ -32,8 +31,7 @@ export function CadProgressPart({ data }: { data: CadProgress }) {
|
|||||||
{data.questions?.length ? <ul className="cad-waiting-questions">{data.questions.map((question, index) => <li key={`${question}-${index}`}>{question}</li>)}</ul> : null}
|
{data.questions?.length ? <ul className="cad-waiting-questions">{data.questions.map((question, index) => <li key={`${question}-${index}`}>{question}</li>)}</ul> : null}
|
||||||
{data.issues?.length ? <ul className="cad-waiting-questions">{data.issues.map((issue, index) => <li key={`${issue}-${index}`}>{issue}</li>)}</ul> : null}
|
{data.issues?.length ? <ul className="cad-waiting-questions">{data.issues.map((issue, index) => <li key={`${issue}-${index}`}>{issue}</li>)}</ul> : null}
|
||||||
{data.verificationWarnings?.length ? <details className="cad-event-details" open><summary>验证风险 ({data.verificationWarnings.length})</summary><ul>{data.verificationWarnings.map((warning, index) => <li key={`${warning}-${index}`}>{warning}</li>)}</ul></details> : null}
|
{data.verificationWarnings?.length ? <details className="cad-event-details" open><summary>验证风险 ({data.verificationWarnings.length})</summary><ul>{data.verificationWarnings.map((warning, index) => <li key={`${warning}-${index}`}>{warning}</li>)}</ul></details> : null}
|
||||||
{data.appliedNormalizations?.length ? <details className="cad-event-details"><summary>自动归一化 ({data.appliedNormalizations.length})</summary><JsonTree data={data.appliedNormalizations} /></details> : null}
|
{documentMarkdown ? <details className="cad-event-details cad-document-details"><summary>文档内容</summary><MarkdownDocument>{documentMarkdown}</MarkdownDocument></details> : null}
|
||||||
{documentMarkdown ? <details className="cad-event-details cad-document-details" open={data.step === "requirements_document"}><summary>{documentTitle || "文档内容"}</summary><MarkdownDocument>{documentMarkdown}</MarkdownDocument></details> : null}
|
|
||||||
{data.arguments ? <details className="cad-event-details"><summary>调用参数</summary><JsonTree data={data.arguments} /></details> : null}
|
{data.arguments ? <details className="cad-event-details"><summary>调用参数</summary><JsonTree data={data.arguments} /></details> : null}
|
||||||
{data.result !== undefined ? <details className="cad-event-details"><summary>执行结果</summary><JsonTree data={data.result} /></details> : null}
|
{data.result !== undefined ? <details className="cad-event-details"><summary>执行结果</summary><JsonTree data={data.result} /></details> : null}
|
||||||
{evidence.length ? <details className="cad-event-details"><summary>证据 ({evidence.length})</summary><ul>{evidence.map((item, index) => <li key={`${item}-${index}`}>{item}</li>)}</ul></details> : null}
|
{evidence.length ? <details className="cad-event-details"><summary>证据 ({evidence.length})</summary><ul>{evidence.map((item, index) => <li key={`${item}-${index}`}>{item}</li>)}</ul></details> : null}
|
||||||
@@ -41,13 +39,6 @@ export function CadProgressPart({ data }: { data: CadProgress }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function withoutRequirementsFilename(markdown: string) {
|
|
||||||
return markdown
|
|
||||||
.replace(/^\s*#{1,6}\s*`?requirements\.md`?\s*\n+/i, "")
|
|
||||||
.replace(/^\s*`?requirements\.md`?\s*\n+/i, "")
|
|
||||||
.trimStart();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CadResultPart({ data }: { data: CadResult }) {
|
export function CadResultPart({ data }: { data: CadResult }) {
|
||||||
const downloads: Array<[string, string]> = data.checkpoint ? [] : [
|
const downloads: Array<[string, string]> = data.checkpoint ? [] : [
|
||||||
["STEP", data.stepPath],
|
["STEP", data.stepPath],
|
||||||
@@ -69,7 +60,6 @@ export function CadResultPart({ data }: { data: CadResult }) {
|
|||||||
</div>
|
</div>
|
||||||
{data.referenceIds.length ? <div className="cad-result-notes"><strong>参考</strong><span>{data.referenceIds.join(";")}</span></div> : null}
|
{data.referenceIds.length ? <div className="cad-result-notes"><strong>参考</strong><span>{data.referenceIds.join(";")}</span></div> : null}
|
||||||
{data.verificationWarnings?.length ? <div className="cad-result-notes"><strong>验证风险</strong><span>{data.verificationWarnings.join(";")}</span></div> : null}
|
{data.verificationWarnings?.length ? <div className="cad-result-notes"><strong>验证风险</strong><span>{data.verificationWarnings.join(";")}</span></div> : null}
|
||||||
{data.appliedNormalizations?.length ? <details className="cad-event-details"><summary>自动归一化 ({data.appliedNormalizations.length})</summary><JsonTree data={data.appliedNormalizations} /></details> : null}
|
|
||||||
{downloads.length ? <div className="download-row">
|
{downloads.length ? <div className="download-row">
|
||||||
{downloads.map(([label, path]) => (
|
{downloads.map(([label, path]) => (
|
||||||
<a key={label} className="download-link" href={encodeArtifactUrl(data.taskId, path)} download aria-label={`下载 ${label}`}>
|
<a key={label} className="download-link" href={encodeArtifactUrl(data.taskId, path)} download aria-label={`下载 ${label}`}>
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ function resultForRevision(task: TaskRecord, revisionId: string, checkpoint: boo
|
|||||||
lifecycle: task.lifecycle || "completed",
|
lifecycle: task.lifecycle || "completed",
|
||||||
verificationStatus: task.verification_status,
|
verificationStatus: task.verification_status,
|
||||||
verificationWarnings: task.verification_warnings || [],
|
verificationWarnings: task.verification_warnings || [],
|
||||||
appliedNormalizations: task.applied_normalizations || [],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ export function restoreTaskProjection(messages: CadUIMessage[], task: TaskRecord
|
|||||||
userActionRequired: Boolean(task.user_action_required),
|
userActionRequired: Boolean(task.user_action_required),
|
||||||
verificationStatus: task.verification_status,
|
verificationStatus: task.verification_status,
|
||||||
verificationWarnings: task.verification_warnings || [],
|
verificationWarnings: task.verification_warnings || [],
|
||||||
appliedNormalizations: task.applied_normalizations || [],
|
|
||||||
};
|
};
|
||||||
return [...messages, {
|
return [...messages, {
|
||||||
id: `projection_${task.task_id}_${task.state_version || 0}`,
|
id: `projection_${task.task_id}_${task.state_version || 0}`,
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ test("keeps terminal schema field errors visible to the CAD error part", () => {
|
|||||||
event: "cad_error",
|
event: "cad_error",
|
||||||
data: {
|
data: {
|
||||||
stage: "generation",
|
stage: "generation",
|
||||||
tool: "patch_requirements_draft",
|
tool: "submit_requirements_spec",
|
||||||
message: "Author repeatedly failed the schema.",
|
message: "Author repeatedly failed the schema.",
|
||||||
fieldErrors: [{ path: "/patches", message: "Field required" }],
|
fieldErrors: [{ path: "/patches", message: "Field required" }],
|
||||||
},
|
},
|
||||||
@@ -54,7 +54,7 @@ test("keeps terminal schema field errors visible to the CAD error part", () => {
|
|||||||
assert.equal(chunk?.type, "data-cad-error");
|
assert.equal(chunk?.type, "data-cad-error");
|
||||||
assert.deepEqual("data" in chunk! ? chunk.data : null, {
|
assert.deepEqual("data" in chunk! ? chunk.data : null, {
|
||||||
stage: "generation",
|
stage: "generation",
|
||||||
tool: "patch_requirements_draft",
|
tool: "submit_requirements_spec",
|
||||||
message: "Author repeatedly failed the schema.",
|
message: "Author repeatedly failed the schema.",
|
||||||
fieldErrors: [{ path: "/patches", message: "Field required" }],
|
fieldErrors: [{ path: "/patches", message: "Field required" }],
|
||||||
});
|
});
|
||||||
@@ -69,7 +69,7 @@ test("maps a waiting terminal into a visible user-decision state", () => {
|
|||||||
lifecycle: "waiting_for_user",
|
lifecycle: "waiting_for_user",
|
||||||
message: "Requirements need a user decision.",
|
message: "Requirements need a user decision.",
|
||||||
questions: [question],
|
questions: [question],
|
||||||
reviewPath: "documents/requirements-review.json",
|
clarificationPath: "documents/requirements-clarification.json",
|
||||||
},
|
},
|
||||||
}, "text_1");
|
}, "text_1");
|
||||||
assert.equal(chunk?.type, "data-cad-progress");
|
assert.equal(chunk?.type, "data-cad-progress");
|
||||||
@@ -81,7 +81,7 @@ test("maps a waiting terminal into a visible user-decision state", () => {
|
|||||||
taskId: "cad_abc",
|
taskId: "cad_abc",
|
||||||
lifecycle: "waiting_for_user",
|
lifecycle: "waiting_for_user",
|
||||||
questions: [question],
|
questions: [question],
|
||||||
reviewPath: "documents/requirements-review.json",
|
clarificationPath: "documents/requirements-clarification.json",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ test("restores a complete waiting terminal from the task projection", () => {
|
|||||||
task_id: "cad_abc", current_revision: "", lifecycle: "waiting_for_user", phase: "WAITING_FOR_USER",
|
task_id: "cad_abc", current_revision: "", lifecycle: "waiting_for_user", phase: "WAITING_FOR_USER",
|
||||||
state_version: 7, message: "A decision is required.", questions: ["Four holes or six?"],
|
state_version: 7, message: "A decision is required.", questions: ["Four holes or six?"],
|
||||||
blocker_type: "requirements_ambiguity", user_action_required: true,
|
blocker_type: "requirements_ambiguity", user_action_required: true,
|
||||||
verification_status: "verified", verification_warnings: [], applied_normalizations: [], revisions: [],
|
verification_status: "pending", verification_warnings: [], revisions: [],
|
||||||
});
|
});
|
||||||
const part = restored[0].parts[0];
|
const part = restored[0].parts[0];
|
||||||
assert.equal(part.type, "data-cad-progress");
|
assert.equal(part.type, "data-cad-progress");
|
||||||
@@ -101,14 +101,14 @@ test("restores a complete waiting terminal from the task projection", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps unmet review issues visible when requirements review cannot converge", () => {
|
test("keeps explicit runtime issues visible when generation stops", () => {
|
||||||
const issue = "The counterbores need a feature-scoped front-side claim.";
|
const issue = "The selected host face is unavailable.";
|
||||||
const chunk = backendEventToUiChunk({
|
const chunk = backendEventToUiChunk({
|
||||||
event: "task_terminal",
|
event: "task_terminal",
|
||||||
data: {
|
data: {
|
||||||
taskId: "cad_abc",
|
taskId: "cad_abc",
|
||||||
lifecycle: "waiting_for_user",
|
lifecycle: "waiting_for_user",
|
||||||
message: "Requirements review did not converge.",
|
message: "Runtime validation stopped generation.",
|
||||||
issues: [issue],
|
issues: [issue],
|
||||||
},
|
},
|
||||||
}, "text_1");
|
}, "text_1");
|
||||||
@@ -117,7 +117,7 @@ test("keeps unmet review issues visible when requirements review cannot converge
|
|||||||
step: "task_terminal",
|
step: "task_terminal",
|
||||||
label: "生成任务",
|
label: "生成任务",
|
||||||
status: "waiting",
|
status: "waiting",
|
||||||
message: "Requirements review did not converge.",
|
message: "Runtime validation stopped generation.",
|
||||||
taskId: "cad_abc",
|
taskId: "cad_abc",
|
||||||
lifecycle: "waiting_for_user",
|
lifecycle: "waiting_for_user",
|
||||||
issues: [issue],
|
issues: [issue],
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function backendEventToUiChunk(
|
|||||||
data: { ...item.data, sequence },
|
data: { ...item.data, sequence },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (["requirements_review", "action_selection", "tool_call", "candidate_result", "candidate_review", "final_review", "task_terminal"].includes(item.event)) {
|
if (["image_observation", "requirements_ready", "completion_result_ready", "action_selection", "tool_call", "candidate_result", "candidate_review", "final_review", "task_terminal"].includes(item.event)) {
|
||||||
const review = item.data.review && typeof item.data.review === "object"
|
const review = item.data.review && typeof item.data.review === "object"
|
||||||
? item.data.review as Record<string, unknown>
|
? item.data.review as Record<string, unknown>
|
||||||
: null;
|
: null;
|
||||||
@@ -44,7 +44,7 @@ export function backendEventToUiChunk(
|
|||||||
type: "data-cad-progress",
|
type: "data-cad-progress",
|
||||||
id: `event_${eventId}`,
|
id: `event_${eventId}`,
|
||||||
data: { step: item.event, label: ({
|
data: { step: item.event, label: ({
|
||||||
requirements_review: "需求合同独立复核", action_selection: "动作选择", tool_call: "建模工具", candidate_result: "候选构建", candidate_review: "候选独立复核", final_review: "最终独立复核", task_terminal: "生成任务",
|
image_observation: "参考图片观察", requirements_ready: "需求规格已就绪", completion_result_ready: "完成结果已就绪", action_selection: "动作选择", tool_call: "建模工具", candidate_result: "候选构建", candidate_review: "候选独立复核", final_review: "最终独立复核", task_terminal: "生成任务",
|
||||||
} as Record<string, string>)[item.event], status, ...metadata, message: String(
|
} as Record<string, string>)[item.event], status, ...metadata, message: String(
|
||||||
item.data.message || item.data.reason
|
item.data.message || item.data.reason
|
||||||
|| (Array.isArray(item.data.questions) ? item.data.questions.map(String).filter(Boolean).join(";") : "")
|
|| (Array.isArray(item.data.questions) ? item.data.questions.map(String).filter(Boolean).join(";") : "")
|
||||||
@@ -56,13 +56,11 @@ export function backendEventToUiChunk(
|
|||||||
...(item.data.lifecycle ? { lifecycle: String(item.data.lifecycle) } : {}),
|
...(item.data.lifecycle ? { lifecycle: String(item.data.lifecycle) } : {}),
|
||||||
...(Array.isArray(item.data.questions) ? { questions: item.data.questions.map(String).filter(Boolean) } : {}),
|
...(Array.isArray(item.data.questions) ? { questions: item.data.questions.map(String).filter(Boolean) } : {}),
|
||||||
...(Array.isArray(item.data.issues) ? { issues: item.data.issues.map(String).filter(Boolean) } : {}),
|
...(Array.isArray(item.data.issues) ? { issues: item.data.issues.map(String).filter(Boolean) } : {}),
|
||||||
...(Array.isArray(item.data.unresolved) ? { unresolved: item.data.unresolved } : {}),
|
|
||||||
...(item.data.blockerType ? { blockerType: String(item.data.blockerType) } : {}),
|
...(item.data.blockerType ? { blockerType: String(item.data.blockerType) } : {}),
|
||||||
...(typeof item.data.userActionRequired === "boolean" ? { userActionRequired: item.data.userActionRequired } : {}),
|
...(typeof item.data.userActionRequired === "boolean" ? { userActionRequired: item.data.userActionRequired } : {}),
|
||||||
...(item.data.verificationStatus ? { verificationStatus: String(item.data.verificationStatus) } : {}),
|
...(item.data.verificationStatus ? { verificationStatus: String(item.data.verificationStatus) } : {}),
|
||||||
...(Array.isArray(item.data.verificationWarnings) ? { verificationWarnings: item.data.verificationWarnings.map(String).filter(Boolean) } : {}),
|
...(Array.isArray(item.data.verificationWarnings) ? { verificationWarnings: item.data.verificationWarnings.map(String).filter(Boolean) } : {}),
|
||||||
...(Array.isArray(item.data.appliedNormalizations) ? { appliedNormalizations: item.data.appliedNormalizations } : {}),
|
...(item.data.clarificationPath ? { clarificationPath: String(item.data.clarificationPath) } : {}),
|
||||||
...(item.data.reviewPath ? { reviewPath: String(item.data.reviewPath) } : {}),
|
|
||||||
...(item.data.timestamp ? { timestamp: String(item.data.timestamp) } : {}),
|
...(item.data.timestamp ? { timestamp: String(item.data.timestamp) } : {}),
|
||||||
...(item.data.markdown ? { markdown: String(item.data.markdown) } : {}),
|
...(item.data.markdown ? { markdown: String(item.data.markdown) } : {}),
|
||||||
...(item.data.tool ? { tool: String(item.data.tool) } : {}),
|
...(item.data.tool ? { tool: String(item.data.tool) } : {}),
|
||||||
|
|||||||
@@ -18,14 +18,12 @@ export type CadProgress = {
|
|||||||
evidence?: string[];
|
evidence?: string[];
|
||||||
questions?: string[];
|
questions?: string[];
|
||||||
issues?: string[];
|
issues?: string[];
|
||||||
unresolved?: Array<{ draftId?: string; reasonCode?: string; question?: string }>;
|
|
||||||
blockerType?: string;
|
blockerType?: string;
|
||||||
userActionRequired?: boolean;
|
userActionRequired?: boolean;
|
||||||
verificationStatus?: "verified" | "completed_with_risks" | string;
|
verificationStatus?: "verified" | "completed_with_risks" | string;
|
||||||
verificationWarnings?: string[];
|
verificationWarnings?: string[];
|
||||||
appliedNormalizations?: Array<Record<string, unknown>>;
|
|
||||||
review?: Record<string, unknown>;
|
review?: Record<string, unknown>;
|
||||||
reviewPath?: string;
|
clarificationPath?: string;
|
||||||
lifecycle?: "running" | "completed" | "failed" | "waiting_retry" | "waiting_for_user" | string;
|
lifecycle?: "running" | "completed" | "failed" | "waiting_retry" | "waiting_for_user" | string;
|
||||||
attempt?: number;
|
attempt?: number;
|
||||||
maxAttempts?: number;
|
maxAttempts?: number;
|
||||||
@@ -47,7 +45,6 @@ export type CadResult = {
|
|||||||
lifecycle?: "running" | "completed" | "failed" | string;
|
lifecycle?: "running" | "completed" | "failed" | string;
|
||||||
verificationStatus?: "verified" | "completed_with_risks" | string;
|
verificationStatus?: "verified" | "completed_with_risks" | string;
|
||||||
verificationWarnings?: string[];
|
verificationWarnings?: string[];
|
||||||
appliedNormalizations?: Array<Record<string, unknown>>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CadError = {
|
export type CadError = {
|
||||||
@@ -118,14 +115,16 @@ export type TaskRecord = {
|
|||||||
phase?: string;
|
phase?: string;
|
||||||
state_version?: number;
|
state_version?: number;
|
||||||
active_candidate_id?: string;
|
active_candidate_id?: string;
|
||||||
requirements_draft?: Record<string, unknown> | null;
|
requirements_spec?: Record<string, unknown> | null;
|
||||||
requirements_draft_path?: string;
|
requirements_spec_path?: string;
|
||||||
requirements_review?: Record<string, unknown> | null;
|
clarification_path?: string;
|
||||||
requirements_review_path?: string;
|
|
||||||
requirements_contract?: Record<string, unknown> | null;
|
requirements_contract?: Record<string, unknown> | null;
|
||||||
requirements_contract_path?: string;
|
requirements_contract_path?: string;
|
||||||
requirements_markdown?: string | null;
|
requirements_markdown?: string | null;
|
||||||
completion_markdown?: string | null;
|
completion_target_markdown?: string | null;
|
||||||
|
completion_target_path?: string;
|
||||||
|
completion_result_markdown?: string | null;
|
||||||
|
completion_result_path?: string;
|
||||||
claim_summary?: Array<{
|
claim_summary?: Array<{
|
||||||
requirement_id: string;
|
requirement_id: string;
|
||||||
claim_id: string;
|
claim_id: string;
|
||||||
@@ -145,7 +144,6 @@ export type TaskRecord = {
|
|||||||
user_action_required?: boolean;
|
user_action_required?: boolean;
|
||||||
verification_status?: "verified" | "completed_with_risks" | string;
|
verification_status?: "verified" | "completed_with_risks" | string;
|
||||||
verification_warnings?: string[];
|
verification_warnings?: string[];
|
||||||
applied_normalizations?: Array<Record<string, unknown>>;
|
|
||||||
revisions: TaskRevision[];
|
revisions: TaskRevision[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user