DEV Community

Keo Fung | FormLM
Keo Fung | FormLM

Posted on

[6/10]Page-Templated PDF Generation: From YAML Slots to BentoUI Widgets

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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>"
Enter fullscreen mode Exit fullscreen mode

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:

  1. It's predictable — the AI can reason about layout mathematically
  2. It's responsive — the same grid produces valid layouts at different PDF sizes
  3. 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
Enter fullscreen mode Exit fullscreen mode

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>"
Enter fullscreen mode Exit fullscreen mode

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!"
Enter fullscreen mode Exit fullscreen mode

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>"
Enter fullscreen mode Exit fullscreen mode

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>"
Enter fullscreen mode Exit fullscreen mode

How it works:

  1. The engine evaluates the user's score against each --logic range
  2. First match wins (so order matters)
  3. The matched content replaces the widget's display
  4. If no logic matches, --value is 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"
Enter fullscreen mode Exit fullscreen mode

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 "..."
Enter fullscreen mode Exit fullscreen mode

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 ...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/--content HTML 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

  1. 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.

  2. Always have a runtime fallback. If templates aren't applied, recover from in-memory scale data. Don't fail silently.

  3. Bare variables are the #1 report bug. Validate every --value and --logic for {{}} wrapping before accepting the command.

  4. Logic order matters. High-to-low, first-match-wins. Document this explicitly.

  5. 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)