Series: Building a Modular Assessment Engine (6/10)
The report module generates personalized PDF reports. Each user who completes an assessment gets a unique report with their scores, dimension interpretations, and visualizations. This post covers the page template system, the widget grid, and the variable pipeline that makes personalization work.
The Template-First Approach
Reports don't start from blank pages. They start from page templates — predefined page structures that the engine assembles based on the assessment's dimension count and theme.
The plan agent declares two values:
DimN=3
theme=Noir
The engine computes the page template list automatically:
cover-noir → Cover page (Noir theme)
summary-3dim → Summary page (radar chart for 3 dimensions)
detail-dim1 → Dimension 1 detail page
detail-dim2 → Dimension 2 detail page
detail-dim3 → Dimension 3 detail page
total-assessment → Total score conclusion page
No AI involvement in this step. The engine reads the dimension count from the in-memory scale data and picks templates from a registry. This was a hard-won lesson — earlier versions let the AI choose template IDs, and it would invent non-existent templates or pick wrong page counts.
The Runtime Fallback
What happens when the plan agent forgets to declare dimension count? The engine recovers:
// In AssessAgent.skillGenerateStream()
if ("report".equals(skillName) && ObjectUtil.isEmpty(task.getPageTemplates())) {
Factor _factor = session.getFlower().getModel().getFactor();
if (_factor != null && !ObjectUtil.isEmpty(_factor.getScales())) {
int _dimCount = _factor.getScales().size();
List<String> _recovered = buildPageTemplates(_dimCount, task, planType);
task.setPageTemplates(_recovered);
FLog.w(TAG, "pageTemplates recovered from scale data, dimCount=" + _dimCount);
}
}
By the time the report skill runs, the scale skill has already created dimensions in memory. The engine counts them and builds the template list. The AI's declaration is a hint, not a dependency.
The Widget Grid System
Each page is a 68×48 grid (0-indexed). Widgets are placed by coordinates:
assess report widget add \
--page cover_1 \
--type text \
--x 2 --y 3 --w 44 --h 6 \
--value "<h1>{{DisplayName}}'s Assessment Report</h1>"
Constraints:
-
x + w ≤ 48(cannot exceed grid width) -
y + h ≤ 68(cannot exceed grid height) - No overlapping widgets on the same page
The grid system was chosen over free-form positioning because:
- It's predictable — the AI can reason about layout mathematically
- It's responsive — the same grid produces valid layouts at different PDF sizes
- It prevents overlaps — a common bug with absolute positioning
BentoUI Style Parameters
Widgets support a constrained set of style parameters:
assess report widget update \
--page detail_1 --id score_card \
--backgroundColor "#1a1a2e" \
--fontColor "#E2E8F0" \
--roundCorner 16 \
--enableBorder true \
--borderColor "#33334c" \
--borderSize 1 \
--paddingSize 16 \
--bold true \
--alignX left
The parameter whitelist is strict. The AI can't invent --shadow or --gradient — they don't exist. This prevents the AI from generating CSS that the PDF renderer (iText) doesn't support.
The Variable System
Reports are personalized through template variables:
assess report widget update \
--page summary_1 --id greeting \
--value "<p>Hello, {{DisplayName}}!</p>"
Variables are wrapped in {{double curly braces}}. The engine replaces them with actual user data at render time:
| Variable | Replaced With |
|---|---|
{{DisplayName}} |
User's name from the form |
{{DateTime}} |
Report generation timestamp |
{{DimensionName}} |
Current dimension's name |
{{DimensionScore}} |
User's score on current dimension |
{{DimensionTotal}} |
Maximum possible score for dimension |
{{ScaleScore}} |
Total score across all dimensions |
{{PersonalScore}} |
User's personal total |
{{TotalScore}} |
Maximum possible total |
{{ScoreRate}} |
Score percentage |
The Bare Variable Bug
The most common report bug: variables without braces.
# Wrong — the engine treats "DisplayName" as literal text
--value "<p>Hello, DisplayName!</p>"
# The user sees "Hello, DisplayName!" instead of "Hello, John!"
This happens because variable names look like regular English words. The AI writes DisplayName when it means {{DisplayName}}. The bug is invisible during generation (the command succeeds) and only surfaces when a user reads their report.
The fix: a P0 validation rule that scans all --value and --logic parameters for bare variable names and rejects them. The SKILL.md includes a checklist of common mistakes:
❌ "Hello, DisplayName" → ✅ "Hello, {{DisplayName}}"
❌ "Score: DimensionScore/DimensionTotal" → ✅ "Score: {{DimensionScore}}/{{DimensionTotal}}"
❌ "<p>DateTime</p>" → ✅ "<p>{{DateTime}}</p>"
The Logic System: Score-Tiered Content
Different score tiers need different interpretations. A user with severe burnout needs different text than one with mild burnout. This is handled through --logic parameters:
assess report widget update \
--page detail_1 --id burnout_interpretation \
--type scale-dimension \
--scaleId burnout \
--enableOwn \
--value "<p>Your burnout level: {{DimensionScore}}/{{DimensionTotal}}</p>" \
--logic "19-999|<p>Your burnout is low. You're managing well — keep up the practices that work.</p>" \
--logic "11-18|<p>You show moderate signs of burnout. Consider reviewing your workload and recovery routines.</p>" \
--logic "5-10|<p>Your burnout level is high. This signals a need for active intervention — seek support.</p>"
How it works:
- The engine evaluates the user's score against each
--logicrange - First match wins (so order matters)
- The matched content replaces the widget's display
- If no logic matches,
--valueis the fallback
The High-Score-First Rule
Logic ranges must be added from highest to lowest:
# Correct order: high → mid → low
--logic "19-999|High interpretation" \
--logic "11-18|Mid interpretation" \
--logic "5-10|Low interpretation"
Why? Because the engine uses first-match-wins evaluation. If the low range is first and the user scores 20, they'd match the low range (5-10 doesn't match 20, but if ranges overlap, the first one wins). Ordering high-to-low ensures the most specific high range is checked first.
The Symptom Scale Trap
For negative-direction dimensions (symptom scales, high = bad), the logic interpretation direction must flip:
- High score → empathy + improvement suggestions
- Low score → affirmation + maintenance advice
For positive-direction dimensions (ability scales, high = good):
- High score → affirmation + advanced challenges
- Low score → support + foundational steps
The SKILL.md reads the direction field from scale data and adjusts interpretation direction accordingly. The AI doesn't need to figure this out — the SKILL.md tells it: "if direction=negative for all dimensions, low total score = affirm good state, high total score = empathize + suggest improvement."
The PageTemplates Empty Report Problem
Sometimes the pageTemplates application fails silently — the templates are declared but not applied. The report skill runs and finds an empty report (no pages exist).
The SKILL.md handles this with a two-mode strategy:
Normal mode (pages exist):
# Only update widgets — pages are already created by templates
assess report widget update --page cover_1 --id title --value "..."
Empty report mode (no pages):
# Step 1: Create pages manually
assess report page update --page cover_1 --name "Cover" --backgroundColor "#1a1a2e"
# (response contains "⚠️ Page was auto-created" — confirms creation)
# Step 2: Add widgets (not update — they don't exist yet)
assess report widget add --page cover_1 --type text --x 2 --y 3 --w 44 --h 6 ...
The detection is simple: if the report-pages reference shows no page rows, switch to empty report mode.
The Contrast Problem
Dark-themed reports (Noir, Mystic, Classic) need dark page backgrounds with light text. But the AI would frequently create dark-background pages with dark text — invisible content.
The rule: fontColor and backgroundColor must be one dark, one light. Always.
Dark background (#000~#555 or rgba alpha < 0.3) → fontColor: #E2E8F0, #F1F5F9, #FFFFFF
Light background (#AAA~#FFF) → fontColor: #1A202C, #2D3748, #111827
The trap: rgba(255,255,255,0.06) — a semi-transparent white that looks dark visually but has "255" in the RGB values. The AI would see "white" and assign dark text. The rule now explicitly covers this: "rgba with alpha < 0.3 is visually dark, use light fontColor."
The Resource Honesty Principle
The SKILL.md has a P0 rule about fabricated resources:
--value/--contentHTML must NOT contain fabricated image URLs, external resources, or base64 data. Only plain text + HTML + inline CSS + CSS gradients are allowed.
The AI would sometimes generate <img src="https://example.com/chart.png"> — a URL that doesn't exist. The PDF renderer would try to fetch it, fail, and produce a broken image icon in the report.
The only visual elements allowed are:
- CSS gradients (
background: linear-gradient(...)) - Inline SVG (but see the SVG rules from the connect module post)
- Text with styling
No external images. No base64 blobs. If the AI can't generate it with CSS, it doesn't go in the report.
Lessons Learned
Templates are engine-computed, not AI-chosen. The AI declares intent (dimension count, theme); the engine picks templates. This eliminated an entire class of bugs.
Always have a runtime fallback. If templates aren't applied, recover from in-memory scale data. Don't fail silently.
Bare variables are the #1 report bug. Validate every
--valueand--logicfor{{}}wrapping before accepting the command.Logic order matters. High-to-low, first-match-wins. Document this explicitly.
Contrast validation must handle edge cases. Semi-transparent backgrounds, named colors, and hex shorthand all need explicit rules.
Next: [Expert Module — Giving AI assistants personality and boundaries]

Top comments (0)