Skip to content

Commit 170f639

Browse files
committed
Create GitHub automation for editing Colab NB
1 parent a5c5fba commit 170f639

File tree

5 files changed

+598
-0
lines changed

5 files changed

+598
-0
lines changed

.github/scripts/update_notebook.py

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Script to automatically update PyImageJ AI Guide notebook cells based on
4+
personas and rulesets files.
5+
"""
6+
7+
import json
8+
import os
9+
import re
10+
from pathlib import Path
11+
from typing import Dict, List, Tuple
12+
13+
import jinja2
14+
import nbformat
15+
16+
17+
def scan_persona_files(personas_dir: Path) -> Dict[str, Dict[str, str]]:
18+
"""
19+
Scan personas directory and build category mappings.
20+
Expected format: activities/{category}_{level}.md
21+
"""
22+
activities_dir = personas_dir / "activities"
23+
category_mappings = {}
24+
25+
if not activities_dir.exists():
26+
return category_mappings
27+
28+
# Scan all activity files and group by category
29+
for file_path in sorted(activities_dir.glob("*.md")):
30+
stem = file_path.stem
31+
if "_" in stem:
32+
category, level = stem.rsplit("_", 1)
33+
if category not in category_mappings:
34+
category_mappings[category] = {}
35+
category_mappings[category][level] = file_path.name
36+
37+
return category_mappings
38+
39+
40+
def scan_ruleset_files(rulesets_dir: Path) -> Dict[str, str]:
41+
"""
42+
Scan rulesets directory for environment files.
43+
Expected format: environments/env_{environment}.md
44+
"""
45+
env_dir = rulesets_dir / "environments"
46+
environment_mapping = {}
47+
48+
if not env_dir.exists():
49+
return environment_mapping
50+
51+
# Scan environment files
52+
for file_path in sorted(env_dir.glob("env_*.md")):
53+
env_name = file_path.stem
54+
if env_name.startswith("env_"):
55+
# Convert env_colab -> Google Colab, env_script_editor -> Fiji Script Editor, etc.
56+
display_name = format_environment_name(env_name[4:]) # Remove "env_" prefix
57+
environment_mapping[display_name] = env_name
58+
59+
return environment_mapping
60+
61+
62+
def format_environment_name(env_key: str) -> str:
63+
"""Convert environment key to display name."""
64+
mapping = {
65+
"colab": "Google Colab",
66+
"interactive": "Interactive Desktop",
67+
"headless": "True Headless",
68+
"script_editor": "Fiji Script Editor"
69+
}
70+
return mapping.get(env_key, env_key.replace("_", " ").title())
71+
72+
73+
def build_persona_template_data(category_mappings: Dict[str, Dict[str, str]]) -> Dict:
74+
"""Build template data for persona cell."""
75+
76+
# Create categories with their options
77+
categories = {}
78+
experience_levels = {}
79+
80+
level_mapping = {
81+
"beginner": "beginner",
82+
"intermediate": "intermediate",
83+
"advanced": "advanced"
84+
}
85+
86+
# Build categories and experience level mappings in desired order
87+
# Define preferred order for known categories, but include any new ones automatically
88+
preferred_order = ["colab", "coding", "pyimagej"]
89+
90+
# Start with preferred categories in order, then add any new ones alphabetically
91+
ordered_categories = []
92+
for category in preferred_order:
93+
if category in category_mappings:
94+
ordered_categories.append(category)
95+
96+
# Add any categories not in preferred_order (future categories)
97+
remaining_categories = sorted([cat for cat in category_mappings.keys() if cat not in preferred_order])
98+
ordered_categories.extend(remaining_categories)
99+
100+
for category in ordered_categories:
101+
levels = category_mappings[category]
102+
category_key = f"{category}"
103+
104+
# Create display options for this category in specific order
105+
level_order = ["beginner", "intermediate", "advanced"] # Define desired order
106+
options = []
107+
for level in level_order: # Use specific order instead of sorted()
108+
if level in levels:
109+
display_name = f"{'New to' if level == 'beginner' else 'Some' if level == 'intermediate' else category.title() + ' expert'} {category.replace('_', ' ')}"
110+
if category == "coding":
111+
display_name = f"{'New to programming' if level == 'beginner' else 'Some programming experience' if level == 'intermediate' else 'Advanced programmer'}"
112+
elif category == "pyimagej":
113+
display_name = f"{'New to PyImageJ' if level == 'beginner' else 'Some PyImageJ experience' if level == 'intermediate' else 'PyImageJ expert'}"
114+
elif category == "colab":
115+
display_name = f"{'New to Google Colab' if level == 'beginner' else 'Some Colab experience' if level == 'intermediate' else 'Colab expert'}"
116+
117+
options.append(display_name)
118+
experience_levels[display_name] = level_mapping[level]
119+
120+
if options:
121+
categories[category_key] = options
122+
123+
return {
124+
"categories": categories,
125+
"experience_levels": experience_levels,
126+
"category_mappings": category_mappings
127+
}
128+
129+
130+
def update_notebook_cell(notebook: nbformat.NotebookNode, cell_id: str, new_content: str) -> bool:
131+
"""Update a specific cell in the notebook by ID."""
132+
for cell in notebook.cells:
133+
if cell.get("id") == cell_id:
134+
cell["source"] = new_content
135+
return True
136+
return False
137+
138+
139+
def update_colab_badge_cell(notebook: nbformat.NotebookNode, branch_name: str, notebook_filename: str) -> bool:
140+
"""Update the Colab badge cell to point to the correct branch and filename."""
141+
badge_cell_id = "#VSC-8ce7bebd" # The badge cell ID from the notebook
142+
143+
for cell in notebook.cells:
144+
if cell.get("id") == badge_cell_id:
145+
source = cell["source"]
146+
# Update the Colab badge URL to use the correct branch and filename
147+
# Pattern matches the full Colab URL structure
148+
updated_source = re.sub(
149+
r'https://colab\.research\.google\.com/github/imagej/pyimagej/blob/[^/]+/doc/llms/[^"]+',
150+
f'https://colab.research.google.com/github/imagej/pyimagej/blob/{branch_name}/doc/llms/{notebook_filename}',
151+
source
152+
)
153+
cell["source"] = updated_source
154+
return True
155+
return False
156+
157+
158+
def update_download_cell(notebook: nbformat.NotebookNode, commit_sha: str, branch_name: str) -> bool:
159+
"""Update the download cell to checkout the specific commit."""
160+
download_cell_id = "#VSC-b7af7e7c" # The download cell ID from the notebook
161+
162+
for cell in notebook.cells:
163+
if cell.get("id") == download_cell_id:
164+
source = cell["source"]
165+
# Replace any existing git checkout with the specific commit SHA
166+
updated_source = re.sub(
167+
r'!cd /content/pyimagej && git checkout \S+',
168+
f'!cd /content/pyimagej && git checkout {commit_sha}',
169+
source
170+
)
171+
cell["source"] = updated_source
172+
return True
173+
return False
174+
175+
176+
def main():
177+
"""Main script execution."""
178+
179+
# Setup paths
180+
script_dir = Path(__file__).parent
181+
repo_root = script_dir.parent.parent
182+
notebook_path = repo_root / "doc" / "llms" / "pyimagej-ai-guide.ipynb"
183+
personas_dir = repo_root / "doc" / "llms" / "personas"
184+
rulesets_dir = repo_root / "doc" / "llms" / "rulesets"
185+
templates_dir = script_dir / "templates"
186+
187+
# Get commit SHA and branch name from environment
188+
commit_sha = os.environ.get("COMMIT_SHA", "main")
189+
branch_name = os.environ.get("BRANCH_NAME", "main")
190+
191+
# Determine commit message prefix based on branch
192+
if branch_name == "main":
193+
commit_prefix = "Auto-update"
194+
else:
195+
commit_prefix = "WIP: Auto-update"
196+
197+
print(f"Updating notebook: {notebook_path}")
198+
print(f"Using commit SHA: {commit_sha}")
199+
print(f"From branch: {branch_name}")
200+
print(f"Commit prefix: {commit_prefix}")
201+
202+
# Load notebook
203+
with open(notebook_path, "r", encoding="utf-8") as f:
204+
notebook = nbformat.read(f, as_version=4)
205+
206+
# Setup Jinja environment
207+
jinja_env = jinja2.Environment(
208+
loader=jinja2.FileSystemLoader(templates_dir),
209+
trim_blocks=True,
210+
lstrip_blocks=True
211+
)
212+
213+
# Scan and update persona cell
214+
category_mappings = scan_persona_files(personas_dir)
215+
if category_mappings:
216+
print(f"Found persona categories: {list(category_mappings.keys())}")
217+
218+
template_data = build_persona_template_data(category_mappings)
219+
template = jinja_env.get_template("personalize_gemini_cell.py.j2")
220+
new_content = template.render(**template_data)
221+
222+
# Update the persona cell
223+
persona_cell_id = "#VSC-672bc454"
224+
if update_notebook_cell(notebook, persona_cell_id, new_content):
225+
print("✅ Updated Personalize Gemini cell")
226+
else:
227+
print("❌ Failed to find Personalize Gemini cell")
228+
229+
# Scan and update ruleset cell
230+
environment_mapping = scan_ruleset_files(rulesets_dir)
231+
if environment_mapping:
232+
print(f"Found environments: {list(environment_mapping.keys())}")
233+
234+
template_data = {
235+
"environments": sorted(environment_mapping.keys()),
236+
"environment_mapping": environment_mapping
237+
}
238+
template = jinja_env.get_template("set_coding_rules_cell.py.j2")
239+
new_content = template.render(**template_data)
240+
241+
# Update the rules cell
242+
rules_cell_id = "#VSC-382943dc"
243+
if update_notebook_cell(notebook, rules_cell_id, new_content):
244+
print("✅ Updated Set Coding Rules cell")
245+
else:
246+
print("❌ Failed to find Set Coding Rules cell")
247+
248+
# Update download cell with commit SHA
249+
if update_download_cell(notebook, commit_sha, branch_name):
250+
print(f"✅ Updated Download cell to use commit {commit_sha}")
251+
else:
252+
print("❌ Failed to find Download cell")
253+
254+
# Update Colab badge cell with correct branch and filename
255+
notebook_filename = notebook_path.name
256+
if update_colab_badge_cell(notebook, branch_name, notebook_filename):
257+
print(f"✅ Updated Colab badge to use branch {branch_name}")
258+
else:
259+
print("❌ Failed to find Colab badge cell")
260+
261+
# Save updated notebook
262+
with open(notebook_path, "w", encoding="utf-8") as f:
263+
nbformat.write(notebook, f, version=4)
264+
265+
print("✅ Notebook update complete!")
266+
267+
268+
if __name__ == "__main__":
269+
main()
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
#@title 🤖 Personalize Gemini { display-mode: "form", run: "auto" }
2+
#@markdown This cell tailors Gemini's persona based on your selected experience levels.
3+
#@markdown
4+
#@markdown This takes advantage of Gemini's "knowledge" of all cell outputs in the notebook: all we need to do is print the persona (or rule) text, and Gemini will automatically pick it up.
5+
#@markdown
6+
#@markdown Select your experience levels with the relevant tools, then run this cell to set Gemini's persona.
7+
#@markdown
8+
#@markdown 💡 Tip: Changing selections automatically updates the AI (after manually running once)
9+
#@markdown
10+
#@markdown 💡 Tip: Copy output text to use with other LLMs (ChatGPT, Claude, etc.)
11+
12+
# AUTO-GENERATED CONTENT BELOW - DO NOT EDIT MANUALLY
13+
# This section is automatically updated based on files in doc/llms/personas/
14+
15+
# Experience level parameters (Colab forms)
16+
{% for category, options in categories.items() %}
17+
{{ category }}_experience = "{{ options[0] }}" #@param {{ options | tojson }}
18+
{% endfor %}
19+
20+
beginner = "beginner"
21+
intermediate = "intermediate"
22+
advanced = "advanced"
23+
24+
# Convert display names to internal values
25+
experience_mapping = {
26+
{% for category, options in categories.items() %}
27+
{% for option in options %}
28+
"{{ option }}": {{ experience_levels[option] }},
29+
{% endfor %}
30+
{% endfor %}
31+
}
32+
33+
# Template file mapping for different experience categories
34+
activity_dir = 'activities/'
35+
36+
{% for category, mapping in category_mappings.items() %}
37+
{{ category }}_activities = {
38+
{% for level, filename in mapping.items() %}
39+
{{ level }}: activity_dir + '{{ filename }}',
40+
{% endfor %}
41+
}
42+
43+
{% endfor %}
44+
45+
from pathlib import Path
46+
def load_persona_file(filename):
47+
"""Load a persona template file"""
48+
try:
49+
# First check if we're in a cloned pyimagej repo
50+
persona_path = Path('/content/pyimagej/doc/llms/personas') / filename
51+
if not persona_path.exists():
52+
# Fall back to current directory structure
53+
persona_path = Path('./personas') / filename
54+
55+
if persona_path.exists():
56+
return persona_path.read_text(encoding='utf-8')
57+
else:
58+
return f"# Template not found: {filename}\n(Using basic fallback)"
59+
except Exception as e:
60+
return f"# Error loading {filename}: {e}\n(Using basic fallback)"
61+
62+
# Load base persona
63+
persona_text = "===START OF PERSONA TEXT===\n"
64+
persona_text += load_persona_file('base_persona.md')
65+
persona_text += "\n===END OF PERSONA TEXT===\n\n"
66+
67+
# Get experience levels
68+
{% for category in categories.keys() %}
69+
{{ category }}_level = experience_mapping[{{ category }}_experience]
70+
{% endfor %}
71+
72+
# Add activities based on experience levels
73+
persona_text = "===START OF ACTIVITY TEXT===\n"
74+
{% for category in categories.keys() %}
75+
if {{ category }}_level in {{ category }}_activities:
76+
persona_text += "\n" + load_persona_file({{ category }}_activities[{{ category }}_level])
77+
78+
{% endfor %}
79+
persona_text += "\n===END OF ACTIVITY TEXT===\n\n"
80+
81+
# Register the persona with the LLM
82+
print(persona_text)
83+
84+
# Add copy button for convenience
85+
from IPython.display import HTML, display
86+
import base64
87+
88+
# Encode text as base64 to avoid all escaping issues
89+
persona_text_b64 = base64.b64encode(persona_text.encode('utf-8')).decode('ascii')
90+
91+
copy_button_html = f'''
92+
<button onclick="
93+
const encodedText = '{persona_text_b64}';
94+
const byteChars = atob(encodedText);
95+
const byteNumbers = new Array(byteChars.length);
96+
for (let i = 0; i < byteChars.length; i++) {{
97+
byteNumbers[i] = byteChars.charCodeAt(i);
98+
}}
99+
const byteArray = new Uint8Array(byteNumbers);
100+
const decodedText = new TextDecoder('utf-8').decode(byteArray);
101+
102+
navigator.clipboard.writeText(decodedText).then(() => {{
103+
this.innerHTML = '✅ Copied to clipboard!';
104+
setTimeout(() => {{ this.innerHTML = '📋 Copy Persona Text'; }}, 2000);
105+
}}).catch(() => {{
106+
this.innerHTML = '❌ Copy failed - please copy manually from output above';
107+
setTimeout(() => {{ this.innerHTML = '📋 Copy Persona Text'; }}, 3000);
108+
}})
109+
">📋 Copy Persona Text</button>
110+
'''
111+
display(HTML(copy_button_html))

0 commit comments

Comments
 (0)