← Files ClaraARCHIVED FILE

scripts/run_deck_revision.py

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

↓ Download file

"""Resume mechanical deck-revision preparation using existing canonical helpers."""

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 hashlib
import json
import logging
from dataclasses import fields
from pathlib import Path
from typing import Any, Callable

from advisor_case_core import CaseWorkspaceError, validate_case_workspace
from analyze_deck_revision_materials import analyze_deck_revision_materials
from apply_deck_revision_plan import apply_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 case_store import atomic_text, transaction
from complete_deck_revision_output_review import (
    _verified_output_review,
    verify_deck_revision_output_review,
)
from finalize_deck_revision_plan import finalize_deck_revision_plan

__all__ = ["run_deck_revision", "main"]
LOGGER = logging.getLogger(__name__)


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


def _hashes(paths: list[Path]) -> dict[str, str | None]:
    result = {}
    for path in paths:
        if not path.is_file():
            result[str(path)] = None
            continue
        digest = hashlib.sha256()
        with path.open("rb") as source:
            for block in iter(lambda: source.read(1024 * 1024), b""):
                digest.update(block)
        result[str(path)] = digest.hexdigest()
    return result


def run_deck_revision(
    case_dir: Path, *, voice_session: Path, apply_approved: bool = False
) -> dict[str, Any]:
    """Regenerate views by exact dependency identity; never invent interpretation or approval."""
    case_dir = case_dir.resolve()
    errors = validate_case_workspace(case_dir)
    if errors:
        raise CaseWorkspaceError("; ".join(errors))
    session = (case_dir / voice_session).resolve()
    if not session.is_relative_to(case_dir / "voice_sessions") or not session.is_dir():
        raise CaseWorkspaceError("An existing case-owned voice session is required")
    receipt = session / "deck_revision_runner.json"
    with transaction(case_dir):
        state = (
            _read(receipt) if receipt.is_file() else {"schema_version": 1, "stages": {}}
        )
        state["status"] = "preparing"
        state["semantic_review_performed"] = False
        state["interpretation_stale"] = False
        state.pop("missing_material", None)
        state.pop("material_needs", None)
        state.pop("final_review_error", None)

        def step(
            name: str,
            function: Callable[..., Any],
            dependencies: list[Path],
            **kwargs: Any,
        ) -> list[Path]:
            # Exact cache identity is mechanical; no semantic stage selection occurs here.
            code = Path(__file__).parent / f"{function.__name__}.py"
            identity = _hashes([*dependencies, code, Path(__file__)])
            previous = state["stages"].get(name, {})
            outputs = [Path(path) for path in previous.get("outputs", {})]
            if (
                outputs
                and previous.get("inputs") == identity
                and _hashes(outputs) == previous["outputs"]
                and all(value is not None for value in previous["outputs"].values())
            ):
                return outputs
            result = function(case_dir, voice_session=session, **kwargs)
            outputs = []
            for field in fields(result):
                value = getattr(result, field.name)
                # The finalizer replaces the initial workbench review template;
                # only that finalizer owns its identity after interpretation.
                if name == "workbench" and field.name == "review_path":
                    continue
                if not isinstance(value, Path) or field.name == "session_dir":
                    continue
                outputs.extend(sorted(value.rglob("*")) if value.is_dir() else [value])
            outputs = [path for path in outputs if path.is_file()]
            if _hashes([*dependencies, code, Path(__file__)]) != identity:
                raise CaseWorkspaceError(
                    "Deck preparation inputs changed while generating views"
                )
            state["stages"][name] = {"inputs": identity, "outputs": _hashes(outputs)}
            atomic_text(receipt, json.dumps(state, indent=2) + "\n")
            return outputs

        intake_path = session / "deck_revision_intake.json"
        if not intake_path.is_file():
            state.update(
                status="intake_required",
                next_action="Run prepare_voice_deck_revision.py against the current deck and attributed transcript.",
            )
        else:
            intake = _read(intake_path)
            dependencies = [intake_path, case_dir / "case_manifest.json"]
            for section, keys in {
                "speaker_attribution": ["attributed_transcript_path"],
                "deck": ["path", "snapshot_path"],
                "deck_style": ["snapshot_path", "spec_path"],
                "evidence": [
                    "feedback_timeline_path",
                    "video_timeline_path",
                    "screen_video_path",
                    "raw_transcript_path",
                ],
                "company_profile": ["path"],
            }.items():
                for key in keys:
                    raw = intake.get(section, {}).get(key)
                    if raw:
                        dependencies.append((case_dir / raw).resolve())
            workbench = step("workbench", build_deck_revision_workbench, dependencies)
            packets = step(
                "interpretation_packets",
                build_deck_revision_interpretation_packets,
                workbench,
            )
            changes = session / "deck_revision_changes.json"
            previous_inputs = (
                state["stages"].get("normalized_plan", {}).get("inputs", {})
            )
            current_evidence = _hashes([*workbench, *packets])
            interpretation_stale = (
                bool(previous_inputs)
                and previous_inputs.get(str(changes))
                == _hashes([changes])[str(changes)]
                and any(
                    previous_inputs.get(path) != digest
                    for path, digest in current_evidence.items()
                )
            )
            if not changes.is_file() or interpretation_stale:
                state.update(
                    status="interpretation_required",
                    next_action="Inspect interpretation packets and author deck_revision_changes.json from the feedback evidence.",
                    interpretation_stale=interpretation_stale,
                )
            else:
                normalized = step(
                    "normalized_plan",
                    finalize_deck_revision_plan,
                    [changes, *workbench, *packets],
                    changes_path=changes,
                )
                plan = step(
                    "execution_plan", build_deck_revision_execution_plan, normalized
                )
                quote = session / "deck_revision_quote_candidate_matrix.json"
                step(
                    "execution_packets",
                    build_deck_revision_execution_packets,
                    [*normalized, *plan, *workbench, quote],
                )
                # Approval and missing materials must be evaluated fresh, including revoked approval.
                needs = analyze_deck_revision_materials(case_dir, voice_session=session)
                payload = _read(needs.needs_path)
                status = payload["summary"]["status"]
                actions = {
                    "ready_for_auto_apply": "Run apply_deck_revision_plan.py, then render and review the exact output.",
                    "ready_for_approval": "Review deck_revision_understanding.md with the user and record the existing authorized approval.",
                    "no_changes": "Review whether the feedback requires any changes.",
                    "partial_or_manual_work_required": "Resolve the listed material gaps or execute the model-assisted packets before application.",
                }
                state.update(
                    status=status,
                    next_action=actions[status],
                    material_needs=str(needs.needs_path),
                    missing_material=[
                        {"change_id": item["change_id"], "missing": item["missing"]}
                        for item in payload["changes"]
                        if item["missing"]
                    ],
                )
                review_path = session / "deck_revision_output_review.json"
                external_review = (
                    review_path.is_file()
                    and _read(review_path).get("execution_mode")
                    == "registered_external_output"
                )
                if external_review:
                    # Never overwrite an external edit while reconciling its exact identities.
                    try:
                        _verified_output_review(case_dir, review_path)
                    except CaseWorkspaceError as error:
                        state["status"] = "external_output_review_stale"
                        state["final_review_error"] = str(error)
                        state["next_action"] = (
                            "Review current approval and re-register the external deck; its bytes have been preserved."
                        )
                    else:
                        state["status"] = "external_output_pending_review"
                        state["next_action"] = (
                            "Render and review the registered external deck, then complete its exact-output review."
                        )
                        if (
                            session / "deck_revision_output_review_completion.json"
                        ).is_file():
                            try:
                                verify_deck_revision_output_review(
                                    case_dir, voice_session=session
                                )
                            except CaseWorkspaceError as error:
                                state["final_review_error"] = str(error)
                            else:
                                state["status"] = "final_review_record_current"
                                state["next_action"] = (
                                    "Use the reviewed output in the advisory delivery gate when applicable."
                                )
                elif status == "ready_for_auto_apply" and apply_approved:
                    step(
                        "application",
                        apply_deck_revision_plan,
                        [
                            *normalized,
                            *workbench,
                            session / "deck_revision_approval.json",
                            *dependencies,
                        ],
                    )
                    applied = _read(session / "deck_revision_apply_report.json")
                    state["status"] = applied["summary"]["status"]
                    state["next_action"] = (
                        "Inspect the apply and verification reports; render the corrected deck and complete its exact-output review."
                    )
                    completion = session / "deck_revision_output_review_completion.json"
                    if completion.is_file():
                        try:
                            verify_deck_revision_output_review(
                                case_dir, voice_session=session
                            )
                        except CaseWorkspaceError as error:
                            state["final_review_error"] = str(error)
                        else:
                            state["status"] = "final_review_record_current"
                            state["next_action"] = (
                                "Use the reviewed output in the advisory delivery gate when applicable."
                            )
        atomic_text(receipt, json.dumps(state, indent=2) + "\n")
        return state


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("case_dir", type=Path)
    parser.add_argument("--voice-session", type=Path, required=True)
    parser.add_argument(
        "--apply-approved",
        action="store_true",
        help="Apply only a current approved automatic plan; never create approval or review confirmations.",
    )
    args = parser.parse_args()
    state = run_deck_revision(
        args.case_dir,
        voice_session=args.voice_session,
        apply_approved=args.apply_approved,
    )
    LOGGER.info("%s: %s", state["status"], state["next_action"])
    return 0


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO, format="%(message)s")
    raise SystemExit(main())

SHA-256: 44ffa92de9dd6005677595b00f44915aa001bb8fcbe4590abc950de9a9a6e098