Sentence Selection is a passage of prose and a Prompt describing what to find in it — “identify every sentence describing a PO accountability” — where the student clicks every sentence they believe matches. Any number, including all or none. Multiple choice tests recognition of a single fact; Word Match tests producing a term in context; Sentence Selection tests something neither of those do: reading a whole passage critically enough to separate what belongs from what doesn’t.
It’s the fifth Lesson type, and the fifth one is where a shortcut that worked for four stopped working. Most of this post is actually about that.
Where four types broke
CatalogLive.Lesson was one LiveView, 575 lines, with render/1 and mount/3 each branching on lesson_type, and nine handle_event clauses for Quiz and Word Match sharing one flat assigns namespace. :feedback already meant two different things — {:correct, option_id, msg} in the Quiz, {:errors, n} | :success in Word Match — and the only reason they hadn’t collided yet was that a Lesson has exactly one type at a time. That’s luck, not encapsulation. A fifth type reusing an assign name — :mode, :selected, :attempt — would have silently corrupted a sibling type’s state, with no compiler error and no failing test to catch it.
erDiagram
LESSON ||--|| TYPE_MODULE : "lesson_type dispatches to"
TYPE_MODULE {
function component "LiveComponent module"
function authoring_path
function icon
function label
}
TYPE_MODULE ||--|| COMPONENT : owns
COMPONENT {
assigns own_socket "invisible to siblings"
handlers own_events "phx-target={@myself}"
}
LESSON_LIVE ||--o{ TYPE_MODULE : "registry lookup, never branches"
So before writing a line of Sentence Selection’s own logic, the first slice was ADR 0020: each Lesson type becomes its own LiveComponent, with its own render, its own handle_events routed via phx-target={@myself}, and its own socket assigns — invisible to every sibling. A thin Forgia.Lessons.Type behaviour captures what’s non-UI per type (which component renders it, where its authoring screen lives, its outline icon, its label), looked up through one registry instead of a branch:
defmodule Forgia.Lessons.Type do
@callback component() :: module()
@callback authoring_path(Course.t(), Lesson.t()) :: String.t()
@callback icon() :: Phoenix.LiveView.Rendered.t()
@callback label() :: String.t()
end
CatalogLive.Lesson now does exactly three things: loads the Lesson, enforces the Enrollment/Free-Preview gate, and owns mark_complete / complete_and_next — the two actions any type’s template can trigger without a phx-target, so they bubble up regardless of which type is on screen. Everything else belongs to the type.
The safety net for this move was the existing Quiz and Word Match LiveView tests — they drive rendered DOM and assert against the database, never internal function calls, so a component extraction changes where handle_event lives, not what lands in Postgres. All 62 of them passed unchanged. A regression there would have meant a real bug, not a brittle test.
One bug fell out of doing this move properly. complete_and_next used to decide “did the student just finish the course” by checking whether there was a next Lesson positionally — not whether every Lesson was actually done. Skip an earlier Lesson, finish the last one, and it promised “Finish course,” then immediately bounced you back to the overview once you clicked it. Centralizing the routing decision in one function — Enrollments.record_completion_and_progress/4 — made the honest check (“is the real percentage 100?”) the only version that exists, instead of three copies that could each get it wrong differently.
Authoring: type the passage, click to mark it
No delimiter syntax, unlike Word Match’s [[ ]]. The instructor types the passage as one free-text field, and the same panel renders a live preview where clicking a sentence toggles it correct.

Sentence boundaries come from one function — split on ., ! or ? followed by whitespace — used identically by this preview and by the student’s render. That’s the whole point of building the preview this way: a bad split is visible and fixable by rewriting the text, immediately, rather than silently diverging between what the instructor saw and what the student gets.
def split(text) when is_binary(text) do
text
|> String.split(~r/(?<=[.!?])\s+/)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
Saving refuses a passage with zero sentences marked correct — the changeset is invalid, and nothing is persisted. Marking itself isn’t form data; it’s server-held selection state, positional into whatever’s currently typed in the passage. Editing the passage after marking can shift which sentence an index points to — the same tradeoff Word Match already accepts by re-parsing its whole document on every save.
The document, and what never leaves the server
One jsonb column, same shape discipline as Word Match: string keys throughout, because anything loaded back from Postgres has string keys.
erDiagram
LESSON ||--o| SENTENCE_SELECTION : "sentence_selection (jsonb)"
SENTENCE_SELECTION ||--o{ SENTENCE : passage
SENTENCE_SELECTION {
string source "raw passage, as typed"
string prompt "markdown, on the Lesson itself"
}
SENTENCE {
string id "s1, s2… stable within a save"
string text "safe to render"
bool correct "NEVER rendered"
}
A Sentence’s correct flag never reaches the browser — the template only ever interpolates id and text. On an incorrect Check the student sees one number:

“You made 2 mistakes” — missed correct sentences plus wrongly-selected ones, added together, never which. The same ADR 0014 reasoning that shaped Word Match’s Blank/chip split applies here for a simpler reason: there’s no chip to hide behind. If the DOM carried a correctness flag at all, the answer key would just be sitting there in view-source.
sequenceDiagram
participant S as Student
participant LV as Sentence Selection LiveComponent
participant SS as SentenceSelection (domain)
participant DB as Completions + Challenge Attempts
S->>LV: click a Sentence (any number, any order)
LV-->>S: toggled, no server round-trip needed to judge yet
S->>LV: Check my answer
LV->>SS: check(document, selected_ids)
LV->>DB: one Challenge Attempt per Sentence, correct or not
alt some wrong
SS-->>LV: {:mistakes, n}
LV-->>S: "You made n mistakes" — nothing more
else exact match
SS-->>LV: :correct
LV->>DB: record Completion
LV-->>S: success modal → next Lesson
end
Every Check — right or wrong — records one Challenge Attempt per sentence, so Analytics can eventually rank which specific sentences confuse students most, the same way it already ranks Quiz Questions and Word Match Blanks. That meant teaching Forgia.Analytics.Extractor a third clause and nothing else; the aggregation, the windowing, the whole Analytics screen were already written against “Challenge,” never against lesson_type.
Attempts are unlimited. Navigating away before checking discards the selection — there’s no SentenceSelectionAttempt to resume, on purpose. Unlike a Quiz or a Word Match, a Sentence Selection instance is meant to be substantial enough to deliver a Learning Outcome by itself: one passage, one Prompt, not three of them bundled into a longer Lesson.

Get every sentence right and the Completion lands, with no per-sentence reveal even in success — just confirmation and a way forward.

What’s deferred
- Partial credit or a scoring threshold. It’s all-or-nothing on the exact set, consistent with the “no reveal” stance — partial credit would need to say something about which sentences were close.
- Multiple passages per Lesson. That’s Word Match’s job if it’s ever wanted; a Sentence Selection instance stays one passage.
- Sentence order randomization — deferred platform-wide, not specific to this type.
- NLP-grade sentence boundary detection. The punctuation heuristic is the whole rule, by design: an instructor who hits a bad split rewrites the sentence, the same way a Word Match instructor rewrites a passage the parser rejects.
The bigger deferred item isn’t a Sentence Selection feature at all — it’s every Lesson type after this one. That’s what the refactor was for.