← Files ClaraARCHIVED FILE

scripts/start_deck_feedback.py

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

↓ Download file

"""Launch Clara deck feedback capture and import the completed session."""

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
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable

from advisor_case_core import CaseWorkspaceError, validate_case_workspace
from import_hosted_voice_bundle import (
    HostedVoiceImportResult,
    import_hosted_voice_bundle,
)
from import_latest_hosted_voice_bundle import (
    BUNDLE_PATTERNS,
    LatestHostedVoiceBundle,
    find_latest_hosted_voice_bundle,
)
from launch_hosted_voice import (
    DEFAULT_CHROME_PROFILE_DIR,
    DEFAULT_VOICE_LAUNCH_URL,
    open_launch_url,
    prepare_launch_url,
    read_private_text_file,
)
from prepare_voice_deck_revision import prepare_voice_deck_revision_intake

__all__ = [
    "DeckFeedbackCaptureResult",
    "start_deck_feedback",
    "wait_for_new_hosted_voice_bundle",
]

LOGGER = logging.getLogger(__name__)
DEFAULT_TIMEOUT_SECONDS = 7_200.0
DEFAULT_POLL_SECONDS = 2.0
HANDOFF_FILENAME = "deck_feedback_capture.json"


@dataclass(frozen=True)
class DeckFeedbackCaptureResult:
    """Imported capture and durable target-deck handoff."""

    launch_url: str
    target_deck_path: Path
    target_kind: str
    selected_bundle_path: Path
    import_result: HostedVoiceImportResult
    handoff_path: Path
    deck_revision_intake_path: Path | None
    case_context_attached: bool


def _now_iso() -> str:
    return datetime.now(timezone.utc).replace(microsecond=0).isoformat()


def _bundle_snapshot(downloads_dir: Path) -> dict[Path, tuple[int, int]]:
    snapshot: dict[Path, tuple[int, int]] = {}
    for pattern in BUNDLE_PATTERNS:
        for path in downloads_dir.glob(pattern):
            if not path.is_file():
                continue
            stat = path.stat()
            snapshot[path.resolve()] = (stat.st_mtime_ns, stat.st_size)
    return snapshot


def _target_kind(target_deck_path: Path) -> str:
    if target_deck_path.is_file() and target_deck_path.suffix.lower() == ".pptx":
        return "pptx"
    if target_deck_path.is_file() and target_deck_path.suffix.lower() in {
        ".html",
        ".htm",
    }:
        return "html"
    if target_deck_path.is_dir() and any(
        (target_deck_path / filename).is_file()
        for filename in ("index.html", "deck-plan.json")
    ):
        return "html"
    raise CaseWorkspaceError(
        "deck feedback target must be a PPTX, an HTML file, or a Clara HTML "
        "deck/work directory"
    )


def _portable_path(case_dir: Path, path: Path) -> str:
    try:
        return path.relative_to(case_dir).as_posix()
    except ValueError:
        return str(path)


def wait_for_new_hosted_voice_bundle(
    case_dir: Path,
    *,
    downloads_dir: Path,
    baseline: dict[Path, tuple[int, int]],
    timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
    poll_seconds: float = DEFAULT_POLL_SECONDS,
    sleep: Callable[[float], None] = time.sleep,
) -> LatestHostedVoiceBundle:
    """Wait for a valid bundle created or changed after capture launch."""

    if timeout_seconds <= 0:
        raise CaseWorkspaceError("timeout_seconds must be positive")
    if poll_seconds <= 0:
        raise CaseWorkspaceError("poll_seconds must be positive")

    deadline = time.monotonic() + timeout_seconds
    while True:
        try:
            selected = find_latest_hosted_voice_bundle(
                case_dir,
                downloads_dir=downloads_dir,
            )
        except CaseWorkspaceError:
            selected = None

        if selected is not None:
            resolved = selected.path.resolve()
            stat = resolved.stat()
            fingerprint = (stat.st_mtime_ns, stat.st_size)
            if baseline.get(resolved) != fingerprint:
                return selected

        if time.monotonic() >= deadline:
            raise CaseWorkspaceError(
                "timed out waiting for a new Clara Voice Capture bundle in "
                f"{downloads_dir}"
            )
        sleep(poll_seconds)


def _write_handoff(
    *,
    case_dir: Path,
    target_deck_path: Path,
    target_kind: str,
    selected_bundle_path: Path,
    import_result: HostedVoiceImportResult,
    case_context_attached: bool,
) -> Path:
    handoff_path = import_result.session_dir / HANDOFF_FILENAME
    payload = {
        "schema_version": 1,
        "source": "clara_deck_feedback_capture",
        "status": "imported",
        "captured_for": "deck_revision",
        "target_deck": {
            "path": _portable_path(case_dir, target_deck_path),
            "kind": target_kind,
        },
        "source_bundle_path": str(selected_bundle_path),
        "voice_session_path": _portable_path(case_dir, import_result.session_dir),
        "transcript_material_id": import_result.material_id,
        "screen_video_path": (
            _portable_path(case_dir, import_result.video_path)
            if import_result.video_path is not None
            else None
        ),
        "feedback_timeline_path": (
            _portable_path(case_dir, import_result.feedback_timeline_path)
            if import_result.feedback_timeline_path is not None
            else None
        ),
        "recorded_at": _now_iso(),
        "data_posture": {
            "local_files_read": (
                [_portable_path(case_dir, case_dir / "case_brief.md")]
                if case_context_attached
                else []
            ),
            "local_files_referenced": [_portable_path(case_dir, target_deck_path)],
            "hosted_capture_used": True,
            "hosted_case_context_attached": case_context_attached,
            "external_connectors_used": [],
            "local_bundle_imported": True,
        },
    }
    handoff_path.write_text(
        json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
        encoding="utf-8",
    )
    return handoff_path


def start_deck_feedback(
    case_dir: Path,
    *,
    target_deck_path: Path,
    downloads_dir: Path | None = None,
    browser: str = "chrome",
    server_url: str = DEFAULT_VOICE_LAUNCH_URL,
    cookie_header: str = "",
    magic_link: str = "",
    chrome_profile_dir: Path = DEFAULT_CHROME_PROFILE_DIR,
    timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
    poll_seconds: float = DEFAULT_POLL_SECONDS,
    title: str = "Deck feedback capture",
    open_browser: bool = True,
    sleep: Callable[[float], None] = time.sleep,
) -> DeckFeedbackCaptureResult:
    """Open capture, import its new bundle, and bind it to the target deck."""

    case_dir = case_dir.expanduser().resolve()
    errors = validate_case_workspace(case_dir)
    if errors:
        raise CaseWorkspaceError("; ".join(errors))

    target_deck_path = target_deck_path.expanduser().resolve()
    target_kind = _target_kind(target_deck_path)
    downloads_dir = (downloads_dir or Path("~/Downloads")).expanduser().resolve()
    if not downloads_dir.is_dir():
        raise CaseWorkspaceError(f"downloads folder does not exist: {downloads_dir}")

    baseline = _bundle_snapshot(downloads_dir)
    launch_url, case_context_attached = prepare_launch_url(
        case_dir,
        server_url=server_url,
        cookie_header=cookie_header,
        magic_link=magic_link,
    )
    if case_context_attached:
        LOGGER.info("Authenticated case context attached to Hosted Voice capture.")
    else:
        LOGGER.warning(
            "Opening the browser-authenticated fallback without case context; "
            "case_brief.md was not read for this launch."
        )
    if open_browser:
        open_launch_url(
            launch_url,
            browser=browser,
            chrome_profile_dir=chrome_profile_dir,
        )

    LOGGER.info(
        "Clara Voice Capture opened for %s. End the capture normally; the new "
        "bundle will be imported automatically.",
        target_deck_path.name,
    )
    selected = wait_for_new_hosted_voice_bundle(
        case_dir,
        downloads_dir=downloads_dir,
        baseline=baseline,
        timeout_seconds=timeout_seconds,
        poll_seconds=poll_seconds,
        sleep=sleep,
    )
    import_result = import_hosted_voice_bundle(
        case_dir,
        selected.path,
        title=title,
    )
    handoff_path = _write_handoff(
        case_dir=case_dir,
        target_deck_path=target_deck_path,
        target_kind=target_kind,
        selected_bundle_path=selected.path,
        import_result=import_result,
        case_context_attached=case_context_attached,
    )

    deck_revision_intake_path: Path | None = None
    if target_kind == "pptx":
        intake = prepare_voice_deck_revision_intake(
            case_dir,
            voice_session=import_result.session_dir,
            transcript_material_id=import_result.material_id,
            deck_path=target_deck_path,
        )
        deck_revision_intake_path = intake.intake_path

    return DeckFeedbackCaptureResult(
        launch_url=launch_url,
        target_deck_path=target_deck_path,
        target_kind=target_kind,
        selected_bundle_path=selected.path,
        import_result=import_result,
        handoff_path=handoff_path,
        deck_revision_intake_path=deck_revision_intake_path,
        case_context_attached=case_context_attached,
    )


def main() -> int:
    """Run the one-command Clara deck-feedback workflow."""

    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("case_dir", type=Path)
    parser.add_argument("--deck", type=Path, required=True)
    parser.add_argument("--downloads-dir", type=Path, default=Path("~/Downloads"))
    parser.add_argument("--browser", choices=("default", "chrome"), default="chrome")
    parser.add_argument("--server-url", default=DEFAULT_VOICE_LAUNCH_URL)
    parser.add_argument("--magic-link-file", type=Path)
    parser.add_argument("--cookie-header-file", type=Path)
    parser.add_argument(
        "--chrome-profile-dir", type=Path, default=DEFAULT_CHROME_PROFILE_DIR
    )
    parser.add_argument(
        "--timeout-seconds", type=float, default=DEFAULT_TIMEOUT_SECONDS
    )
    parser.add_argument("--poll-seconds", type=float, default=DEFAULT_POLL_SECONDS)
    parser.add_argument("--title", default="Deck feedback capture")
    parser.add_argument("--no-open", action="store_true")
    args = parser.parse_args()
    logging.basicConfig(level=logging.INFO, format="%(message)s")

    cookie_header = ""
    if args.cookie_header_file:
        cookie_header = read_private_text_file(
            args.cookie_header_file,
            label="session cookie",
        )
    magic_link = ""
    if args.magic_link_file:
        magic_link = read_private_text_file(
            args.magic_link_file,
            label="magic link",
        )

    result = start_deck_feedback(
        args.case_dir,
        target_deck_path=args.deck,
        downloads_dir=args.downloads_dir,
        browser=args.browser,
        server_url=args.server_url,
        cookie_header=cookie_header,
        magic_link=magic_link,
        chrome_profile_dir=args.chrome_profile_dir,
        timeout_seconds=args.timeout_seconds,
        poll_seconds=args.poll_seconds,
        title=args.title,
        open_browser=not args.no_open,
    )
    LOGGER.info("Capture bundle: %s", result.selected_bundle_path)
    LOGGER.info("Voice session: %s", result.import_result.session_dir)
    LOGGER.info("Deck feedback handoff: %s", result.handoff_path)
    if result.deck_revision_intake_path is not None:
        LOGGER.info("Deck revision intake: %s", result.deck_revision_intake_path)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: 608f63e705eedcb0e3b43af6ff624ab8f05587fd3d4f41ff21168564f61618a8