← Files ClaraARCHIVED FILE

scripts/build_deck_revision_execution_packets.py

21.4 KB · Oct 3, 2026 · 06:30 UTC

↓ Download file

"""Build focused Clara deck-revision execution packets."""

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, validate_case_workspace

__all__ = [
    "DeckRevisionExecutionPacketsResult",
    "build_deck_revision_execution_packets",
    "main",
]

LOGGER = logging.getLogger(__name__)
PACKET_SCOPES = {"slide", "slide_cluster", "deck"}


@dataclass(frozen=True)
class DeckRevisionExecutionPacketsResult:
    """Artifacts created for focused deck-revision execution."""

    session_dir: Path
    packet_index_path: Path
    review_path: Path
    packet_dir: 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 _read_optional_json(path: Path) -> dict[str, Any] | None:
    if not path.is_file():
        return None
    return _read_json(path)


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 | None) -> str | None:
    if path is None:
        return None
    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 _slide_records(workbench: Mapping[str, Any]) -> list[dict[str, Any]]:
    deck = workbench.get("deck")
    if not isinstance(deck, Mapping):
        raise CaseWorkspaceError("deck revision workbench is missing deck metadata")
    slides: list[dict[str, Any]] = []
    for slide in deck.get("slides", []):
        if isinstance(slide, dict) and isinstance(slide.get("slide_number"), int):
            slides.append(dict(slide))
    if not slides:
        raise CaseWorkspaceError("deck revision workbench has no slide records")
    return slides


def _deck_outline(slides: list[Mapping[str, Any]]) -> list[dict[str, Any]]:
    return [
        {
            "slide_number": slide.get("slide_number"),
            "title": slide.get("title") or "",
        }
        for slide in slides
    ]


def _text_list(value: Any) -> list[str]:
    if not isinstance(value, list):
        return []
    return [str(item).strip() for item in value if str(item).strip()]


def _affected_slide_numbers(
    change: Mapping[str, Any], slide_lookup: Mapping[int, Mapping[str, Any]]
) -> list[int]:
    raw_numbers = change.get("affected_slide_numbers")
    numbers: list[int] = []
    if isinstance(raw_numbers, list):
        for item in raw_numbers:
            if isinstance(item, int) and not isinstance(item, bool):
                numbers.append(item)
    if not numbers and isinstance(change.get("slide_number"), int):
        numbers.append(int(change["slide_number"]))
    seen: set[int] = set()
    valid_numbers: list[int] = []
    for number in numbers:
        if number not in slide_lookup or number in seen:
            continue
        valid_numbers.append(number)
        seen.add(number)
    if not valid_numbers:
        raise CaseWorkspaceError(
            f"{change.get('change_id') or 'change'}: no valid affected slide numbers"
        )
    return valid_numbers


def _has_slide_count_criterion(change: Mapping[str, Any]) -> bool:
    criteria = change.get("success_criteria")
    if not isinstance(criteria, list):
        return False
    return any(
        isinstance(item, Mapping) and item.get("check_type") == "slide_count_equals"
        for item in criteria
    )


def _packet_scope(change: Mapping[str, Any], affected_slide_numbers: list[int]) -> str:
    explicit_scope = str(change.get("packet_scope") or "").strip()
    if explicit_scope in PACKET_SCOPES:
        return explicit_scope
    if (
        change.get("execution_strategy") == "deck_restructure"
        or change.get("change_scope") == "structure"
        or _has_slide_count_criterion(change)
    ):
        return "deck"
    if len(affected_slide_numbers) > 1 or str(change.get("execution_group_id") or ""):
        return "slide_cluster"
    return "slide"


def _group_key(
    change: Mapping[str, Any], packet_scope: str, affected_slide_numbers: list[int]
) -> str:
    group_id = str(change.get("execution_group_id") or "").strip()
    if packet_scope == "deck":
        return f"deck:{group_id or 'deck'}"
    if packet_scope == "slide_cluster":
        slide_key = "-".join(str(number) for number in affected_slide_numbers)
        return f"cluster:{group_id or slide_key}"
    return f"slide:{affected_slide_numbers[0]}"


def _change_summary(change: Mapping[str, Any]) -> dict[str, Any]:
    return {
        "change_id": change.get("change_id"),
        "slide_number": change.get("slide_number"),
        "affected_slide_numbers": change.get("affected_slide_numbers")
        or [change.get("slide_number")],
        "packet_scope": change.get("packet_scope"),
        "execution_group_id": change.get("execution_group_id"),
        "dependency_change_ids": change.get("dependency_change_ids") or [],
        "requested_change": change.get("requested_change"),
        "execution_strategy": change.get("execution_strategy"),
    }


def _records_by_change_id(
    payload: Mapping[str, Any] | None,
) -> dict[str, dict[str, Any]]:
    if payload is None:
        return {}
    records: dict[str, dict[str, Any]] = {}
    changes = payload.get("changes")
    if not isinstance(changes, list):
        return records
    for change in changes:
        if not isinstance(change, dict):
            continue
        change_id = str(change.get("change_id") or "").strip()
        if change_id:
            records[change_id] = dict(change)
    return records


def _packet_markdown(packet: Mapping[str, Any]) -> str:
    lines = [
        f"# Deck Revision Execution Packet {packet['packet_id']}",
        "",
        f"Scope: `{packet['packet_scope']}`",
        f"Slides: `{packet['affected_slide_numbers']}`",
        f"Changes: `{packet['change_ids']}`",
        "",
        "Use this packet as the focused input for PPTX editing. Do not apply",
        "changes outside this packet unless the dependency notes explicitly require it.",
        "",
        "## Slides",
        "",
    ]
    for slide in packet["slides"]:
        lines.append(f"- Slide {slide['slide_number']}: {slide.get('title') or ''}")
    lines.extend(["", "## Changes", ""])
    for change in packet["changes"]:
        lines.extend(
            [
                f"### {change['change_id']}",
                "",
                f"- Requested change: {change['requested_change']}",
                f"- Interpretation: {change['interpretation']}",
                f"- Strategy: `{change['execution_strategy']}`",
                f"- Confidence: `{change['confidence']}` / `{change['explicitness']}`",
                "",
            ]
        )
    lines.extend(
        [
            "## Guardrails",
            "",
            "- Treat this packet as the execution unit.",
            "- Use related-change summaries only for coherence, not as permission to edit them.",
            "- Verify this packet's success criteria before moving on.",
            "",
        ]
    )
    return "\n".join(lines)


def _index_markdown(payload: Mapping[str, Any]) -> str:
    lines = [
        "# Deck Revision Execution Packets",
        "",
        f"Status: `{payload['summary']['status']}`",
        "",
        "These packets prevent a single huge deck-editing prompt. Clara/Codex should",
        "execute one packet at a time, not one deck-editing prompt for the whole plan,",
        "then run packet-level and full-deck review.",
        "",
        f"- Packet count: {payload['summary']['packet_count']}",
        f"- Deck packets: {payload['summary']['deck_packets']}",
        f"- Slide-cluster packets: {payload['summary']['slide_cluster_packets']}",
        f"- Slide packets: {payload['summary']['slide_packets']}",
        "",
        "## Packets",
        "",
    ]
    for packet in payload["packets"]:
        lines.append(
            f"- `{packet['packet_id']}` `{packet['packet_scope']}` slides "
            f"`{packet['affected_slide_numbers']}` changes `{packet['change_ids']}`: "
            f"`{packet['packet_path']}`"
        )
    lines.append("")
    return "\n".join(lines)


def build_deck_revision_execution_packets(
    case_dir: Path,
    *,
    voice_session: Path | None = None,
    plan_path: Path | None = None,
    now: datetime | None = None,
) -> DeckRevisionExecutionPacketsResult:
    """Build slide/cluster/deck execution packets from a normalized plan.

    This deterministic step is justified because it only groups already
    model-authored change records by explicit scope, strategy, group id, and
    affected slides. It does not infer the semantic meaning of a requested edit.
    """

    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, plan = _load_plan(session_dir, plan_path, case_dir)
    workbench = _required_workbench(session_dir)
    slides = _slide_records(workbench)
    slide_lookup = {slide["slide_number"]: slide for slide in slides}
    changes = plan.get("changes")
    if not isinstance(changes, list):
        raise CaseWorkspaceError("normalized deck revision plan has no changes list")

    execution_payload = _read_optional_json(
        session_dir / "deck_revision_execution_plan.json"
    )
    material_payload = _read_optional_json(
        session_dir / "deck_revision_material_needs.json"
    )
    execution_by_change = _records_by_change_id(execution_payload)
    material_by_change = _records_by_change_id(material_payload)
    quote_matrix_path = session_dir / "deck_revision_quote_candidate_matrix.json"
    quote_matrix_review_path = session_dir / "deck_revision_quote_candidate_matrix.md"

    packet_groups: dict[str, dict[str, Any]] = {}
    ordered_group_keys: list[str] = []
    normalized_changes: list[dict[str, Any]] = [
        dict(change) for change in changes if isinstance(change, dict)
    ]
    for index, change in enumerate(normalized_changes, start=1):
        affected = _affected_slide_numbers(change, slide_lookup)
        scope = _packet_scope(change, affected)
        key = _group_key(change, scope, affected)
        if key not in packet_groups:
            ordered_group_keys.append(key)
            packet_groups[key] = {
                "packet_scope": scope,
                "first_change_index": index,
                "affected_slide_numbers": [],
                "changes": [],
            }
        group = packet_groups[key]
        group["changes"].append(change)
        for number in affected:
            if number not in group["affected_slide_numbers"]:
                group["affected_slide_numbers"].append(number)

    packet_dir = session_dir / "deck_revision_execution_packets"
    packet_dir.mkdir(parents=True, exist_ok=True)
    created_at = _now_iso(now)
    deck_outline = _deck_outline(slides)
    all_change_summaries = [_change_summary(change) for change in normalized_changes]
    packets: list[dict[str, Any]] = []

    for packet_number, key in enumerate(ordered_group_keys, start=1):
        group = packet_groups[key]
        packet_id = f"packet-{packet_number:03d}"
        affected = sorted(group["affected_slide_numbers"])
        packet_changes = group["changes"]
        packet_change_ids = [change["change_id"] for change in packet_changes]
        packet_path = packet_dir / f"{packet_id}.json"
        review_path = packet_dir / f"{packet_id}.md"
        packet = {
            "schema_version": 1,
            "source": "clara_deck_revision_execution_packet",
            "created_at": created_at,
            "packet_id": packet_id,
            "packet_scope": group["packet_scope"],
            "execution_order": packet_number,
            "group_key": key,
            "change_ids": packet_change_ids,
            "affected_slide_numbers": affected,
            "global_context": {
                "workbench_path": _relative_path(
                    case_dir, session_dir / "deck_revision_workbench.json"
                ),
                "normalized_plan_path": _relative_path(case_dir, resolved_plan_path),
                "execution_plan_path": (
                    _relative_path(
                        case_dir, session_dir / "deck_revision_execution_plan.json"
                    )
                    if execution_payload is not None
                    else None
                ),
                "material_needs_path": (
                    _relative_path(
                        case_dir, session_dir / "deck_revision_material_needs.json"
                    )
                    if material_payload is not None
                    else None
                ),
                "quote_candidate_matrix_path": (
                    _relative_path(case_dir, quote_matrix_path)
                    if quote_matrix_path.is_file()
                    else None
                ),
                "quote_candidate_matrix_review_path": (
                    _relative_path(case_dir, quote_matrix_review_path)
                    if quote_matrix_review_path.is_file()
                    else None
                ),
                "deck_path": workbench["source_paths"]["deck_path"],
                "deck_snapshot_path": workbench["source_paths"]["deck_snapshot_path"],
                "deck_style_spec_path": workbench["source_paths"][
                    "deck_style_spec_path"
                ],
                "attributed_transcript_path": workbench["source_paths"][
                    "attributed_transcript_path"
                ],
            },
            "deck_outline": deck_outline,
            "slides": [slide_lookup[number] for number in affected],
            "changes": packet_changes,
            "execution_status": [
                execution_by_change[change_id]
                for change_id in packet_change_ids
                if change_id in execution_by_change
            ],
            "material_needs": [
                material_by_change[change_id]
                for change_id in packet_change_ids
                if change_id in material_by_change
            ],
            "related_change_summaries": [
                summary
                for summary in all_change_summaries
                if summary.get("change_id") not in packet_change_ids
            ],
            "dependency_change_ids": sorted(
                {
                    dependency
                    for change in packet_changes
                    for dependency in _text_list(change.get("dependency_change_ids"))
                }
            ),
            "execution_guardrails": [
                "Execute only this packet before moving to the next packet.",
                "Use related-change summaries only to preserve coherence.",
                "If a deck-level packet changes slide order or count, refresh slide references before local slide packets.",
                "Verify the packet success criteria, then run the full-deck output review loop.",
            ],
            "deterministic_boundary": (
                "Packet creation groups approved change records for focused execution; "
                "it does not decide what the partner meant."
            ),
        }
        _write_json(packet_path, packet)
        review_path.write_text(_packet_markdown(packet), encoding="utf-8")
        packets.append(
            {
                "packet_id": packet_id,
                "packet_scope": group["packet_scope"],
                "execution_order": packet_number,
                "change_ids": packet_change_ids,
                "affected_slide_numbers": affected,
                "packet_path": _relative_path(case_dir, packet_path),
                "review_path": _relative_path(case_dir, review_path),
            }
        )

    payload: dict[str, Any] = {
        "schema_version": 1,
        "source": "clara_deck_revision_execution_packets",
        "created_at": created_at,
        "voice_session": _relative_path(case_dir, session_dir),
        "normalized_plan_path": _relative_path(case_dir, resolved_plan_path),
        "summary": {
            "status": "ready_for_packet_execution" if packets else "no_changes",
            "packet_count": len(packets),
            "change_count": len(normalized_changes),
            "deck_packets": sum(
                1 for packet in packets if packet["packet_scope"] == "deck"
            ),
            "slide_cluster_packets": sum(
                1 for packet in packets if packet["packet_scope"] == "slide_cluster"
            ),
            "slide_packets": sum(
                1 for packet in packets if packet["packet_scope"] == "slide"
            ),
        },
        "deterministic_boundary": (
            "Packets are deterministic packaging around model-authored changes; "
            "semantic understanding remains Clara/Codex work."
        ),
        "packets": packets,
    }
    index_path = session_dir / "deck_revision_execution_packets.json"
    review_path = session_dir / "deck_revision_execution_packets.md"
    _write_json(index_path, payload)
    review_path.write_text(_index_markdown(payload), encoding="utf-8")
    return DeckRevisionExecutionPacketsResult(
        session_dir=session_dir,
        packet_index_path=index_path,
        review_path=review_path,
        packet_dir=packet_dir,
    )


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(
        description="Build focused Clara deck-revision execution packets.",
    )
    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 = build_deck_revision_execution_packets(
        args.case_dir,
        voice_session=args.voice_session,
        plan_path=args.plan,
    )
    LOGGER.info("wrote execution packets to %s", result.packet_index_path)
    return 0


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

SHA-256: aa888eb6776800297fc90a9f617a306732fc281413823f0ca7a24f295dca3067