← Files ClaraARCHIVED FILE
scripts/build_deck_revision_execution_plan.py
13.7 KB · Oct 2, 2026 · 00:29 UTC
"""Build the execution plan for a finalized 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 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 deck_revision_execution_contract import (
MODEL_OR_HUMAN_EXECUTION_STRATEGIES,
PATCH_EXECUTION_STRATEGY,
SUPPORTED_PATCH_OPERATIONS,
execution_strategy_requirement,
)
__all__ = [
"DeckRevisionExecutionPlanResult",
"build_deck_revision_execution_plan",
"main",
]
LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True)
class DeckRevisionExecutionPlanResult:
"""Artifacts created for the deck-revision execution plan."""
session_dir: Path
execution_plan_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 _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 _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 _strategy_status(strategy: str, patch_ready: bool, patches: list[Any]) -> str:
if strategy == PATCH_EXECUTION_STRATEGY:
if not patches:
return "blocked_missing_patch_details"
return "ready_for_patcher" if patch_ready else "blocked_patch_details"
if strategy == "needs_human_decision":
return "needs_human_decision"
if strategy in MODEL_OR_HUMAN_EXECUTION_STRATEGIES:
return f"requires_{strategy}"
return "unsupported_strategy"
def _next_action(strategy: str, status: str) -> str:
if status == "ready_for_patcher":
return "After approval, run apply_deck_revision_plan.py and verify output."
if strategy == "model_assisted_edit":
return (
"Use Codex/presentation editing to modify the slide from the approved "
"understanding, then run verification."
)
if strategy == "slide_rebuild":
return (
"Rebuild the affected slide from the style spec, deck context, and "
"approved change before verification."
)
if strategy == "deck_restructure":
return (
"Apply the approved deck sequence or section-structure change before "
"slide-level verification."
)
if strategy == "needs_human_decision":
return "Resolve the listed human decision or missing material before editing."
return "Provide a supported execution strategy and required execution details."
def _execution_record(change: Mapping[str, Any]) -> 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]] = []
patch_missing: list[str] = []
for patch in patches:
if not isinstance(patch, dict):
patch_missing.append("patch object")
continue
ready, missing = _patch_ready(patch)
patch_records.append(
{
"patch_id": patch.get("patch_id"),
"operation": patch.get("operation"),
"ready": ready,
"missing": missing,
}
)
patch_missing.extend(f"{patch.get('patch_id')}: {item}" for item in missing)
patch_ready = bool(patches) and not patch_missing
status = _strategy_status(strategy, patch_ready, patches)
material_requirements = change.get("material_requirements")
if not isinstance(material_requirements, list):
material_requirements = []
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,
"status": status,
"requirement": execution_strategy_requirement(strategy),
"next_action": _next_action(strategy, status),
"ready_for_deterministic_apply": status == "ready_for_patcher",
"requires_model_or_human_work": strategy in MODEL_OR_HUMAN_EXECUTION_STRATEGIES,
"material_requirements": material_requirements,
"patches": patch_records,
"missing": patch_missing,
"success_criteria": (
change.get("success_criteria")
if isinstance(change.get("success_criteria"), list)
else []
),
}
def _render_markdown(payload: Mapping[str, Any]) -> str:
lines = [
"# Deck Revision Execution Plan",
"",
f"Status: `{payload['summary']['status']}`",
"",
f"- Total changes: {payload['summary']['total_changes']}",
f"- Ready for deterministic patcher: {payload['summary']['ready_for_patcher']}",
f"- Require model or human work: {payload['summary']['requires_model_or_human_work']}",
"",
]
for change in payload["changes"]:
lines.extend(
[
f"## Slide {change['slide_number']} - {change['change_id']}",
"",
f"Status: `{change['status']}`",
f"Strategy: `{change['execution_strategy']}`",
f"Change: {change['requested_change']}",
f"Interpretation: {change['interpretation']}",
f"Next action: {change['next_action']}",
"",
]
)
if change["material_requirements"]:
lines.append("Material requirements:")
lines.extend(f"- {item}" for item in change["material_requirements"])
lines.append("")
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}"
)
for item in patch["missing"]:
lines.append(f" - Missing: {item}")
lines.append("")
return "\n".join(lines)
def build_deck_revision_execution_plan(
case_dir: Path,
*,
voice_session: Path | None = None,
plan_path: Path | None = None,
now: datetime | None = None,
) -> DeckRevisionExecutionPlanResult:
"""Write the execution route for every interpreted deck-revision change."""
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)
changes = plan.get("changes")
if not isinstance(changes, list):
raise CaseWorkspaceError("normalized deck revision plan has no changes list")
change_records = [
_execution_record(change) for change in changes if isinstance(change, dict)
]
ready_count = sum(
1 for change in change_records if change["ready_for_deterministic_apply"]
)
model_or_human_count = sum(
1 for change in change_records if change["requires_model_or_human_work"]
)
if not change_records:
status = "no_changes"
elif ready_count == len(change_records):
status = "ready_for_deterministic_apply"
elif ready_count > 0:
status = "mixed_execution_required"
else:
status = "model_or_human_execution_required"
payload: dict[str, Any] = {
"schema_version": 1,
"source": "clara_deck_revision_execution_plan",
"created_at": _now_iso(now),
"voice_session": _relative_path(case_dir, session_dir),
"plan_path": _relative_path(case_dir, resolved_plan_path),
"summary": {
"status": status,
"total_changes": len(change_records),
"ready_for_patcher": ready_count,
"requires_model_or_human_work": model_or_human_count,
},
"changes": change_records,
}
execution_plan_path = session_dir / "deck_revision_execution_plan.json"
review_path = session_dir / "deck_revision_execution_plan.md"
_write_json(execution_plan_path, payload)
review_path.write_text(_render_markdown(payload), encoding="utf-8")
return DeckRevisionExecutionPlanResult(
session_dir=session_dir,
execution_plan_path=execution_plan_path,
review_path=review_path,
)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Build the execution plan for 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 = build_deck_revision_execution_plan(
args.case_dir,
voice_session=args.voice_session,
plan_path=args.plan,
)
LOGGER.info("wrote deck revision execution plan to %s", result.execution_plan_path)
LOGGER.info("wrote deck revision execution review to %s", result.review_path)
return 0
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())
SHA-256: fcc3707a7d7eeab55ef4e754f657c2f2120c1497657907b4f5dc7bfc7a9f4033