← Files ClaraARCHIVED FILE

scripts/approve_deck_revision_plan.py

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

↓ Download file

"""Approve a reviewed Clara deck-revision plan for PPTX application."""

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 dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Mapping

from advisor_case_core import CaseWorkspaceError, validate_case_workspace

__all__ = [
    "DeckRevisionApprovalResult",
    "approve_deck_revision_plan",
    "main",
]

LOGGER = logging.getLogger(__name__)


@dataclass(frozen=True)
class DeckRevisionApprovalResult:
    """Approval artifact for a reviewed deck-revision plan."""

    session_dir: Path
    approval_path: Path
    review_path: Path
    understanding_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 _relative_path(case_dir: Path, path: Path) -> str:
    try:
        return str(path.resolve().relative_to(case_dir.resolve()))
    except ValueError:
        return str(path.resolve())


def _sha256(path: Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest()


def _resolve_voice_session_dir(case_dir: Path, voice_session: Path | None) -> Path:
    sessions_root = case_dir / "voice_sessions"
    if voice_session is None:
        if not sessions_root.is_dir():
            raise CaseWorkspaceError("case has no voice_sessions folder")
        sessions = sorted(path for path in sessions_root.iterdir() if path.is_dir())
        if not sessions:
            raise CaseWorkspaceError("case has no imported voice sessions")
        return sessions[-1].resolve()

    candidate = voice_session.expanduser()
    candidates = (
        [candidate]
        if candidate.is_absolute()
        else [case_dir / candidate, sessions_root / candidate]
    )
    for path in candidates:
        if path.is_dir():
            resolved = path.resolve()
            try:
                resolved.relative_to(sessions_root.resolve())
            except ValueError as error:
                raise CaseWorkspaceError(
                    f"voice session must live under {sessions_root}: {resolved}"
                ) from error
            return resolved
    raise CaseWorkspaceError(f"voice session does not exist: {voice_session}")


def _resolve_plan_path(
    case_dir: Path,
    session_dir: Path,
    plan_path: Path | None,
) -> Path:
    candidate = plan_path or (session_dir / "deck_revision_changes.normalized.json")
    if not candidate.is_absolute():
        candidate = case_dir / candidate
    if not candidate.is_file():
        raise CaseWorkspaceError(
            f"normalized deck revision plan is missing: {candidate}; run finalize_deck_revision_plan.py first"
        )
    return candidate.resolve()


def _resolve_understanding_path(
    case_dir: Path,
    session_dir: Path,
    understanding_path: Path | None,
) -> Path:
    candidate = understanding_path or (session_dir / "deck_revision_understanding.md")
    if not candidate.is_absolute():
        candidate = case_dir / candidate
    if not candidate.is_file():
        raise CaseWorkspaceError(
            "deck revision understanding checkpoint is missing: "
            f"{candidate}; run finalize_deck_revision_plan.py and show "
            "deck_revision_understanding.md before approval"
        )
    if not candidate.read_text(encoding="utf-8").strip():
        raise CaseWorkspaceError(
            f"deck revision understanding checkpoint is empty: {candidate}"
        )
    return candidate.resolve()


def _render_markdown(payload: Mapping[str, Any]) -> str:
    return "\n".join(
        [
            "# Deck Revision Approval",
            "",
            f"Status: `{'approved' if payload['approved'] else 'not approved'}`",
            "",
            f"- Approved by: {payload['approved_by']}",
            f"- Plan: `{payload['plan_path']}`",
            f"- Plan SHA-256: `{payload['plan_sha256']}`",
            f"- Understanding reviewed: `{payload['understanding_reviewed']}`",
            f"- Understanding: `{payload['understanding_path']}`",
            f"- Understanding SHA-256: `{payload['understanding_sha256']}`",
            f"- Note: {payload.get('approval_note') or 'none'}",
            "",
        ]
    )


def approve_deck_revision_plan(
    case_dir: Path,
    *,
    voice_session: Path | None = None,
    plan_path: Path | None = None,
    understanding_path: Path | None = None,
    reviewer: str,
    note: str = "",
    understanding_reviewed: bool = False,
    allow_open_questions: bool = False,
    now: datetime | None = None,
) -> DeckRevisionApprovalResult:
    """Write an approval artifact tied to the exact normalized plan hash."""

    reviewer_clean = reviewer.strip()
    if not reviewer_clean:
        raise CaseWorkspaceError("reviewer is required")
    errors = validate_case_workspace(case_dir)
    if errors:
        raise CaseWorkspaceError("; ".join(errors))

    case_dir = case_dir.resolve()
    session_dir = _resolve_voice_session_dir(case_dir, voice_session)
    resolved_plan_path = _resolve_plan_path(case_dir, session_dir, plan_path)
    plan = _read_json(resolved_plan_path)
    open_questions = plan.get("open_questions")
    if isinstance(open_questions, list) and open_questions and not allow_open_questions:
        raise CaseWorkspaceError(
            "deck revision plan has open questions; resolve them or pass --allow-open-questions"
        )
    changes = plan.get("changes")
    if not isinstance(changes, list) or not changes:
        raise CaseWorkspaceError("deck revision plan has no changes to approve")
    if not understanding_reviewed:
        raise CaseWorkspaceError(
            "deck revision understanding checkpoint has not been marked reviewed; "
            "show deck_revision_understanding.md to the user, then rerun approval "
            "with --understanding-reviewed"
        )
    resolved_understanding_path = _resolve_understanding_path(
        case_dir,
        session_dir,
        understanding_path,
    )

    payload: dict[str, Any] = {
        "schema_version": 1,
        "source": "clara_deck_revision_approval",
        "created_at": _now_iso(now),
        "approved": True,
        "approved_by": reviewer_clean,
        "approval_note": note.strip() or None,
        "voice_session": _relative_path(case_dir, session_dir),
        "plan_path": _relative_path(case_dir, resolved_plan_path),
        "plan_sha256": _sha256(resolved_plan_path),
        "understanding_reviewed": True,
        "understanding_path": _relative_path(case_dir, resolved_understanding_path),
        "understanding_sha256": _sha256(resolved_understanding_path),
    }
    approval_path = session_dir / "deck_revision_approval.json"
    review_path = session_dir / "deck_revision_approval.md"
    _write_json(approval_path, payload)
    review_path.write_text(_render_markdown(payload), encoding="utf-8")
    return DeckRevisionApprovalResult(
        session_dir=session_dir,
        approval_path=approval_path,
        review_path=review_path,
        understanding_path=resolved_understanding_path,
    )


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(
        description="Approve a reviewed Clara deck-revision plan for PPTX application.",
    )
    parser.add_argument("case_dir", type=Path)
    parser.add_argument("--reviewer", required=True)
    parser.add_argument("--note", default="")
    parser.add_argument(
        "--voice-session",
        type=Path,
        default=None,
        help="Voice session folder name/path. Defaults to latest voice session.",
    )
    parser.add_argument(
        "--plan",
        type=Path,
        default=None,
        help="Normalized change plan. Defaults to deck_revision_changes.normalized.json.",
    )
    parser.add_argument(
        "--understanding",
        type=Path,
        default=None,
        help="Consultant-readable understanding checkpoint. Defaults to deck_revision_understanding.md.",
    )
    parser.add_argument(
        "--understanding-reviewed",
        action="store_true",
        help=(
            "Confirm deck_revision_understanding.md was shown to and reviewed with "
            "the user before approving PPTX edits."
        ),
    )
    parser.add_argument(
        "--allow-open-questions",
        action="store_true",
        help="Allow approval even when the normalized plan still has open questions.",
    )
    args = parser.parse_args(argv)

    logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
    result = approve_deck_revision_plan(
        args.case_dir,
        voice_session=args.voice_session,
        plan_path=args.plan,
        understanding_path=args.understanding,
        reviewer=args.reviewer,
        note=args.note,
        understanding_reviewed=args.understanding_reviewed,
        allow_open_questions=args.allow_open_questions,
    )
    LOGGER.info("wrote deck revision approval to %s", result.approval_path)
    LOGGER.info("wrote deck revision approval review to %s", result.review_path)
    return 0


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

SHA-256: 066673b928fbbf8a276be2e1d4656d6b783c1ea1df2ce9f647c95b9eafee778e