← Files ClaraARCHIVED FILE

scripts/run_deck_revision_fixture.py

13.9 KB · Oct 2, 2026 · 00:29 UTC

↓ Download file

"""Run a Clara deck-revision fixture through the local harness."""

from __future__ import annotations

# Direct CLI calls must select dependencies before importing workflow modules.
if __name__ == "__main__":
    import runpy as _runpy
    from pathlib import Path as _Path

    for _parent in _Path(__file__).resolve().parents:
        _launcher = _parent / "scripts" / "self_relaunch.py"
        if not _launcher.is_file():
            _launcher = _parent / "clara" / "scripts" / "self_relaunch.py"
        if _launcher.is_file():
            _runpy.run_path(str(_launcher))["ensure_running_in_managed_venv"](__file__)
            break
    else:
        # Standalone components retain their host's dependency setup.
        if any(
            (_p / "components.json").is_file()
            for _p in _Path(__file__).resolve().parents
        ):
            raise SystemExit(
                "Managed Python launcher is missing; rebuild the plugin package."
            )

import argparse
import json
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Mapping

from advisor_case_core import CaseWorkspaceError
from analyze_deck_revision_materials import analyze_deck_revision_materials
from apply_deck_revision_plan import apply_deck_revision_plan
from approve_deck_revision_plan import approve_deck_revision_plan
from build_deck_revision_execution_packets import build_deck_revision_execution_packets
from build_deck_revision_execution_plan import build_deck_revision_execution_plan
from build_deck_revision_interpretation_packets import (
    build_deck_revision_interpretation_packets,
)
from build_deck_revision_workbench import build_deck_revision_workbench
from finalize_deck_revision_plan import finalize_deck_revision_plan

__all__ = [
    "DeckRevisionFixtureResult",
    "run_deck_revision_fixture",
    "main",
]

LOGGER = logging.getLogger(__name__)


@dataclass(frozen=True)
class DeckRevisionFixtureResult:
    """Artifacts created by a deck-revision fixture run."""

    fixture_dir: Path
    report_path: Path
    review_path: Path


def _now_iso(now: datetime | None = None) -> str:
    value = now or datetime.now(timezone.utc)
    return value.astimezone(timezone.utc).replace(microsecond=0).isoformat()


def _read_json(path: Path) -> dict[str, Any]:
    payload = json.loads(path.read_text(encoding="utf-8"))
    if not isinstance(payload, dict):
        raise CaseWorkspaceError(f"expected JSON object in {path}")
    return payload


def _write_json(path: Path, payload: Mapping[str, Any]) -> None:
    path.write_text(
        json.dumps(payload, indent=2, ensure_ascii=True) + "\n",
        encoding="utf-8",
    )


def _resolve_fixture_path(fixture_dir: Path, raw_path: Any, *, label: str) -> Path:
    if not isinstance(raw_path, str) or not raw_path.strip():
        raise CaseWorkspaceError(f"fixture {label} is missing")
    candidate = Path(raw_path).expanduser()
    if not candidate.is_absolute():
        candidate = fixture_dir / candidate
    return candidate.resolve()


def _optional_fixture_path(fixture_dir: Path, raw_path: Any) -> Path | None:
    if not isinstance(raw_path, str) or not raw_path.strip():
        return None
    candidate = Path(raw_path).expanduser()
    if not candidate.is_absolute():
        candidate = fixture_dir / candidate
    return candidate.resolve()


def _relative_path(base_dir: Path, path: Path | None) -> str | None:
    if path is None:
        return None
    try:
        return str(path.resolve().relative_to(base_dir.resolve()))
    except ValueError:
        return str(path.resolve())


def _expectation_records(
    expected: Mapping[str, Any],
    actual: Mapping[str, Any],
) -> list[dict[str, Any]]:
    records: list[dict[str, Any]] = []
    for key, expected_value in expected.items():
        if key == "change_strategies":
            actual_value = actual.get(key)
        else:
            actual_value = actual.get(key)
        records.append(
            {
                "field": key,
                "expected": expected_value,
                "actual": actual_value,
                "passed": actual_value == expected_value,
            }
        )
    return records


def _render_markdown(payload: Mapping[str, Any]) -> str:
    lines = [
        "# Deck Revision Fixture Report",
        "",
        f"Status: `{payload['summary']['status']}`",
        "",
        f"- Fixture: `{payload['fixture_dir']}`",
        f"- Case: `{payload['case_dir']}`",
        f"- Voice session: `{payload['voice_session']}`",
        f"- Normalized plan: `{payload['artifacts'].get('normalized_plan')}`",
        f"- Understanding: `{payload['artifacts'].get('understanding')}`",
        f"- Interpretation packets: `{payload['artifacts'].get('interpretation_packets')}`",
        f"- Execution plan: `{payload['artifacts'].get('execution_plan')}`",
        f"- Execution packets: `{payload['artifacts'].get('execution_packets')}`",
        f"- Material needs: `{payload['artifacts'].get('material_needs')}`",
        f"- Corrected deck: `{payload['artifacts'].get('corrected_deck') or 'not produced'}`",
        f"- Verification: `{payload['artifacts'].get('verification') or 'not run'}`",
        f"- Final output review: `{payload['artifacts'].get('output_review') or 'not run'}`",
        "",
    ]
    if payload["expectations"]:
        lines.append("## Expectations")
        lines.append("")
        for record in payload["expectations"]:
            result = "passed" if record["passed"] else "failed"
            lines.append(
                f"- `{record['field']}`: {result} "
                f"(expected `{record['expected']}`, actual `{record['actual']}`)"
            )
        lines.append("")
    return "\n".join(lines)


def run_deck_revision_fixture(
    fixture_dir: Path,
    *,
    config_path: Path | None = None,
    now: datetime | None = None,
) -> DeckRevisionFixtureResult:
    """Run a fixture through workbench, interpretation validation, routing, and verification."""

    fixture_dir = fixture_dir.resolve()
    resolved_config_path = config_path or (fixture_dir / "fixture.json")
    if not resolved_config_path.is_absolute():
        resolved_config_path = fixture_dir / resolved_config_path
    if not resolved_config_path.is_file():
        raise CaseWorkspaceError(f"fixture config is missing: {resolved_config_path}")
    config = _read_json(resolved_config_path)

    case_dir = _resolve_fixture_path(
        fixture_dir, config.get("case_dir"), label="case_dir"
    )
    voice_session = _optional_fixture_path(fixture_dir, config.get("voice_session"))
    changes_json = _resolve_fixture_path(
        fixture_dir, config.get("changes_json"), label="changes_json"
    )

    if bool(config.get("build_workbench", True)):
        build_deck_revision_workbench(
            case_dir,
            voice_session=voice_session,
            now=now,
        )
    interpretation_packets = build_deck_revision_interpretation_packets(
        case_dir,
        voice_session=voice_session,
        now=now,
    )
    finalizer_result = finalize_deck_revision_plan(
        case_dir,
        changes_json,
        voice_session=voice_session,
        now=now,
    )
    execution_result = build_deck_revision_execution_plan(
        case_dir,
        voice_session=voice_session,
        plan_path=finalizer_result.normalized_plan_path,
        now=now,
    )
    execution_packets = build_deck_revision_execution_packets(
        case_dir,
        voice_session=voice_session,
        plan_path=finalizer_result.normalized_plan_path,
        now=now,
    )
    material_result = analyze_deck_revision_materials(
        case_dir,
        voice_session=voice_session,
        plan_path=finalizer_result.normalized_plan_path,
        now=now,
    )

    approval_path: Path | None = None
    apply_result = None
    if bool(config.get("approve", False)):
        approval = approve_deck_revision_plan(
            case_dir,
            voice_session=voice_session,
            plan_path=finalizer_result.normalized_plan_path,
            reviewer=str(config.get("reviewer") or "fixture"),
            understanding_reviewed=bool(config.get("understanding_reviewed", True)),
            now=now,
        )
        approval_path = approval.approval_path
    if bool(config.get("apply", False)):
        apply_result = apply_deck_revision_plan(
            case_dir,
            voice_session=voice_session,
            plan_path=finalizer_result.normalized_plan_path,
            now=now,
            allow_unapproved=not bool(config.get("approve", False)),
        )

    execution_payload = _read_json(execution_result.execution_plan_path)
    material_payload = _read_json(material_result.needs_path)
    apply_payload = (
        _read_json(apply_result.apply_report_path) if apply_result is not None else {}
    )
    verification_payload = (
        _read_json(apply_result.verification_report_path)
        if apply_result is not None
        else {}
    )
    normalized_payload = _read_json(finalizer_result.normalized_plan_path)
    actual = {
        "change_count": len(normalized_payload.get("changes", [])),
        "execution_status": execution_payload.get("summary", {}).get("status"),
        "material_status": material_payload.get("summary", {}).get("status"),
        "apply_status": apply_payload.get("summary", {}).get("status"),
        "final_output_review_status": apply_payload.get("summary", {}).get(
            "final_output_review_status"
        ),
        "verification_status": verification_payload.get("summary", {}).get("status"),
        "change_strategies": [
            change.get("execution_strategy")
            for change in normalized_payload.get("changes", [])
            if isinstance(change, dict)
        ],
    }
    expected = (
        config.get("expected") if isinstance(config.get("expected"), dict) else {}
    )
    expectations = _expectation_records(expected, actual)
    status = (
        "passed"
        if all(record["passed"] for record in expectations)
        else "failed" if expectations else "completed"
    )
    payload: dict[str, Any] = {
        "schema_version": 1,
        "source": "clara_deck_revision_fixture_report",
        "created_at": _now_iso(now),
        "fixture_dir": str(fixture_dir),
        "case_dir": _relative_path(fixture_dir, case_dir),
        "voice_session": _relative_path(fixture_dir, voice_session),
        "changes_json": _relative_path(fixture_dir, changes_json),
        "summary": {
            "status": status,
            **actual,
        },
        "artifacts": {
            "normalized_plan": _relative_path(
                fixture_dir, finalizer_result.normalized_plan_path
            ),
            "changes_review": _relative_path(fixture_dir, finalizer_result.review_path),
            "understanding": _relative_path(
                fixture_dir, finalizer_result.understanding_path
            ),
            "interpretation_packets": _relative_path(
                fixture_dir, interpretation_packets.packet_index_path
            ),
            "interpretation_packets_review": _relative_path(
                fixture_dir, interpretation_packets.review_path
            ),
            "handoff": _relative_path(fixture_dir, finalizer_result.handoff_path),
            "execution_plan": _relative_path(
                fixture_dir, execution_result.execution_plan_path
            ),
            "execution_review": _relative_path(
                fixture_dir, execution_result.review_path
            ),
            "execution_packets": _relative_path(
                fixture_dir, execution_packets.packet_index_path
            ),
            "execution_packets_review": _relative_path(
                fixture_dir, execution_packets.review_path
            ),
            "material_needs": _relative_path(fixture_dir, material_result.needs_path),
            "material_review": _relative_path(fixture_dir, material_result.review_path),
            "approval": _relative_path(fixture_dir, approval_path),
            "corrected_deck": (
                _relative_path(fixture_dir, apply_result.corrected_deck_path)
                if apply_result is not None
                else None
            ),
            "apply_report": (
                _relative_path(fixture_dir, apply_result.apply_report_path)
                if apply_result is not None
                else None
            ),
            "output_review": (
                _relative_path(fixture_dir, apply_result.output_review_path)
                if apply_result is not None
                else None
            ),
            "verification": (
                _relative_path(fixture_dir, apply_result.verification_report_path)
                if apply_result is not None
                else None
            ),
        },
        "expectations": expectations,
    }

    report_path = fixture_dir / "deck_revision_eval_report.json"
    review_path = fixture_dir / "deck_revision_eval_report.md"
    _write_json(report_path, payload)
    review_path.write_text(_render_markdown(payload), encoding="utf-8")
    return DeckRevisionFixtureResult(
        fixture_dir=fixture_dir,
        report_path=report_path,
        review_path=review_path,
    )


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(
        description="Run a Clara deck-revision fixture through the local harness.",
    )
    parser.add_argument("fixture_dir", type=Path)
    parser.add_argument(
        "--config",
        type=Path,
        default=None,
        help="Fixture config path. Defaults to fixture.json inside the fixture folder.",
    )
    args = parser.parse_args(argv)

    logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
    result = run_deck_revision_fixture(args.fixture_dir, config_path=args.config)
    LOGGER.info("wrote deck revision fixture report to %s", result.report_path)
    LOGGER.info("wrote deck revision fixture review to %s", result.review_path)
    return 0


if __name__ == "__main__":  # pragma: no cover
    raise SystemExit(main())

SHA-256: c11a2bfc7b6fd346cda7ecba756ba04d4ef89482fbf7eb18dd5ed8d9fb5339d0