Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 13 additions & 104 deletions .github/scripts/generate_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,6 @@ def qmd_to_skill_offline(filepath: Path, base_url: str = DEFAULT_BASE_URL) -> Di
"tutorial_url": tutorial_url,
"skill": skill_text,
"source_file": str(filepath),
"skill_file": f"skills/{category}/{filepath.stem}_skill.md",
}


Expand Down Expand Up @@ -641,7 +640,6 @@ def generate_skill(self, filepath: Path,
"tutorial_url": tutorial_url,
"skill": skill_text,
"source_file": str(filepath),
"skill_file": f"skills/{category}/{filepath.stem}_skill.md",
}


Expand Down Expand Up @@ -687,28 +685,18 @@ def main():
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--input", default=".", help="Input QMD file or directory")
parser.add_argument("--output", default="skills", help="Output directory")
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
parser.add_argument("--format", choices=["markdown", "json", "both"],
default="both")
parser.add_argument("--offline", action="store_true",
help="Use offline rule-based extraction (no LLM)")
parser.add_argument("--files", nargs="*",
help="Specific QMD files to process")
parser.add_argument("--changed-files",
help="Space-separated list of changed QMD files")
help="Space-separated list of changed QMD files (ignored; always processes all)")
parser.add_argument("--verbose", action="store_true")
args = parser.parse_args()

output_dir = Path(args.output)
output_dir.mkdir(parents=True, exist_ok=True)

# Determine which files to process
if args.changed_files:
qmd_files = [Path(f.strip()) for f in args.changed_files.split()
if f.strip().endswith(".qmd")
and not f.strip().endswith(".zh.qmd")]
elif args.files:
if args.files:
qmd_files = [Path(f) for f in args.files
if f.endswith(".qmd") and not f.endswith(".zh.qmd")]
else:
Expand Down Expand Up @@ -755,89 +743,8 @@ def main():
errors.append((f, exc))
print(f" ✗ {f}: {exc}", file=sys.stderr)

write_json = args.format in ("json", "both")
write_markdown = args.format in ("markdown", "both")

# Write individual skill Markdown files
if write_markdown:
for skill in skills:
cat_dir = output_dir / skill["category"]
cat_dir.mkdir(parents=True, exist_ok=True)
stem = Path(skill["source_file"]).stem
out_file = cat_dir / f"{stem}_skill.md"
out_file.write_text(skill["skill"], encoding="utf-8")
print(f"\nWrote {len(skills)} skill file(s) to {output_dir}/")

# Build or update index
# In full mode (no specific files), replace entirely; in incremental mode, merge
incremental = bool(args.changed_files or args.files)
index_file = output_dir / "index.json"

if incremental and index_file.exists():
# Load existing index and merge with new skills
try:
existing_index = json.loads(index_file.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
existing_index = []

new_entries = {s["source_file"]: {k: v for k, v in s.items() if k != "skill"}
for s in skills}

updated_index = []
seen = set()
for entry in existing_index:
src = entry.get("source_file", "")
if src in new_entries:
updated_index.append(new_entries[src])
seen.add(src)
else:
updated_index.append(entry)
for src, entry in new_entries.items():
if src not in seen:
updated_index.append(entry)
else:
# Full replacement
updated_index = [{k: v for k, v in s.items() if k != "skill"}
for s in skills]

with open(index_file, "w", encoding="utf-8") as fh:
json.dump(updated_index, fh, ensure_ascii=False, indent=2)
print(f"Index written to {index_file} ({len(updated_index)} entries)")

# Write full skills JSON
if write_json:
full_file = output_dir / "bizard_skills.json"

if incremental and full_file.exists():
# Merge with existing full JSON
try:
existing_full = json.loads(full_file.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
existing_full = []

new_full = {s["source_file"]: s for s in skills}
updated_full = []
seen_full = set()
for entry in existing_full:
src = entry.get("source_file", "")
if src in new_full:
updated_full.append(new_full[src])
seen_full.add(src)
else:
updated_full.append(entry)
for src, entry in new_full.items():
if src not in seen_full:
updated_full.append(entry)
else:
# Full replacement
updated_full = list(skills)

with open(full_file, "w", encoding="utf-8") as fh:
json.dump(updated_full, fh, ensure_ascii=False, indent=2)
print(f"Full skills JSON written to {full_file} ({len(updated_full)} entries)")

# Generate unified SKILL.md from the full index
generate_unified_skill(index_file, Path("files/gallery_data.csv"),
# Generate unified SKILL.md directly from in-memory skills list
generate_unified_skill(skills, Path("files/gallery_data.csv"),
Path("SKILL.md"))

# Summary
Expand Down Expand Up @@ -888,7 +795,7 @@ def main():
]


def generate_unified_skill(index_path: Path, gallery_csv: Path,
def generate_unified_skill(all_skills: List[dict], gallery_csv: Path,
output_path: Path) -> None:
"""Generate the unified ``SKILL.md`` — an AI skill instruction document.

Expand All @@ -898,13 +805,8 @@ def generate_unified_skill(index_path: Path, gallery_csv: Path,
"""
import csv as _csv

if not index_path.exists():
print(f" ⚠ Skipping unified SKILL.md: {index_path} not found")
return

all_skills = json.loads(index_path.read_text(encoding="utf-8"))
if not all_skills:
print(" ⚠ Skipping unified SKILL.md: index is empty")
print(" ⚠ Skipping unified SKILL.md: no skills provided")
return

# Gallery row count
Expand Down Expand Up @@ -984,6 +886,13 @@ def generate_unified_skill(index_path: Path, gallery_csv: Path,
)
L.append(" website: https://openbiox.github.io/Bizard/")
L.append(" repository: https://github.com/openbiox/Bizard")
L.append(" citation: >")
L.append(
" - Li, K., Zheng, H., Huang, K., Chai, Y., Peng, Y., Wang, C., "
"... & Wang, S. (2026). Bizard: A Community\u2010Driven Platform for "
"Accelerating and Enhancing Biomedical Data Visualization. iMetaMed, "
"e70038. <https://doi.org/10.1002/imm3.70038>"
)
L.append("---")
L.append("")
# ── Title & role ────────────────────────────────────────────────
Expand Down
63 changes: 14 additions & 49 deletions .github/workflows/generate-skills.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,6 @@ on:
- '.github/scripts/generate_skills.py'
workflow_dispatch:
inputs:
files:
description: 'Space-separated list of QMD files to process (empty = all)'
required: false
default: ''
pr_number:
description: 'PR number (for command-triggered runs)'
required: false
Expand Down Expand Up @@ -69,78 +65,47 @@ jobs:
- name: Install dependencies
run: pip install openai

# Detect changed files for push events
- name: Detect changed QMD files
if: github.event_name == 'push' && inputs.files == ''
id: changed
run: |
CHANGED=$(git diff --name-only HEAD~1 HEAD -- '*.qmd' | grep -v '\.zh\.qmd$' || true)
echo "files<<EOF" >> "$GITHUB_OUTPUT"
echo "$CHANGED" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
echo "Changed QMD files: $CHANGED"

- name: Generate skill documents
- name: Generate unified SKILL.md
env:
AI_Model_API_KEY: ${{ secrets.AI_Model_API_KEY }}
AI_Model_BASE_URL: ${{ secrets.AI_Model_BASE_URL }}
AI_Model_Name: ${{ secrets.AI_Model_Name }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
ARGS="--output skills/ --base-url https://openbiox.github.io/Bizard/ --format both --verbose"
ARGS="--base-url https://openbiox.github.io/Bizard/ --verbose"

# Check if offline mode is requested
if [ "${{ inputs.offline }}" = "true" ]; then
ARGS="${ARGS} --offline"
fi

# Determine which files to process
if [ -n "${{ inputs.files }}" ]; then
# Specific files from workflow dispatch
python .github/scripts/generate_skills.py ${ARGS} --changed-files "${{ inputs.files }}"
elif [ -n "${{ steps.changed.outputs.files }}" ]; then
# Changed files from push event
FILES=$(echo "${{ steps.changed.outputs.files }}" | tr '\n' ' ')
python .github/scripts/generate_skills.py ${ARGS} --changed-files "${FILES}"
else
# Process all files (manual dispatch without file list)
python .github/scripts/generate_skills.py ${ARGS}
fi
python .github/scripts/generate_skills.py ${ARGS}

- name: Check for changes
id: git-check
run: |
if git diff --quiet && [ -z "$(git ls-files --others --exclude-standard skills/)" ]; then
if git diff --quiet SKILL.md; then
echo "committed=false" >> "$GITHUB_OUTPUT"
echo "No skill file changes detected."
echo "No SKILL.md changes detected."
else
echo "committed=true" >> "$GITHUB_OUTPUT"
echo "Skill files updated."
git status --short
fi

- name: Check for changes (including unified SKILL.md)
id: git-check-unified
run: |
if git diff --quiet SKILL.md && git diff --quiet -- skills/; then
echo "No unified skill or skills directory changes."
else
echo "unified_changed=true" >> "$GITHUB_OUTPUT"
echo "SKILL.md updated."
git status --short SKILL.md
fi

- name: Commit and push skill documents
if: steps.git-check.outputs.committed == 'true' || steps.git-check-unified.outputs.unified_changed == 'true'
- name: Commit and push SKILL.md
if: steps.git-check.outputs.committed == 'true'
run: |
git config user.email "github-actions[bot]@users.noreply.github.com"
git config user.name "github-actions[bot]"
git add skills/ SKILL.md
git commit -m "chore: regenerate AI skill documents" \
-m "Auto-generated skill documents from QMD tutorials."
git add SKILL.md
git commit -m "chore: regenerate AI skill document" \
-m "Auto-generated SKILL.md from QMD tutorials."
BRANCH="${PR_BRANCH:-${{ github.ref_name }}}"
git push origin "${BRANCH}"

- name: Post PR comment
if: (steps.git-check.outputs.committed == 'true' || steps.git-check-unified.outputs.unified_changed == 'true') && inputs.pr_number != ''
if: steps.git-check.outputs.committed == 'true' && inputs.pr_number != ''
uses: actions/github-script@v7
with:
script: |
Expand All @@ -150,5 +115,5 @@ jobs:
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: `✅ **Skill documents regenerated** and pushed to the PR branch.\n\nSee the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.`
body: `✅ **SKILL.md regenerated** and pushed to the PR branch.\n\nSee the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.`
});
1 change: 0 additions & 1 deletion .github/workflows/quarto-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ on:
paths-ignore:
- '*.md'
- 'LICENSE'
- 'skills/**'
- 'scripts/**'
- '.github/scripts/**'
- '.github/workflows/auto-translate.yml'
Expand Down
13 changes: 2 additions & 11 deletions .github/workflows/skill-command.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@ name: Handle /skill PR Comment Command
# 3. Dispatches generate-skills.yml (workflow_dispatch) with the PR context
#
# Usage in a PR comment:
# /skill → regenerate skills for all changed QMD files
# /skill Omics/VolcanoPlot.qmd → regenerate skill for one specific file
# /skill File1.qmd File2.qmd → regenerate skills for multiple files
# /skill → regenerate SKILL.md from all QMD tutorials
# /skill --offline → use offline mode (no LLM API)
# ──────────────────────────────────────────────────────────────────────────────
on:
Expand Down Expand Up @@ -79,9 +77,6 @@ jobs:
// ── Parse arguments ───────────────────────────────────────────────
const parts = context.payload.comment.body.trim().split(/\s+/).slice(1);
const offline = parts.includes('--offline') ? 'true' : 'false';
const files = parts
.filter(p => (p.endsWith('.qmd') || p.endsWith('.Qmd')) && !p.endsWith('.zh.qmd'))
.join(' ');

// ── Dispatch the skill generation workflow ────────────────────────
const defaultBranch = context.payload.repository.default_branch;
Expand All @@ -92,20 +87,16 @@ jobs:
ref: defaultBranch,
inputs: {
pr_number: String(context.issue.number),
files: files || '',
offline: offline,
},
});

// ── Post acknowledgement comment ──────────────────────────────────
const runsUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/workflows/generate-skills.yml`;
const filesMsg = files
? `\n\n**Target files:** \`${files.replace(/ /g, '`, `')}\``
: '';
const modeMsg = offline === 'true' ? ' (offline mode)' : '';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `🤖 Skill generation workflow dispatched by @${commenter}${modeMsg}.${filesMsg}\n\n[View recent workflow runs](${runsUrl})`,
body: `🤖 Skill generation workflow dispatched by @${commenter}${modeMsg}.\n\n[View recent workflow runs](${runsUrl})`,
});
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,9 @@ __pycache__

# Julia generated files
Julia/Manifest.toml

# Individual per-tutorial skill markdown files (unified into SKILL.md)
skills/**/*_skill.md

# Entire skills/ directory (superseded by SKILL.md)
skills/
2 changes: 2 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ metadata:
skill-author: Bizard Collaboration Group, Luo Lab, and Wang Lab
website: https://openbiox.github.io/Bizard/
repository: https://github.com/openbiox/Bizard
citation: >
- Li, K., Zheng, H., Huang, K., Chai, Y., Peng, Y., Wang, C., ... & Wang, S. (2026). Bizard: A Community‐Driven Platform for Accelerating and Enhancing Biomedical Data Visualization. iMetaMed, e70038. <https://doi.org/10.1002/imm3.70038>
---

# Bizard — Biomedical Visualization Atlas AI Skill
Expand Down
Loading