Initial commit: Hermes Agent Skills collection

This commit is contained in:
Debian
2026-07-12 19:02:59 +00:00
commit e9cc106625
789 changed files with 233126 additions and 0 deletions
+199
View File
@@ -0,0 +1,199 @@
---
name: visual-qa
description: "Automated visual QA of rendered documents (slides, PDFs, diagrams, screenshots). Detect overlap, overflow, contrast issues by sending images to a vision-capable model. Trigger on 'QA', 'review slides', 'check presentation', 'visual inspection', or when converting documents to images for review."
---
# Visual QA Skill
Automated visual quality assurance of rendered documents — presentations, PDFs, diagrams, screenshots. Sends images to a vision-capable LLM and gets structured feedback on layout issues.
## When to Use
- After generating or editing a .pptx deck (pair with the `powerpoint` skill)
- After rendering PDFs, diagrams, or HTML artifacts to images
- Before delivering visual content to a user
- Any time you need "fresh eyes" on rendered output
## Workflow
1. **Render to images** (if not already done):
```bash
# PPTX → PDF → JPEGs
soffice --headless --convert-to pdf input.pptx
pdftoppm -jpeg -r 150 input.pdf slide
# Produces slide-01.jpg, slide-02.jpg, etc.
```
2. **Verify a vision-capable model exists** on your endpoint (see [references/model-capabilities.md](references/model-capabilities.md)):
```bash
curl -s <base_url>/models -H "Authorization: Bearer <key>" | python3 -m json.tool | grep -i model
```
Not all models accept images — test with one image first.
3. **Run batch QA** — use the batch script for efficiency:
```bash
NORIS_API_KEY=<key> python scripts/batch_vision_qa.py /path/to/slides/ <num_slides>
```
4. **Fix issues found**, re-render affected slides, re-verify.
## Key Techniques
### Direct API Calls for Batch Processing
For QA of many images, calling the vision model API directly is far more efficient than individual `vision_analyze` tool calls. A 17-slide deck processes in ~22 seconds via batch API vs. 17 sequential tool calls.
Pattern (Python with `urllib.request`):
```python
import urllib.request, json, base64
with open(slide_path, "rb") as f:
img_b64 = base64.b64encode(f.read()).decode()
payload = {
"model": "<vision_model>",
"messages": [{"role": "user", "content": [
{"type": "text", "text": "<QA prompt>"},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}}
]}],
"max_tokens": 300
}
req = urllib.request.Request(
"<base_url>/chat/completions",
data=json.dumps(payload).encode(),
headers={"Authorization": "Bearer <key>", "Content-Type": "application/json"}
)
resp = urllib.request.urlopen(req, timeout=60)
result = json.loads(resp.read())["choices"][0]["message"]["content"]
```
### Structured QA Prompt
Use a prompt that forces structured JSON output for easy parsing. The base prompt checks overlap, overflow, and contrast:
```
Analyze this slide for: 1) TEXT OVERLAP, 2) TEXT OVERFLOW, 3) CONTRAST issues.
Respond as JSON: {"slide": N, "overlap": "none|<desc>", "overflow": "none|<desc>", "contrast": "ok|<desc>", "overall": "clean|issues found"}
```
For comprehensive QA, expand the prompt to also check symbols and content consistency:
```
Analyze this slide for: 1) TEXT OVERLAP, 2) TEXT OVERFLOW, 3) CONTRAST, 4) LAYOUT (misalignment, spacing, margins),
5) CONTENT (unclear messaging, placeholder text, spelling, logical inconsistencies), 6) SYMBOLS (broken/missing
Unicode characters showing as boxes or empty circles).
Respond as JSON: {"slide": N, "overlap": "...", "overflow": "...", "contrast": "...", "layout": "...",
"content": "...", "symbols": "...", "overall": "clean|issues found"}
```
### Two-Tier QA Strategy
A single batch pass with the base prompt catches obvious layout issues but **misses rendering defects and content problems** that require closer inspection. Use a two-tier approach:
1. **Tier 1 — Batch scan** (breadth): Send all slides through the batch script with the expanded prompt above. Identifies which slides have potential issues.
2. **Tier 2 — Targeted deep-dive** (depth): For slides flagged in Tier 1 (or slides with complex elements like diagrams, badges, special characters), use individual `vision_analyze` calls with **element-specific questions**:
- "Do the Unicode symbols ∅, ≡, ⚙ inside the circles render correctly or show as boxes?"
- "Are the connecting lines between neurons visible? What color are they?"
- "Does the example value '42' match the description 'Temperatur = 23°C'?"
- "Is there adequate padding between the text 'API' and the circle edge?"
Targeted prompts catch what generic prompts miss — the vision model focuses on the specific element rather than scanning the whole slide.
### Model Capability Detection
Before batch QA, verify the target model accepts image input. Some models return `400: At most 0 image(s)` or claim "I cannot see images" even with proper `image_url` format. Test with a single image first.
See [references/model-capabilities.md](references/model-capabilities.md) for tested models.
## Config: vision_analyze Tool
The `vision_analyze` tool reads model config from TWO places in `~/.hermes/config.yaml`:
- `auxiliary.vision.*` — provider, model, base_url, api_key
- Top-level `vision.*` — model, base_url, api_key (overrides auxiliary)
Both must have the correct model name (including any `release/` prefix) and non-empty base_url + api_key. Use `hermes config set` to update:
```bash
hermes config set auxiliary.vision.model "vllm/release/<model>"
hermes config set auxiliary.vision.base_url "https://<endpoint>/v1"
hermes config set auxiliary.vision.api_key "<key>"
hermes config set vision.model "vllm/release/<model>"
hermes config set vision.base_url "https://<endpoint>/v1"
hermes config set vision.api_key "<key>"
```
Changes require a session restart to take effect in the `vision_analyze` tool. For immediate use without restart, call the API directly (see above).
## Corporate Template Adoption QA
When a user provides a `.pptx` template and asks to rebuild a deck to match its design, the QA phase must verify **design conformance** in addition to layout correctness:
1. **Template extraction QA** — render template slides to JPEGs and inspect with `vision_analyze` to understand: background color, card style (dark/light, shadows, corners), accent bar style, logo placement, typography, decorative elements.
2. **Theme extraction** — parse `ppt/theme/theme1.xml` via `zipfile` + `xml.etree.ElementTree` (not python-pptx's theme API, which can raise `AttributeError`). Extract `<a:srgbClr>` values and `<a:latin typeface="...">` font names.
3. **Embedded image inspection** — extract images from `ppt/media/` via `zipfile`, inspect each with `vision_analyze`. Logos may be white-on-transparent (invisible on white backgrounds). If unusable, build a text+shape logo instead.
4. **Post-rebuild QA prompt** — add design-conformance checks to the standard QA prompt:
```
Check this slide for: 1) noris corporate design (white bg, light grey cards #F2F2F2,
petrol #234D5B accents, Open Sans font) 2) logo visible top right 3) no text overlaps
4) no layout issues 5) readable text. Reply CLEAN or list ISSUES.
```
5. **Non-ASCII quote pitfall in source code** — German curly quotes (`„…"` / `»…«`) cause `SyntaxError: Unexpected identifier` in Node.js string literals. Use `\u201E` (left double) and `\u201C` (right double) Unicode escapes instead. The `write_file` linter catches this but only if the quotes land inside a JS string.
6. **Logo extraction — use the REAL logo, not a text approximation** — When a user provides a `.pptx` template and asks to use its design, they expect the actual corporate logo image, not a reconstructed text+shape approximation. Identify the logo by counting image references across slide `_rels/*.xml.rels` files (logo appears on 20+ slides). **Pitfall:** the most-referenced image may be an EMF (unusable in LibreOffice/pptxgenjs) — check for PNG/JPG alternatives. Multi-part logos (two color variants in one image) require pixel-alpha scanning to find the split point and selective inversion for dark-background variants. See [references/template-adoption.md](references/template-adoption.md) §6 for the full workflow.
See [references/template-adoption.md](references/template-adoption.md) for the full extraction workflow with code snippets, including §10 (inserting new slides into an existing deck — module organization, renumbering, agenda/summary updates).
## Pitfalls
- **Model name prefixes matter**: `vllm/gemma-4-31b-it` ≠ `vllm/release/gemma-4-31b-it`. Wrong prefix → "no keys found that support model" error.
- **Empty base_url/api_key in auxiliary.vision**: Even if top-level `vision.*` is correct, empty auxiliary fields can cause failures. Set both sections.
- **Not all LLMs are multimodal**: A model serving text completions may reject image inputs. Always test one image first.
- **vision_analyze caches config**: After `hermes config set`, the running session may still use old values. Direct API calls bypass this.
- **Rate limiting**: Add 0.5s sleep between batch calls to avoid throttling.
- **Unicode symbols silently fail in LibreOffice PDF rendering**: Characters like ∅, ≡, ⚙, ★, ✓, Σ, Ω, → may look fine in PowerPoint but render as blank circles or broken boxes when exported to PDF via `soffice --headless`. Since visual QA relies on PDF→JPEG conversion, these defects are visible in QA images but the **generic batch prompt may not flag them** — it sees "a circle with something in it" and passes it. Always use targeted Tier 2 prompts that explicitly ask "do the symbols inside the circles render correctly?" for slides with badges or icon placeholders.
- **Content consistency requires targeted prompts**: A batch prompt won't catch logical inconsistencies like "example shows 42 but description says 23°C" unless the prompt explicitly asks about content consistency. Add content checks to the expanded prompt and follow up with targeted questions.
- **PowerPoint rejects negative shape dimensions/positions — LibreOffice doesn't**: pptxgenjs silently emits negative `cx`/`cy` (extent) or `x`/`y` (offset) values in the OOXML when LINE shapes connect nodes bottom-to-top or right-to-left, or when decorative OVALs are placed partially off-slide (`x: -2, y: -2`). LibreOffice opens these files fine and the PDF→JPEG render looks correct, so visual QA passes — but PowerPoint refuses to open the file. After generating a PPTX, always run `scripts/validate_pptx_compat.py <file.pptx>` to catch negative extents/offsets before delivery. Fix in source: use `Math.abs()` for dimensions, `Math.min()` for positions, and clamp decorative shapes to non-negative coordinates. See [references/template-adoption.md](references/template-adoption.md) §9.
## Narrative Structure Review
After creating or substantially expanding a deck, review the **narrative flow** ("roter Faden") before delivery. A deck can be visually clean but structurally confused.
### Consolidation Rules
- **Max 8 agenda points** — beyond that, the audience loses the thread. Group related slides under one agenda item.
- **Max 5 takeaways** — distill into the fewest memorable points. Merge related ones (e.g., "ML learns from data" + "parameters store knowledge" → one takeaway).
- **One concept per slide** — if a slide teaches two things, split it or merge into a progression.
### Flow Audit Steps
1. **List all slides** with titles and the concept they teach.
2. **Identify redundancy**: slides repeating the same idea at different depths (e.g., "KI ist nicht intelligent" as both slide 3 and slide 17).
3. **Identify interruptions**: deep-dives that break the narrative arc (e.g., tensor dimensionality between neural networks and LLMs).
4. **Check proximity of linked concepts**: Attention-Mechanismus and KV-Cache are technically coupled — 7 slides apart breaks the connection. Keep linked concepts adjacent.
5. **Propose merges**: combine slides teaching the same concept (e.g., MoE architecture + Dense-vs-MoE comparison → one slide).
6. **Propose cuts**: slides that don't advance the narrative.
### Output Format
Present as a table mapping new agenda items to current slides with actions (Merge/Consolidate/Keep/Cut), then list ≤5 proposed takeaways showing which current points they unify.
### Pitfall: Expanding Without Consolidating
Adding content slides to an existing deck is common. Each addition shifts slide numbers, grows the agenda, and lengthens the summary. After expansion, always run a flow audit and propose consolidation — a 23-slide deck with 14 agenda points and 12 takeaways is harder to follow than 18 slides with 8 and 5.
## Files
| File | Purpose |
|------|---------|
| `scripts/batch_vision_qa.py` | Batch QA script — sends all slides to vision model, reports issues |
| `scripts/validate_pptx_compat.py` | PPTX structural validation — catches negative dimensions/positions that LibreOffice tolerates but PowerPoint rejects |
| `references/model-capabilities.md` | Tested model vision capabilities (vLLM/Noris) |
| `references/iterative-fix-verify-loop.md` | Multi-cycle QA pattern: fix → re-render → re-QA until zero defects |
| `references/template-adoption.md` | Corporate template extraction: theme colors, fonts, **logo identification & multi-part processing** (§6), design-system rebuild |
## Related Skills
- `powerpoint` — PPTX creation/editing; this skill handles the visual QA step of its workflow
@@ -0,0 +1,58 @@
# Iterative Fix-Verify Loop for Presentation QA
Pattern for achieving zero-defect presentations through repeated visual QA cycles.
Derived from a 17-slide deck QA session (5 cycles, 6 issues → 0 issues).
## The Loop
```
┌─────────────────────────────────────────┐
│ 1. GENERATE → node main.js │
│ 2. RENDER → soffice → pdftoppm │
│ 3. BATCH QA → batch_vision_qa.py │
│ 4. TARGETED → vision_analyze (deep) │
│ 5. FIX → patch source files │
│ 6. REPEAT 2-5 until ALL CLEAN │
└─────────────────────────────────────────┘
```
## Cycle Progression (Real Example)
| Cycle | Issues Found | Action | Remaining |
|-------|-------------|--------|-----------|
| 1 | 1 (batch) + 6 (targeted) | Fixed all 6 | 3 remaining |
| 2 | 3 (targeted) | Fixed all 3 | 1 remaining |
| 3 | 1 (targeted) | Fixed | 1 remaining |
| 4 | 1 (targeted) | Fixed | 0 |
| 5 | 0 (full batch) | Done | 0 ✅ |
## Why Multiple Cycles Are Needed
Fixes introduce new problems:
- Moving a subtitle down to fix overlap may push it into content below
- Enlarging a badge circle may overlap neighboring elements
- Changing line color for visibility may reduce contrast with another element
- Shortening a label to prevent wrapping may lose meaning
**Never declare success after the first fix-and-verify.** Always re-render and re-QA.
## Batch + Targeted Prompt Division
### Batch Prompt (Tier 1 — Breadth)
Generic, checks all slides quickly (~25s for 17 slides). Uses expanded 6-category prompt:
overlap, overflow, contrast, layout, content, symbols.
### Targeted Prompts (Tier 2 — Depth)
Element-specific questions for slides with:
- **Diagrams**: "Are the connecting lines visible? What color?"
- **Badges/icons**: "Do the letters inside circles render correctly?"
- **Tables**: "Are all cells populated? Any placeholder text?"
- **Examples**: "Does the example value match the description?"
- **Arrows/connectors**: "Do arrows render properly between elements?"
## Efficiency Tips
- Only re-render and re-QA slides that were changed (not the entire deck)
- But ALWAYS do a full batch scan at the end to confirm no regressions
- Batch QA via direct API calls is 10-20x faster than sequential vision_analyze calls
- Save QA results to JSON for comparison between cycles
@@ -0,0 +1,56 @@
# Vision Model Capabilities (vLLM / Noris)
Tested against `https://ai.noris.de/v1` — OpenAI-compatible vLLM endpoint.
## Multimodal (Accepts Images) ✅
| Model | Notes |
|-------|-------|
| `vllm/release/gemma-4-31b-it` | Works. Despite not being labeled "vision", accepts `image_url` content type and describes images accurately. Good for slide QA. |
## Text-Only (Rejects Images) ❌
| Model | Error | Notes |
|-------|-------|-------|
| `vllm/release/qwen3.6-27b` | `400: At most 0 image(s) may be provided in one prompt` | Explicitly rejects image input |
| `vllm/release/qwen3.6-35b-a3b` | `400: Input should be a valid string` | Rejects multimodal content format |
| `vllm/release/gpt-oss-120b` | Returns "I'm not able to view images" | Accepts the request but model itself can't process images |
| `vllm/release/glm-5-2` | Not tested for images | Primary chat model — assumed text-only |
## Testing Procedure
Send a single image with a simple "describe this image" prompt:
```python
import urllib.request, json, base64
with open("test.jpg", "rb") as f:
img_b64 = base64.b64encode(f.read()).decode()
payload = {
"model": "<model_to_test>",
"messages": [{"role": "user", "content": [
{"type": "text", "text": "Describe this image in 1 sentence."},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}}
]}],
"max_tokens": 80
}
req = urllib.request.Request(
"https://ai.noris.de/v1/chat/completions",
data=json.dumps(payload).encode(),
headers={"Authorization": "Bearer <key>", "Content-Type": "application/json"}
)
resp = urllib.request.urlopen(req, timeout=30)
print(json.loads(resp.read())["choices"][0]["message"]["content"])
```
If the response describes the image content → multimodal. If it apologizes about not seeing images or returns HTTP 400 → text-only.
## Config Gotcha
The `auxiliary.vision` section in `~/.hermes/config.yaml` must have:
- Correct model name WITH prefix (e.g. `vllm/release/gemma-4-31b-it`, not `vllm/gemma-4-31b-it`)
- Non-empty `base_url` and `api_key`
If these are empty or wrong, `vision_analyze` fails with "no keys found that support model" — even if the top-level `vision.*` section is correct. Both sections must be consistent.
@@ -0,0 +1,445 @@
# Corporate Template Adoption
Workflow for extracting a corporate design system from a `.pptx` template and applying it to a deck built with pptxgenjs.
## 1. Extract Theme Colors & Fonts
python-pptx's theme API (`slide_master.element.find(...)`) can raise `AttributeError`.
Parse the XML directly via `zipfile` instead:
```python
import zipfile, xml.etree.ElementTree as ET
with zipfile.ZipFile('template.pptx', 'r') as z:
theme_files = [f for f in z.namelist() if 'theme' in f and f.endswith('.xml')]
for tf in theme_files:
xml = z.read(tf)
root = ET.fromstring(xml)
ns = {'a': 'http://schemas.openxmlformats.org/drawingml/2006/main'}
# Colors
for clr in root.findall('.//a:srgbClr', ns):
print(f"Color: #{clr.get('val')}")
# Fonts
for latin in root.findall('.//a:latin', ns):
print(f"Font: {latin.get('typeface')}")
```
## 2. Extract Embedded Images (Logos, Icons)
```python
import zipfile, os
with zipfile.ZipFile('template.pptx', 'r') as z:
images = [f for f in z.namelist()
if f.startswith('ppt/media/') and f.endswith(('.png', '.jpg', '.jpeg'))]
for img in images:
data = z.read(img)
fname = os.path.basename(img)
with open(f'/tmp/{fname}', 'wb') as f:
f.write(data)
```
Inspect each with `vision_analyze`. Logos may be white-on-transparent and
invisible on white backgrounds — if so, build a text+shape logo instead.
## 3. Render Template Slides for Visual Reference
```bash
python3 ~/.hermes/skills/productivity/powerpoint/scripts/office/soffice.py \
--headless --convert-to pdf template.pptx
mkdir -p tpl_qa
pdftoppm -jpeg -r 100 -f 1 -l 5 template.pdf tpl_qa/tplate
pdftoppm -jpeg -r 100 -f 32 -l 39 template.pdf tpl_qa/tplate
```
Use `vision_analyze` on selected template slides to understand:
- Background color (white, dark, gradient?)
- Card style (dark/light, shadows, rounded/sharp corners)
- Accent bar style (vertical/horizontal, thickness, position)
- Logo placement (usually top right)
- Typography (font family, sizes, weights)
- Decorative elements (hexagons, circles, patterns)
## 4. Define Custom Layout Dimensions
If the template uses non-standard dimensions (e.g., 13.33" × 7.50"), define
a custom layout in pptxgenjs **before adding any slides**:
```javascript
pres.defineLayout({ name: "noris_wide", width: 13.33, height: 7.5 });
pres.layout = "noris_wide"; // Must set AFTER defineLayout
```
Common corporate template dimensions:
- 13.33" × 7.50" — widescreen 16:9 (noris, many corporate templates)
- 10" × 5.625" — LAYOUT_16x9 (pptxgenjs default)
All coordinate values in helper functions (logo position, card widths, etc.)
must be recalculated for the new dimensions — a logo at `x: 8.5` on a 10"-wide
slide needs `x: 11.13` on a 13.33"-wide slide.
## 5. Build Design-System Constants File
Create a `helpers.js` with the extracted colors, fonts, and reusable functions:
```javascript
const C = {
white: "FFFFFF",
bgLight: "F2F2F2", // Card background
petrol: "234D5B", // Primary brand color
accent: "118291", // Accent teal
burgundy: "9E1946", // Red accent
text: "212427", // Body text
textMid: "5F7D86", // Secondary text
greyLt: "D3D3D3",
greyMd: "A6A6A6",
};
const FONT = "Open Sans";
```
Include helpers: `addLogo()`, `slideNumber()`, `card()`, `accentBar()`,
`shieldBadge()`, `title()`, `subtitle()`, `footerBar()`.
## 6. Logo Extraction — Find and Use the REAL Logo
**Do not fall back to a text+shape logo approximation until you have exhausted
all extraction options.** Users expect the actual corporate logo from the
template, not a reconstruction. Only build a text-based logo (section 6c below)
if no usable image exists after trying all techniques in sections 6a6b.
### 6a. Identify the Logo Among Hundreds of Media Files
Templates may contain 100+ images. Find the logo systematically:
**Step 1 — Count image references across slides:**
```python
import zipfile, xml.etree.ElementTree as ET, collections
with zipfile.ZipFile('template.pptx', 'r') as z:
rel_counts = collections.Counter()
slide_rels = sorted([f for f in z.namelist()
if f.startswith('ppt/slides/_rels/') and f.endswith('.xml.rels')])
for sf in slide_rels:
tree = ET.parse(z.open(sf))
ns = {'r': 'http://schemas.openxmlformats.org/package/2006/relationships'}
for rel in tree.findall('.//r:Relationship', ns):
target = rel.get('Target', '')
if 'media/' in target:
rel_counts[target.split('/')[-1]] += 1
for img, cnt in rel_counts.most_common(10):
print(f'{img:30s} appears on {cnt:3d} slides')
```
The logo typically appears on 20+ slides. Decorative elements appear on 1-4.
**Step 2 — Check format and usability of top candidates:**
```python
from PIL import Image
import os
for img_name in top_candidates:
path = f'/tmp/{img_name}'
if img_name.endswith('.emf'):
print(f'{img_name}: EMF format — may not render in LibreOffice/pptxgenjs')
# EMF files are often tiny (100-200 bytes) and unusable
continue
if os.path.exists(path):
im = Image.open(path)
print(f'{img_name}: {im.size} mode={im.mode}')
```
**Pitfall — EMF images:** The most-referenced image may be an EMF (Windows Enhanced
Metafile) that LibreOffice cannot convert and pptxgenjs cannot embed. Check for
PNG/JPG alternatives among the top candidates. In the noris template,
`image1.emf` appeared on 26 slides but was only 188 bytes and unusable; the
actual logo was `image2.png` (2268×336 RGBA), which was NOT the most-referenced
image but was the largest PNG with the right aspect ratio for a logo.
**Step 3 — Inspect candidate images with `vision_analyze`:**
Send each top candidate to `vision_analyze` with:
> "Is this a company logo? Describe the text, colors, and layout. Is it
> suitable for white backgrounds? Dark backgrounds?"
### 6b. Multi-Part Logo Processing
Some logos are single images containing multiple color variants side-by-side
(e.g., "noris" in white-on-dark-box next to "network." in dark-on-transparent).
To use these:
**Find the split point by scanning pixel alpha:**
```python
from PIL import Image
img = Image.open('logo.png')
w, h = img.size
px = img.load()
# Scan a row above the text (y=10) for the transition
split_x = None
found_opaque = False
for x in range(w):
r, g, b, a = px[x, 10]
if a == 255 and r < 100: # opaque dark pixel (bg of left part)
found_opaque = True
elif found_opaque and a == 0: # transition to transparent
split_x = x
break
```
**Crop and save variants:**
```python
# For white slides: use as-is (left=dark box with white text, right=dark text)
img.save('logo_white_bg.png')
# For dark slides: invert the RIGHT part only (dark text → white text)
# Keep the LEFT part as-is (white text on dark box is already visible on dark bg)
dark_ver = img.copy()
dv_px = dark_ver.load()
for x in range(w):
for y in range(h):
r, g, b, a = px[x, y]
if x >= split_x and a > 0:
dv_px[x, y] = (255-r, 255-g, 255-b, a) # invert right part
dark_ver.save('logo_dark_bg.png')
```
**Use in pptxgenjs with correct aspect ratio:**
```javascript
function addLogo(slide, w = 1.8, dark = false) {
const logoPath = dark ? "logo_dark_bg.png" : "logo_white_bg.png";
const h = w / 6.74; // original aspect ratio (width/height)
slide.addImage({
path: logoPath,
x: 13.33 - w - 0.4, y: 0.25, w, h,
});
}
```
### 6c. Text-Based Logo (last resort only)
Only when no usable logo image can be extracted after all attempts above:
```javascript
function addLogo(slide, w = 1.8) {
const h = 0.32;
const x = 13.33 - w - 0.4;
const y = 0.25;
const boxW = w * 0.35;
slide.addShape("roundRect", {
x, y, w: boxW, h,
fill: { color: C.petrol }, line: { type: "none" },
rectRadius: 0.03,
});
slide.addText("noris", {
x, y, w: boxW, h,
fontSize: 13, fontFace: FONT, bold: true,
color: C.white, align: "center", valign: "middle", margin: 0,
});
slide.addText("network.", {
x: x + boxW + 0.05, y, w: w - boxW - 0.05, h,
fontSize: 13, fontFace: FONT, bold: true,
color: C.petrol, align: "left", valign: "middle", margin: 0,
});
}
```
## 7. Non-ASCII Quote Pitfall
German curly quotes (`„…"` / `»…«`) cause `SyntaxError: Unexpected identifier`
in Node.js string literals. The parser treats `"` as a string terminator and
the following text as a bare identifier.
**Wrong:**
```javascript
{ text: "Milliarden von Parametern speichern das „Wissen" des Modells", ... }
```
**Right** — use Unicode escapes:
```javascript
{ text: "Milliarden von Parametern speichern das \u201EWissen\u201C des Modells", ... }
```
The `write_file` linter catches this on save. Always check the lint output
after writing JS files with German text.
## 8. Design-Conformance QA
After rebuilding all slides, verify each slide matches the template's design
language. Use a design-specific QA prompt:
```
Check this slide for: 1) [company] corporate design (white bg, light grey cards,
petrol #XXXXX accents, [Font] font) 2) logo visible top right 3) no text overlaps
4) no layout issues 5) readable text. Reply CLEAN or list ISSUES.
```
Run all slides through this check — a slide can be layout-clean but
design-nonconformant (wrong background, missing logo, wrong card style).
## 9. PowerPoint Compatibility Validation (Post-Generation)
**Critical:** LibreOffice and PowerPoint have different tolerance levels for
malformed OOXML. A PPTX that opens perfectly in LibreOffice (and therefore
passes PDF→JPEG visual QA) may be refused by PowerPoint entirely.
The most common cause is **negative shape dimensions or positions** that
pptxgenjs silently generates:
| Source Pattern | XML Result | Effect |
|---|---|---|
| LINE shape from node A (low y) to node B (high y) | `cy < 0` in `<a:ext>` | PowerPoint refuses to open |
| LINE shape from node A (right) to node B (left) | `cx < 0` in `<a:ext>` | PowerPoint refuses to open |
| LINE with equal source/target y | `cy = 0` in `<a:ext>` | PowerPoint refuses to open |
| OVAL at `x: -2, y: -2` (decorative, partially off-slide) | `x < 0, y < 0` in `<a:off>` | PowerPoint refuses to open |
### Fix in Source Code
**Option A — Fix coordinates (when the shape is meaningful):**
```javascript
// LINE shapes: always use Math.abs/min
const dx = dstX - srcX;
const dy = dstY - srcY;
s.addShape(pres.shapes.LINE, {
x: Math.min(srcX, dstX), y: Math.min(srcY, dstY),
w: Math.abs(dx), h: Math.max(Math.abs(dy), 1), // min 1 EMU
line: { color: "CCCCCC", width: 1 },
});
// Decorative shapes: clamp to non-negative positions
s.addShape(pres.shapes.OVAL, {
x: 0, y: 0, w: 3, h: 3, // was x: -2, y: -2
fill: { color: "F2F2F2" },
});
```
**Option B — Remove purely decorative shapes (often the better choice):**
When decorative shapes (background circles, hexagon patterns, ambient ovals)
cause negative coordinates, consider removing them entirely rather than
repositioning. They serve no informational purpose and their absence improves
compatibility without affecting the design. In the noris deck, decorative
OVALs at `x: -2, y: -2` on the title and closing slides were deleted outright
— the slides looked cleaner without them and the compatibility issue vanished.
```javascript
// Simply delete the decorative shape block — no replacement needed
// s.addShape(pres.shapes.OVAL, { x: -2, y: -2, ... }); // REMOVED
```
### Validate Before Delivery
After `node main.js`, run the validation script:
```bash
python3 ~/.hermes/skills/productivity/visual-qa/scripts/validate_pptx_compat.py \
output.pptx
```
This checks for negative extents/offsets, malformed XML, missing required parts,
broken relationship references, and missing Content_Type overrides. Exit code 0
means clean; 1 means issues found.
**Add this to your standard build pipeline:**
```bash
node main.js && \
python3 scripts/validate_pptx_compat.py AI_101.pptx && \
soffice --headless --convert-to pdf AI_101.pptx && \
pdftoppm -jpeg -r 150 AI_101.pdf qa/slide
```
The validation step takes <1 second and catches the #1 cause of "PowerPoint
can't open the file" complaints before they happen.
## 10. Inserting New Slides into an Existing Deck
When a user asks to add new content slides to an existing pptxgenjs-built deck
(e.g. "ergänze Folien zu Tokens, KV-Cache, aktiven Parametern"), follow this
workflow:
### Step 1: Create a New Module File
Put new slide functions in a separate file (e.g. `slides_extra.js`) to avoid
rewriting existing modules:
```javascript
const { C, FONT, addLogo, slideNumber, card, accentBar, /* ... */ } = require("./helpers");
function slide_tokens(pres) {
const s = pres.addSlide();
s.background = { color: C.white };
addLogo(s, 1.5);
slideNumber(s, 13); // ← set to the FINAL slide number after insertion
title(s, "Was ist ein Token?");
// ...
}
module.exports = { slide_tokens, /* other new slides */ };
```
### Step 2: Update main.js — Insert at the Right Position
```javascript
const se = require("./slides_extra");
// ...
s10.slide11_finetuning(pres);
se.slide_transition_llm(pres); // ← new slide inserted here
se.slide_tokens(pres); // ← new slide
s10.slide12_llm_no_search(pres); // ← existing slide, now shifted
// ...
```
### Step 3: Renumber ALL slideNumber() Calls
Every `slideNumber(s, N)` in EVERY module file must be updated to reflect the
new ordering. A single off-by-one makes the deck look unprofessional.
Track the mapping explicitly:
```
Old 1-11 → unchanged (1-11)
New 12 → slide_transition_llm (NEW)
New 13 → slide_tokens (NEW)
Old 12 → slide12_llm_no_search → now 14
Old 13 → slide13_not_intelligent → now 15
Old 14 → slide14_moe → now 16
New 17 → slide_active_params (NEW)
New 18 → slide_kv_cache (NEW)
Old 15 → slide15_noris → now 19
Old 16 → slide16_summary → now 20
Old 17 → slide17_thanks → now 21 (no slideNumber, it's the closing slide)
```
### Step 4: Update Agenda and Summary Slides
- **Agenda**: add new entries, adjust numbering, reduce font size / spacing
if the list grows beyond 9 items (12 items needs `fontSize: 16`, `y_step: 0.49`)
- **Summary/Takeaways**: add new key points, compress spacing similarly
### Step 5: Rebuild + Validate + QA
```bash
node main.js && \
python3 scripts/validate_pptx_compat.py output.pptx && \
soffice --headless --convert-to pdf output.pptx && \
pdftoppm -jpeg -r 150 output.pdf qa/slide
```
Then run visual QA on at least the new slides + agenda + summary.
### Pitfall: Forgetting to Renumber
The most common mistake is updating `main.js` insertion order but forgetting
to update `slideNumber(s, N)` inside existing slide functions. The slides will
appear in the right order but show wrong page numbers. Always grep for all
`slideNumber` calls after restructuring:
```bash
grep -rn "slideNumber" slides_*.js
```
@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""Batch visual QA of rendered document pages/slides via direct vLLM API calls.
Bypasses the vision_analyze tool — useful when:
- vision_analyze fails due to config caching / model resolution issues
- You need to QA many images efficiently in one pass
- The active chat model is text-only but a multimodal model is on the same endpoint
Usage:
python batch_vision_qa.py /path/to/images/ <num_images> [options]
Environment variables:
NORIS_API_KEY - API key for the vLLM endpoint (required)
VISION_BASE_URL - Full chat completions URL (default: https://ai.noris.de/v1/chat/completions)
VISION_MODEL - Model name (default: vllm/release/gemma-4-31b-it)
Example:
NORIS_API_KEY=sk-bf-xxx python batch_vision_qa.py /home/debian/ai101/qa/ 17
Expects files named slide-01.jpg, slide-02.jpg, ..., slide-NN.jpg.
Outputs per-slide status and a summary of any issues found.
Before using: verify the model accepts images (see references/model-capabilities.md).
"""
import sys
import os
import base64
import json
import time
import urllib.request
API_KEY = os.environ.get("NORIS_API_KEY", "")
BASE_URL = os.environ.get(
"VISION_BASE_URL", "https://ai.noris.de/v1/chat/completions"
)
MODEL = os.environ.get("VISION_MODEL", "vllm/release/gemma-4-31b-it")
# Expanded 6-category prompt: overlap, overflow, contrast, layout, content, symbols
QA_PROMPT_TEMPLATE = """You are a visual QA reviewer for a professional presentation slide.
Analyze this slide carefully for ALL of these issues:
1. TEXT OVERLAP: Any text overlapping other text or graphics?
2. TEXT OVERFLOW: Any text extending beyond edges or getting cut off?
3. CONTRAST: Any low contrast between text and background making text hard to read?
4. LAYOUT: Misalignment, elements too close, insufficient margins, awkward spacing?
5. CONTENT: Unclear messaging, missing information, placeholder text, spelling errors, logical inconsistencies?
6. SYMBOLS: Any broken/missing Unicode characters showing as boxes or empty circles?
Respond in this exact JSON format:
{{"slide": {slide_num}, "overlap": "none|<description>", "overflow": "none|<description>", "contrast": "ok|<description>", "layout": "ok|<description>", "content": "ok|<description>", "symbols": "ok|<description>", "overall": "clean|issues found"}}
Be precise and thorough. If no issues, say "clean". If issues exist, describe them specifically."""
def qa_single_image(image_path: str, slide_num: int) -> dict:
"""Send one image to the vision model and return parsed QA result."""
with open(image_path, "rb") as f:
img_b64 = base64.b64encode(f.read()).decode()
payload = {
"model": MODEL,
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": QA_PROMPT_TEMPLATE.format(slide_num=slide_num),
},
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{img_b64}"
},
},
],
}
],
"max_tokens": 500,
}
req = urllib.request.Request(
BASE_URL,
data=json.dumps(payload).encode(),
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
)
resp = urllib.request.urlopen(req, timeout=60)
data = json.loads(resp.read())
content = data["choices"][0]["message"].get("content", "")
# Strip markdown code fences if present
content = content.strip()
if content.startswith("```"):
content = content.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
try:
return json.loads(content)
except json.JSONDecodeError:
return {
"slide": slide_num,
"overlap": "parse error",
"overflow": "parse error",
"contrast": "parse error",
"layout": "parse error",
"content": "parse error",
"symbols": "parse error",
"overall": content[:200],
}
def main():
if len(sys.argv) < 3:
print(f"Usage: {sys.argv[0]} <images_dir> <num_images>")
print(f"Example: {sys.argv[0]} /home/debian/ai101/qa/ 17")
sys.exit(1)
images_dir = sys.argv[1]
num_slides = int(sys.argv[2])
if not API_KEY:
print("ERROR: Set NORIS_API_KEY environment variable.")
sys.exit(1)
results = []
issues_found = []
for i in range(1, num_slides + 1):
slide_path = os.path.join(images_dir, f"slide-{i:02d}.jpg")
if not os.path.exists(slide_path):
print(f"Slide {i}: file not found — {slide_path}")
continue
try:
result = qa_single_image(slide_path, i)
results.append(result)
status = result.get("overall", "?")
marker = "⚠️" if "issue" in status.lower() else ""
print(f"Slide {i}/{num_slides} {marker} {status}")
if "issue" in status.lower():
issues_found.append(result)
except Exception as e:
print(f"Slide {i} FAILED: {e}")
results.append({"slide": i, "error": str(e)})
time.sleep(0.5)
print("\n" + "=" * 60)
if issues_found:
print(f"ISSUES ON {len(issues_found)} SLIDE(S):")
for r in issues_found:
print(f" Slide {r['slide']}:")
for key in ["overlap", "overflow", "contrast", "layout", "content", "symbols"]:
val = r.get(key, "")
if val and val not in ("none", "ok", ""):
print(f" {key}: {val}")
else:
print("ALL SLIDES CLEAN ✅")
if __name__ == "__main__":
main()
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""
Validate a .pptx file for PowerPoint compatibility.
Checks for issues that LibreOffice tolerates but PowerPoint rejects:
- Negative shape dimensions (cx, cy ≤ 0) in <a:ext> elements
- Negative shape positions (x, y < 0) in <a:off> elements
- Missing required OOXML parts ([Content_Types].xml, presentation.xml, etc.)
- Malformed XML in any part
- Broken relationship references (rId in slide XML not found in .rels)
- Missing Content_Type overrides for slides
Usage:
python3 validate_pptx_compat.py <file.pptx>
Exit codes:
0 = all checks passed
1 = issues found (printed to stderr)
"""
import sys
import zipfile
import xml.etree.ElementTree as ET
NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"
NS_P = "http://schemas.openxmlformats.org/presentationml/2006/main"
NS_R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
NS_REL = "{http://schemas.openxmlformats.org/package/2006/relationships}"
def validate(pptx_path: str) -> list[str]:
"""Return a list of issue strings; empty list means clean."""
issues = []
with zipfile.ZipFile(pptx_path, "r") as z:
# 1. Zip integrity
bad = z.testzip()
if bad:
issues.append(f"BAD ZIP ENTRY: {bad}")
# 2. XML parse check on all XML/rels parts
for name in z.namelist():
if name.endswith(".xml") or name.endswith(".rels"):
try:
ET.parse(z.open(name))
except ET.ParseError as e:
issues.append(f"XML PARSE ERROR in {name}: {e}")
# 3. Required parts
required = [
"[Content_Types].xml",
"_rels/.rels",
"ppt/presentation.xml",
"ppt/_rels/presentation.xml.rels",
"ppt/theme/theme1.xml",
]
for req in required:
if req not in z.namelist():
issues.append(f"MISSING REQUIRED PART: {req}")
# 4. Slide rels existence
slides = [
f for f in z.namelist()
if f.startswith("ppt/slides/slide") and f.endswith(".xml")
]
for s in sorted(slides):
rels = s.replace("ppt/slides/", "ppt/slides/_rels/") + ".rels"
if rels not in z.namelist():
issues.append(f"MISSING RELS for {s}")
# 5. Content_Type overrides for slides
ct = ET.parse(z.open("[Content_Types].xml"))
ct_ns = {"ct": "http://schemas.openxmlformats.org/package/2006/content-types"}
overrides = [
o.get("PartName") for o in ct.findall(".//ct:Override", ct_ns)
]
for s in sorted(slides):
part_name = "/" + s
if part_name not in overrides:
issues.append(f"Missing Content_Type override for {s}")
# 6. Negative dimensions and positions (THE key check)
for i in range(1, len(slides) + 1):
slide_path = f"ppt/slides/slide{i}.xml"
if slide_path not in z.namelist():
continue
root = ET.parse(z.open(slide_path)).getroot()
for sp in root.iter(f"{{{NS_P}}}sp"):
spPr = sp.find(f".//{{{NS_P}}}spPr")
if spPr is None:
continue
# Get shape name for reporting
cNvPr = sp.find(f".//{{{NS_P}}}cNvPr")
name = cNvPr.get("name", "?") if cNvPr is not None else "?"
xfrm = spPr.find(f"{{{NS_A}}}xfrm")
if xfrm is None:
continue
off = xfrm.find(f"{{{NS_A}}}off")
ext = xfrm.find(f"{{{NS_A}}}ext")
if ext is not None:
cx = int(ext.get("cx", "0"))
cy = int(ext.get("cy", "0"))
if cx <= 0 or cy <= 0:
issues.append(
f"slide{i}: shape \"{name}\" has zero/negative extent: "
f"cx={cx} cy={cy}"
)
if off is not None:
x = int(off.get("x", "0"))
y = int(off.get("y", "0"))
if x < 0 or y < 0:
issues.append(
f"slide{i}: shape \"{name}\" has negative position: "
f"x={x} y={y}"
)
# 7. Broken image embed references
for i in range(1, len(slides) + 1):
slide_path = f"ppt/slides/slide{i}.xml"
if slide_path not in z.namelist():
continue
root = ET.parse(z.open(slide_path)).getroot()
rels_path = f"ppt/slides/_rels/slide{i}.xml.rels"
if rels_path not in z.namelist():
continue
rels_tree = ET.parse(z.open(rels_path))
rels_root = rels_tree.getroot()
valid_rids = {
rel.get("Id") for rel in rels_root.findall(f"{NS_REL}Relationship")
}
for blip in root.iter(f"{{{NS_A}}}blip"):
embed = blip.get(f"{{{NS_R}}}embed")
if embed is not None and embed not in valid_rids:
issues.append(
f"slide{i}: blipFill embed '{embed}' not found in rels"
)
return issues
def main():
if len(sys.argv) != 2:
print("Usage: validate_pptx_compat.py <file.pptx>", file=sys.stderr)
sys.exit(2)
pptx_path = sys.argv[1]
try:
issues = validate(pptx_path)
except FileNotFoundError:
print(f"File not found: {pptx_path}", file=sys.stderr)
sys.exit(2)
except zipfile.BadZipFile:
print(f"Not a valid ZIP/PPTX file: {pptx_path}", file=sys.stderr)
sys.exit(2)
if issues:
print(f"{len(issues)} issue(s) found:", file=sys.stderr)
for iss in issues:
print(f"{iss}", file=sys.stderr)
sys.exit(1)
else:
print(f"{pptx_path}: all checks passed")
sys.exit(0)
if __name__ == "__main__":
main()