diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 85a07de9..23019d5c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -816,37 +816,49 @@ jobs: fail-fast: false matrix: python-version: [3.12] + # `assistant`: the assistant repo whose colab/contexts.yaml stamps each + # generated notebook's Colab Gemini instructions (PyAutoHands#324). + # Empty = no instructions yet: autogalaxy/autofit mappings land in + # phase 2, and a root without the file would fail generation loudly. + # Merge order: the assistant's colab/contexts.yaml must be on its main + # before this is released (PyAutoBrain -> autolens_assistant -> PyAutoHands). workspace: - repository: PyAutoLabs/autofit_workspace name: autofit package: PyAutoFit generate_notebooks: true bump_colab_urls: true + assistant: "" - repository: PyAutoLabs/autogalaxy_workspace name: autogalaxy package: PyAutoGalaxy generate_notebooks: true bump_colab_urls: true + assistant: "" - repository: PyAutoLabs/autolens_workspace name: autolens package: PyAutoLens generate_notebooks: true bump_colab_urls: true + assistant: autolens_assistant - repository: PyAutoLabs/HowToGalaxy name: howtogalaxy package: PyAutoGalaxy generate_notebooks: true bump_colab_urls: true + assistant: "" - repository: PyAutoLabs/HowToLens name: howtolens package: PyAutoLens generate_notebooks: true bump_colab_urls: true + assistant: autolens_assistant - repository: PyAutoLabs/HowToFit name: howtofit package: PyAutoFit generate_notebooks: true bump_colab_urls: true + assistant: "" - repository: PyAutoLabs/euclid_strong_lens_modeling_pipeline name: euclid_pipeline package: PyAutoLens @@ -884,6 +896,16 @@ jobs: path: workspace fetch-depth: 0 persist-credentials: false + - name: Checkout assistant (Colab Gemini instructions) + # Public repo: anonymous read-only, like the workspace checkout above. Only + # its colab/contexts.yaml and skills/ are read, by generate.py. + if: "${{ matrix.workspace.generate_notebooks == true && matrix.workspace.assistant != '' }}" + uses: actions/checkout@v4 + with: + repository: PyAutoLabs/${{ matrix.workspace.assistant }} + ref: main + path: assistant + persist-credentials: false - name: Resolve bare repository name (App token scope) id: repo env: @@ -909,6 +931,9 @@ jobs: run: | export PYTHONPATH=$PYTHONPATH:$(pwd)/PyAutoHands AUTOHANDS_PATH="$(pwd)/PyAutoHands/autohands" + if [ -n "${{ matrix.workspace.assistant }}" ]; then + export PYAUTO_ASSISTANT_ROOT="$(pwd)/assistant" + fi pushd workspace python3 "$AUTOHANDS_PATH/generate.py" ${{ matrix.workspace.name }} # Stage exactly what generate.py produces: the notebooks tree, the diff --git a/autohands/build_util.py b/autohands/build_util.py index 6ec374e5..2baf44ef 100644 --- a/autohands/build_util.py +++ b/autohands/build_util.py @@ -10,6 +10,7 @@ import tempfile import time import traceback +import uuid from pathlib import Path from typing import List @@ -376,6 +377,318 @@ def inject_colab_setup(notebook_path, project: str): return True +# Colab Gemini instructions (PyAutoHands#324). Colab's Gemini sidebar cannot +# read a repository, but it honours per-notebook instructions stored in +# `metadata.colab.aiContexts` + `selectedAiContext` (verified in Colab +# 2026-10-26, contexts up to at least 52k characters). Each project's assistant +# owns `colab/contexts.yaml` — notebook-path globs mapped to skills, plus a +# preface and core rules — and generation writes the assembled text into every +# notebook as its active instruction: a release-time snapshot of the skills, +# which stay single-sourced in the assistant. +# +# Which assistant maps which project's notebooks, the repository those +# notebooks live in, and the id URL template are instance facts, so they live +# in config/workspaces.yaml (`colab_gemini:`), not here. The repo name is the +# "/" prefix the mapping globs match; it cannot be read from the cwd +# because the release runner checks every workspace out into `workspace/`. +# CTI is listed there although it is not in COLAB_PROJECTS: its workspace is +# outside the release generate and is stamped with `autohands gemini_context apply`. +def _colab_gemini_config() -> dict: + import yaml + + path = BUILD_PATH / "config" / "workspaces.yaml" + return (yaml.safe_load(path.read_text()) or {})["colab_gemini"] + + +_COLAB_GEMINI = _colab_gemini_config() + +# Project -> the assistant repository holding its mapping. +COLAB_ASSISTANTS = { + project: spec["assistant"] for project, spec in _COLAB_GEMINI["projects"].items() +} + +# Project -> the repository its notebooks live in. +COLAB_WORKSPACE_REPOS = { + project: spec["repo"] for project, spec in _COLAB_GEMINI["projects"].items() +} + +GEMINI_CONTEXT_FILE = Path("colab") / "contexts.yaml" + +# Human choice (#324): one instruction never exceeds this. Over-cap fails +# generation naming the mapping entry; the text is never truncated. +GEMINI_CONTEXT_CAP = 20_000 + +# Fixed URL template for the per-project instruction id (uuid5 over it), so +# regenerating a notebook rewrites the same `aiContexts` entry rather than +# adding another. The assistants' own notebook stamper +# (autoassistant/colab_contexts.py) derives ids from the same URL; change both +# or neither — ids already committed in notebooks would stop matching. +_GEMINI_ID_URL = _COLAB_GEMINI["id_url"] + + +def gemini_context_id(project: str) -> str: + """The deterministic `aiContexts` id for ``project``'s notebooks.""" + return str(uuid.uuid5(uuid.NAMESPACE_URL, _GEMINI_ID_URL.format(project=project))) + + +def load_gemini_contexts(assistant_root, enforce_never_map: bool = True) -> dict: + """ + Read and validate ``/colab/contexts.yaml``. + + A missing file raises: callers only pass a root when the project's + assistant is meant to supply instructions, so silence would ship + notebooks without them. An entry mapping a skill on the file's own + ``never_map`` list raises too, unless ``enforce_never_map`` is False (the + coverage report lists such entries instead of stopping at the first). + """ + import yaml + + path = Path(assistant_root) / GEMINI_CONTEXT_FILE + if not path.is_file(): + raise FileNotFoundError( + f"Gemini instructions: no mapping at {path}. An assistant root was given, " + f"so its colab/contexts.yaml must exist (add it to the assistant, or do not " + f"pass --assistant-root / PYAUTO_ASSISTANT_ROOT for this project)." + ) + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + for key in ("preface", "rules", "default", "contexts"): + if key not in data: + raise ValueError(f"Gemini instructions: {path} has no '{key}' entry.") + if not data["default"].get("skills"): + raise ValueError(f"Gemini instructions: {path} 'default' maps no skills.") + for entry in data["contexts"]: + if not entry.get("match") or not entry.get("skills"): + raise ValueError( + f"Gemini instructions: {path} has an entry without 'match' and 'skills': {entry}" + ) + never_map = set(data.get("never_map") or []) + if enforce_never_map and never_map: + entries = [("default", data["default"]["skills"])] + [ + (e["match"], e["skills"]) for e in data["contexts"] + ] + for label, skills in entries: + banned = sorted(never_map.intersection(skills)) + if banned: + raise ValueError( + f"Gemini instructions: {path} entry '{label}' maps never_map " + f"skill(s) {banned}." + ) + return data + + +def validate_gemini_contexts(assistant_root, notebook_names, contexts=None) -> None: + """ + Assemble every name in ``notebook_names`` ("/") and raise + one error listing each that would fail (missing skill, over the cap). + + ``generate.py`` calls this before it clears ``notebooks/``, so a bad + mapping stops the run with the committed notebooks intact rather than + halfway through regenerating them. + """ + if contexts is None: + contexts = load_gemini_contexts(assistant_root) + problems = [] + for name in notebook_names: + try: + gemini_context_from(assistant_root, name, contexts=contexts) + except (FileNotFoundError, ValueError) as exc: + problems.append(str(exc)) + if problems: + raise ValueError( + f"Gemini instructions: {len(problems)} notebook(s) would fail; nothing was " + f"modified.\n" + "\n".join(problems) + ) + + +def match_gemini_context(contexts: dict, notebook_name: str): + """ + Return ``(entry_label, skills)`` for ``notebook_name`` ("/"). + + The first `contexts` entry whose fnmatch glob matches wins (the file lists + them most specific first); otherwise the `default` entry applies. + """ + import fnmatch + + for entry in contexts["contexts"]: + if fnmatch.fnmatchcase(notebook_name, entry["match"]): + return entry["match"], list(entry["skills"]) + return "default", list(contexts["default"]["skills"]) + + +def assemble_gemini_context(assistant_root, notebook_name: str, contexts: dict): + """ + Assemble the Gemini instruction for ``notebook_name`` ("/") + without enforcing anything — the coverage report needs sizes and missing + skills reported, not raised. ``gemini_context_from`` is the enforcing door. + + Preface + rules (``{notebook}`` / ``{repo}`` filled in) + the text of each + mapped skill, in mapping order. Returns + ``(entry_label, skills, text, missing_skills)``. + """ + assistant_root = Path(assistant_root) + label, skills = match_gemini_context(contexts, notebook_name) + repo, _, relpath = notebook_name.partition("/") + + sections = [ + contexts["preface"].strip(), + contexts["rules"].replace("{notebook}", relpath).replace("{repo}", repo).strip(), + "# Reference material: assistant skills", + ] + missing = [] + for skill in skills: + skill_path = assistant_root / "skills" / f"{skill}.md" + if not skill_path.is_file(): + missing.append(skill) + continue + sections.append(f"## Skill: {skill}\n\n{skill_path.read_text(encoding='utf-8').strip()}") + return label, skills, "\n\n".join(sections) + "\n", missing + + +def gemini_context_from(assistant_root, notebook_name: str, contexts=None): + """ + Assemble the Gemini instruction for ``notebook_name`` ("/"). + + Returns ``(entry_label, skills, text)``. Raises if a mapped skill file is + missing or the text exceeds ``GEMINI_CONTEXT_CAP`` — never truncates. + """ + assistant_root = Path(assistant_root) + if contexts is None: + contexts = load_gemini_contexts(assistant_root) + label, skills, text, missing = assemble_gemini_context( + assistant_root, notebook_name, contexts + ) + if missing: + raise FileNotFoundError( + f"Gemini instructions: mapping entry '{label}' names skill(s) {missing} " + f"with no file under {assistant_root / 'skills'}." + ) + if len(text) > GEMINI_CONTEXT_CAP: + raise ValueError( + f"Gemini instructions for {notebook_name}: mapping entry '{label}' " + f"(skills {skills}) assembles to {len(text)} characters, over the " + f"{GEMINI_CONTEXT_CAP} cap. Map fewer or smaller skills to it in " + f"{assistant_root / GEMINI_CONTEXT_FILE}; the text is never truncated." + ) + return label, skills, text + + +def dump_notebook_like(original_text: str, notebook: dict) -> str: + """ + Serialise ``notebook`` in the JSON layout ``original_text`` was written in. + + Notebooks reach here from several writers (ipynb-py-convert, json.dump with + indent=1, nbformat's sorted keys), so the layout that reproduces the + original byte-for-byte is reused, and a metadata write stays a metadata-only + diff. Falls back to nbformat's own layout when none reproduces it. + """ + import itertools + import json + + original = json.loads(original_text) + newline = "\n" if original_text.endswith("\n") else "" + for indent, ensure_ascii, sort_keys in itertools.product((1, 2, 4), (False, True), (False, True)): + kwargs = dict(indent=indent, ensure_ascii=ensure_ascii, sort_keys=sort_keys) + if json.dumps(original, **kwargs) + newline == original_text: + return json.dumps(notebook, **kwargs) + newline + return json.dumps(notebook, indent=1, ensure_ascii=False, sort_keys=True) + "\n" + + +def inject_gemini_instructions( + notebook_path, project: str, assistant_root, notebook_relpath, repo_name=None, contexts=None +): + """ + Write the project's assistant instructions into a notebook's Colab metadata. + + Sets ``metadata.colab.aiContexts[] = {id, name, context}`` and makes it + the ``selectedAiContext``; any other keys under ``metadata.colab`` (and any + other `aiContexts` entries) are kept. The id is fixed per project + (``gemini_context_id``), so rerunning rewrites the same entry. Applies to + every notebook, prose-only and hand-written-setup ones included. + + Parameters + ---------- + notebook_relpath + The notebook's path relative to its repository root, e.g. + ``notebooks/imaging/modeling.ipynb`` or ``start_here.ipynb``. + repo_name + The repository the notebook lives in; defaults to the project's entry + in ``COLAB_WORKSPACE_REPOS``. + + Returns the mapping entry that matched. + """ + import json + + if project not in COLAB_ASSISTANTS: + raise ValueError( + f"inject_gemini_instructions: unknown project '{project}' — add it to " + f"COLAB_ASSISTANTS in build_util.py. Known: {sorted(COLAB_ASSISTANTS)}" + ) + if contexts is None: + contexts = load_gemini_contexts(assistant_root) + repo_name = repo_name or COLAB_WORKSPACE_REPOS[project] + notebook_name = f"{repo_name}/{Path(notebook_relpath).as_posix()}" + label, _, text = gemini_context_from(assistant_root, notebook_name, contexts=contexts) + + original_text = Path(notebook_path).read_text(encoding="utf-8") + notebook = json.loads(original_text) + context_id = gemini_context_id(project) + colab = notebook.setdefault("metadata", {}).setdefault("colab", {}) + colab.setdefault("aiContexts", {})[context_id] = { + "id": context_id, + "name": contexts.get("name") or f"{COLAB_ASSISTANTS[project]} instructions", + "context": text, + } + colab["selectedAiContext"] = context_id + + new_text = dump_notebook_like(original_text, notebook) + if new_text != original_text: + Path(notebook_path).write_text(new_text, encoding="utf-8") + return label + + +def resolve_assistant_root(project: str, explicit=None): + """ + The assistant checkout whose mapping stamps ``project``'s notebooks, or None. + + Order: ``explicit`` (``--assistant-root``), then ``$PYAUTO_ASSISTANT_ROOT``, + then — local runs only — the project's assistant in this workspace, found + through the Brain's repo-path resolver (``_workspace.repo_path``). + + An explicit or environment root is returned as given, and a missing + ``colab/contexts.yaml`` under it then fails generation loudly. A + workspace-resolved root without the file is skipped with a warning + instead: that is an assistant whose mapping has not landed yet (galaxy and + fit until phase 2 of #324), and the release must not break before it does. + """ + if project not in COLAB_ASSISTANTS: + return None + given = explicit or os.environ.get("PYAUTO_ASSISTANT_ROOT") + if given: + return Path(given).resolve() + + assistant = COLAB_ASSISTANTS[project] + try: + import _workspace + + candidate = Path(_workspace.repo_path(_workspace.workspace_root(), assistant)) + except Exception as exc: # no Brain / no workspace root in reach (e.g. CI) + print( + f"WARNING: Gemini instructions skipped for '{project}': no --assistant-root or " + f"PYAUTO_ASSISTANT_ROOT, and {assistant} could not be resolved locally ({exc}).", + file=sys.stderr, + ) + return None + if not (candidate / GEMINI_CONTEXT_FILE).is_file(): + print( + f"WARNING: Gemini instructions skipped for '{project}': no " + f"{GEMINI_CONTEXT_FILE} in {candidate} (pass --assistant-root or set " + f"PYAUTO_ASSISTANT_ROOT to require one).", + file=sys.stderr, + ) + return None + return candidate + + def uncomment_jupyter_magic(f): with open(f, "r") as sources: lines = sources.readlines() diff --git a/autohands/config/workspaces.yaml b/autohands/config/workspaces.yaml index e71c2161..c3b61a93 100644 --- a/autohands/config/workspaces.yaml +++ b/autohands/config/workspaces.yaml @@ -34,3 +34,20 @@ slow_skip_default: - autolens_workspace - autofit_workspace_test - autolens_workspace_test + +# colab_gemini: Colab Gemini instructions (PyAutoHands#324). Per project, the +# repository its notebooks live in (the "/" prefix the assistant's +# colab/contexts.yaml globs match) and the assistant owning that mapping. +# id_url is the uuid5 seed of each project's instruction id; it must stay +# byte-identical (ids are committed in notebooks, and the assistants derive the +# same ids in autoassistant/colab_contexts.py). +colab_gemini: + id_url: "https://github.com/PyAutoLabs/colab-gemini/{project}" + projects: + autofit: {repo: autofit_workspace, assistant: autofit_assistant} + autogalaxy: {repo: autogalaxy_workspace, assistant: autogalaxy_assistant} + autolens: {repo: autolens_workspace, assistant: autolens_assistant} + howtofit: {repo: HowToFit, assistant: autofit_assistant} + howtogalaxy: {repo: HowToGalaxy, assistant: autogalaxy_assistant} + howtolens: {repo: HowToLens, assistant: autolens_assistant} + autocti: {repo: autocti_workspace, assistant: autocti_assistant} diff --git a/autohands/gemini_context.py b/autohands/gemini_context.py new file mode 100644 index 00000000..a2103652 --- /dev/null +++ b/autohands/gemini_context.py @@ -0,0 +1,230 @@ +""" +Stamp or audit Colab Gemini instructions on a notebooks tree (PyAutoHands#324). + +The release's ``generate.py`` stamps every notebook it generates. This is the +same injector for notebooks outside that path — the CTI workspace, which the +release does not generate — plus the coverage report that assistant upkeep and +PyAutoBroca read. + + autohands gemini_context apply --project P --assistant-root A + [--repo-name R] [--repo-root DIR] + autohands gemini_context report --workspace W --project P --assistant-root A + [--repo-name R] [--json FILE] + +``apply`` names each notebook "/" for +matching. The repo root defaults to the parent of ```` when that +folder is called ``notebooks`` (the workspace layout), else the folder itself. + +``report`` is deterministic: the notebooks only the ``default`` entry reaches +(and the folders made up entirely of them), mapping entries for this repo that +match nothing, mapped skills missing on disk, skills mapped against the +mapping's own ``never_map`` list, and the assembled sizes against the cap. It +writes JSON (``--json``, else stdout after the text summary) and exits 1 when a +skill is missing, a never_map skill is mapped, or any notebook is over the cap. +""" + +import json +import statistics +import sys +from argparse import ArgumentParser +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import build_util + +_SKIP_PARTS = {".git", ".ipynb_checkpoints", "output"} + + +def iter_notebooks(root): + """Every ``*.ipynb`` under ``root`` in sorted order, minus checkpoints and outputs.""" + root = Path(root) + return sorted( + ( + p + for p in root.rglob("*.ipynb") + if not _SKIP_PARTS.intersection(p.relative_to(root).parts) + ), + key=lambda p: p.as_posix(), + ) + + +def _repo_name(project, repo_name): + if repo_name: + return repo_name + if project not in build_util.COLAB_WORKSPACE_REPOS: + raise SystemExit(f"gemini_context: pass --repo-name for project '{project}'.") + return build_util.COLAB_WORKSPACE_REPOS[project] + + +def apply(notebooks_dir, project, assistant_root, repo_name=None, repo_root=None): + """Stamp every notebook under ``notebooks_dir``; returns ``{relpath: entry}``.""" + notebooks_dir = Path(notebooks_dir).resolve() + if repo_root is None: + repo_root = notebooks_dir.parent if notebooks_dir.name == "notebooks" else notebooks_dir + repo_root = Path(repo_root).resolve() + repo_name = _repo_name(project, repo_name) + contexts = build_util.load_gemini_contexts(assistant_root) + + applied = {} + for notebook in iter_notebooks(notebooks_dir): + relpath = notebook.relative_to(repo_root).as_posix() + applied[relpath] = build_util.inject_gemini_instructions( + notebook, project, assistant_root, relpath, repo_name=repo_name, contexts=contexts + ) + return applied + + +def report(workspace, project, assistant_root, repo_name=None): + """The coverage report for one workspace against one assistant mapping.""" + workspace = Path(workspace).resolve() + assistant_root = Path(assistant_root).resolve() + repo_name = _repo_name(project, repo_name) + contexts = build_util.load_gemini_contexts(assistant_root, enforce_never_map=False) + cap = build_util.GEMINI_CONTEXT_CAP + + rows = [] + for notebook in iter_notebooks(workspace): + relpath = notebook.relative_to(workspace).as_posix() + label, skills, text, missing = build_util.assemble_gemini_context( + assistant_root, f"{repo_name}/{relpath}", contexts + ) + rows.append({"notebook": relpath, "entry": label, "chars": len(text), "missing": missing}) + + entries = [("default", contexts["default"]["skills"])] + [ + (e["match"], e["skills"]) for e in contexts["contexts"] + ] + missing_skills = { + label: [s for s in skills if not (assistant_root / "skills" / f"{s}.md").is_file()] + for label, skills in entries + } + missing_skills = {k: v for k, v in missing_skills.items() if v} + never_map = set(contexts.get("never_map") or []) + never_mapped = { + label: sorted(never_map.intersection(skills)) + for label, skills in entries + if never_map.intersection(skills) + } + + matched = {r["entry"] for r in rows} + unused_entries = [ + e["match"] + for e in contexts["contexts"] + if e["match"].startswith(f"{repo_name}/") and e["match"] not in matched + ] + + default_notebooks = [r["notebook"] for r in rows if r["entry"] == "default"] + folders = {} + for r in rows: + folders.setdefault(str(Path(r["notebook"]).parent.as_posix()), []).append(r["entry"]) + default_only_folders = sorted( + folder for folder, labels in folders.items() if set(labels) == {"default"} + ) + + sizes = [r["chars"] for r in rows] + per_entry = {} + for r in rows: + stats = per_entry.setdefault(r["entry"], {"notebooks": 0, "max_chars": 0}) + stats["notebooks"] += 1 + stats["max_chars"] = max(stats["max_chars"], r["chars"]) + + result = { + "workspace": repo_name, + "project": project, + "assistant": assistant_root.name, + "cap": cap, + "notebooks": len(rows), + "default_notebooks": default_notebooks, + "default_only_folders": default_only_folders, + "unused_entries": unused_entries, + "missing_skills": missing_skills, + "never_map_violations": never_mapped, + "sizes": { + "max": max(sizes) if sizes else 0, + "median": int(statistics.median(sizes)) if sizes else 0, + "over_cap": [ + {"notebook": r["notebook"], "entry": r["entry"], "chars": r["chars"]} + for r in rows + if r["chars"] > cap + ], + "largest": [ + {"notebook": r["notebook"], "entry": r["entry"], "chars": r["chars"]} + for r in sorted(rows, key=lambda r: (-r["chars"], r["notebook"]))[:5] + ], + }, + "entries": dict(sorted(per_entry.items())), + } + result["ok"] = not (missing_skills or never_mapped or result["sizes"]["over_cap"]) + return result + + +def format_report(result): + """A short human summary of ``report``'s result.""" + sizes = result["sizes"] + lines = [ + f"{result['workspace']} vs {result['assistant']}: {result['notebooks']} notebooks, " + f"{len(result['entries'])} mapping entries used", + f" sizes: max {sizes['max']}, median {sizes['median']}, cap {result['cap']}, " + f"over cap {len(sizes['over_cap'])}", + f" default only: {len(result['default_notebooks'])} notebooks, " + f"{len(result['default_only_folders'])} folders", + ] + for folder in result["default_only_folders"]: + lines.append(f" {folder}/") + if result["unused_entries"]: + lines.append(f" entries matching nothing: {', '.join(result['unused_entries'])}") + for label, skills in result["missing_skills"].items(): + lines.append(f" MISSING skills in '{label}': {', '.join(skills)}") + for label, skills in result["never_map_violations"].items(): + lines.append(f" NEVER_MAP skills in '{label}': {', '.join(skills)}") + for row in sizes["over_cap"]: + lines.append(f" OVER CAP: {row['notebook']} ({row['entry']}, {row['chars']})") + lines.append(" OK" if result["ok"] else " FAILED") + return "\n".join(lines) + + +def main(argv=None): + parser = ArgumentParser(prog="autohands gemini_context", description=__doc__.split("\n\n")[1]) + sub = parser.add_subparsers(dest="command", required=True) + + p_apply = sub.add_parser("apply", help="Stamp every notebook under a folder") + p_apply.add_argument("notebooks_dir") + p_apply.add_argument("--project", required=True) + p_apply.add_argument("--assistant-root", required=True) + p_apply.add_argument("--repo-name", default=None) + p_apply.add_argument("--repo-root", default=None) + + p_report = sub.add_parser("report", help="Coverage report for a workspace") + p_report.add_argument("--workspace", required=True) + p_report.add_argument("--project", required=True) + p_report.add_argument("--assistant-root", required=True) + p_report.add_argument("--repo-name", default=None) + p_report.add_argument("--json", default=None, help="Write the JSON report here") + + args = parser.parse_args(argv) + + if args.command == "apply": + applied = apply( + args.notebooks_dir, args.project, args.assistant_root, + repo_name=args.repo_name, repo_root=args.repo_root, + ) + counts = {} + for label in applied.values(): + counts[label] = counts.get(label, 0) + 1 + print(f"Stamped {len(applied)} notebooks:") + for label, count in sorted(counts.items()): + print(f" {count:4d} {label}") + return 0 + + result = report(args.workspace, args.project, args.assistant_root, repo_name=args.repo_name) + print(format_report(result)) + payload = json.dumps(result, indent=2) + "\n" + if args.json: + Path(args.json).write_text(payload) + else: + print(payload) + return 0 if result["ok"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/autohands/generate.py b/autohands/generate.py index c83f6ca1..b1d57ea3 100644 --- a/autohands/generate.py +++ b/autohands/generate.py @@ -17,6 +17,14 @@ default=None, help="Directory to write structured JSON generation results to", ) +parser.add_argument( + "--assistant-root", + type=str, + default=None, + help="Checkout of the project's assistant whose colab/contexts.yaml supplies " + "each notebook's Colab Gemini instructions (default: $PYAUTO_ASSISTANT_ROOT, " + "then the assistant in this workspace; skipped with a warning if neither).", +) args = parser.parse_args() @@ -76,6 +84,46 @@ def copy_to_notebooks(source): f"{sorted(build_util.COLAB_PROJECTS)}. Nothing was modified." ) + # The assistant whose mapping stamps each notebook's Colab Gemini + # instructions (#324). Resolved before the rmtree so a mapping that cannot + # load stops the run with the committed notebooks intact. + assistant_root = build_util.resolve_assistant_root(project, args.assistant_root) + gemini_contexts = ( + build_util.load_gemini_contexts(assistant_root) if assistant_root else None + ) + + # Every notebook name this run will produce, assembled and checked up + # front: a missing skill or an over-cap entry fails here, before the + # start_here conversion and the notebooks/ rmtree below touch anything. + if assistant_root is not None: + repo = build_util.COLAB_WORKSPACE_REPOS[project] + scripts_path = WORKSPACE_PATH / "scripts" + names = [ + f"{repo}/{f.with_suffix('.ipynb').name}" + for f in Path(".").glob("start_here*.py") + if f.name != "welcome.py" + ] + names += [ + f"{repo}/notebooks/{p.relative_to(scripts_path).with_suffix('.ipynb').as_posix()}" + for p in iter_script_paths(scripts_path) + ] + if project == "autofit": + names += [ + f"{repo}/{p.with_suffix('.ipynb').relative_to(generate_autofit.WORKSPACE_PATH).as_posix()}" + for p in generate_autofit.project_scripts() + ] + try: + build_util.validate_gemini_contexts(assistant_root, names, contexts=gemini_contexts) + except ValueError as exc: + sys.exit(f"generate.py: {exc}") + + def inject_gemini(notebook, notebook_relpath): + if assistant_root is not None: + build_util.inject_gemini_instructions( + notebook, project, assistant_root, notebook_relpath, + contexts=gemini_contexts, + ) + report = None if args.report_dir: from result_collector import RunReport @@ -85,7 +133,12 @@ def copy_to_notebooks(source): run_type="generate", ) - generate_autofit.generate_project_folders() + # autofit's projects/ notebooks are stamped only when autofit itself is + # being generated: this step runs for every project, and another project's + # assistant mapping does not describe them. + generate_autofit.generate_project_folders( + inject_gemini=inject_gemini if project == "autofit" else None + ) p = Path(".") @@ -102,6 +155,7 @@ def copy_to_notebooks(source): try: notebook = build_util.py_to_notebook(start_here_file) build_util.inject_colab_setup(notebook, project) + inject_gemini(notebook, notebook.name) # Checked call, no -f: start_here.ipynb is tracked and unignored in # every workspace that has one (measured, #156); a failure here must # surface, not vanish into an ignored os.system return. @@ -140,6 +194,10 @@ def copy_to_notebooks(source): try: source_path = build_util.py_to_notebook(script_path) build_util.inject_colab_setup(source_path, project) + inject_gemini( + source_path, + Path("notebooks") / script_path.relative_to(scripts_path).with_suffix(".ipynb"), + ) copy_to_notebooks(source_path) os.remove(source_path) source_path = None diff --git a/autohands/generate_autofit.py b/autohands/generate_autofit.py index 5b854114..c0a38a16 100644 --- a/autohands/generate_autofit.py +++ b/autohands/generate_autofit.py @@ -11,7 +11,23 @@ PROJECTS_FOLDERS_OMIT = ["config", "dataset", "src"] -def generate_project_folders(): +def project_scripts(): + """The ``projects//*.py`` files ``generate_project_folders`` converts.""" + return [ + python_file + for project_path in map(Path, glob.glob(f"{PROJECTS_ROOT_PATH}/*/")) + for python_file in map(Path, glob.glob(f"{project_path}/*.py")) + if python_file.name != "__init__.py" + ] + + +def generate_project_folders(inject_gemini=None): + """ + Convert each ``projects//*.py`` to a notebook beside it. + + ``inject_gemini(notebook, relpath)``, when given, stamps the notebook's + Colab Gemini instructions; ``relpath`` is relative to the workspace root. + """ for project_path in map(Path, glob.glob(f"{PROJECTS_ROOT_PATH}/*/")): try: os.remove(project_path / "temp.py") @@ -27,4 +43,6 @@ def generate_project_folders(): new_filename = build_util.py_to_notebook(python_file) build_util.inject_colab_setup(new_filename, "autofit") + if inject_gemini is not None: + inject_gemini(new_filename, Path(new_filename).relative_to(WORKSPACE_PATH)) os.system(f"git add -f {new_filename}") diff --git a/bin/autohands b/bin/autohands index a8e8d433..5c9201f4 100755 --- a/bin/autohands +++ b/bin/autohands @@ -36,6 +36,7 @@ SUBCOMMAND_ORDER=( generate_markdown regenerate_navigator regenerate_search_roster + gemini_context run run_python run_all @@ -76,6 +77,7 @@ declare -A SHORT_DESC=( [generate]="Convert workspace scripts/ to notebooks/ (run from a workspace root)" [generate_markdown]="Render curated workspace scripts to executed markdown pages with figures" [regenerate_navigator]="Rebuild a workspace's llms-full.txt + workspace_index.json catalogue" + [gemini_context]="Stamp or audit Colab Gemini instructions on a notebooks tree from an assistant mapping" [check_navigator]="Guard: navigator/instruction path references all resolve on disk" [check_search_memory]="Guard: every MultiStart* search in a workspace sets batch_size explicitly" [regenerate_search_roster]="Re-render fenced search-roster blocks from the installed autofit search manifest" @@ -212,15 +214,23 @@ cmd_bump_colab_urls() { help_generate() { cat <<'EOF' -autohands generate [--report-dir DIR] +autohands generate [--report-dir DIR] [--assistant-root DIR] Convert every script in scripts/ to a Jupyter notebook in notebooks/, run from inside the workspace root. The argument selects the Colab setup cell injected into each generated notebook. +Each notebook also gets its Colab Gemini instructions from the project's +assistant (colab/contexts.yaml; see `autohands help gemini_context`). + Arguments: - project Project name (e.g. autofit, autogalaxy, autolens, howtolens) - --report-dir Directory to write structured JSON generation results to + project Project name (e.g. autofit, autogalaxy, autolens, howtolens) + --report-dir Directory to write structured JSON generation results to + --assistant-root The assistant checkout supplying the instructions (default: + $PYAUTO_ASSISTANT_ROOT, then the assistant in this workspace). + A root given here or in the env var must have + colab/contexts.yaml; one found in the workspace without it + is skipped with a warning (mapping not landed yet). Run from the workspace root, not from PyAutoHands. EOF @@ -229,6 +239,34 @@ cmd_generate() { _python_in_autohands generate.py "$@" } +help_gemini_context() { + cat <<'EOF' +autohands gemini_context apply --project P --assistant-root A + [--repo-name R] [--repo-root DIR] +autohands gemini_context report --workspace W --project P --assistant-root A + [--repo-name R] [--json FILE] + +Colab Gemini instructions (PyAutoHands#324): each assistant's +colab/contexts.yaml maps notebook-path globs to skills; the assembled text +(preface + core rules + mapped skills, at most 20,000 characters) is written +into metadata.colab.aiContexts and selected. + + apply Stamp every notebook under . `generate` already does + this for the workspaces it builds; use apply for notebooks outside + the release generate (e.g. autocti_workspace). + report Deterministic coverage report for a workspace: notebooks/folders + reached only by `default`, entries matching nothing, missing or + never_map skills, size max/median vs the cap. JSON to --json (else + stdout). Exits 1 on a missing skill, never_map skill or over-cap. + +Notebooks are matched as "/"; the repo +name defaults from the project (autolens -> autolens_workspace, ...). +EOF +} +cmd_gemini_context() { + _python_in_autohands gemini_context.py "$@" +} + help_generate_markdown() { cat <<'EOF' autohands generate_markdown [--only SUBSTRING] [--optimize-only] diff --git a/skills/pre_build/pre_build.md b/skills/pre_build/pre_build.md index 1557cc8e..b10c9ffb 100644 --- a/skills/pre_build/pre_build.md +++ b/skills/pre_build/pre_build.md @@ -83,7 +83,7 @@ The script handles every mechanical step of the pre-build flow: 1. Fails before any side effects unless PyAutoHands is on clean `main`; the run produces no PyAutoHands files and never stages or commits that repository. 2. Sweeps **every** workspace for untracked files under the directories it reformats and stages (`notebooks/`, `scripts/`, `slam_pipeline/`) and aborts, naming each repo and path, if any exist. This runs before the first repo is touched, because the script commits and pushes each workspace before moving to the next — a check that aborted midway would leave earlier repos already published. Remedy is to commit, stash or move the files; there is deliberately no override flag, since an override is exactly the operator vigilance this replaces. 3. Ensures the canonical `pending-release` label exists on each release-window repo. -4. For every workspace, runs black on the staged dirs (`scripts/`, `slam_pipeline/`), runs `generate.py` for projects with a notebook target, and stages only what the run itself produced (`notebooks/`, `scripts/`, plus `slam_pipeline/` for `autolens_workspace`) — tracked edits and deletions via `git add -u`, plus newly created files by explicit path. It never runs `git add /`, which also sweeps in untracked files; that is what committed and pushed a human's uncommitted script during the 2026-08-07 release rehearsal, and is the same leak class as #126. It does not stage `dataset/` or `config/` — nothing in the run modifies them. Root-level artifacts and README Colab URLs are committed by `release.yml` on the runner, not here. +4. For every workspace, runs black on the staged dirs (`scripts/`, `slam_pipeline/`), runs `generate.py` for projects with a notebook target, and stages only what the run itself produced (`notebooks/`, `scripts/`, plus `slam_pipeline/` for `autolens_workspace`) — tracked edits and deletions via `git add -u`, plus newly created files by explicit path. It never runs `git add /`, which also sweeps in untracked files; that is what committed and pushed a human's uncommitted script during the 2026-08-07 release rehearsal, and is the same leak class as #126. It does not stage `dataset/` or `config/` — nothing in the run modifies them. Root-level artifacts and README Colab URLs are committed by `release.yml` on the runner, not here. `generate.py` also writes each notebook's Colab Gemini instructions from the project's assistant (`colab/contexts.yaml`), found locally via `$PYAUTO_ASSISTANT_ROOT` or else the assistant checkout in this workspace; one without that file is skipped with a warning. 5. Commits and pushes each workspace (skipping if no changes are staged). 6. Dispatches `gh workflow run release.yml --repo PyAutoLabs/PyAutoHands --field minor_version=`. diff --git a/tests/test_gemini_instructions.py b/tests/test_gemini_instructions.py new file mode 100644 index 00000000..99d2a788 --- /dev/null +++ b/tests/test_gemini_instructions.py @@ -0,0 +1,330 @@ +"""Tests for the Colab Gemini instructions stamped into generated notebooks (#324). + +A small fixture assistant (colab/contexts.yaml + skills/) is built per test, so +these pin the assembly, matching and metadata rules without any real assistant. +""" + +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "autohands")) + +import build_util +import gemini_context + +# The autolens project's notebook repo, from config/workspaces.yaml (tests name +# no instance repo themselves — tenant firewall). +REPO = build_util.COLAB_WORKSPACE_REPOS["autolens"] + +CONTEXTS_YAML = """\ +name: Test assistant +preface: | + PREFACE written for a coding agent. +rules: | + You are helping with {notebook} in {repo}. +never_map: [maintainer_skill] +default: + skills: [skill_default] +contexts: + - match: REPO/notebooks/imaging/features/* + skills: [skill_feature] + - match: REPO/notebooks/imaging/* + skills: [skill_imaging, skill_default] + - match: REPO/notebooks/big/* + skills: [skill_big] + - match: REPO/notebooks/broken/* + skills: [skill_missing] +""".replace("REPO", REPO) + + +@pytest.fixture +def assistant(tmp_path): + root = tmp_path / "test_assistant" + (root / "colab").mkdir(parents=True) + (root / "colab" / "contexts.yaml").write_text(CONTEXTS_YAML) + skills = root / "skills" + skills.mkdir() + for name in ("skill_default", "skill_feature", "skill_imaging", "maintainer_skill"): + (skills / f"{name}.md").write_text(f"# {name}\n\nBody of {name}.\n") + (skills / "skill_big.md").write_text("x" * (build_util.GEMINI_CONTEXT_CAP + 1)) + return root + + +def make_notebook(path, cells, metadata=None): + path.parent.mkdir(parents=True, exist_ok=True) + notebook = { + "cells": cells, + "metadata": metadata if metadata is not None else {}, + "nbformat": 4, + "nbformat_minor": 4, + } + path.write_text(json.dumps(notebook, indent=1)) + return path + + +def code_cell(text): + return {"cell_type": "code", "execution_count": None, "metadata": {}, + "outputs": [], "source": [text]} + + +def markdown_cell(text): + return {"cell_type": "markdown", "metadata": {}, "source": [text]} + + +def colab_metadata(path): + return json.loads(Path(path).read_text())["metadata"]["colab"] + + +def test_writes_selected_context_with_fixed_uuid5(tmp_path, assistant): + nb = make_notebook(tmp_path / "modeling.ipynb", [code_cell("import autolens as al")]) + + label = build_util.inject_gemini_instructions( + nb, "autolens", assistant, "notebooks/imaging/modeling.ipynb" + ) + + # Byte-identical to the ids already committed in notebooks (and derived by + # the assistants' colab_contexts.py): the config's id_url must never drift. + expected_id = "44dc52b3-fd5a-58a2-9ef9-33c87750474e" + assert build_util.gemini_context_id("autolens") == expected_id + colab = colab_metadata(nb) + assert colab["selectedAiContext"] == expected_id + entry = colab["aiContexts"][expected_id] + assert entry["id"] == expected_id + assert entry["name"] == "Test assistant" + assert label == f"{REPO}/notebooks/imaging/*" + assert "Body of skill_imaging." in entry["context"] + assert "Body of skill_default." in entry["context"] + assert entry["context"].index("PREFACE") < entry["context"].index("skill_imaging") + + +def test_ids_differ_per_project(): + assert build_util.gemini_context_id("autolens") != build_util.gemini_context_id("howtolens") + + +def test_notebook_path_and_repo_appear_in_text(tmp_path, assistant): + nb = make_notebook(tmp_path / "modeling.ipynb", [code_cell("x = 1")]) + build_util.inject_gemini_instructions(nb, "autolens", assistant, "notebooks/imaging/modeling.ipynb") + (entry,) = colab_metadata(nb)["aiContexts"].values() + assert f"notebooks/imaging/modeling.ipynb in {REPO}" in entry["context"] + + +def test_idempotent(tmp_path, assistant): + nb = make_notebook(tmp_path / "modeling.ipynb", [code_cell("x = 1")]) + build_util.inject_gemini_instructions(nb, "autolens", assistant, "notebooks/imaging/modeling.ipynb") + first = nb.read_text() + build_util.inject_gemini_instructions(nb, "autolens", assistant, "notebooks/imaging/modeling.ipynb") + assert nb.read_text() == first + assert len(colab_metadata(nb)["aiContexts"]) == 1 + + +def test_prose_only_notebook_gets_instructions(tmp_path, assistant): + nb = make_notebook(tmp_path / "readme.ipynb", [markdown_cell("Just prose.")]) + assert build_util.inject_colab_setup(nb, "autolens") is False + build_util.inject_gemini_instructions(nb, "autolens", assistant, "notebooks/imaging/readme.ipynb") + assert "selectedAiContext" in colab_metadata(nb) + + +def test_hand_written_setup_notebook_gets_instructions(tmp_path, assistant): + nb = make_notebook(tmp_path / "setup.ipynb", [code_cell('setup_colab.setup("autolens")')]) + assert build_util.inject_colab_setup(nb, "autolens") is False + build_util.inject_gemini_instructions(nb, "autolens", assistant, "notebooks/imaging/setup.ipynb") + assert "selectedAiContext" in colab_metadata(nb) + + +def test_most_specific_entry_wins(assistant): + contexts = build_util.load_gemini_contexts(assistant) + label, skills = build_util.match_gemini_context( + contexts, f"{REPO}/notebooks/imaging/features/pixelization/fit.ipynb" + ) + assert label == f"{REPO}/notebooks/imaging/features/*" + assert skills == ["skill_feature"] + + +def test_default_fallback(tmp_path, assistant): + nb = make_notebook(tmp_path / "other.ipynb", [code_cell("x = 1")]) + label = build_util.inject_gemini_instructions(nb, "autolens", assistant, "notebooks/weak/other.ipynb") + assert label == "default" + (entry,) = colab_metadata(nb)["aiContexts"].values() + assert "Body of skill_default." in entry["context"] + + +def test_over_cap_raises_naming_the_entry(tmp_path, assistant): + nb = make_notebook(tmp_path / "huge.ipynb", [code_cell("x = 1")]) + before = nb.read_text() + with pytest.raises(ValueError, match=rf"{REPO}/notebooks/big/\*.*over the 20000 cap"): + build_util.inject_gemini_instructions(nb, "autolens", assistant, "notebooks/big/huge.ipynb") + assert nb.read_text() == before + + +def test_missing_skill_raises(tmp_path, assistant): + nb = make_notebook(tmp_path / "x.ipynb", [code_cell("x = 1")]) + with pytest.raises(FileNotFoundError, match="skill_missing"): + build_util.inject_gemini_instructions(nb, "autolens", assistant, "notebooks/broken/x.ipynb") + + +def test_missing_mapping_raises(tmp_path): + nb = make_notebook(tmp_path / "x.ipynb", [code_cell("x = 1")]) + (tmp_path / "bare_assistant").mkdir() + with pytest.raises(FileNotFoundError, match="colab/contexts.yaml"): + build_util.inject_gemini_instructions(nb, "autolens", tmp_path / "bare_assistant", "x.ipynb") + + +def test_unknown_project_raises(tmp_path, assistant): + nb = make_notebook(tmp_path / "x.ipynb", [code_cell("x = 1")]) + with pytest.raises(ValueError, match="COLAB_ASSISTANTS"): + build_util.inject_gemini_instructions(nb, "nonsense", assistant, "x.ipynb") + + +def test_other_colab_metadata_preserved(tmp_path, assistant): + nb = make_notebook( + tmp_path / "x.ipynb", + [code_cell("x = 1")], + metadata={ + "kernelspec": {"name": "python3"}, + "colab": {"provenance": [{"file_id": "abc"}], + "aiContexts": {"user-own": {"id": "user-own", "name": "Mine", "context": "hi"}}}, + }, + ) + build_util.inject_gemini_instructions(nb, "autolens", assistant, "notebooks/imaging/x.ipynb") + metadata = json.loads(nb.read_text())["metadata"] + assert metadata["kernelspec"] == {"name": "python3"} + assert metadata["colab"]["provenance"] == [{"file_id": "abc"}] + assert "user-own" in metadata["colab"]["aiContexts"] + assert metadata["colab"]["selectedAiContext"] == build_util.gemini_context_id("autolens") + + +def test_layout_preserved_so_cells_are_untouched(tmp_path, assistant): + """A sorted-key, trailing-newline notebook (nbformat's layout) keeps its layout.""" + notebook = {"cells": [code_cell("x = 'é'")], "metadata": {"kernelspec": {}}, + "nbformat": 4, "nbformat_minor": 5} + nb = tmp_path / "x.ipynb" + nb.write_text(json.dumps(notebook, indent=1, sort_keys=True, ensure_ascii=False) + "\n") + build_util.inject_gemini_instructions(nb, "autolens", assistant, "notebooks/imaging/x.ipynb") + text = nb.read_text() + assert text.endswith("}\n") + assert "x = 'é'" in text + rewritten = json.loads(text) + assert rewritten["cells"] == notebook["cells"] + + +def test_resolve_assistant_root_explicit_and_env(tmp_path, monkeypatch): + monkeypatch.delenv("PYAUTO_ASSISTANT_ROOT", raising=False) + assert build_util.resolve_assistant_root("autolens", str(tmp_path)) == tmp_path.resolve() + monkeypatch.setenv("PYAUTO_ASSISTANT_ROOT", str(tmp_path)) + assert build_util.resolve_assistant_root("autolens") == tmp_path.resolve() + assert build_util.resolve_assistant_root("not_a_project") is None + + +def test_resolve_assistant_root_skips_workspace_checkout_without_mapping(tmp_path, monkeypatch, capsys): + monkeypatch.delenv("PYAUTO_ASSISTANT_ROOT", raising=False) + import _workspace + + monkeypatch.setattr(_workspace, "workspace_root", lambda: tmp_path) + monkeypatch.setattr(_workspace, "repo_path", lambda root, name: Path(root) / name) + (tmp_path / build_util.COLAB_ASSISTANTS["autogalaxy"]).mkdir() + assert build_util.resolve_assistant_root("autogalaxy") is None + assert "skipped" in capsys.readouterr().err + + +def test_cli_apply_and_report(tmp_path, assistant, capsys): + workspace = tmp_path / REPO + make_notebook(workspace / "notebooks" / "imaging" / "modeling.ipynb", [code_cell("x = 1")]) + make_notebook(workspace / "notebooks" / "imaging" / "features" / "a.ipynb", [code_cell("x = 1")]) + make_notebook(workspace / "notebooks" / "weak" / "w.ipynb", [markdown_cell("prose")]) + make_notebook(workspace / "notebooks" / "imaging" / ".ipynb_checkpoints" / "c.ipynb", []) + + applied = gemini_context.apply(workspace / "notebooks", "autolens", assistant) + assert applied == { + "notebooks/imaging/features/a.ipynb": f"{REPO}/notebooks/imaging/features/*", + "notebooks/imaging/modeling.ipynb": f"{REPO}/notebooks/imaging/*", + "notebooks/weak/w.ipynb": "default", + } + + report_path = tmp_path / "report.json" + rc = gemini_context.main([ + "report", "--workspace", str(workspace), "--project", "autolens", + "--assistant-root", str(assistant), "--json", str(report_path), + ]) + result = json.loads(report_path.read_text()) + assert result["notebooks"] == 3 + assert result["default_notebooks"] == ["notebooks/weak/w.ipynb"] + assert result["default_only_folders"] == ["notebooks/weak"] + assert result["unused_entries"] == [ + f"{REPO}/notebooks/big/*", f"{REPO}/notebooks/broken/*", + ] + assert result["missing_skills"] == {f"{REPO}/notebooks/broken/*": ["skill_missing"]} + assert result["sizes"]["over_cap"] == [] + assert 0 < result["sizes"]["median"] <= result["sizes"]["max"] < build_util.GEMINI_CONTEXT_CAP + # The fixture maps a missing skill, so the report fails. + assert result["ok"] is False and rc == 1 + assert "MISSING skills" in capsys.readouterr().out + + +def test_report_flags_never_map(tmp_path, assistant): + yaml_path = assistant / "colab" / "contexts.yaml" + yaml_path.write_text( + yaml_path.read_text().replace("skills: [skill_feature]", "skills: [maintainer_skill]") + ) + workspace = tmp_path / REPO + make_notebook(workspace / "notebooks" / "imaging" / "m.ipynb", [code_cell("x = 1")]) + result = gemini_context.report(workspace, "autolens", assistant) + assert result["never_map_violations"] == { + f"{REPO}/notebooks/imaging/features/*": ["maintainer_skill"] + } + + +def test_never_map_enforced_at_load(assistant): + yaml_path = assistant / "colab" / "contexts.yaml" + yaml_path.write_text( + yaml_path.read_text().replace("skills: [skill_feature]", "skills: [maintainer_skill]") + ) + with pytest.raises(ValueError, match=r"imaging/features/\*.*never_map.*maintainer_skill"): + build_util.load_gemini_contexts(assistant) + # The coverage report still loads it, to list every violation. + assert build_util.load_gemini_contexts(assistant, enforce_never_map=False) + + +def test_validate_lists_every_failing_notebook(assistant): + with pytest.raises(ValueError) as info: + build_util.validate_gemini_contexts(assistant, [ + f"{REPO}/notebooks/imaging/ok.ipynb", + f"{REPO}/notebooks/big/a.ipynb", + f"{REPO}/notebooks/broken/b.ipynb", + ]) + message = str(info.value) + assert "2 notebook(s) would fail" in message + assert "over the 20000 cap" in message and "skill_missing" in message + + +@pytest.mark.parametrize("folder", ["big", "broken"]) +def test_generate_fails_before_touching_notebooks(tmp_path, assistant, folder): + """An over-cap or missing-skill mapping stops generate.py before its rmtree.""" + import os + import subprocess + + workspace = tmp_path / "ws" + (workspace / "scripts" / folder).mkdir(parents=True) + (workspace / "scripts" / folder / "modeling.py").write_text('"""\nT\n=\n"""\n\nvalue = 1\n') + (workspace / "notebooks" / folder).mkdir(parents=True) + (workspace / "notebooks" / folder / "modeling.ipynb").write_text('{"cells": [], "nbformat": 4}\n') + subprocess.run(["git", "init", "-q"], cwd=workspace, check=True) + subprocess.run(["git", "add", "-A"], cwd=workspace, check=True) + subprocess.run(["git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "seed"], + cwd=workspace, check=True) + + autohands = Path(__file__).resolve().parent.parent / "autohands" + result = subprocess.run( + [sys.executable, str(autohands / "generate.py"), "autolens", "--assistant-root", str(assistant)], + cwd=workspace, capture_output=True, text=True, + env=dict(os.environ, PYTHONPATH=str(autohands)), + ) + + assert result.returncode != 0 + assert "nothing was modified" in result.stderr + status = subprocess.run(["git", "status", "--porcelain"], cwd=workspace, + capture_output=True, text=True, check=True) + assert status.stdout == "" + assert list(workspace.glob("scripts/**/*.ipynb")) == []