Series: Building a Modular Assessment Engine (3/10)
The form module is where the AI generates the most CLI commands — sometimes 30-50 per app. Get one parameter wrong and the entire scoring pipeline breaks silently. This post covers the field type system, the inline scoring pattern, and the reverse-question bug that took three days to track down.
The Field Type System
Forms in this system support 15 field types, but not all types are available in all scenarios. A scene decision table controls this:
| planType | Allowed Types | Forbidden |
|---|---|---|
assessment |
scale, vas-scale, radio, select, rate, yes-no-select | — |
exam |
radio, checkbox, yes-no-select, input | scale, vas-scale |
survey |
radio, checkbox, input, long-text, number, date, etc. | scale, vas-scale |
learn |
radio, yes-no-select, block-text, flashcard | scale, vas-scale |
The key insight: scale and vas-scale types are scoring instruments, not generic input fields. They belong in assessments and consultations. They do NOT belong in surveys (which don't score) or exams (which use correct/incorrect marking, not Likert scales).
When the AI tries to use a forbidden type, the CLI rejects it. But catching it at the SKILL.md level — before the command is generated — is better than catching it at execution time.
Inline Scoring: The label:score Pattern
This is the most important pattern in the form module. Radio and select options can carry inline scores:
assess form add --id risk_level --type radio \
--title "What is your risk tolerance?" \
--options "Low:1,Medium:2,High:3" \
--required true
The :score suffix tells the system that "Low" = 1 point, "Medium" = 2, "High" = 3. The scale module later reads these scores to compute dimension totals.
What Happens When You Forget the Score
This bug was silent and devastating:
# Missing :score — all options score 0
assess form add --id risk_level --type radio \
--options "Low,Medium,High" \
--required true
The scale module would compute a dimension score of 0 for every user, regardless of their answers. The score ranges would all resolve to the lowest tier. Every user gets "severe stress" even if they answered "never" to everything.
The fix: the scale module now checks for unscored fields and emits a critical warning:
⚠️ CRITICAL: Fields X3, X4 have NO scoring
When this warning appears, the form skill must fix it immediately — either by adding :score to the options or by setting --score on the field itself — before proceeding to set score ranges.
The --id vs --key Confusion
Every form field has two identifiers:
-
--id: semantic snake_case name (e.g.,workload_1,risk_level) -
--key: auto-generated field key (e.g.,X3,X7)
The --id is human-readable and set during creation. The --key is system-generated and used for scale association and data lookups.
The confusion: some CLI commands accept --id, others accept --key, and the AI would mix them up constantly. Using --id X3 (a fieldKey as an ID) or --key workload_1 (an ID as a fieldKey) both fail silently — the command runs but targets the wrong field.
The fix was a P0 rule in the SKILL.md:
⚠️ --id vs --key strict separation (P0):
id column (e.g., name, policy_1) → use --id
fieldKey column (e.g., X1, X3) → use --key
NEVER mix them.
And a runtime validation that rejects --id values matching the pattern ^X\d+$.
The Reverse-Question Bug
This was the hardest bug in the entire system. It took three days.
The Setup
In a "Workplace Burnout" assessment (negative direction — high score = worse burnout), the form has 5 questions. Four measure burnout symptoms ("I feel exhausted," "I feel cynical"). But one measures the opposite: "I feel energetic at work."
This fifth question is a reverse question. Higher scores on "I feel energetic" should mean LESS burnout, not more. But the scale just sums all 5 scores, so a high score on "energetic" inflates the burnout total.
The Wrong Fix (Attempt 1)
Initially, I tried reversing the option scores for reverse questions:
# Normal question: 1=never, 5=always (high=more burnout)
assess form add --id burnout_1 --type scale --min 1 --max 5 \
--title "I feel exhausted at work"
# Reverse question: reverse the scores (5=never, 1=always)
assess form add --id burnout_5 --type scale --min 1 --max 5 \
--title "I feel energetic at work"
# But wait — this changes the question semantics!
# Now "5" means "never energetic" which is confusing for the user
This breaks the user experience. The Likert scale anchors (1=never, 5=always) should stay the same for all questions. The user sees "I feel energetic at work" and picks "5=always." The scoring engine should handle the reversal, not the user.
The Right Fix: --negFields
The scale module now supports a --negFields parameter:
# Define the dimension (negative direction = high score = worse)
assess scale add --id burnout --name "Burnout" \
--format sum --direction negative \
--kbText "Burnout is..."
# Associate all 5 fields, marking X5 as reverse
assess scale keys add --scale burnout \
--fields X1,X2,X3,X4,X5 \
--negFields X5
When the system encounters --negFields X5, it sets enableNegScore=true on that field. During scoring, instead of using the raw score, it computes:
adjusted = (max - raw + min)
So if the user picks "5=always" on "I feel energetic," the adjusted score becomes 5 - 5 + 1 = 1. High energy → low burnout contribution. Correct.
The keys set Correction Command
Of course, the AI would sometimes mark the wrong fields as negFields. Rather than removing and re-adding the association, there's a correction command:
# X5 was wrongly marked as negative, X3 should be negative instead
assess scale keys set --scale burnout --fields X5 --negScore false
assess scale keys set --scale burnout --fields X3 --negScore true
This updates the enableNegScore flag without touching the field association. No remove/re-add cycle needed.
Special ID Fields
Three IDs are reserved for special purposes:
| ID | Purpose | When to Add |
|---|---|---|
name |
User's display name | Required in most assessments |
code |
Unique identifier (student ID, phone) | Only when explicitly needed |
node |
Workflow status node | Only for multi-stage processes |
The AI must not proactively create these. If the plan says "anonymous survey," no name field. If it's an exam, --id name --title "Student Name".
The trap: the AI would create contact_name or participant instead of using the reserved name ID. Then the report module couldn't find the display name, and the PDF would show "Hello, {undefined}" instead of "Hello, John."
HTML Content in Fields
block-text and flashcard fields accept HTML in their --content parameter. But not any HTML — a strict subset:
# Correct: inline CSS, rem units, proper structure
assess form add --id intro --type block-text \
--title "Workload" \
--content "<p style='font-size:0.875rem;line-height:1.6;'>Work overload is the core source of burnout.</p>"
Rules:
-
styleattribute must use single quotes (the parameter value is wrapped in double quotes) -
font-sizemust userem, notpx -
<p>tags must carrystylewith at leastfont-sizeandline-height -
<ul>cannot be nested inside<p>(block element inside inline context) - No JavaScript, no template syntax (
#if, Handlebars, Jinja)
The AI would constantly use px for font sizes or nest <ul> inside <p>. These don't break the form, but they break the PDF renderer later. Catching them at the SKILL.md level saves debugging time downstream.
Lessons Learned
Inline scoring is the single most failure-prone pattern. A missing
:scoresilently zeros out an entire dimension. Always validate before proceeding to scale configuration.Reverse questions need engine-level support, not UX-level hacks. Don't flip the scale anchors — flip the scoring math.
Separate human-facing IDs from system-facing keys. Mixing them causes silent failures that are nearly impossible to debug.
HTML constraints must be enforced upstream. By the time the PDF renderer chokes on
pxunits, you've lost the context of which form field caused it.
Next: [Scale Module — Solving the dimension direction problem]


Top comments (0)