change
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
"""Bounded, file-backed author guidance for the CDSL workflow.
|
||||
|
||||
The corpus is deliberately non-authoritative: contracts, schemas, topology
|
||||
tokens, and server preflight always remain the executable source of truth.
|
||||
Loading errors return an empty selection so authoring continues with the
|
||||
pre-guidance prompt instead of turning documentation into an availability
|
||||
dependency.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.domain.state import TaskPhase
|
||||
from app.cad_agent.ports import AuthorGuidanceSelection
|
||||
|
||||
|
||||
_MIN_CHARS = 1_200
|
||||
_MAX_CHARS = 6_000
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Section:
|
||||
section_id: str
|
||||
title: str
|
||||
priority: int
|
||||
mandatory: bool
|
||||
content: str
|
||||
|
||||
@property
|
||||
def block(self) -> str:
|
||||
return f"## {self.title}\n{self.content.strip()}"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Corpus:
|
||||
version: str
|
||||
sections: dict[str, _Section]
|
||||
phase_sections: dict[str, tuple[str, ...]]
|
||||
repair_sections: tuple[str, ...]
|
||||
final_sections: tuple[str, ...]
|
||||
operation_sections: dict[str, tuple[str, ...]]
|
||||
|
||||
|
||||
class FileAuthorGuidance:
|
||||
"""Read and select the checked-in corpus deterministically.
|
||||
|
||||
Selection depends exclusively on workflow state and the runtime operation
|
||||
registry. It intentionally receives neither the user's request nor image
|
||||
observations, so it cannot become an implicit part-family classifier.
|
||||
"""
|
||||
|
||||
def __init__(self, root: Path, *, enabled: bool = True, max_chars: int = 3_600) -> None:
|
||||
self.root = root
|
||||
self.enabled = enabled
|
||||
self.max_chars = min(_MAX_CHARS, max(_MIN_CHARS, max_chars))
|
||||
self._corpus: _Corpus | None = None
|
||||
self._load_error = ""
|
||||
|
||||
def select(
|
||||
self,
|
||||
*,
|
||||
phase: TaskPhase,
|
||||
atomic_id: str,
|
||||
repair_required: bool,
|
||||
supported_atomic_ids: tuple[str, ...],
|
||||
) -> AuthorGuidanceSelection:
|
||||
if not self.enabled:
|
||||
return AuthorGuidanceSelection(fallback_reason="guidance_disabled")
|
||||
corpus = self._load()
|
||||
if corpus is None:
|
||||
return AuthorGuidanceSelection(fallback_reason=self._load_error or "guidance_unavailable")
|
||||
supported = set(supported_atomic_ids)
|
||||
if set(corpus.operation_sections) != supported:
|
||||
return AuthorGuidanceSelection(fallback_reason="guidance_operation_coverage_mismatch")
|
||||
if atomic_id and atomic_id not in supported:
|
||||
return AuthorGuidanceSelection(fallback_reason="guidance_unknown_atomic_id")
|
||||
|
||||
if repair_required:
|
||||
requested = list(corpus.repair_sections)
|
||||
elif phase == TaskPhase.FINAL_VALIDATION:
|
||||
requested = list(corpus.final_sections)
|
||||
else:
|
||||
requested = list(corpus.phase_sections.get(phase.value, corpus.phase_sections.get("DEFAULT", ())))
|
||||
if atomic_id:
|
||||
requested.extend(corpus.operation_sections[atomic_id])
|
||||
requested = list(dict.fromkeys(requested))
|
||||
if not requested:
|
||||
return AuthorGuidanceSelection(fallback_reason="guidance_no_matching_sections")
|
||||
|
||||
mandatory = [section_id for section_id in requested if corpus.sections[section_id].mandatory]
|
||||
optional = [section_id for section_id in requested if not corpus.sections[section_id].mandatory]
|
||||
optional.sort(key=lambda section_id: (-corpus.sections[section_id].priority, requested.index(section_id)))
|
||||
selected: list[str] = []
|
||||
text = ""
|
||||
for section_id in [*mandatory, *optional]:
|
||||
block = corpus.sections[section_id].block
|
||||
candidate = block if not text else f"{text}\n\n{block}"
|
||||
if len(candidate) <= self.max_chars:
|
||||
text = candidate
|
||||
selected.append(section_id)
|
||||
elif section_id in mandatory:
|
||||
# Do not silently drop contract, datum, or operation guidance.
|
||||
return AuthorGuidanceSelection(fallback_reason="guidance_required_sections_exceed_budget")
|
||||
return AuthorGuidanceSelection(
|
||||
version=corpus.version,
|
||||
section_ids=tuple(selected),
|
||||
content=text,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
def _load(self) -> _Corpus | None:
|
||||
if self._corpus is not None:
|
||||
return self._corpus
|
||||
if self._load_error:
|
||||
return None
|
||||
try:
|
||||
manifest_path = self.root / "manifest.json"
|
||||
raw = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("manifest is not an object")
|
||||
version = raw.get("version")
|
||||
if raw.get("schema_version") != "cdsl.author-guidance.manifest.v1" or not isinstance(version, str) or not version:
|
||||
raise ValueError("manifest version is invalid")
|
||||
raw_sections = raw.get("sections")
|
||||
if not isinstance(raw_sections, list) or not raw_sections:
|
||||
raise ValueError("manifest sections are invalid")
|
||||
sections: dict[str, _Section] = {}
|
||||
root = self.root.resolve()
|
||||
for item in raw_sections:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("section declaration is invalid")
|
||||
section_id = item.get("id")
|
||||
filename = item.get("file")
|
||||
title = item.get("title")
|
||||
priority = item.get("priority")
|
||||
mandatory = item.get("mandatory", False)
|
||||
if (
|
||||
not isinstance(section_id, str) or not section_id
|
||||
or not isinstance(filename, str) or not filename
|
||||
or not isinstance(title, str) or not title
|
||||
or not isinstance(priority, int) or isinstance(priority, bool)
|
||||
or not isinstance(mandatory, bool)
|
||||
or section_id in sections
|
||||
):
|
||||
raise ValueError("section metadata is invalid")
|
||||
path = (self.root / filename).resolve()
|
||||
if root not in path.parents or not path.is_file():
|
||||
raise ValueError("section file is unavailable")
|
||||
content = path.read_text(encoding="utf-8").strip()
|
||||
if not content:
|
||||
raise ValueError("section content is empty")
|
||||
sections[section_id] = _Section(section_id, title, priority, mandatory, content)
|
||||
|
||||
def identifiers(value: Any, field: str) -> tuple[str, ...]:
|
||||
if not isinstance(value, list) or not value or not all(isinstance(item, str) and item in sections for item in value):
|
||||
raise ValueError(f"{field} is invalid")
|
||||
return tuple(dict.fromkeys(value))
|
||||
|
||||
raw_phases = raw.get("phase_sections")
|
||||
if not isinstance(raw_phases, dict) or "DEFAULT" not in raw_phases:
|
||||
raise ValueError("phase sections are invalid")
|
||||
phase_sections = {
|
||||
phase: identifiers(section_ids, f"phase {phase}")
|
||||
for phase, section_ids in raw_phases.items()
|
||||
if isinstance(phase, str)
|
||||
}
|
||||
if len(phase_sections) != len(raw_phases):
|
||||
raise ValueError("phase name is invalid")
|
||||
operation_sections = {
|
||||
atomic_id: identifiers(section_ids, f"operation {atomic_id}")
|
||||
for atomic_id, section_ids in (raw.get("operation_sections") or {}).items()
|
||||
if isinstance(atomic_id, str)
|
||||
}
|
||||
if not operation_sections or len(operation_sections) != len(raw.get("operation_sections") or {}):
|
||||
raise ValueError("operation sections are invalid")
|
||||
self._corpus = _Corpus(
|
||||
version=version,
|
||||
sections=sections,
|
||||
phase_sections=phase_sections,
|
||||
repair_sections=identifiers(raw.get("repair_sections"), "repair sections"),
|
||||
final_sections=identifiers(raw.get("final_sections"), "final sections"),
|
||||
operation_sections=operation_sections,
|
||||
)
|
||||
return self._corpus
|
||||
except (OSError, ValueError, TypeError, json.JSONDecodeError) as error:
|
||||
self._load_error = f"guidance_load_failed:{type(error).__name__}"
|
||||
return None
|
||||
@@ -46,13 +46,67 @@ class StructuredModelGateway:
|
||||
|
||||
async def call_tool(self, *, messages: list[dict[str, Any]], tool: dict[str, Any], provider_id: str, model_id: str, required_tool_name: str) -> dict[str, Any]:
|
||||
provider, model = self._provider_model(provider_id, model_id)
|
||||
payload = self._payload(provider, model.id, messages, [tool], required_tool_name)
|
||||
response = await self._request(provider, payload)
|
||||
response, compatibility_mode = await self._request_tool_call(
|
||||
provider, model.id, messages, [tool], required_tool_name,
|
||||
)
|
||||
try:
|
||||
return self._normalized_response(provider, response)
|
||||
normalized = self._normalized_response(provider, response)
|
||||
normalized["usage"]["structured_compatibility_mode"] = compatibility_mode
|
||||
return normalized
|
||||
except (KeyError, TypeError, ValueError) as error:
|
||||
raise StructuredModelError("Provider response cannot be normalized as a structured tool-call result") from error
|
||||
|
||||
async def _request_tool_call(
|
||||
self,
|
||||
provider: ProviderConfig,
|
||||
model_id: str,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]],
|
||||
required_tool_name: str,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
"""Keep forced tool choice unless a provider explicitly rejects thinking mode.
|
||||
|
||||
Some OpenAI-compatible gateways reject an otherwise valid forced
|
||||
``tool_choice`` whenever reasoning is enabled or implicit. This is a
|
||||
request compatibility issue rather than an author error. First retry
|
||||
without the client-requested reasoning option; only if that exact
|
||||
rejection remains do we allow automatic choice among the single tool
|
||||
already exposed to the model. The workflow's one-call/schema checks
|
||||
still reject free-form or wrong-tool responses.
|
||||
"""
|
||||
try:
|
||||
return await self._request(
|
||||
provider,
|
||||
self._payload(provider, model_id, messages, tools, required_tool_name),
|
||||
), "forced"
|
||||
except StructuredModelError as first_error:
|
||||
if not self._thinking_tool_choice_rejection(first_error):
|
||||
raise
|
||||
try:
|
||||
return await self._request(
|
||||
provider,
|
||||
self._payload(
|
||||
provider, model_id, messages, tools, required_tool_name,
|
||||
include_reasoning=False,
|
||||
),
|
||||
), "reasoning_disabled"
|
||||
except StructuredModelError as second_error:
|
||||
if not self._thinking_tool_choice_rejection(second_error):
|
||||
raise
|
||||
return await self._request(
|
||||
provider,
|
||||
self._payload(
|
||||
provider, model_id, messages, tools, required_tool_name,
|
||||
include_reasoning=False,
|
||||
force_tool_name=False,
|
||||
),
|
||||
), "single_tool_auto"
|
||||
|
||||
@staticmethod
|
||||
def _thinking_tool_choice_rejection(error: StructuredModelError) -> bool:
|
||||
message = str(error).casefold()
|
||||
return "thinking" in message and ("tool_choice" in message or "tool choice" in message)
|
||||
|
||||
async def conformance(self, *, provider_id: str, model_id: str, tools: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
failures: list[dict[str, str]] = []
|
||||
usage = {"prompt_tokens": 0, "completion_tokens": 0}
|
||||
@@ -190,20 +244,31 @@ class StructuredModelGateway:
|
||||
})
|
||||
return values
|
||||
|
||||
def _payload(self, provider: ProviderConfig, model_id: str, messages: list[dict[str, Any]], tools: list[dict[str, Any]], required_tool_name: str) -> dict[str, Any]:
|
||||
def _payload(
|
||||
self,
|
||||
provider: ProviderConfig,
|
||||
model_id: str,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]],
|
||||
required_tool_name: str,
|
||||
*,
|
||||
include_reasoning: bool = True,
|
||||
force_tool_name: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
if len(tools) != 1 or not required_tool_name:
|
||||
raise StructuredModelError("The v3 protocol requires exactly one named tool per provider call.")
|
||||
request_options = provider.request_options if include_reasoning else {}
|
||||
if provider.api_style == "responses":
|
||||
payload: dict[str, Any] = {
|
||||
"model": model_id, "input": self._responses_input(messages), "tools": self._responses_tools(tools),
|
||||
"tool_choice": {"type": "function", "name": required_tool_name},
|
||||
**provider.request_options,
|
||||
"tool_choice": {"type": "function", "name": required_tool_name} if force_tool_name else "auto",
|
||||
**request_options,
|
||||
}
|
||||
return payload
|
||||
return {
|
||||
"model": model_id, "messages": messages, "tools": tools,
|
||||
"tool_choice": {"type": "function", "function": {"name": required_tool_name}},
|
||||
"temperature": 0, **provider.request_options,
|
||||
"tool_choice": {"type": "function", "function": {"name": required_tool_name}} if force_tool_name else "auto",
|
||||
"temperature": 0, **request_options,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -33,7 +33,17 @@ from app.cad_agent.domain.feature_plan import FeaturePlan, plan_hash
|
||||
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
|
||||
from app.cad_agent.domain.operation_contract import fragment_schema
|
||||
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,
|
||||
AuthorGuidance,
|
||||
AuthorGuidanceSelection,
|
||||
CadRuntime,
|
||||
ModelGateway,
|
||||
NullAuthorGuidance,
|
||||
ReviewGateway,
|
||||
TaskRepository,
|
||||
)
|
||||
|
||||
|
||||
T = TypeVar("T", bound=BaseModel)
|
||||
@@ -116,6 +126,7 @@ class WorkflowCoordinator:
|
||||
review_gateway: ReviewGateway,
|
||||
requirements: RequirementsCommandHandler,
|
||||
actions: ActionCommandHandler,
|
||||
author_guidance: AuthorGuidance | None = None,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.repository = repository
|
||||
@@ -125,6 +136,7 @@ class WorkflowCoordinator:
|
||||
self.review_gateway = review_gateway
|
||||
self.requirements = requirements
|
||||
self.actions = actions
|
||||
self.author_guidance = author_guidance or NullAuthorGuidance()
|
||||
|
||||
def create_task(
|
||||
self,
|
||||
@@ -1078,14 +1090,14 @@ class WorkflowCoordinator:
|
||||
"message": message[:1000],
|
||||
}
|
||||
|
||||
async def _author_turn(self, task_id: str, author: ModelIdentity, tools: list[dict[str, Any]], feedback: list[dict[str, Any]]) -> tuple[str, str, dict[str, int]] | WorkflowError:
|
||||
async def _author_turn(self, task_id: str, author: ModelIdentity, tools: list[dict[str, Any]], feedback: list[dict[str, Any]]) -> tuple[str, str, dict[str, Any]] | WorkflowError:
|
||||
if len(tools) != 1:
|
||||
raise RuntimeError("Workflow state must expose exactly one author tool.")
|
||||
tool = tools[0]
|
||||
name = str((tool.get("function") or {}).get("name") or "")
|
||||
if not name:
|
||||
raise RuntimeError("Workflow exposed an unnamed author tool.")
|
||||
messages = self._author_context(task_id, feedback)
|
||||
messages, guidance = self._author_context(task_id, feedback)
|
||||
try:
|
||||
response = await self.model_gateway.call_tool(
|
||||
messages=messages, tool=tool, provider_id=author.provider_id,
|
||||
@@ -1103,10 +1115,26 @@ class WorkflowCoordinator:
|
||||
"model_id": author.model_id,
|
||||
"retry_reason": "provider_unavailable",
|
||||
"cache_hit": False,
|
||||
**guidance.usage_metadata(),
|
||||
})
|
||||
return WorkflowError(ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE, str(error)[:1000], retryable=True)
|
||||
call = validate_one_tool_call(response["tool_calls"], name)
|
||||
if isinstance(call, WorkflowError):
|
||||
# A provider response is still a billable author attempt even when
|
||||
# it violates the one-tool-call protocol. Keep guidance audit
|
||||
# metadata on that record so A/B reports do not silently omit the
|
||||
# failures this corpus is intended to reduce.
|
||||
self.repository.record_usage(task_id, {
|
||||
**response["usage"],
|
||||
"context_chars": len(json.dumps(messages, ensure_ascii=False)),
|
||||
"tool": name,
|
||||
"provider_id": author.provider_id,
|
||||
"model_id": author.model_id,
|
||||
"raw_arguments_hash": "",
|
||||
"retry_reason": "invalid_tool_call",
|
||||
"cache_hit": False,
|
||||
**guidance.usage_metadata(),
|
||||
})
|
||||
self._record_rejected_tool_calls(
|
||||
task_id,
|
||||
actor="author",
|
||||
@@ -1132,6 +1160,7 @@ class WorkflowCoordinator:
|
||||
"raw_arguments_hash": raw_arguments_hash(raw),
|
||||
"retry_reason": "",
|
||||
"cache_hit": False,
|
||||
**guidance.usage_metadata(),
|
||||
}
|
||||
self.repository.record_usage(task_id, usage)
|
||||
return name, raw, usage
|
||||
@@ -1400,10 +1429,10 @@ class WorkflowCoordinator:
|
||||
return []
|
||||
return [self._tool("record_geometry_conclusion", StatelessGeometryConclusion)]
|
||||
|
||||
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]]) -> tuple[list[dict[str, Any]], AuthorGuidanceSelection]:
|
||||
state = self.repository.get_state(task_id)
|
||||
if state is None:
|
||||
return []
|
||||
return [], AuthorGuidanceSelection(fallback_reason="task_state_unavailable")
|
||||
if state.phase == TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT:
|
||||
content = {
|
||||
"protocol": "cad.v3.2.feature-dag",
|
||||
@@ -1557,8 +1586,17 @@ class WorkflowCoordinator:
|
||||
"feature_plan_hash": state.feature_plan_hash,
|
||||
"feature_node_statuses": self._feature_node_statuses(task_id, None),
|
||||
})
|
||||
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:]]
|
||||
guidance = self.author_guidance.select(
|
||||
phase=state.phase,
|
||||
atomic_id=state.pending_feature.atomic_id if state.pending_feature is not None else "",
|
||||
repair_required=state.repair_required,
|
||||
supported_atomic_ids=self.runtime.supported_atomic_ids(),
|
||||
)
|
||||
system = "You are the autonomous CAD author. Use exactly one offered structured tool call. Never emit Markdown plans or free-form JSON."
|
||||
if guidance.content:
|
||||
system += "\n\nThe following is non-authoritative CDSL author guidance. The current tool schema, operation contract, and server facts take precedence.\n\n" + guidance.content
|
||||
messages: list[dict[str, Any]] = [{"role": "system", "content": system}, {"role": "user", "content": json.dumps(content, ensure_ascii=False)}]
|
||||
return [*messages, *feedback[-2:]], guidance
|
||||
|
||||
def _user_clarifications(self, task_id: str) -> list[dict[str, str]]:
|
||||
clarifications: list[dict[str, str]] = []
|
||||
|
||||
@@ -6,6 +6,7 @@ from dataclasses import dataclass
|
||||
import shutil
|
||||
|
||||
from app.cad_agent.adapters.artifact_store import FileArtifactStore
|
||||
from app.cad_agent.adapters.author_guidance import FileAuthorGuidance
|
||||
from app.cad_agent.adapters.event_publisher import IdempotentInProcessPublisher
|
||||
from app.cad_agent.adapters.runtime import ProfileCadRuntime
|
||||
from app.cad_agent.adapters.review_gateway import RenderedReviewGateway
|
||||
@@ -17,7 +18,7 @@ from app.cad_agent.application.outbox import OutboxDispatcher
|
||||
from app.cad_agent.application.requirements import RequirementsCommandHandler
|
||||
from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator
|
||||
from app.cad_agent.domain.verifier_registry import default_registry
|
||||
from app.settings import Settings
|
||||
from app.settings import BACKEND_ROOT, Settings
|
||||
from app.services.storage import WorkspaceStore
|
||||
|
||||
|
||||
@@ -65,5 +66,10 @@ def compose_v3(settings: Settings) -> V3Services:
|
||||
RenderedReviewGateway(models),
|
||||
requirements,
|
||||
actions,
|
||||
FileAuthorGuidance(
|
||||
BACKEND_ROOT / "agent" / "skills" / "cdsl-author-guidance",
|
||||
enabled=settings.agent_author_guidance_enabled,
|
||||
max_chars=settings.agent_author_guidance_max_chars,
|
||||
),
|
||||
)
|
||||
return V3Services(repository, artifacts, workflow, models, outbox)
|
||||
|
||||
@@ -15,6 +15,7 @@ from hashlib import sha256
|
||||
import json
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
from statistics import median
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
@@ -100,12 +101,15 @@ def _arguments() -> argparse.Namespace:
|
||||
parser.add_argument("--author-model")
|
||||
parser.add_argument("--review-provider")
|
||||
parser.add_argument("--review-model")
|
||||
parser.add_argument("--scenario", help="Run one fixture scenario by its stable ID for targeted regression validation.")
|
||||
parser.add_argument("--scenario", action="append", dest="scenarios", help="Run a fixture scenario by stable ID. Repeat this option to select a comparison set.")
|
||||
parser.add_argument("--repetitions", type=int, help="Run every selected scenario this many times.")
|
||||
parser.add_argument("--author-guidance", choices=("on", "off"), help="Override CDSL author guidance for this run.")
|
||||
parser.add_argument("--compare-guidance-reports", nargs=2, type=Path, metavar=("CONTROL", "TREATMENT"), help="Compare matched --author-guidance off/on report.json files without invoking providers.")
|
||||
parser.add_argument("--baseline-report", type=Path, help="Measured pre-v3 token baseline JSON for a release run.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _fixture(suite: str, scenario_id: str | None = None) -> list[dict[str, Any]]:
|
||||
def _fixture(suite: str, scenario_ids: list[str] | None = None) -> list[dict[str, Any]]:
|
||||
fixture_name = "comprehensive.json" if suite == "comprehensive" else "release.json"
|
||||
value = json.loads((Path(__file__).parent / "fixtures" / fixture_name).read_text(encoding="utf-8"))
|
||||
if fixture_name == "comprehensive.json":
|
||||
@@ -122,11 +126,14 @@ def _fixture(suite: str, scenario_id: str | None = None) -> list[dict[str, Any]]
|
||||
raise ValueError("Comprehensive fixture is not synchronized with its source document.")
|
||||
values = [item for item in value.get("scenarios") or () if isinstance(item, dict)]
|
||||
values = values[:2] if suite == "smoke" else values
|
||||
if scenario_id is None:
|
||||
if not scenario_ids:
|
||||
return values
|
||||
selected = [item for item in values if str(item.get("id") or "") == scenario_id]
|
||||
if not selected:
|
||||
raise ValueError(f"Unknown scenario {scenario_id!r} for suite {suite!r}")
|
||||
requested = list(dict.fromkeys(scenario_ids))
|
||||
available = {str(item.get("id") or "") for item in values}
|
||||
unknown = [scenario_id for scenario_id in requested if scenario_id not in available]
|
||||
if unknown:
|
||||
raise ValueError(f"Unknown scenario {unknown[0]!r} for suite {suite!r}")
|
||||
selected = [item for item in values if str(item.get("id") or "") in set(requested)]
|
||||
return selected
|
||||
|
||||
|
||||
@@ -567,12 +574,180 @@ def _safe_artifact_manifest(artifact_root: Path) -> dict[str, Any]:
|
||||
return {"schema_version": "cad.live-eval-artifact-manifest.v1", "artifact_root": str(artifact_root), "files": files}
|
||||
|
||||
|
||||
def _guidance_metadata(usage: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Summarize author-only guidance audit metadata without retaining prompts."""
|
||||
records = [
|
||||
item for item in usage.get("records") or ()
|
||||
if isinstance(item, dict) and item.get("role") != "reviewer"
|
||||
]
|
||||
sections = sorted({
|
||||
section_id
|
||||
for item in records
|
||||
for section_id in item.get("guidance_section_ids") or ()
|
||||
if isinstance(section_id, str)
|
||||
})
|
||||
versions = sorted({
|
||||
str(item.get("guidance_version") or "")
|
||||
for item in records
|
||||
if str(item.get("guidance_version") or "")
|
||||
})
|
||||
return {
|
||||
"enabled": bool(records) and any(item.get("guidance_enabled") is True for item in records),
|
||||
"versions": versions,
|
||||
"section_ids": sections,
|
||||
"chars_total": sum(int(item.get("guidance_chars") or 0) for item in records),
|
||||
"fallback_reasons": sorted({
|
||||
str(item.get("guidance_fallback_reason") or "")
|
||||
for item in records
|
||||
if str(item.get("guidance_fallback_reason") or "")
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
def _has_unsupported_capability(row: dict[str, Any]) -> bool:
|
||||
"""Recognize engine-declared unsupported capability without hiding model errors."""
|
||||
for event in row.get("ledger") or ():
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
for failure in event.get("operation_failures") or ():
|
||||
if isinstance(failure, dict) and "unsupported_" in str(failure.get("message") or ""):
|
||||
return True
|
||||
if "unsupported_" in str(event.get("message") or ""):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _guidance_metric_rows(report: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
results = [item for item in report.get("results") or () if isinstance(item, dict)]
|
||||
capability_gaps = [
|
||||
item for item in results
|
||||
if item.get("outcome") == "validation_capability_gap" or _has_unsupported_capability(item)
|
||||
]
|
||||
return [item for item in results if item not in capability_gaps], capability_gaps
|
||||
|
||||
|
||||
def _guidance_metrics(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
if not rows:
|
||||
return {"eligible_runs": 0}
|
||||
author_calls = [
|
||||
sum(1 for item in (row.get("usage") or {}).get("records") or () if isinstance(item, dict) and item.get("role") != "reviewer")
|
||||
for row in rows
|
||||
]
|
||||
context_chars = [
|
||||
sum(int(item.get("context_chars") or 0) for item in (row.get("usage") or {}).get("records") or () if isinstance(item, dict) and item.get("role") != "reviewer")
|
||||
for row in rows
|
||||
]
|
||||
prompt_tokens = [
|
||||
sum(int(item.get("prompt_tokens") or 0) for item in (row.get("usage") or {}).get("records") or () if isinstance(item, dict) and item.get("role") != "reviewer")
|
||||
for row in rows
|
||||
]
|
||||
failure_layers: dict[str, int] = {}
|
||||
for row in rows:
|
||||
layer = str((row.get("failure_attribution") or {}).get("layer") or "passed")
|
||||
failure_layers[layer] = failure_layers.get(layer, 0) + 1
|
||||
def rate(predicate: Any) -> float:
|
||||
return sum(1 for row in rows if predicate(row)) / len(rows)
|
||||
return {
|
||||
"eligible_runs": len(rows),
|
||||
"executable_checkpoint_rate": rate(lambda row: bool(row.get("revision_ids"))),
|
||||
"completion_rate": rate(lambda row: str((row.get("projection") or {}).get("phase") or "") == "COMPLETED"),
|
||||
"deterministic_claim_success_rate": rate(lambda row: bool((row.get("checks") or {}).get("deterministic_claims_pass"))),
|
||||
"schema_or_decision_rejections": sum(int(row.get("schema_rejection_count") or 0) for row in rows),
|
||||
"cdsl_expression_failures": sum(1 for row in rows if str((row.get("failure_attribution") or {}).get("layer") or "") == "cdsl_expression"),
|
||||
"median_author_calls": median(author_calls),
|
||||
"median_author_context_chars": median(context_chars),
|
||||
"total_author_prompt_tokens": sum(prompt_tokens),
|
||||
"median_author_prompt_tokens": median(prompt_tokens),
|
||||
"failure_layers": dict(sorted(failure_layers.items())),
|
||||
}
|
||||
|
||||
|
||||
def compare_guidance_reports(control: dict[str, Any], treatment: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Compare paired guidance-off/on runs without treating engine gaps as prompt results."""
|
||||
control_rows, control_gaps = _guidance_metric_rows(control)
|
||||
treatment_rows, treatment_gaps = _guidance_metric_rows(treatment)
|
||||
control_by_key = {(str(row.get("scenario") or ""), int(row.get("repetition") or 0)): row for row in control_rows}
|
||||
treatment_by_key = {(str(row.get("scenario") or ""), int(row.get("repetition") or 0)): row for row in treatment_rows}
|
||||
paired = sorted(set(control_by_key).intersection(treatment_by_key))
|
||||
control_only = sorted(set(control_by_key).difference(treatment_by_key))
|
||||
treatment_only = sorted(set(treatment_by_key).difference(control_by_key))
|
||||
control_pairs = [control_by_key[key] for key in paired]
|
||||
treatment_pairs = [treatment_by_key[key] for key in paired]
|
||||
control_metrics = _guidance_metrics(control_pairs)
|
||||
treatment_metrics = _guidance_metrics(treatment_pairs)
|
||||
same_runtime = (
|
||||
control.get("author") == treatment.get("author")
|
||||
and control.get("reviewer") == treatment.get("reviewer")
|
||||
and control.get("runtime_profile_sha256") == treatment.get("runtime_profile_sha256")
|
||||
and control.get("operation_contracts") == treatment.get("operation_contracts")
|
||||
)
|
||||
control_guidance = bool((control.get("author_guidance") or {}).get("enabled"))
|
||||
treatment_guidance = bool((treatment.get("author_guidance") or {}).get("enabled"))
|
||||
same_budgets = all(
|
||||
control_by_key[key].get("scenario_budget") == treatment_by_key[key].get("scenario_budget")
|
||||
for key in paired
|
||||
)
|
||||
calls_control = control_metrics.get("median_author_calls")
|
||||
calls_treatment = treatment_metrics.get("median_author_calls")
|
||||
calls_within_limit = (
|
||||
isinstance(calls_control, (int, float))
|
||||
and isinstance(calls_treatment, (int, float))
|
||||
and calls_treatment <= calls_control * 1.10
|
||||
)
|
||||
improved = (
|
||||
treatment_metrics.get("schema_or_decision_rejections", 0) < control_metrics.get("schema_or_decision_rejections", 0)
|
||||
or treatment_metrics.get("cdsl_expression_failures", 0) < control_metrics.get("cdsl_expression_failures", 0)
|
||||
)
|
||||
gates = {
|
||||
"complete_pairing": bool(paired) and not control_only and not treatment_only,
|
||||
"control_off_treatment_on": not control_guidance and treatment_guidance,
|
||||
"same_author_reviewer_runtime_and_contracts": same_runtime,
|
||||
"same_per_scenario_budgets": same_budgets,
|
||||
"checkpoint_rate_not_lower": treatment_metrics.get("executable_checkpoint_rate", -1) >= control_metrics.get("executable_checkpoint_rate", 0),
|
||||
"completion_rate_not_lower": treatment_metrics.get("completion_rate", -1) >= control_metrics.get("completion_rate", 0),
|
||||
"median_author_calls_within_ten_percent": calls_within_limit,
|
||||
"model_or_cdsl_failure_improved": improved,
|
||||
}
|
||||
return {
|
||||
"schema_version": "cad.author-guidance-comparison.v1",
|
||||
"status": "passed" if all(gates.values()) else "failed",
|
||||
"gates": gates,
|
||||
"paired_runs": [{"scenario": scenario, "repetition": repetition} for scenario, repetition in paired],
|
||||
"unpaired_runs": {
|
||||
"control_only": [{"scenario": scenario, "repetition": repetition} for scenario, repetition in control_only],
|
||||
"treatment_only": [{"scenario": scenario, "repetition": repetition} for scenario, repetition in treatment_only],
|
||||
},
|
||||
"control": control_metrics,
|
||||
"treatment": treatment_metrics,
|
||||
"excluded_capability_gaps": {
|
||||
"control": [{"scenario": item.get("scenario"), "repetition": item.get("repetition")} for item in control_gaps],
|
||||
"treatment": [{"scenario": item.get("scenario"), "repetition": item.get("repetition")} for item in treatment_gaps],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def compare_guidance_report_paths(control_path: Path, treatment_path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
control = json.loads(control_path.read_text(encoding="utf-8"))
|
||||
treatment = json.loads(treatment_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
return {"status": "failed", "error": f"GUIDANCE_COMPARISON_INPUT_INVALID: {type(error).__name__}"}
|
||||
if not isinstance(control, dict) or not isinstance(treatment, dict):
|
||||
return {"status": "failed", "error": "GUIDANCE_COMPARISON_INPUT_INVALID: report must be an object"}
|
||||
return compare_guidance_reports(control, treatment)
|
||||
|
||||
|
||||
async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, Any]:
|
||||
try:
|
||||
scenarios = _fixture(arguments.suite, arguments.scenario)
|
||||
scenarios = _fixture(arguments.suite, arguments.scenarios)
|
||||
except ValueError as error:
|
||||
return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)}
|
||||
repetitions = arguments.repetitions if arguments.repetitions is not None else 3 if arguments.suite == "release" else 1
|
||||
if repetitions < 1:
|
||||
return {"status": "LIVE_EVAL_BLOCKED", "error": "--repetitions must be at least 1"}
|
||||
settings = get_settings()
|
||||
if arguments.author_guidance is not None:
|
||||
settings = replace(settings, agent_author_guidance_enabled=arguments.author_guidance == "on")
|
||||
try:
|
||||
author_provider, author_model = settings.resolve_model(arguments.author_provider, arguments.author_model)
|
||||
if arguments.review_provider or arguments.review_model:
|
||||
@@ -628,7 +803,7 @@ async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, An
|
||||
}
|
||||
results: list[dict[str, Any]] = []
|
||||
for scenario in scenarios:
|
||||
for repetition in range(1, 4 if arguments.suite == "release" else 2):
|
||||
for repetition in range(1, repetitions + 1):
|
||||
services.workflow.config = replace(
|
||||
services.workflow.config,
|
||||
# State transitions include local candidate recovery, so the
|
||||
@@ -716,6 +891,13 @@ async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, An
|
||||
"terminal": terminal, "projection": projection, "usage": usage, "checks": checks,
|
||||
"acceptance_coverage": acceptance,
|
||||
"failure_attribution": failure_attribution,
|
||||
"guidance": _guidance_metadata(usage),
|
||||
"scenario_budget": {
|
||||
"max_author_turns": int(scenario["max_author_turns"]),
|
||||
"max_reviewer_turns": int(scenario["max_reviewer_turns"]),
|
||||
"max_total_calls": int(scenario["max_total_calls"]),
|
||||
"max_total_tokens": int(scenario["max_total_tokens"]),
|
||||
},
|
||||
"rejection_codes": _rejection_codes(events),
|
||||
"schema_rejection_count": len(_rejection_codes(events)),
|
||||
"retry_count": sum(1 for event in events if (event.get("payload") or {}).get("status") == "error"),
|
||||
@@ -745,6 +927,10 @@ async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, An
|
||||
"author": {"provider": author_provider.id, "model": author_model.id},
|
||||
"author_request_identity": current_author_identity,
|
||||
"reviewer": {"provider": review_provider.id, "model": review_model.id},
|
||||
"author_guidance": {
|
||||
"enabled": settings.agent_author_guidance_enabled,
|
||||
"max_chars": settings.agent_author_guidance_max_chars,
|
||||
},
|
||||
"author_capability": author_capability,
|
||||
"reviewer_capability": reviewer_capability,
|
||||
"structured_output_mode": {
|
||||
@@ -759,6 +945,7 @@ async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, An
|
||||
"verifier_schema_hash": verifier_schema_hash,
|
||||
"token_comparison": token_comparison,
|
||||
"baseline_report": str(baseline_path) if baseline_path else "",
|
||||
"repetitions": repetitions,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
@@ -768,7 +955,11 @@ def main() -> int:
|
||||
report_root = BACKEND_ROOT / "live-evals" / datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
report_root.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
result = asyncio.run(_run(arguments, report_root))
|
||||
result = (
|
||||
compare_guidance_report_paths(*arguments.compare_guidance_reports)
|
||||
if arguments.compare_guidance_reports
|
||||
else asyncio.run(_run(arguments, report_root))
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
# Let an explicit operator interruption retain its normal CLI
|
||||
# semantics. An external kill cannot be reported reliably either.
|
||||
|
||||
@@ -5,13 +5,60 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
|
||||
from .domain.state import TaskState
|
||||
from .domain.state import TaskPhase, TaskState
|
||||
|
||||
|
||||
class AdapterUnavailable(RuntimeError):
|
||||
"""A bounded external-service outage; handlers must preserve checkpoints."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthorGuidanceSelection:
|
||||
"""Non-authoritative author context selected from the local guidance corpus."""
|
||||
|
||||
version: str = ""
|
||||
section_ids: tuple[str, ...] = ()
|
||||
content: str = ""
|
||||
enabled: bool = False
|
||||
fallback_reason: str = ""
|
||||
|
||||
def usage_metadata(self) -> dict[str, object]:
|
||||
return {
|
||||
"guidance_version": self.version,
|
||||
"guidance_section_ids": list(self.section_ids),
|
||||
"guidance_chars": len(self.content),
|
||||
"guidance_enabled": self.enabled,
|
||||
"guidance_fallback_reason": self.fallback_reason,
|
||||
}
|
||||
|
||||
|
||||
class AuthorGuidance(Protocol):
|
||||
"""Select bounded local author guidance without interpreting user intent."""
|
||||
|
||||
def select(
|
||||
self,
|
||||
*,
|
||||
phase: TaskPhase,
|
||||
atomic_id: str,
|
||||
repair_required: bool,
|
||||
supported_atomic_ids: tuple[str, ...],
|
||||
) -> AuthorGuidanceSelection: ...
|
||||
|
||||
|
||||
class NullAuthorGuidance:
|
||||
"""Compatibility default that retains the pre-guidance author prompt."""
|
||||
|
||||
def select(
|
||||
self,
|
||||
*,
|
||||
phase: TaskPhase,
|
||||
atomic_id: str,
|
||||
repair_required: bool,
|
||||
supported_atomic_ids: tuple[str, ...],
|
||||
) -> AuthorGuidanceSelection:
|
||||
return AuthorGuidanceSelection(fallback_reason="guidance_not_configured")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InvocationRecord:
|
||||
invocation_id: str
|
||||
|
||||
Reference in New Issue
Block a user