← Files VeraARCHIVED FILE

scripts/local_onboarding_case.py

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

↓ Download file

#!/usr/bin/env python3
"""Prepare an isolated tutorial using the current workflow intake.

The user's archive configuration is unchanged. The native model selects inputs and the
specialist workflow; this adapter stages archive inputs or creates actual
hash-bound ledger contexts without bypassing the production input/output contract.
"""

from __future__ import annotations

import argparse
import hashlib
import importlib.util
import json
import secrets
import shutil
import sys
from pathlib import Path
from types import ModuleType
from typing import Any

from local_onboarding import MARKER, OnboardingError, Store

__all__ = ["prepare_case", "main"]


def _ledger() -> ModuleType:
    vera = Path(__file__).resolve().parents[1]
    packaged = vera / "modules/studio-archive/scripts/client_ledger.py"
    source = vera.parent / "studio-archive/scripts/client_ledger.py"
    spec = importlib.util.spec_from_file_location(
        "vera_tutorial_ledger", packaged if packaged.is_file() else source
    )
    if spec is None or spec.loader is None:
        raise OnboardingError("The packaged Studio Archive ledger is unavailable")
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    return module


def _archive() -> ModuleType:
    vera = Path(__file__).resolve().parents[1]
    packaged = vera / "modules/studio-archive/scripts/archive_core.py"
    source = vera.parent / "studio-archive/scripts/archive_core.py"
    spec = importlib.util.spec_from_file_location(
        "vera_tutorial_archive", packaged if packaged.is_file() else source
    )
    if spec is None or spec.loader is None:
        raise OnboardingError("The packaged Studio Archive is unavailable")
    module = importlib.util.module_from_spec(spec)
    sys.modules[spec.name] = module
    spec.loader.exec_module(module)
    return module


def _stage_archive_sources(sources: list[Path], root: Path, destination: Path) -> None:
    """Copy selected ordinary kit files while retaining their folder structure."""
    for source in sources:
        target = destination / source.relative_to(root)
        target.parent.mkdir(parents=True, exist_ok=True)
        before = hashlib.sha256(source.read_bytes()).digest()
        shutil.copyfile(source, target)
        if (
            hashlib.sha256(source.read_bytes()).digest() != before
            or hashlib.sha256(target.read_bytes()).digest() != before
        ):
            raise OnboardingError("A teaching source changed during staging")


def prepare_case(
    store: Store,
    *,
    thread_id: str,
    workflow: str,
    token: str,
    sources: list[Path],
    phase: str,
    source_root: Path | None = None,
) -> dict[str, Any]:
    """Stage archive search or start a ledger run beneath the local marker."""
    handoff = store.worker(thread_id, workflow, token)
    if not handoff["local_only"]:
        raise OnboardingError(
            "Use the specialist normal real-work intake, not the tutorial adapter"
        )
    if phase not in {"demo", "practice"} or not sources:
        raise OnboardingError(
            "A tutorial case needs demo/practice and selected source files"
        )
    if phase == "practice" and not handoff["lesson"].get("demo"):
        raise OnboardingError(
            "Explain and record the demonstration before preparing practice"
        )
    source_paths = [path.expanduser().absolute() for path in sources]
    if any(
        not path.is_file() or path.is_symlink() or path.resolve() != path
        for path in source_paths
    ):
        raise OnboardingError(
            "Select existing ordinary local files without symbolic links"
        )
    archive_source = None
    if workflow in {"studio-archive", "archive-organization"}:
        if source_root is None:
            raise OnboardingError("Archive teaching needs the prepared archive root")
        archive_source = source_root.expanduser().absolute()
        if (
            not archive_source.is_dir()
            or archive_source.resolve() != archive_source
            or any(
                not path.is_relative_to(archive_source)
                or len(path.relative_to(archive_source).parts)
                < (2 if workflow == "studio-archive" else 1)
                or path.relative_to(archive_source).parts[0].casefold() == "vera"
                for path in source_paths
            )
        ):
            raise OnboardingError(
                "Select only prepared files beneath their client folders"
            )
    elif source_root is not None:
        raise OnboardingError("An archive source root applies only to archive lessons")
    if workflow == "vouching" and (
        sum(path.suffix.lower() in {".csv", ".xlsx", ".xls"} for path in source_paths)
        != 1
        or not any(
            path.suffix.lower() in {".xml", ".zip", ".pdf"} for path in source_paths
        )
    ):
        raise OnboardingError(
            "Vouching teaching needs one CSV/Excel journal and selected invoice documents"
        )
    root = Path(handoff["lesson"]["directory"])
    if not (store.root / MARKER).is_file():
        raise OnboardingError(
            "Restore the enrollment's local-only marker before running a tutorial"
        )
    attempt = root / f"{phase}-{secrets.token_hex(8)}"
    case = (
        attempt / "archive" / "Teaching client" if workflow == "vouching" else attempt
    )
    case.mkdir(mode=0o700, parents=True)
    if archive_source is not None and workflow == "studio-archive":
        # Search indexes client folders, not a workflow's ledger input view.
        # Stage only the selected kit files; never configure the real archive.
        archive = case / "archive"
        _stage_archive_sources(source_paths, archive_source, archive)
        result = {
            "tutorial": True,
            "local_only": True,
            "phase": phase,
            "workflow_id": workflow,
            "archive_root": str(archive),
            "archive_state_dir": str(case / "private-index"),
            "archive_session_id": "tutorial-" + secrets.token_hex(12),
            "setup_required": True,
            "execution_receipt": False,
        }
        (case / "tutorial_case.json").write_text(
            json.dumps(result, ensure_ascii=False, indent=2) + "\n",
            encoding="utf-8",
        )
        return result
    if workflow == "presenza-digitale-studio":
        # This specialist owns a website workspace, rather than a ledger run.
        inputs = case / "inputs"
        outputs = case / "outputs"
        inputs.mkdir()
        outputs.mkdir()
        records = []
        for index, source in enumerate(source_paths):
            target = inputs / f"{index + 1}-{source.name}"
            shutil.copyfile(source, target)
            records.append(
                {
                    "path": str(target),
                    "sha256": hashlib.sha256(target.read_bytes()).hexdigest(),
                }
            )
        result = {
            "tutorial": True,
            "local_only": True,
            "phase": phase,
            "workflow_id": workflow,
            "directory": str(case),
            "inputs": records,
            "output_dir": str(outputs),
            "status": "prepared",
        }
        (case / "tutorial_case.json").write_text(
            json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
        )
        return result
    if archive_source is not None:
        _stage_archive_sources(source_paths, archive_source, case)
    ledger = _ledger()
    client_id = "client_" + secrets.token_hex(12)
    ledger.create_client_manifest(case, client_id)
    engagement = ledger.create_engagement(
        case, client_id, f"Vera tutorial: {workflow} ({phase})"
    )
    engagement_id = engagement["engagement_id"]
    # These wrappers delegate to the same published intake component; exact IDs,
    # not a classifier of the user's professional request.
    ledger_workflow = (
        "client-file-preparation"
        if workflow
        in {
            "fatture-xml-check",
            "dati-fiscali-strutturati",
            "avviso-intake",
            "email-cliente",
        }
        else workflow
    )
    components = json.loads(
        (Path(__file__).parents[1] / "components.json").read_text(encoding="utf-8")
    )
    skill_components = {
        metadata["skill"]: component
        for component, metadata in components["workflow_roles"].items()
        if "skill" in metadata
    }
    ledger_workflow = skill_components.get(workflow, ledger_workflow)
    if workflow == "quesito-legale-fiscale":
        # The named answer journey starts with a real preparation run. Its
        # closed artifacts feed answer review in the same engagement.
        ledger_workflow = "prompt-optimizer"
    if workflow == "vouching":
        # Raw kit sources are not a prepared Vouching population. First run
        # actual Journal Sampling; its closed artifacts feed the later check.
        ledger_workflow = "journal-sampling"
    inputs = (
        [ledger.snapshot_client_folder(case, client_id, engagement_id)]
        if workflow == "archive-organization"
        else [
            ledger.import_document(
                case,
                client_id,
                engagement_id,
                source,
                (
                    "journal"
                    if ledger_workflow == "journal-sampling"
                    and source.suffix.lower() in {".csv", ".xlsx", ".xls"}
                    else (
                        "support"
                        if workflow == "vouching"
                        and source.suffix.lower() in {".xml", ".zip", ".pdf"}
                        else "source"
                    )
                ),
            )
            for source in source_paths
        ]
    )
    manifest = json.loads(
        (Path(__file__).parents[1] / ".codex-plugin/plugin.json").read_text(
            encoding="utf-8"
        )
    )
    run = ledger.prepare_run(
        case,
        client_id,
        engagement_id,
        ledger_workflow,
        manifest["version"],
        input_ids=[
            item["receipt"]["input_id"]
            for item in inputs
            if workflow != "vouching" or item["receipt"]["role"] != "support"
        ],
        purpose="Local Vera onboarding tutorial; no Mparanza transmission",
    )
    started = ledger.start_run(case, engagement_id, run["run"]["run_id"])
    result = {
        "tutorial": True,
        "local_only": True,
        "phase": phase,
        "workflow_id": workflow,
        "client_root": str(case),
        **started,
    }
    if workflow == "vouching":
        # The normal handoff resolves client IDs through an archive index.
        # This private tutorial index never changes the user's archive setup.
        state = attempt / "archive-state"
        _archive().configure_archive(case.parent, state_dir=state)
        result.update(
            prerequisite_workflow="journal-sampling",
            archive_state_dir=str(state),
            support_input_ids=[
                item["receipt"]["input_id"]
                for item in inputs
                if item["receipt"]["role"] == "support"
            ],
        )
    metadata = case / (
        "Vera/tutorial_case.json"
        if workflow == "archive-organization"
        else "tutorial_case.json"
    )
    result["tutorial_case_path"] = str(metadata)
    metadata.write_text(
        json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
    )
    return result


def main(argv: list[str] | None = None) -> int:
    """Prepare selected local files for the active paired lesson."""
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--state-root", type=Path)
    parser.add_argument(
        "--session", help="Repeated teaching session; omit for onboarding"
    )
    parser.add_argument("--thread-id", required=True)
    parser.add_argument("--workflow", required=True)
    parser.add_argument("--token", required=True)
    parser.add_argument("--phase", choices=["demo", "practice"], required=True)
    parser.add_argument("--source", type=Path, action="append", required=True)
    parser.add_argument("--source-root", type=Path)
    args = parser.parse_args(argv)
    try:
        store = Store(args.state_root)
        if args.session:
            from local_teaching import TeachingStore

            store = TeachingStore(args.state_root, args.session)
        result = prepare_case(
            store,
            thread_id=args.thread_id,
            workflow=args.workflow,
            token=args.token,
            sources=args.source,
            phase=args.phase,
            source_root=args.source_root,
        )
        sys.stdout.write(json.dumps(result, indent=2) + "\n")
        return 0
    except (ValueError, OSError) as exc:
        sys.stdout.write(json.dumps({"status": "blocked", "error": str(exc)}) + "\n")
        return 2


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

SHA-256: cc3d99fdbcd1ff02961caa05392a33e6d791c6d7d15634262f83273f67875880