Report Builder
A worked example of a real hybrid module: the wizard UI, the capability bridge, and server-side DOCX generation.
Report Builder turns job details, written notes, and site photos into a polished field-service report (.docx). A technician fills a five-step wizard; the module lays the content out to match the company's real reports — green section headers, a running footer, an equipment table, and photos sized so they never bleed off the page — cleans the wording with AI, saves the file to a Reports folder, downloads it, and keeps it in a History list for later editing and regeneration.
It ships as a third-party module owned by the admin account "David Nguyen" but is built to first-party quality, and it is a good worked example of the hybrid module pattern (a sandboxed UI package talking to a first-party API router through the capability bridge).
At a glance #
| Module id | report-builder |
| Version | 0.9.0 |
| Window | 1240 × 840 (min 940 × 620), single instance, resizable |
| UI entrypoint | ui/index.html (fully self-contained, inlined at build) |
| Output | Word .docx (pure OOXML, no Word/Office automation) |
| AI | Featherless.ai (Google Gemini fallback), server-side only |
| Input folder | Modules/Report Builder/Photos (role input) |
| Output folder | Modules/Report Builder/Reports (role output) |
| Draft store | module data store, collection reports |
Permissions (10): files.read, files.write, files.manage, files.read.all, files.write.all, settings.self, notifications, storage.read, data.store, modules.use. The two *.all permissions are consent-gated (they allow picking a logo/photo from anywhere on "This PC"); everything else is baseline.
What it produces #
Every report is one of two skeletons, chosen automatically from the report tags:
- Full site-survey — used by the First Visit and Modified First Visit tags. A complete site assessment: cover → table of contents → preface → office location & scope → a fixed run of assessment/diagram/photo sections. Title suffix:
… - Site Assessment Summary. - Job Completion — every other tag. A cover, the office-location & scope summary, then only the optional pages the job actually needs. Title suffix:
… - Job Completion Report.
The output targets the company's house format precisely: US Letter portrait, body/detail text at 12 pt, green (`#92D050`) section headers, an "Office Location Details:" sub-header, a scope summary that opens "Our team visited {client} on {date}, for a {job type}.", equipment tables with a green header row, borderless 2-up photo pairs, and a right-aligned running footer carrying the job type, report kind, client, PO#(s) and UTS#(s).
Architecture — a hybrid module #
Report Builder is split across a sandboxed browser package and a first-party server router:
┌─────────────────────────────────────────────────────────────┐ │ Desktop (browser) │ │ │ │ ┌───────────────────────────┐ postMessage ┌────────┐ │ │ │ Report Builder UI │ HANABI_* v1 │ Host │ │ │ │ (sandboxed iframe, │◄────────────────►│ shell │ │ │ │ opaque origin, no │ └───┬────┘ │ │ │ allow-same-origin) │ │ │ │ │ bridge.js · model.js · │ │ │ │ │ crop.js · app.js │ │ │ │ └───────────────────────────┘ │ │ └──────────────────────────────────────────────────────┼──────┘ │ moduleRequest → proxy ▼ /runtime/modules/report-builder/* (FastAPI, first-party) ├─ GET section-catalog ├─ POST clean-text → Featherless / Gemini └─ POST generate → sections · photos · document (DOCX)
Key facts:
- The UI is sandboxed. The runtime serves
ui/index.htmlinto an iframe without `allow-same-origin`, so the document has an opaque origin and a strict per-module CSP. External same-package files are blocked, which is whybuild.mjsinlines all CSS/JS into one HTML file, and why the module vendors no third-party libraries. - The UI cannot reach the network. It talks to its own backend through the host bridge's
moduleRequest, which the host proxies (with the session cookie - CSRF) to routes clamped under
/runtime/modules/report-builder/*. The Featherless API key never leaves the server. - DOCX is built server-side as pure OOXML — no
python-docx, no Word/Office COM. The router is mounted first-party inmain.py(app.include_router(report_builder.router)); backend changes require an API restart, but UI-only changes do not.
Domain model #
The client (src/model.js) and server (services/report_builder/sections.py) keep mirrored copies of this registry. At boot the UI fetches GET section-catalog, so the server copy is authoritative and scope wording can be refined without rebuilding the UI.
Report tags
Pick 1–2 tags. Selecting either "full" tag switches the whole report to the full site-survey skeleton.
| id | Chip label | Skeleton |
|---|---|---|
first-visit | First Visit | full |
modified-first-visit | Modified First Visit | full |
recycling | Recycling Service Visit | job-completion |
av | AV Service Visit | job-completion |
office-closure | Office Closure Visit | job-completion |
network | Network Service Visit | job-completion |
cabling | Cabling Service Visit | job-completion |
Title formation
The job type is built from the chosen tag labels, then a fixed suffix is appended:
- One tag → that tag's label.
- Two tags → a shared trailing-word merge: e.g.
Cabling Service Visit+Recycling Service Visit→ `Cabling & Recycling Service Visit` (the commonService Visitsuffix is merged). With no shared suffix it falls back to"{A} & {B}". - Suffix →
… - Site Assessment Summary(full) or… - Job Completion Report(job-completion).
Example: tags network + av, job-completion → `Network & AV Service Visit - Job Completion Report`.
Sections per skeleton
Each section has a kind that decides how the wizard collects it: boilerplate (fixed template text, no input), narrative (free text, AI-cleanable), assessment, form, image, screenshots, porttable, or photos.
Full site-survey (fixed order, all required):
| # | Section | kind |
|---|---|---|
| 1 | Cover | boilerplate |
| 2 | Table of Contents | boilerplate |
| 3 | Preface | boilerplate |
| 4 | Office Location & Scope Summary & Limitations | narrative |
| 5 | Network Equipment & Infrastructure Equipment, Cabling, & Security | assessment |
| 6 | Floor Plan | image |
| 7 | Floor Plan with Data Drop | image |
| 8 | Speed Test | screenshots |
| 9 | Video Conferencing Assessment | form |
| 10 | Network Switch - Port Map | porttable |
| 11 | Network Diagram | image |
| 12 | Workstation(s) & Server(s) & Backup & Printing | assessment |
| 13 | UPS & Phone System | assessment |
| 14 | Assessment Summary & Recommendations | narrative |
| 15 | General Site Photos - Network Area | photos |
| 16 | General Site Photos | photos |
Job Completion (required head + optional pages):
| # | Section | kind | required? |
|---|---|---|---|
| 1 | Cover | boilerplate | required |
| 2 | Office Location & Scope Summary & Limitations | narrative | required |
| 3 | Floor Plan | image | optional |
| 4 | Floor Plan with Data Drop | image | optional |
| 5 | Speed Test | screenshots | optional |
| 6 | Network Switch - Port Map | porttable | optional |
| 7 | Network Diagram | image | optional |
| 8 | Assessment Summary & Recommendations | narrative | optional |
| 9 | General Site Photos - Network Area | photos | optional |
| 10 | General Site Photos | photos | optional |
Scope blocks
The Scope Summary is assembled from service-dependent sub-sections. Each block is a bold run-in label plus editable house boilerplate; a block whose tags match the chosen report tags is auto-seeded into the Scope step.
| id | Label | Auto-seeded by tag |
|---|---|---|
network | Network Service | network |
cabling | Cabling Service | cabling |
recycling | Recycling Service | recycling |
av | AV Service | av |
office-closure | Office Closure | office-closure |
access-point | Access Point Installation | — (manual) |
cabinet-install | Network Cabinet Installation | — (manual) |
workstation-install | Workstation Installation | — (manual) |
electrical | Electrical Service | — (manual) |
Date format
Dates are stored ISO (YYYY-MM-DD) and displayed as Month D, YYYY with no zero-padding on the day — e.g. 2026-07-02 → `July 2, 2026`.
The wizard — UI / UX #
The window is a fixed 264 px left rail + fluid main area. The rail holds the brand mark (RB · Report Builder · Field-service reports), the New Report / History tabs, the five-step list, and a live report-title preview at the bottom. The main area shows the active step; a sticky footer carries Back, an inline hint, and Next (which becomes Generate Report on the last step). The dark theme uses a blue primary accent (#4f8cff) with green (#37c98b) for completed steps and success; the generated document uses the brand green `#92D050` for its headers.
Steps can be visited freely by clicking the rail. Only Step 1 is gated: Next stays disabled until it has 1–2 tags, a client, an address, a date, and at least one UTS#.
Step 1 — Job Details
Collects everything that names the report and prints in the footer:
- UTS # (repeatable, "usually 2–3") — placeholder
e.g. 3275,+ Add UTS#. At least one is required. - PO # (repeatable, "2–3; #### if unknown") — placeholder
e.g. 9003785. Optional; unknown PO prints as####. - Job Type — pick 1–2 report tags — the tag chips (green chips = full site-survey). A hard cap of two; the title preview updates live.
- Client name — autofilled from a datalist of past clients.
- Date of work — opens a custom in-module calendar (see below).
- Job address — autofilled from a datalist of past addresses.
- Main contact / Contact email — optional.
- Client logo (cover) — pick + crop an image, or leave blank to default to the bundled BrokerLink logo.
Step 2 — Report Type & Sections
Shows the resolved skeleton in a callout (green for full site-survey) with the computed title, then a checklist of the skeleton's sections. Required sections are shown as included and locked; for Job Completion, each optional page (floor plan, speed test, port map, network diagram, assessment summary, site photos) can be toggled on only if the job has it.
Step 3 — Scope Summary & Limitations
Builds the scope from sub-sections — one is auto-seeded per matching service tag (e.g. "Network Service", "AV Service"). Each sub-section has an editable label and a stack of content blocks (see below) so text, photos, and equipment tables can be arranged in any order — photos can sit between paragraphs, exactly like the real reports. + Add scope sub-section adds more.
Step 4 — Content & Photos
One card per active optional section (boilerplate and the scope section are excluded). Each card shows the section title + its kind and the same content-blocks editor. For a Job Completion report with no optional pages this step shows "No optional sections selected. All content lives under Scope Summary for this report."
Step 5 — Review & Generate
A summary table (title, client, address, date, UTS#, PO#, logo, skeleton, scope sub-section count, section count, photo count), a "Clean up all text with AI during generation" toggle (on by default), and Generate Report. After a successful run a green callout appears with a persistent ⬇ Download {file} button so the file can be re-downloaded without regenerating.
Content blocks
Blocks are the shared unit used by both scope sub-sections and content sections. Three types, added with + Text / + Photo / + Equipment table, each reorderable (↑ / ↓) and removable (−):
- Text — a textarea; if AI is configured, a ✨ Clean up with AI button rewrites just that block.
- Photo — a thumbnail, a caption (
Caption (e.g. Rack — before)), and a layout select: Full width, Large, or 2-up (side by side). - Equipment table — a 4-column table with the fixed headers Equipment Type · Make · Model · S/N.
Older saved drafts that stored plain text/tables/photos are converted to blocks on load (serialized format is versioned v: 2).
Photos & the cropper
Adding a photo runs: host file picker (.jpg .jpeg .png .heic .heif .webp) → read bytes → in-module cropper → write into the module's Photos area. The cropper (crop.js) is a full-screen modal — "Crop photo — drag the box or its handles" — with a free-aspect crop box, 8 resize handles, a 28 px minimum, and three actions: Cancel, Use full image, or Crop & add (exports JPEG at quality 0.9). Only file_id/name/caption/layout are saved into the draft; thumbnails are re-derived on edit. On the server each image is normalized with Pillow before embedding.
Custom date picker
The date field is read-only and opens a custom calendar (native month navigation was unreliable in the sandbox): a month header with ‹ / › navigation (wrapping across December/January), a 7-column day grid, and click-to-select. The picker writes ISO and displays July 2, 2026.
Client / address autofill
From saved History, the module builds a { client → address } map. The client and address fields are backed by datalists, and picking a returning client auto-fills their address if the address field is still blank (it never overwrites something you typed).
History #
The History tab lists saved reports (newest first, up to 200), each showing title, client · date · "updated {timestamp}", and three actions:
- Edit — rehydrates the full wizard state (and re-reads photo bytes to restore thumbnails), then opens the wizard for changes.
- Regenerate — rehydrates and immediately regenerates the
.docxas-is. - − Delete — removes the saved report.
Reports persist in the module data storeBaseline storage of JSON documents in named collections (data.store), for app-like state. (collection reports). Generate always saves the draft first, so History reflects the latest state even before the file returns.
Generate & download flow #
- The draft is saved to the
reportscollection. - A payload is assembled — title, skeleton, tags, trimmed UTS/PO, date + formatted
dateText, client/address/contact/email, logofile_id, the scope sub-items (each{ label, blocks }), the content sections (each{ id, title, kind, blocks }, empties dropped), and thecleanWithAiflag. POST generatereturns{ file_id, filename, size_bytes, docx_b64 }.- The UI decodes the base64 to a Blob, auto-downloads it, fires a desktop notification, and shows the persistent download button on the Review step.
Because the sandboxed UI can't fetch /api/vfs itself, the bytes ride back inline as docx_b64; the saved file in the Reports folder is the durable copy. Regenerating overwrites the same file (same name → bytes rewritten in place, same History entry) rather than creating duplicates.
Backend API #
All routes are under /runtime/modules/report-builder, require an authenticated user, and (for the POSTs) a CSRF token.
GET /section-catalog
Returns the registry the wizard renders from plus AI availability: { catalog: { tags, skeletons: { full, "job-completion" }, scopeBlocks }, aiAvailable }.
POST /clean-text
Body { text } (≤ 20 000 chars). Runs a strict single-field AI cleanup and returns { text, changed }. Failures surface as { code, message } with a matching HTTP status (not-configured 503, busy 503, failed 502).
POST /generate
Body is the full report payload (see the flow above). Steps:
- Best-effort AI cleanup of the scope + section text (only if
cleanWithAi; never fails the request). - Build the
.docxviadocument.build_report(...), resolving each photofile_idthrough a provider that reads the stored file and normalizes it. - Save to
Modules/Report Builder/Reports, overwriting any existing file with the same display name ({title} - {client} - {dateText}.docx). - Return
{ file_id, filename, size_bytes, docx_b64 }.
DOCX generation #
services/report_builder/document.py builds the file by filling a tokenized donor template (assets/template-job-completion.docx) carved from a real report — the cover, footer, styles, theme, fonts, and numbering are kept byte-verbatim so the layout is pixel-identical. It does string/regex replacement on the raw XML (never re-parses the whole document), which avoids the namespace-loss corruption that ElementTree re-serialization causes.
What it fills:
- Cover value tokens —
___TITLE___,___CLIENT___,___ADDRESS___,___DATE___. - Footer tokens —
___JOBTYPE___,___REPORTKIND___,___CLIENT___,___PO___(joined,####if none),___UTS___. - Cover logo — swaps
word/media/image1.jpegfor the client logo (or the bundled BrokerLink default), keeping the donor's width and recomputing the height for correct aspect. With no logo the logo run is removed cleanly. - Body — generated OOXML injected at the
___CONTENT___marker.
The `_Content` builder emits paragraphs and figures:
heading— Heading1 in green92D050at 14 pt (sz=28).subheader— smaller green header (e.g. "Office Location Details:").scope_intro— "Our team visited {client} on {date}, for a {job}."scope_label/scope_item— bold run-in labels withkeepNextand line-break-preserved descriptions.image/image_pair— centered figures; consecutive half-layout photos pair into a borderless 2-up table.table— bordered table with a green header row; every row iscantSplitand all but the last carrykeepNext, so a table is pushed whole to the next page rather than splitting.
No-bleed sizing is done in EMUs against the printable width (≈ 6.53"): MAX_W_EMU ≈ 5.97 M, half-width ≈ 48.5%, figure-height caps of 4" (normal) and 5.5" ("large"). fit_image scales width to the cap and clamps height. Content always starts on a fresh page after the cover; every top-level section starts on its own page.
AI cleanup #
services/report_builder/ai_prose.py runs server-side against Featherless.ai (default model Qwen/Qwen2.5-72B-Instruct) with a Google Gemini fallback. Because Featherless sits behind Cloudflare — which 403s urllib's default agent — requests send a browser User-Agent.
The copy-editor prompt fixes spelling, grammar, punctuation, and awkward sentences into clean client-facing prose while preserving meaning exactly and keeping every proper noun, model/serial number, address, phone, UTS#, PO#, job number, and port/data-drop label untouched. It returns strict JSON.
- `clean_batch` — bulk, best-effort (chunks of 12, up to 4 workers); used during generation; never raises.
- `clean_one` — strict single-field path for the live button; raises a typed
ProseErrorthe router maps to an HTTP status +{ code, message }.
Photo normalization #
services/report_builder/photos.py (Pillow, with pillow_heif for HEIC/HEIF) normalizes every embedded image: applies EXIF orientation, flattens transparency onto white, downscales so the longest side ≤ 1800 px, and re-encodes JPEG at quality 88. It returns the pixel dimensions the generator uses to compute no-bleed display sizes.
Styling & build #
The UI is a single-state + render() app (no framework); controls are wired by data-* attributes. Colors come from CSS variables (--bg #0f1216, --panel #171b21, --accent #4f8cff, --good #37c98b, …). build.mjs inlines src/app.css + the script files (bridge.js → model.js → crop.js → app.js, in order) into a single self-contained ui/index.html — required because the sandboxed iframe's CSP blocks external same-package files.
Packaging & publishing #
register.py publishes the module the way the Developer Portal + Store would, scripted end to end: back up the DB, find the owner, build the .zip (manifest + README + built ui/index.html + a sample photo), create/reuse the draft, upload + validate + approve, install for the owner with the consent permissions pre-approved, and set the store icon/banner/screenshots from assets/assets.json. Re-running it at the same version refreshes the store assets without a version bump or API restart; the first-party router only needs a restart when the backend Python changes.
Files #
Package (`modules/report-builder/`)
hanabi.module.json— manifest (id, version, permissions, folders, window).src/index.src.html— DOM shell (rail / tabs / steps / nav / toast).src/model.js— tags, skeletons, sections, scope blocks, title & date logic.src/bridge.js— the host capability bridge (RB.bridge).src/app.js— the wizard, history, content blocks, generate/download.src/crop.js— the image cropper (RB.cropImage).src/app.css— the dark theme / design system.build.mjs— inliner that producesui/index.html.register.py— publish + install script.assets/— storeicon.png/hero.svg/banner.png/ screenshots +default-logo.jpeg(BrokerLink).
Backend (`services/api/hanabi_api/`)
routers/report_builder.py— the three routes (mounted inmain.py).services/report_builder/sections.py— the section/tag/scope registry.services/report_builder/document.py— the template-fill DOCX generator.services/report_builder/ai_prose.py— Featherless / Gemini copy editor.services/report_builder/photos.py— Pillow image normalization.services/report_builder/assets/template-job-completion.docx— donor template.schemas.py— theReportBuilder*request models.