diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 9ed5cb7..eaf5c98 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -5,10 +5,12 @@ on: branches: [main] pull_request: branches: [main] + types: [opened, reopened, synchronize, closed] workflow_dispatch: jobs: docs: + if: github.event_name != 'pull_request' || github.event.action != 'closed' runs-on: ubuntu-latest permissions: contents: write @@ -33,3 +35,37 @@ jobs: - name: Deploy to GitHub Pages if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' run: uv run mkdocs gh-deploy --force + + preview: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + concurrency: + group: preview-${{ github.event.number }} + steps: + - uses: actions/checkout@v4 + if: github.event.action != 'closed' + + - uses: actions/setup-python@v5 + if: github.event.action != 'closed' + with: + python-version: "3.12" + + - name: Install uv + if: github.event.action != 'closed' + uses: astral-sh/setup-uv@v5 + + - name: Install docs dependencies + if: github.event.action != 'closed' + run: uv sync --group docs + + - name: Build docs + if: github.event.action != 'closed' + run: uv run mkdocs build + + - name: Deploy PR preview + uses: rossjrw/pr-preview-action@v1 + with: + source-dir: ./site diff --git a/hooks/gen_use_cases.py b/hooks/gen_use_cases.py index 7f7849e..15dc2c7 100644 --- a/hooks/gen_use_cases.py +++ b/hooks/gen_use_cases.py @@ -10,18 +10,34 @@ summary: One or two sentences shown in the overview table and page. status: published # omit, or 'draft' to hide it order: 10 # optional sort key (default 100) - guides: # optional walkthrough links + guides: # optional walkthrough doc(s) - text: Walkthrough path: demo.md # path within the use-case folder --- It then appears in the overview table, gets its own docs page, and lands in the "Use Cases" nav group — no edits to mkdocs.yml or the docs tree required. + +Each ``guides[].path`` doc (often the README itself, sometimes a dedicated +walkthrough file) is inlined into the generated page rather than linked out to +GitHub: its own frontmatter and leading ``# Title`` line are stripped (the +generated page supplies its own), and every relative link/image is rewritten +— to another use case's generated page when it points at that use case's +folder or guide doc, otherwise to a GitHub blob/tree URL, since only ``docs/`` +itself is served by the built site. + +Demo-bundle links (a downloadable ``.tar.gz`` snapshot of the use case's +folder, hosted on Google Drive) come from ``use_cases/links.csv`` (columns: +folder name, URL) keyed by folder name, and are rendered only on the +generated docs page (alongside the GitHub source link) — READMEs carry no +hardcoded copy, so there's nothing to keep in sync. """ from __future__ import annotations +import csv import logging +import re from pathlib import Path import yaml @@ -32,11 +48,25 @@ TABLE_MARKER = "" INDEX_URI = "use-cases/index.md" +_FENCE_RE = re.compile(r"^(```|~~~)") +_H1_RE = re.compile(r"^#\s") +_LINK_RE = re.compile(r"(!?\[[^\]]*\]\()([^()\s]+)(\))") + # Populated in on_config, consumed in on_files / on_page_markdown within the # same build. Module-level is fine: each build re-runs on_config first. _use_cases: list[dict] = [] +def _load_demo_urls(uc_dir: Path) -> dict[str, str]: + csv_path = uc_dir / "links.csv" + if not csv_path.is_file(): + return {} + with csv_path.open(encoding="utf-8", newline="") as f: + return { + name.strip(): url.strip() for name, url in csv.reader(f) if name.strip() + } + + def _parse_frontmatter(text: str) -> dict | None: if not text.startswith("---"): return None @@ -50,11 +80,86 @@ def _parse_frontmatter(text: str) -> dict | None: return data if isinstance(data, dict) else None +def _strip_frontmatter(text: str) -> str: + if not text.startswith("---"): + return text + parts = text.split("---", 2) + return parts[2].lstrip("\n") if len(parts) >= 3 else text + + +def _strip_leading_h1(text: str) -> str: + lines = text.splitlines() + i = 0 + while i < len(lines) and not lines[i].strip(): + i += 1 + if i >= len(lines) or not _H1_RE.match(lines[i]): + return text # no leading title line — leave the body as-is + i += 1 + while i < len(lines) and not lines[i].strip(): + i += 1 + return "\n".join(lines[i:]) + + +def _rewrite_link_target( + target: str, + uc: dict, + gh: str, + uc_names: set[str], + page_by_path: dict[Path, str], +) -> str: + if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:", target) or target.startswith("#"): + return target # absolute URL (incl. mailto:) or a same-page anchor + path, sep, fragment = target.partition("#") + repo_root = uc["dir"].parent.parent + resolved = (uc["dir"] / path).resolve() + try: + rel = resolved.relative_to(repo_root) + except ValueError: + return target # escapes the repo entirely — leave it alone + if resolved in page_by_path: + return f"{page_by_path[resolved]}.md" # links at another use case's guide page + use_cases_dir = uc["dir"].parent + if resolved.parent == use_cases_dir and resolved.name in uc_names: + return f"{resolved.name}.md" # links straight at a sibling use case's folder + kind = "tree" if resolved.is_dir() else "blob" + return f"{gh}/{kind}/main/{rel.as_posix()}" + (sep + fragment if sep else "") + + +def _inline_readme( + uc: dict, + path: str, + gh: str, + uc_names: set[str], + page_by_path: dict[Path, str], +) -> str: + text = (uc["dir"] / path).read_text(encoding="utf-8") + text = _strip_leading_h1(_strip_frontmatter(text)) + in_fence = False + lines = [] + for line in text.splitlines(): + if _FENCE_RE.match(line.strip()): + in_fence = not in_fence + lines.append(line) + continue + if in_fence: + lines.append(line) + continue + line = _LINK_RE.sub( + lambda m: m[1] + + _rewrite_link_target(m[2], uc, gh, uc_names, page_by_path) + + m[3], + line, + ) + lines.append(line) + return "\n".join(lines) + + def _discover(config) -> list[dict]: uc_dir = Path(config.docs_dir).parent / "use_cases" found: list[dict] = [] if not uc_dir.is_dir(): return found + demo_urls = _load_demo_urls(uc_dir) for folder in sorted(p for p in uc_dir.iterdir() if p.is_dir()): readme = folder / "README.md" if not readme.is_file(): @@ -80,6 +185,8 @@ def _discover(config) -> list[dict]: "summary": " ".join(str(fm["summary"]).split()), "order": fm.get("order", 100), "guides": fm.get("guides") or [], + "demo_url": demo_urls.get(folder.name), + "dir": folder.resolve(), } ) found.sort(key=lambda u: (u["order"], u["title"].lower())) @@ -95,9 +202,7 @@ def on_config(config): for item in config.nav or []: if isinstance(item, dict) and isinstance(item.get("Use Cases"), list): children = item["Use Cases"] - present = { - next(iter(c.values())) for c in children if isinstance(c, dict) - } + present = {next(iter(c.values())) for c in children if isinstance(c, dict)} for uc in _use_cases: uri = f"use-cases/{uc['name']}.md" if uri not in present: @@ -105,7 +210,9 @@ def on_config(config): return config -def _render_page(uc: dict, repo_url: str) -> str: +def _render_page( + uc: dict, repo_url: str, uc_names: set[str], page_by_path: dict[Path, str] +) -> str: gh = (repo_url or "").rstrip("/") out = [f"# {uc['title']}", "", f"**Domain:** {uc['domain']}", ""] if gh: @@ -114,30 +221,31 @@ def _render_page(uc: dict, repo_url: str) -> str: f"({gh}/tree/main/use_cases/{uc['name']}/)", "", ] + if uc["demo_url"]: + out += [f"**Demo bundle:** [Download `.tar.gz`]({uc['demo_url']})", ""] out += [uc["summary"], ""] - guides = [g for g in uc["guides"] if isinstance(g, dict)] - if guides: - out += ["## Guides", ""] - for g in guides: - path = g.get("path", "") - text = g.get("text") or path - if gh and path: - out.append( - f"- [{text}]({gh}/blob/main/use_cases/{uc['name']}/{path})" - ) - elif text: - out.append(f"- {text}") - out.append("") + for g in uc["guides"]: + if not isinstance(g, dict): + continue + path = g.get("path", "") + if path: + out += [_inline_readme(uc, path, gh, uc_names, page_by_path), ""] return "\n".join(out) def on_files(files, config): + uc_names = {uc["name"] for uc in _use_cases} + page_by_path: dict[Path, str] = {} + for uc in _use_cases: + for g in uc["guides"]: + if isinstance(g, dict) and g.get("path"): + page_by_path[(uc["dir"] / g["path"]).resolve()] = uc["name"] for uc in _use_cases: files.append( File.generated( config, f"use-cases/{uc['name']}.md", - content=_render_page(uc, config.repo_url), + content=_render_page(uc, config.repo_url, uc_names, page_by_path), ) ) return files diff --git a/use_cases/isaac_skills_demo/README.md b/use_cases/isaac_skills_demo/README.md index 8daf6e5..942c130 100644 --- a/use_cases/isaac_skills_demo/README.md +++ b/use_cases/isaac_skills_demo/README.md @@ -1,3 +1,18 @@ +--- +title: Skill-Driven VASP → ISAAC Conversion +domain: Skill management — external skill catalog (K-Dense) authoring a VASP → ISAAC converter +summary: >- + A lightweight mock of the isaac_vasp workflow where the agent itself + discovers, syncs, installs, and authors skills (pymatgen, skill-creator) to + convert mock VASP output into an ISAAC record, vetting the skill-management + feature end-to-end. +status: published +order: 70 +guides: + - text: Skill-Driven VASP → ISAAC Walkthrough + path: README.md +--- + # DSAgt Demo: Skill-Driven VASP → ISAAC Conversion > **Estimated time:** ~15 minutes — the agent flow runs in seconds on the diff --git a/use_cases/links.csv b/use_cases/links.csv new file mode 100644 index 0000000..0aa25f7 --- /dev/null +++ b/use_cases/links.csv @@ -0,0 +1,10 @@ +aidrin_full_tour,https://drive.google.com/uc?export=download&id=1dngd2AfWkM_8NMXfjE5T7hrXGmh9C4_9 +aidrin_readiness_gate,https://drive.google.com/uc?export=download&id=1QxNup8WG0x1r7VNIB8NplnHrYX0ISv2J +comb-flow-uni,https://drive.google.com/uc?export=download&id=1g53VKzl5rzr0rJsE7KmZkngjNyi3EbuW +cryoem,https://drive.google.com/uc?export=download&id=1xNTL9vqSD4HnWMnpmgwihq-viU0Cus7V +fusion-fm,https://drive.google.com/uc?export=download&id=1qhlcl6Bou4JE2XPzlU3MdFmGlyeFHpHL +genesis_skills,https://drive.google.com/uc?export=download&id=1nji0Avc-n952isq5aKGLoZgTkYHe0jzR +isaac_skills_demo,https://drive.google.com/uc?export=download&id=19PNObF-FZkGITNJ_VIZH8j9BHWSqRPrH +isaac_vasp,https://drive.google.com/uc?export=download&id=1uH0r7ryF9nUJaE1fxXZMAzBjiE4TXxWu +microbial_isolates,https://drive.google.com/uc?export=download&id=1wzS-RwP89bAKDYFXiqssTg7knf6LLCUT +tokamak_stability,https://drive.google.com/uc?export=download&id=1Czzwec9go5JV7tCCli8YJcVOKJqirFgb