Series: Building a Modular Assessment Engine (1/10)
What if a user could type one sentence — "build a workplace stress assessment" — and get a complete, publishable assessment application with a scoring engine, a styled UI, a personalized PDF report, and an AI expert assistant?
That's what I spent the last year building. This series breaks down the architecture, module by module, including the bugs that nearly killed it.
The One-Sentence Input Problem
The core challenge: turn natural language into a fully functional application. Not a template. Not a wizard. An actual app with form fields, scoring logic, visual design, and a PDF report — all generated, all working.
The solution I landed on is a modular skill pipeline. Six independent modules, each with its own AI prompt specification (SKILL.md), each generating CLI commands that mutate a shared domain model. A planning agent decides which modules to activate and in what order.
The Six Modules
User Input → [Plan Agent] → Task Sequence → Execution Engine → Published App
Modules:
1. form — Form field design (question types, scoring, grouping)
2. scale — Dimension scoring (factors, ranges, direction handling)
3. connect — UI pages (cover, main form, final page, visual style)
4. report — PDF report (page templates, widgets, score logic)
5. expert — AI assistant (role, welcome message, behavior boundaries)
6. share — Publishing (access control, permissions, validity)
Each module has:
- A SKILL.md file that serves as the AI's system prompt (commands, rules, examples)
- A CLI command set for mutating the domain model
- References — runtime data the AI reads before generating commands (current form fields, scale dimensions, report pages, etc.)
The AI doesn't write code. It writes CLI commands. The execution engine runs them against the domain model.
The Plan Agent
The plan agent is the brain. It takes user input and outputs a YAML task list:
planType: assessment
description: 'Workplace Stress Assessment'
tasks:
- seq: 1
skill: form
title: 'Design assessment form'
knowledge: |
Scene: assessment. 3 dimensions: Workload (5 questions),
Role Stress (4 questions), Social Support (4 questions).
- seq: 2
skill: scale
title: 'Configure scoring dimensions'
knowledge: |
3 dimensions, all negative direction (high score = worse).
3-tier ranges per dimension.
# ... connect, report, share follow
The knowledge field is critical: it's the only context each skill receives during execution. Task A's knowledge doesn't leak into Task B. This forces each knowledge block to be self-contained — dimensions, question counts, scoring rules, everything.
This was a hard lesson. Early versions had tasks referencing "same as above" or "see form task." The execution engine runs tasks independently, so those references resolved to nothing. Half the generated apps were broken.
The Execution Engine
Execution is SSE-streamed. For each task:
- Load the skill's SKILL.md as system prompt
- Inject the task's
knowledge+ runtime references (current app state) - Start an SSE stream to the AI model
- Each line the AI generates is a CLI command — execute it immediately
- Feed the result back to the execution context
Task: form
AI generates: assess form add --id name --type input --title "Your Name"
Engine: → executes, field created
AI generates: assess form add --id workload_1 --type scale --min 1 --max 5 ...
Engine: → executes, field created
...
Task: scale
AI generates: assess scale add --id workload --name "Workload" --format sum ...
Engine: → executes, dimension created
...
The streaming approach means commands execute as they're generated, not after the full response. This cut generation time by ~40% for typical apps.
The Domain Model
Underneath, there's a Java domain model with a builder pattern:
Flower (root)
└── Model
├── Factor
│ └── Scale[] (dimensions)
│ └── Data[] (score ranges)
├── Form (fields)
│ └── Field[]
├── Connect (pages)
│ ├── CoverPage[]
│ ├── MainPage
│ └── FinalPage[]
├── Report (pages)
│ └── ReportPage[]
│ └── Widget[]
└── Expert (AI assistant config)
Each CLI command mutates this tree. The SessionContext holds the in-memory state during execution, then persists everything in one batch at the end. This avoids N separate database writes during a 50-command form task.
The Two Agents
There are actually two AI agents in the system:
AssessAgent — the one-shot generator. Takes user input → plans → executes → done. Used when creating a new app from scratch. Two phases: plan() and execute().
BuilderAgent — the conversational editor. Used when modifying an existing app. Four phases: Think (analyze current state) → Plan (generate PATCH tasks) → Execute (stream commands) → Reflect (AI verifies results and responds to user).
Same skill SKILL.md files power both agents. The difference is context: AssessAgent starts from empty, BuilderAgent starts from a snapshot of the existing app.
What's Next
This series will deep-dive each module and the infrastructure around them:
- [This post] Architecture overview
- The Planning Engine: YAML task routing and knowledge self-containment
- Form Module: Scene-aware field types and inline scoring
- Scale Module: Solving the dimension direction problem
- Connect Module: AI-driven cover pages and visual styles
- Report Module: Page-templated PDF generation
- Expert Module: Giving AI assistants personality and boundaries
- The One-Click Pipeline: How one SSE stream orchestrates everything
- SSE Reliability: Three rounds of fixing event loss
- PDF Rendering War: SVG, emoji, and the iText NPE
Each post includes real code, real bugs, and the solutions that survived production.
This series documents the architecture of an assessment platform. I'm sharing the engineering decisions and failures — not pitching a product. If you're building something similar (AI-driven code generation, modular skill systems, PDF rendering pipelines), hopefully this saves you some pain.
Top comments (0)