Architecture analysis of Smart Doc's two-layer block model, its three update mechanisms, and what the lesson editor can reuse for selective AI editing.
Smart Doc (internally "SparkDoc") is Allego's AI-powered document authoring tool. It shares the same Vite SPA as the lesson builder — both live under webapp/sco/lesson-builder/ — but forks at the top: AllegoLesson.tsx for lessons, AllegoDocument.tsx for documents.
The critical architectural difference: Smart Doc uses a two-layer model that separates structure from content. Lessons use a flat array. This separation is what makes block-level AI generation straightforward in Smart Doc.
Each block is self-contained — the block object is its own content. No separate spec layer.
Layout holds the grid. Spec holds the content. Linked by uniqueId. Either layer changes independently.
Smart Doc never does full-document replacement after initial generation. Every subsequent change uses one of three targeted mechanisms.
When a user edits a block via the Froala WYSIWYG editor, the change touches exactly one field on one spec item.
onDocumentBlockFieldChange(uniqueId, fieldKey, value)spec[idx][fieldKey] changesThe fieldKey is typed to one of five values: htmlContent, listText, chartConfig, image, or heading. Everything else in the document is untouched.
The per-block sparkle button sends only that one block's metadata to the LLM. The response replaces only that block's spec item.
buildBlockRequest(uniqueId, template, heading)POST /rest/document/createreplaceSpecItem(uniqueId, newItem)The request payload includes a blockTemplateDictionary entry for just the target block's template type, telling the LLM exactly which structured fields to return. On response, replaceSpecItem preserves user-selected images and headings — it merges, it doesn't blindly overwrite.
The layout reducer handles add, remove, move, and replace as immutable tree transforms. Spec items are automatically created or pruned by useEffect hooks that watch the layout state.
ADD_BLOCK_AT_EDGEOther layout actions: REMOVE_BLOCK (auto-prunes orphaned spec), MOVE_BLOCK (drag-and-drop), REPLACE_BLOCK (swap template type).
The lesson editor has block-level manual editing (add, remove, move, clone, edit fields) but no block-level AI editing. Generation is all-or-nothing.
POST /rest/lesson/outline + /rest/lesson/createAIBlock[] — full lesson speccreateFromLessonSpect() — replaces all blockscreateFromLessonSpect iterates the LLM's AIBlock[] array, calls createBlock(type, aiBlock) for each, and sets this.lesson.blocks = blocks. Any blocks the user had — manual edits, image choices, quiz adjustments — are wiped.
The individual block editing infrastructure is solid. onBlockFieldChange patches a single field on a single block. BlockActionType handles Move, Clone, Remove. What's missing is the bridge: telling the LLM "regenerate this one block and slot the result back in."
| Capability | Smart Doc | Lessons |
|---|---|---|
| Data model | Two-layer: layout (structure) + spec (content), linked by uniqueId |
Flat Block[] — each block is its own content. Identified by id |
| Per-block AI generation | buildBlockRequest() sends one block to LLM → replaceSpecItem() slots it back |
Not implemented — LLM always returns full lesson |
| Block template dictionary | 17 templates with structured field definitions the LLM uses to shape its output | Block types exist but no LLM-facing dictionary that describes expected output fields |
| User edit preservation | replaceSpecItem() preserves images and headings; htmlContent (user edits) overrides htmlSnippet (generated) |
No distinction between user-edited and AI-generated content within a block |
| Block CRUD | Layout reducer: ADD_BLOCK_AT_EDGE, REMOVE_BLOCK, MOVE_BLOCK, REPLACE_BLOCK |
Lesson context reducer: ADD_BLOCK, DELETE_BLOCK, MOVE_BLOCK, CLONE_BLOCK |
| Dual-mode AI form | GenerateAIDocumentForm handles both full-doc and per-block via isBlockMode |
GenerateAILessonForm handles full-lesson only |
| Backend endpoint | Same POST /rest/document/create for both modes — payload determines scope |
POST /rest/lesson/create returns full lesson only |
Smart Doc's buildBlockRequest() sends a focused payload with just the target block's metadata and its template dictionary entry. On response, replaceSpecItem() merges the new content while preserving user selections. Lessons can follow the exact same pattern: build a single-block payload from the lesson block's id and blockType, send it through a new or extended API endpoint, and apply the result with a merge function that respects user edits.
Smart Doc's documentBlockTemplateDictionary tells the LLM exactly what structured fields to return per block template (e.g., paragraphs → htmlSnippet, attribute-table → dataMatrix). Lessons need an equivalent lessonBlockTemplateDictionary mapping block types like text-1-col → { bodyText }, question-choose-one → { questionText, answers[] }, etc. The concept maps directly; the field names differ.
GenerateAIDocumentForm switches between full-document and single-block generation with a single isBlockMode flag. The form narrows its UI to show only the target block's heading, guidelines, and source material. GenerateAILessonForm could adopt the same pattern — add an isBlockMode prop, limit the form to one block's parameters, and wire a new onBlockSuccess callback alongside the existing onLessonSpecSuccess.
Smart Doc's handleReplaceSpecItem() in document-context.tsx shows the exact merge logic: find the existing item by uniqueId, preserve user-selected images (via BLOCKS_WITH_PRESERVED_IMAGES), always keep the existing heading. For lessons, a parallel replaceLessonBlock() would find the block by id, preserve user-uploaded images (base64Image, fileName), user-set titles, and quiz scoring state while accepting the new AI-generated text content.
Smart Doc distinguishes htmlSnippet (AI-generated) from htmlContent (user Froala edits). When the user has edited, htmlContent takes precedence in rendering. When AI regenerates a block, it replaces htmlSnippet and clears htmlContent. Lessons currently have no such distinction — all content fields are treated identically regardless of origin. Adding a lightweight "dirty" or "user-modified" flag per field would let the system warn before overwriting manual edits.
Smart Doc's backend reuses POST /rest/document/create for both full-document and single-block generation — the payload shape determines which mode runs. The lesson backend (POST /rest/lesson/create) could follow the same approach: when the request includes a block field (single block metadata), the apiserver generates only that block and returns a single AIBlock instead of a full lesson spec. No new endpoint needed.
Smart Doc already solved the hard design problem — how to scope an AI generation request to a single block and merge the result without losing user work. The lesson editor doesn't need Smart Doc's two-layer model (lessons are simpler; the flat array is fine), but it can directly adopt the patterns that sit on top of it:
The largest open question for the Decide phase is whether the backend API can be extended to accept single-block lesson requests or whether a new prompt type is needed in the apiserver. That's where the bulk of the effort sits — the frontend patterns are well-proven and map cleanly.