花 Hanabi Developer Hub
/ Advanced / Report Builder
Advanced

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 idreport-builder
Version0.9.0
Window1240 × 840 (min 940 × 620), single instance, resizable
UI entrypointui/index.html (fully self-contained, inlined at build)
OutputWord .docx (pure OOXML, no Word/Office automation)
AIFeatherless.ai (Google Gemini fallback), server-side only
Input folderModules/Report Builder/Photos (role input)
Output folderModules/Report Builder/Reports (role output)
Draft storemodule 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:

text
┌─────────────────────────────────────────────────────────────┐
│ 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.html into 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 why build.mjs inlines 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 in main.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.

idChip labelSkeleton
first-visitFirst Visitfull
modified-first-visitModified First Visitfull
recyclingRecycling Service Visitjob-completion
avAV Service Visitjob-completion
office-closureOffice Closure Visitjob-completion
networkNetwork Service Visitjob-completion
cablingCabling Service Visitjob-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 common Service Visit suffix 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):

#Sectionkind
1Coverboilerplate
2Table of Contentsboilerplate
3Prefaceboilerplate
4Office Location & Scope Summary & Limitationsnarrative
5Network Equipment & Infrastructure Equipment, Cabling, & Securityassessment
6Floor Planimage
7Floor Plan with Data Dropimage
8Speed Testscreenshots
9Video Conferencing Assessmentform
10Network Switch - Port Mapporttable
11Network Diagramimage
12Workstation(s) & Server(s) & Backup & Printingassessment
13UPS & Phone Systemassessment
14Assessment Summary & Recommendationsnarrative
15General Site Photos - Network Areaphotos
16General Site Photosphotos

Job Completion (required head + optional pages):

#Sectionkindrequired?
1Coverboilerplaterequired
2Office Location & Scope Summary & Limitationsnarrativerequired
3Floor Planimageoptional
4Floor Plan with Data Dropimageoptional
5Speed Testscreenshotsoptional
6Network Switch - Port Mapporttableoptional
7Network Diagramimageoptional
8Assessment Summary & Recommendationsnarrativeoptional
9General Site Photos - Network Areaphotosoptional
10General Site Photosphotosoptional

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.

idLabelAuto-seeded by tag
networkNetwork Servicenetwork
cablingCabling Servicecabling
recyclingRecycling Servicerecycling
avAV Serviceav
office-closureOffice Closureoffice-closure
access-pointAccess Point Installation— (manual)
cabinet-installNetwork Cabinet Installation— (manual)
workstation-installWorkstation Installation— (manual)
electricalElectrical 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 .docx as-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 #

  1. The draft is saved to the reports collection.
  2. A payload is assembled — title, skeleton, tags, trimmed UTS/PO, date + formatted dateText, client/address/contact/email, logo file_id, the scope sub-items (each { label, blocks }), the content sections (each { id, title, kind, blocks }, empties dropped), and the cleanWithAi flag.
  3. POST generate returns { file_id, filename, size_bytes, docx_b64 }.
  4. 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:

  1. Best-effort AI cleanup of the scope + section text (only if cleanWithAi; never fails the request).
  2. Build the .docx via document.build_report(...), resolving each photo file_id through a provider that reads the stored file and normalizes it.
  3. Save to Modules/Report Builder/Reports, overwriting any existing file with the same display name ({title} - {client} - {dateText}.docx).
  4. 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.jpeg for 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 green 92D050 at 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 with keepNext and 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 is cantSplit and all but the last carry keepNext, 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.

Current limitation: both skeletons render from the Job Completion donor template. A full site-survey report reuses that cover template and injects the Table of Contents + Preface headings as content, rather than using a bespoke First-Visit template. A dedicated full template is a known follow-up.

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 ProseError the 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 produces ui/index.html.
  • register.py — publish + install script.
  • assets/ — store icon.png / hero.svg / banner.png / screenshots + default-logo.jpeg (BrokerLink).

Backend (`services/api/hanabi_api/`)

  • routers/report_builder.py — the three routes (mounted in main.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 — the ReportBuilder* request models.