← Files ClaraARCHIVED FILE

scripts/build_deck_revision_interpretation_packets.py

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

↓ Download file

"""Build focused Clara deck-revision interpretation 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__ = [
    "DeckRevisionInterpretationPacketsResult",
    "build_deck_revision_interpretation_packets",
    "main",
]

LOGGER = logging.getLogger(__name__)


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

    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 _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 _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 _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 _feedback_entries(workbench: Mapping[str, Any]) -> list[dict[str, Any]]:
    visual_context = workbench.get("visual_context")
    if not isinstance(visual_context, Mapping):
        return []
    timeline = visual_context.get("feedback_timeline")
    if not isinstance(timeline, Mapping):
        return []
    entries = timeline.get("entries")
    if not isinstance(entries, list):
        return []
    return [dict(entry) for entry in entries if isinstance(entry, dict)]


def _entry_text(entry: Mapping[str, Any]) -> str:
    for key in ("clean_text", "matched_realtime_text", "text"):
        value = entry.get(key)
        if isinstance(value, str) and value.strip():
            return value.strip()
    return ""


def _matched_slide_number(match: Any) -> int | None:
    if not isinstance(match, Mapping):
        return None
    confidence = str(match.get("confidence") or "")
    if confidence not in {"high", "medium"}:
        return None
    slide_number = match.get("best_slide_number")
    if isinstance(slide_number, int) and not isinstance(slide_number, bool):
        return slide_number
    return None


def _entry_slide_number(entry: Mapping[str, Any]) -> int | None:
    """Narrow context only when every supplied frame supports the same candidate."""

    slide_number = _matched_slide_number(entry.get("slide_match"))
    if slide_number is None:
        return None
    frames = entry.get("frames", [])
    if not isinstance(frames, list):
        return None
    for frame in frames:
        if (
            not isinstance(frame, Mapping)
            or frame.get("status") != "extracted"
            or _matched_slide_number(frame.get("slide_match")) != slide_number
        ):
            # The entry summary keeps only its strongest frame. Retain the full
            # source context when other frames are uncertain or name another slide.
            return None
    return slide_number


def _frame_summary(frame: Mapping[str, Any]) -> dict[str, Any]:
    return {
        "status": frame.get("status"),
        "path": frame.get("path"),
        "frame_time_ms": frame.get("frame_time_ms"),
        "slide_match": (
            frame.get("slide_match")
            if isinstance(frame.get("slide_match"), dict)
            else None
        ),
    }


def _entry_summary(entry: Mapping[str, Any]) -> dict[str, Any]:
    frames = entry.get("frames")
    frame_records = (
        [_frame_summary(frame) for frame in frames if isinstance(frame, Mapping)]
        if isinstance(frames, list)
        else []
    )
    return {
        "feedback_unit_id": entry.get("feedback_unit_id"),
        "text": _entry_text(entry),
        "start_ms": entry.get("start_ms"),
        "end_ms": entry.get("end_ms"),
        "alignment_confidence": entry.get("alignment_confidence"),
        "alignment_confidence_label": entry.get("alignment_confidence_label"),
        "slide_match": (
            entry.get("slide_match")
            if isinstance(entry.get("slide_match"), dict)
            else None
        ),
        "frames": frame_records,
    }


def _packet_markdown(packet: Mapping[str, Any]) -> str:
    lines = [
        f"# Deck Revision Interpretation Packet {packet['packet_id']}",
        "",
        f"Scope: `{packet['packet_scope']}`",
        f"Slides: `{packet['slide_numbers']}`",
        "",
        "This packet is a focused semantic input. Clara/Codex should interpret only",
        "the feedback in this packet, draft candidate change records if needed, and",
        "avoid using the full workbench as one giant prompt.",
        "",
        "## Slides",
        "",
    ]
    for slide in packet["slides"]:
        lines.append(f"- Slide {slide['slide_number']}: {slide.get('title') or ''}")
    lines.extend(["", "## Feedback Units", ""])
    if packet["feedback_units"]:
        for entry in packet["feedback_units"]:
            unit_id = entry.get("feedback_unit_id") or "feedback"
            text = entry.get("text") or ""
            lines.append(f"- `{unit_id}`: {text}")
    else:
        lines.append("- No routed feedback units; use this only for deck-level review.")
    lines.extend(
        [
            "",
            "## Expected Output",
            "",
            "- Candidate changes for this packet only, or an explicit no-change note.",
            "- Each candidate change must keep transcript evidence separate from visual/deck evidence.",
            "- Do not edit the PPTX in this step.",
            "",
        ]
    )
    return "\n".join(lines)


def _index_markdown(payload: Mapping[str, Any]) -> str:
    lines = [
        "# Deck Revision Interpretation Packets",
        "",
        f"Status: `{payload['summary']['status']}`",
        "",
        "Use these packets before writing `deck_revision_changes.json`. The global",
        "workbench remains an evidence index; semantic interpretation should happen",
        "inside the focused slide/deck packets, not as one huge semantic prompt.",
        "",
        f"- Packet count: {payload['summary']['packet_count']}",
        f"- Slide packets: {payload['summary']['slide_packets']}",
        f"- Deck/general packets: {payload['summary']['deck_packets']}",
        "",
        "## Packets",
        "",
    ]
    for packet in payload["packets"]:
        lines.append(
            f"- `{packet['packet_id']}` `{packet['packet_scope']}` slides "
            f"`{packet['slide_numbers']}`: `{packet['packet_path']}`"
        )
    lines.append("")
    return "\n".join(lines)


def build_deck_revision_interpretation_packets(
    case_dir: Path,
    *,
    voice_session: Path | None = None,
    now: datetime | None = None,
) -> DeckRevisionInterpretationPacketsResult:
    """Build focused evidence packets before Clara writes the change list.

    This deterministic step is justified because it only packages slide records
    and already-computed timeline/slide-match evidence into smaller inputs. It
    does not decide what a speaker meant or what deck edits are 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)
    workbench = _required_workbench(session_dir)
    slides = _slide_records(workbench)
    slide_lookup = {slide["slide_number"]: slide for slide in slides}
    entries = _feedback_entries(workbench)

    routed_entries: dict[int, list[dict[str, Any]]] = {}
    general_entries: list[dict[str, Any]] = []
    for entry in entries:
        slide_number = _entry_slide_number(entry)
        if slide_number in slide_lookup:
            routed_entries.setdefault(slide_number, []).append(_entry_summary(entry))
        else:
            general_entries.append(_entry_summary(entry))

    packet_dir = session_dir / "deck_revision_interpretation_packets"
    packet_dir.mkdir(parents=True, exist_ok=True)
    created_at = _now_iso(now)
    packets: list[dict[str, Any]] = []
    packet_payloads: list[dict[str, Any]] = []
    deck_outline = _deck_outline(slides)

    for slide_number in sorted(routed_entries):
        packet_id = f"packet-{len(packet_payloads) + 1:03d}"
        packet_path = packet_dir / f"{packet_id}.json"
        review_path = packet_dir / f"{packet_id}.md"
        packet = {
            "schema_version": 1,
            "source": "clara_deck_revision_interpretation_packet",
            "created_at": created_at,
            "packet_id": packet_id,
            "packet_scope": "slide",
            "slide_numbers": [slide_number],
            "workbench_path": _relative_path(
                case_dir, session_dir / "deck_revision_workbench.json"
            ),
            "changes_output_path": workbench["source_paths"]["changes_output_path"],
            "deck_outline": deck_outline,
            "slides": [slide_lookup[slide_number]],
            "feedback_units": routed_entries[slide_number],
            "semantic_boundary": (
                "Packet creation is deterministic evidence routing only; Clara/Codex "
                "must decide whether the packet contains a requested deck change."
            ),
        }
        _write_json(packet_path, packet)
        review_path.write_text(_packet_markdown(packet), encoding="utf-8")
        packet_payloads.append(packet)
        packets.append(
            {
                "packet_id": packet_id,
                "packet_scope": "slide",
                "slide_numbers": [slide_number],
                "feedback_unit_count": len(routed_entries[slide_number]),
                "packet_path": _relative_path(case_dir, packet_path),
                "review_path": _relative_path(case_dir, review_path),
            }
        )

    if general_entries or not packets:
        packet_id = f"packet-{len(packet_payloads) + 1:03d}"
        packet_path = packet_dir / f"{packet_id}.json"
        review_path = packet_dir / f"{packet_id}.md"
        packet = {
            "schema_version": 1,
            "source": "clara_deck_revision_interpretation_packet",
            "created_at": created_at,
            "packet_id": packet_id,
            "packet_scope": "deck_or_unmatched_context",
            "slide_numbers": [slide["slide_number"] for slide in slides],
            "workbench_path": _relative_path(
                case_dir, session_dir / "deck_revision_workbench.json"
            ),
            "changes_output_path": workbench["source_paths"]["changes_output_path"],
            "deck_outline": deck_outline,
            "slides": slides,
            "feedback_units": general_entries,
            "semantic_boundary": (
                "This packet exists for deck-level, global, unmatched, low-confidence, "
                "or mixed-frame feedback. Clara/Codex decides whether it contains "
                "actionable changes."
            ),
        }
        _write_json(packet_path, packet)
        review_path.write_text(_packet_markdown(packet), encoding="utf-8")
        packet_payloads.append(packet)
        packets.append(
            {
                "packet_id": packet_id,
                "packet_scope": "deck_or_unmatched_context",
                "slide_numbers": [slide["slide_number"] for slide in slides],
                "feedback_unit_count": len(general_entries),
                "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_interpretation_packets",
        "created_at": created_at,
        "voice_session": _relative_path(case_dir, session_dir),
        "workbench_path": _relative_path(
            case_dir, session_dir / "deck_revision_workbench.json"
        ),
        "changes_output_path": workbench["source_paths"]["changes_output_path"],
        "summary": {
            "status": "ready_for_packet_interpretation",
            "packet_count": len(packets),
            "slide_packets": sum(
                1 for packet in packets if packet["packet_scope"] == "slide"
            ),
            "deck_packets": sum(
                1
                for packet in packets
                if packet["packet_scope"] == "deck_or_unmatched_context"
            ),
            "routed_feedback_units": sum(
                packet["feedback_unit_count"]
                for packet in packets
                if packet["packet_scope"] == "slide"
            ),
            "unmatched_feedback_units": len(general_entries),
        },
        "deterministic_boundary": (
            "Packets route available evidence into smaller prompts; they do not "
            "classify feedback as a deck change."
        ),
        "packets": packets,
    }
    index_path = session_dir / "deck_revision_interpretation_packets.json"
    review_path = session_dir / "deck_revision_interpretation_packets.md"
    _write_json(index_path, payload)
    review_path.write_text(_index_markdown(payload), encoding="utf-8")
    return DeckRevisionInterpretationPacketsResult(
        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 interpretation 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.",
    )
    args = parser.parse_args(argv)

    logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
    result = build_deck_revision_interpretation_packets(
        args.case_dir,
        voice_session=args.voice_session,
    )
    LOGGER.info("wrote interpretation packets to %s", result.packet_index_path)
    return 0


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

SHA-256: 61fed12e4f43e821eb69cc3b2a1354d5752ac5ed133ded11a9d55209124bd3ed