← Files ClaraARCHIVED FILE

scripts/analyze_deck_revision_materials.py

18.3 KB · Oct 5, 2026 · 00:02 UTC

↓ Download file

"""Analyze what is needed to apply a Clara deck-revision plan."""

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
from build_deck_revision_quote_candidate_matrix import (
    change_needs_quote_candidate_matrix,
)
from deck_revision_execution_contract import (
    PATCH_EXECUTION_STRATEGY,
    SUPPORTED_PATCH_OPERATIONS,
    execution_strategy_requirement,
)

__all__ = [
    "DeckRevisionMaterialNeedsResult",
    "analyze_deck_revision_materials",
    "main",
]

LOGGER = logging.getLogger(__name__)


@dataclass(frozen=True)
class DeckRevisionMaterialNeedsResult:
    """Material-needs artifacts for a deck-revision plan."""

    session_dir: Path
    needs_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 _sha256(path: Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest()


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 _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 _load_plan(
    session_dir: Path, plan_path: Path | None, case_dir: Path
) -> tuple[Path, dict[str, Any]]:
    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(), _read_json(candidate)


def _required_workbench(session_dir: Path) -> dict[str, Any]:
    workbench_path = session_dir / "deck_revision_workbench.json"
    if not workbench_path.is_file():
        raise CaseWorkspaceError(
            f"deck revision workbench is missing: {workbench_path}; run build_deck_revision_workbench.py first"
        )
    return _read_json(workbench_path)


def _approval_status(
    case_dir: Path, session_dir: Path, plan_path: Path
) -> dict[str, Any]:
    approval_path = session_dir / "deck_revision_approval.json"
    if not approval_path.is_file():
        return {
            "status": "missing",
            "path": None,
            "approved": False,
            "reason": "run approve_deck_revision_plan.py after consultant review",
        }
    approval = _read_json(approval_path)
    expected_hash = _sha256(plan_path)
    approved = bool(approval.get("approved"))
    hash_matches = approval.get("plan_sha256") == expected_hash
    understanding_reviewed = bool(approval.get("understanding_reviewed"))
    raw_understanding_path = approval.get("understanding_path")
    understanding_path: Path | None = None
    understanding_exists = False
    understanding_hash_matches = False
    if isinstance(raw_understanding_path, str) and raw_understanding_path.strip():
        understanding_path = Path(raw_understanding_path).expanduser()
        if not understanding_path.is_absolute():
            understanding_path = case_dir / understanding_path
        understanding_exists = understanding_path.is_file()
        if understanding_exists:
            understanding_hash_matches = approval.get(
                "understanding_sha256"
            ) == _sha256(understanding_path)
    approval_valid = (
        approved
        and hash_matches
        and understanding_reviewed
        and understanding_hash_matches
    )
    status = "approved" if approval_valid else "stale_or_invalid"
    reason = None
    if not approved:
        reason = "approval is not marked approved"
    elif not hash_matches:
        reason = "plan hash changed after approval"
    elif not understanding_reviewed:
        reason = "understanding checkpoint was not marked reviewed"
    elif not understanding_exists:
        reason = "understanding checkpoint is missing"
    elif not understanding_hash_matches:
        reason = "understanding checkpoint changed after approval"
    return {
        "status": status,
        "path": _relative_path(case_dir, approval_path),
        "approved": approval_valid,
        "approved_by": approval.get("approved_by"),
        "plan_sha256": approval.get("plan_sha256"),
        "current_plan_sha256": expected_hash,
        "hash_matches": hash_matches,
        "understanding_reviewed": understanding_reviewed,
        "understanding_path": (
            _relative_path(case_dir, understanding_path)
            if understanding_path is not None
            else None
        ),
        "understanding_exists": understanding_exists,
        "understanding_hash_matches": understanding_hash_matches,
        "reason": reason,
    }


def _patch_ready(patch: Mapping[str, Any]) -> tuple[bool, list[str]]:
    missing: list[str] = []
    operation = str(patch.get("operation", "")).strip()
    if operation not in SUPPORTED_PATCH_OPERATIONS:
        missing.append(f"unsupported patch operation `{operation or 'missing'}`")
    target = patch.get("target") if isinstance(patch.get("target"), dict) else {}
    value = patch.get("value") if isinstance(patch.get("value"), dict) else {}
    if operation in {"set_shape_text", "delete_shape", "move_shape"} and not target.get(
        "shape_index"
    ):
        missing.append("target.shape_index")
    if (
        operation in {"set_title_text", "set_shape_text", "add_textbox"}
        and not str(value.get("text", "")).strip()
    ):
        missing.append("value.text")
    if operation == "replace_text":
        if not str(value.get("old_text", "")).strip():
            missing.append("value.old_text")
        if not str(value.get("new_text", "")).strip():
            missing.append("value.new_text")
    if operation == "move_shape" and "left" not in value and "top" not in value:
        missing.append("value.left or value.top")
    return not missing, missing


def _quote_matrix_status(
    case_dir: Path, session_dir: Path, changes: list[Mapping[str, Any]]
) -> dict[str, Any]:
    required_change_ids = [
        str(change.get("change_id") or "")
        for change in changes
        if change_needs_quote_candidate_matrix(change)
    ]
    required_change_ids = [change_id for change_id in required_change_ids if change_id]
    matrix_path = session_dir / "deck_revision_quote_candidate_matrix.json"
    review_path = session_dir / "deck_revision_quote_candidate_matrix.md"
    available = matrix_path.is_file() and review_path.is_file()
    status = (
        "not_required"
        if not required_change_ids
        else "available" if available else "required_missing"
    )
    return {
        "status": status,
        "required": bool(required_change_ids),
        "required_change_ids": required_change_ids,
        "matrix_path": _relative_path(case_dir, matrix_path) if available else None,
        "review_path": _relative_path(case_dir, review_path) if available else None,
        "reason": (
            None
            if status != "required_missing"
            else "run build_deck_revision_quote_candidate_matrix.py before selecting or applying transcript-backed quotes"
        ),
    }


def _analyze_change(
    change: Mapping[str, Any],
    *,
    quote_matrix_available: bool,
) -> dict[str, Any]:
    strategy = str(change.get("execution_strategy", "")).strip()
    patches = change.get("application_patches")
    if not isinstance(patches, list):
        patches = []
    patch_records: list[dict[str, Any]] = []
    missing: list[str] = []
    for patch in patches:
        if not isinstance(patch, dict):
            missing.append("patch object")
            continue
        ready, patch_missing = _patch_ready(patch)
        patch_records.append(
            {
                "patch_id": patch.get("patch_id"),
                "operation": patch.get("operation"),
                "ready": ready,
                "missing": patch_missing,
            }
        )
        missing.extend(f"{patch.get('patch_id')}: {item}" for item in patch_missing)
    material_requirements = change.get("material_requirements")
    if not isinstance(material_requirements, list):
        material_requirements = []
    quote_matrix_required = change_needs_quote_candidate_matrix(change)
    missing_material_requirements = [
        str(item)
        for item in material_requirements
        if str(item).strip()
        and not (
            quote_matrix_required
            and quote_matrix_available
            and "deck_revision_quote_candidate_matrix" in str(item)
        )
    ]
    if strategy == PATCH_EXECUTION_STRATEGY and not patches:
        missing.append(
            "application_patches: provide supported concrete PPTX patch operations"
        )
    if strategy != PATCH_EXECUTION_STRATEGY:
        missing.append(execution_strategy_requirement(strategy))
        missing.extend(missing_material_requirements)
    if quote_matrix_required and not quote_matrix_available:
        missing.append(
            "deck_revision_quote_candidate_matrix: run quote candidate mining before selecting transcript-backed evidence"
        )
    ready = strategy == PATCH_EXECUTION_STRATEGY and bool(patches) and not missing
    return {
        "change_id": change.get("change_id"),
        "slide_number": change.get("slide_number"),
        "change_scope": change.get("change_scope"),
        "requested_change": change.get("requested_change"),
        "interpretation": change.get("interpretation"),
        "execution_strategy": strategy,
        "execution_requirement": execution_strategy_requirement(strategy),
        "ready_for_auto_apply": ready,
        "patches": patch_records,
        "missing": missing,
        "material_requirements": material_requirements,
        "quote_candidate_matrix_required": quote_matrix_required,
        "quote_candidate_matrix_available": quote_matrix_available,
        "manual_or_codex_work_required": not ready,
    }


def _render_markdown(payload: Mapping[str, Any]) -> str:
    lines = [
        "# Deck Revision Material Needs",
        "",
        f"Status: `{payload['summary']['status']}`",
        "",
        f"- Changes ready for auto-apply: {payload['summary']['ready_changes']}",
        f"- Changes needing manual/Codex detail: {payload['summary']['blocked_changes']}",
        f"- Approval status: `{payload['approval']['status']}`",
        f"- Quote candidate matrix: `{payload['quote_candidate_matrix']['status']}`",
        f"- Supported patch operations: {', '.join(payload['supported_patch_operations'])}",
        "",
    ]
    for change in payload["changes"]:
        status = "ready" if change["ready_for_auto_apply"] else "blocked"
        lines.extend(
            [
                f"## Slide {change['slide_number']} - {change['change_id']}",
                "",
                f"Status: `{status}`",
                "",
                f"Change: {change['requested_change']}",
                f"Strategy: `{change['execution_strategy']}`",
                f"Interpretation: {change.get('interpretation') or 'not recorded'}",
                "",
            ]
        )
        if change["patches"]:
            lines.append("Patches:")
            for patch in change["patches"]:
                patch_status = "ready" if patch["ready"] else "blocked"
                lines.append(
                    f"- `{patch['patch_id']}` `{patch['operation']}`: {patch_status}"
                )
        if change["missing"]:
            lines.append("Missing before automatic application:")
            lines.extend(f"- {item}" for item in change["missing"])
        if change["quote_candidate_matrix_required"]:
            matrix_status = (
                "available" if change["quote_candidate_matrix_available"] else "missing"
            )
            lines.append(f"Quote candidate matrix: `{matrix_status}`")
        lines.append("")
    return "\n".join(lines)


def analyze_deck_revision_materials(
    case_dir: Path,
    *,
    voice_session: Path | None = None,
    plan_path: Path | None = None,
    now: datetime | None = None,
) -> DeckRevisionMaterialNeedsResult:
    """Write material-needs artifacts for applying a deck-revision plan."""

    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)
    workbench = _required_workbench(session_dir)
    resolved_plan_path, plan = _load_plan(session_dir, plan_path, case_dir)
    approval = _approval_status(case_dir, session_dir, resolved_plan_path)
    changes = plan.get("changes")
    if not isinstance(changes, list):
        raise CaseWorkspaceError("normalized deck revision plan has no changes list")

    semantic_changes = [change for change in changes if isinstance(change, dict)]
    quote_matrix = _quote_matrix_status(case_dir, session_dir, semantic_changes)
    change_records = [
        _analyze_change(
            change,
            quote_matrix_available=quote_matrix["status"] == "available",
        )
        for change in semantic_changes
    ]
    ready_count = sum(1 for change in change_records if change["ready_for_auto_apply"])
    blocked_count = len(change_records) - ready_count
    if not change_records:
        status = "no_changes"
    elif blocked_count > 0:
        status = "partial_or_manual_work_required"
    elif approval["approved"]:
        status = "ready_for_auto_apply"
    else:
        status = "ready_for_approval"
    payload: dict[str, Any] = {
        "schema_version": 1,
        "source": "clara_deck_revision_material_needs",
        "created_at": _now_iso(now),
        "voice_session": _relative_path(case_dir, session_dir),
        "plan_path": _relative_path(case_dir, resolved_plan_path),
        "workbench_path": _relative_path(
            case_dir, session_dir / "deck_revision_workbench.json"
        ),
        "deck_path": workbench.get("source_paths", {}).get("deck_path"),
        "approval": approval,
        "quote_candidate_matrix": quote_matrix,
        "supported_patch_operations": sorted(SUPPORTED_PATCH_OPERATIONS),
        "summary": {
            "status": status,
            "approved_for_pptx_revision": bool(approval["approved"]),
            "total_changes": len(change_records),
            "ready_changes": ready_count,
            "blocked_changes": blocked_count,
        },
        "changes": change_records,
    }

    needs_path = session_dir / "deck_revision_material_needs.json"
    review_path = session_dir / "deck_revision_material_needs.md"
    _write_json(needs_path, payload)
    review_path.write_text(_render_markdown(payload), encoding="utf-8")
    return DeckRevisionMaterialNeedsResult(
        session_dir=session_dir,
        needs_path=needs_path,
        review_path=review_path,
    )


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(
        description="Analyze what is needed to apply a Clara deck-revision plan.",
    )
    parser.add_argument("case_dir", type=Path)
    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.",
    )
    args = parser.parse_args(argv)

    logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
    result = analyze_deck_revision_materials(
        args.case_dir,
        voice_session=args.voice_session,
        plan_path=args.plan,
    )
    LOGGER.info("wrote deck revision material needs to %s", result.needs_path)
    LOGGER.info("wrote deck revision material-needs review to %s", result.review_path)
    return 0


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

SHA-256: d619d0235be9ffe41e0aa70b583fd7fc071b281b0ccac28c63ccff7a4db9ad75