Design decisions
Implementation decisions for app behavior beyond SPEC.md's handoff spec — either left open there, or found through real usage/testing.
Open Video dialog: folder navigation
Opens on a default folder (CLI video's parent, else config.toml's
remembered video_folder, else cwd) but isn't limited to it — an "‥ Up"
row and subfolder clicks navigate in place
(open_media_dialog::list_folder_entries). Chosen over a native OS
picker to stay consistent with SPEC.md's "no OS-native file picker"
direction.
Open Subtitles dialog: no OS drag-and-drop
SPEC.md specs the translation link as an OS drag-and-drop target, but
Slint 1.17.1's winit backend doesn't relay external file drops to
DropArea at all (see slint). Instead, a
small in-app file picker (FileListDialog, shared with the Open Video
dialog) scoped to the video's own folder's .srt files links a
translation, re-merging cues immediately. SPEC.md's "(DE)"/"(EN)"
language-code labels are generic "Original subtitle"/"Translation"
instead, since trango doesn't track subtitle language.
Subtitle generation: stub, then whisper-cli
The subtitle crate's SubtitleGenerator trait (fn generate(&self, video_path) -> Result<PathBuf, SubtitleError>) captured the shape before
any STT dependency was added (StubSubtitleGenerator wrote a fixed
placeholder cue). WhisperCliGenerator (crates/subtitle/src/generate.rs)
now runs whisper.cpp's whisper-cli binary as an external process
(std::process::Command) rather than a Rust binding crate like
whisper-rs — no new Cargo dependency, and -osrt already writes a
ready-made .srt. Tests fake the binary with small POSIX shell scripts
standing in for its -of/-osrt contract.
Audio extraction via ffmpeg. whisper-cli only reads raw audio
(flac/mp3/ogg/wav), not video containers — and silently exits 0 while
writing nothing when given one. WhisperCliGenerator::generate now
extracts the video's audio to a temp 16kHz mono WAV with ffmpeg first,
then runs whisper-cli against that. extract_audio and
run_whisper_cli are separately testable against fake binaries;
run_command retries briefly on ETXTBSY (freshly written test binaries
occasionally racing exec).
TODO.md Vaihe 29 reuses this same generate for the Audio source's
"Generate subtitles" (same button/dialog as the Video source — no new call
site was needed, since Vaihe 28 already generalized CurrentMedia to hold
either a video or a recorded/opened .wav path). generate skips
extract_audio when its input is already a .wav — the only extension the
Audio source ever loads — and hands it to whisper-cli directly.
Background thread, not the UI thread. Real transcription can take
minutes; spawn_generate runs it on std::thread::spawn, reporting back
via slint::invoke_from_event_loop (state behind Rc/RefCell isn't
Send, so only a Weak<AppWindow> + owned Result cross the thread
boundary — mirroring video_player.rs's load_file).
Model selection: UI + autodiscovery, persisted to TOML
Replaced an env var (TRANGO_WHISPER_MODEL_PATH) with an in-app picker,
since models are switched more often than the CLI binary path is — the
Open Subtitles dialog's model row opens a FileListDialog scoped to
.bin/.gguf files (model_picker.rs). default_start_folder tries the
config's remembered folder, then a few well-known whisper.cpp model
locations, then cwd — no OS-specific magic. The pick persists to
config.rs's $XDG_CONFIG_HOME/trango/config.toml (trango's first
persistent settings file, added with user approval per CLAUDE.md).
model_picker::language_flag infers -l en vs -l auto from
whisper.cpp's .en filename convention. Smaller models transcribe
non-English audio much worse than English — usage docs recommend
medium/large-v3 for anything else.
Generating subtitles for an open video reloads it
(Superseded as the sole fix by keep-open=yes — see
Video playback — but still done, since
it also re-arms the sentence-by-sentence start seek.) Generating
subtitles for an already-playing video can let it reach EOF
mid-generation, leaving mpv's core idle and every subsequent seek failing
(Raw(-12)). Fix: after linking the generated subtitle,
wire_open_subtitles_dialog also reloads the video via
VideoPlayer::load_video. Since a real VideoPlayer can't be constructed
in main.rs's tests, the handler takes a reload_video closure instead
of the player directly, so tests can assert the reload without a real
mpv instance.
No mode autoplays — only Space starts/stops playback
Initially every navigation action (next_cue/previous_cue/
jump_to_cue/repeat_current_cue) auto-played through to the cue's end
and paused. This broke replay: real STT output commonly produces
contiguous cues (cue N's end == cue N+1's start), so the moment mpv
auto-paused at N's end, sync_current_sentence immediately reclassified
the cursor onto N+1, and Space then replayed the wrong sentence.
Fix: navigation only seeks and leaves mpv paused; Space is the only
thing that starts/stops playback, as a toggle. This needed a type split
in playback-state: SeekCommand { start } (navigation) vs.
PlaySpanCommand { start, end } (repeat). Whether a span should start or
interrupt playback needs live mpv state, so that decision moved to
video_player.rs's toggle_play_span. sync_current_sentence was
further restricted to only re-derive the cursor while pause_at is
actually armed, so a paused cursor never gets silently reclassified — and
pause_and_arm_start_seek now unconditionally pauses on load, only
conditionally arming a start-of-playback seek, so a video with no
subtitle also opens paused.
sync_current_sentence removed entirely
The pause_at-gated fix above still had a same-tick race:
sync_current_sentence and apply_pending_pause run in the same poll
tick, sync first — so on the tick time-pos reaches a cue's end,
pause_at hasn't cleared yet and the cursor still gets reclassified onto
the contiguous next cue before the pause lands. Since every play action
is now a bounded, already-known-cue span (toggle_play_span), there was
no remaining case needing live rediscovery of the cursor from time-pos
— so the function, its poll call, and PlayerState::sync_cue_to_time
were deleted outright rather than patched again. (Both Normal mode
continuous playback and scrub-bar dragging later needed the same kind of
live tracking — see "Normal mode: live time-pos syncing" below — but were
designed fresh against their own seek model, not by reviving this.)
Space works in every mode
Right/Left/Space were all gated behind sentence-mode-active, a
leftover from before autoplay-on-open was removed. Right/Left stay
gated (no Normal-mode cue navigation exists), but Space now works
unconditionally: repeat_current_cue returning Some (a cue in focus,
SentenceBySentence) plays that cue's bounded span via
toggle_play_span; returning None (Normal mode, or no subtitle)
instead calls the new unbounded VideoPlayer::toggle_playback. First
pass missed that current_cue_index is set regardless of mode, so a
subtitle linked while in Normal mode wrongly routed Space to the bounded
path — fixed by adding a mode check directly to repeat_current_cue
itself.
Word analysis: local Ollama, not a cloud API
Word-by-word translation + pronunciation for the on-screen sentence uses
Ollama (localhost:11434) for the same reason
whisper-cli was chosen for subtitle generation: no upload, no per-call
cost, on-device. Ollama is an external program, not a Cargo dependency.
- Crate split: HTTP/JSON/cache logic lives in
crates/word-analysis, free of Slint/libmpv, mirroringsubtitle/playback-state. The app-local wiring modulecrates/app/src/word_analysis.rsshadows the extern crateword_analysisatmain.rs's crate root; call sites needing the crate use a leading::word_analysis::.... - HTTP client:
ureq, notreqwest— no async runtime elsewhere in trango; see ureq. - Prompt/response:
build_promptasks for{"words": [{"word", "translation", "pronunciation"}]}with Ollama'sformat: "json"andstream: false;parse_analysis_responsestrips a defensive```jsonfence some models still add. - Cache: one JSON sidecar per subtitle (
subs.srt→subs.wordanalysis.json), keyed byCue::index(AnalysisCache { model, entries }) so a shifted line doesn't reuse a stale entry. A missing/corrupt cache becomes empty rather than an error. Ctrl+A (single sentence) and "Analyze all sentences" (batch, saving incrementally after every cue so a stopped run loses no progress) share this same file. - Model + target language: an "Ollama model" row reuses the
FileListDialogchrome, backed by a network call (GET /api/tags) run on a background thread. Target language is free text (not a fixed list, per user preference — trango's firstLineEdit), saved to config on every keystroke, defaulting to"English"only in code, not inTrangoConfig::default().
Word analysis: "think": false, and debug logging
Reasoning models (e.g. the qwen3 family) can spend their whole
generation budget on internal "thinking" and return an empty response,
which serde_json fails to parse with a confusing zero-length-input
error. GenerateRequest now sets "think": false; analyze_sentence
also checks for an empty response explicitly, returning a clear
OllamaError::InvalidResponse instead of forwarding the parse error.
Diagnosing this needed the raw prompt/response logged at
tracing::debug!, which surfaced that tracing-subscriber's
env-filter feature had never actually been enabled — RUST_LOG
filtering had silently never worked. Rather than just enabling it and
relying on RUST_LOG, the user asked for a CLI flag instead (per
CLAUDE.md's env-var-vs-flag convention): --debug
(extract_debug_flag) now builds a fixed
"info,trango=debug,word_analysis=debug" filter; RUST_LOG still works
underneath as a finer-grained escape hatch.
Normal mode's hint bar content
The bottom HintBar used to be gated behind sentence-mode-active
entirely, so Normal mode showed no shortcut reminders even though
Space/Ctrl+T/Ctrl+A already worked there. HintBar now takes
sentence-mode-active as an input and shows a mode-dependent subset of
the same five entries (Right/Left only in SentenceBySentence; Space's
label switches between "repeat sentence"/"play-pause"), always
instantiated. The five near-identical labels were factored into a
HintLabel sub-component.
Scrub bar drag-to-seek
A TouchArea (24px tall, taller than the 4px visible track) inside
ScrubBar computes the pointer's fraction across the track on
clicked/moved-while-pressed, firing seek-requested(float) →
video_player::VideoPlayer::seek_to_fraction. Unlike cue-navigation
seeks, this never touches pause (a drag shouldn't start or stop
playback) and clamps the fraction (which can overshoot 0.0..1.0 on an
out-of-bounds drag) in a small pure, unit-tested seek_target_secs
helper. It clears any armed pause_at, same as other seeks.
Normal mode: live time-pos syncing
The last open item: Ctrl+A in Normal mode showed a stale sentence once
playback moved past whatever cue was current when the mode was entered,
since nothing re-derives current_cue_index from live time-pos outside
SentenceBySentence. Designed fresh rather than reviving the removed
sync_current_sentence (see above): PlayerState::sync_cue_to_time
returns whether the cursor changed; video_player.rs's new
sync_current_sentence_normal_mode (a no-op outside Normal mode) runs
on every poll tick and mirrors a changed cue into the sentence card/list.
Normal mode never arms pause_at, so the same-tick race that killed the
original mechanism structurally can't happen here. Scope: only live
tracking — whether the sentence panel should even show in Normal mode
is still open.
Current-sentence card: bounded, scrollable sentence text
The original-language Text had no bounded height, so
CurrentSentenceCard's vertical-stretch: 0 asked its VerticalLayout
parent for exactly the wrapped text's natural height — whenever the
sentence panel column ran short on room, the layout could squeeze the
card below that, clipping the bottom line(s) instead of showing or
scrolling to them. Fixed the same way translation-height already fixes
the same class of bug for the translation line below it: a fixed-height
ScrollView (sentence-height, 150px ≈ 4 lines) so long sentences
scroll instead of clipping. This does not fix mixed-script bidi
rendering glitches — see "Known limitation: bidi text wrapping" below,
which turned out to be the actual cause of the originally reported bug
report this investigation started from.
Known limitation: bidi text wrapping (Slint/femtovg)
A cue mixing Hebrew (RTL) with an embedded Latin word (e.g. "co-working")
renders garbled characters at the line-wrap boundary when the Latin word
falls across a wrap point — not a height/clipping issue (ruled out above),
but character-level bidi reordering going wrong in Slint's text shaping.
video_player.rs requires Slint's OpenGL (femtovg) renderer for the mpv
render context, so switching renderer isn't an available workaround.
Slint's RTL/bidi support is itself incomplete upstream — see
slint-ui/slint#2294 and
#7267. Accepted as a
known limitation for now; no in-repo workaround attempted. Revisit if
Slint's bidi support improves, or if this affects enough real subtitle
content to justify manually inserting Unicode directional-isolate marks
(U+2066/U+2069) around embedded Latin runs before handing cue text to
sentence_card.rs.
Audio source: system-audio capture, not YouTube download/caption scraping
Live subtitle recording without a video (TODO.md Vaihe 25–31) needs some
source of audio/text to transcribe. Two alternatives were considered and
rejected for copyright reasons: playing/downloading the source video
directly (e.g. via yt-dlp + mpv's ytdl_hook), and scraping a site's
already-generated captions (e.g. yt-dlp --write-auto-sub --skip-download).
Both would have trango fetch copyrighted content from a third party.
Instead, Vaihe 26 onward capture the system's own audio output — whatever
is already playing locally, from any source — and never persist more than
the resulting .srt; no video/audio file trango didn't already have is
ever downloaded or saved.
System audio capture: pactl's default-sink monitor, graceful ffmpeg stop
TODO.md Vaihe 26 needed a monitor source to feed ffmpeg -f pulse -i.
Rather than parsing pactl list sources for whichever ones end in
.monitor (several, if multiple outputs exist — ambiguous to pick
between), AudioCapture::default_monitor_source asks pactl get-default-sink and appends .monitor itself, since PulseAudio/
PipeWire guarantee that naming convention. config.rs's
audio_monitor_source overrides this for setups where the default sink
isn't the one to capture.
Killing ffmpeg outright (SIGKILL) leaves the WAV header's size field
wrong, since ffmpeg only finalizes it on a clean exit. AudioCapture::stop
instead writes q to ffmpeg's stdin — the same key it reads
interactively to quit gracefully — and only falls back to kill() after
graceful_stop_timeout (a test-injectable field; production uses 5s).
A missing pactl/ffmpeg install only showed up in the log (usually
invisible to a user running the packaged app), making Ctrl+Space look
like it silently did nothing. system_audio_capture::wire_audio_capture
now also mirrors every start/stop outcome into audio-capture-error-message
(AppWindow property, shown in the Audio source's placeholder), cleared on
success — a small, targeted piece of Vaihe 29's UI pulled forward, without
building the full rec/stop control it also adds.
MediaSource, split out from PlaybackMode
Which source is active (video file vs. audio) and how navigation behaves
(Normal vs. Sentence by sentence) are independent choices, so a single
PlaybackMode enum can't express both — a three-way mode would have no way
to select "audio source" and "Sentence by sentence" together.
playback_state::MediaSource (Video/Audio) exists alongside the original
two-variant PlaybackMode; PlayerState holds both fields independently.
The top bar mirrors this with two separate segmented-control groups —
Video/Audio and Normal/Sentence-by-sentence — rather than one combined
control (not in the mock, sketch/design_reference.dc.html#1c, which only
showed the mode pair). The video area's Rectangle stays unconditionally
instantiated in the Audio source too (so video-frame-x/-y/-width/-height,
read every frame by video_player.rs, keep resolving) — the Audio
placeholder is an overlay child inside it, not a swapped-out sibling.
System audio capture reverted to a single WAV file, not live segmentation
An earlier version of Vaihe 26 had ffmpeg stream raw PCM to its stdout
so a VadSegmenter could chop it into speech segments for per-segment
whisper-cli transcription, growing the sentence list live. That
approach (webrtc-vad, vad.rs, live_transcription.rs) was removed:
it added real complexity (FFI, a non-Send VAD instance, a channel
draining onto the UI thread) for transcription quality no better than
running whisper-cli once over the finished recording. AudioCapture
now just has ffmpeg write directly to a WAV file; TODO.md Vaihe 29
runs "Generate subtitles" over that file as a whole, the same
WhisperCliGenerator path video files use.
Recording filename: chrono dependency, rename only after stop
TODO.md Vaihe 27's default filename needs a local (not UTC) date+time —
the std library has no timezone-aware formatting for SystemTime, so
chrono was added to crates/app rather than hand-rolling civil-date
math. The filename is locked while a recording is in progress and only
renamable afterwards, matching a normal recorder's behavior; the rename
handler rejects any value that isn't a single plain path component so a
pasted value containing / or .. can't move the file outside its
recording folder.
Validated: cue-based features never depended on video
TODO.md Vaihe 30 asked whether sentence list, Ctrl+A word analysis, and
the translation toggle secretly assumed a video was loaded. They don't:
sentence_card.rs/sentence_list.rs/word_analysis.rs and
playback_state::PlayerState's cue navigation only ever read
cues/current_cue_index/show_translation, never MediaSource or
anything video-specific — confirmed by grepping for MediaSource outside
main.rs's own source-selection code and video_player.rs. No code
changed; crates/app/tests/e2e_sentence_navigation.rs and
main.rs's test_app_window_properties gained tests that switch to the
Audio source mid-run and repeat the same navigation/Ctrl+A/sentence-list
assertions, locking the guarantee in against regressions.
Source switch pauses playback and gates controls by the loaded file's kind
Video and Audio share one video_player::VideoPlayer/mpv instance — the
top bar's source buttons only ever swapped which panel was visible, never
touched playback. That meant switching sources left whatever was playing
running audibly behind the hidden panel, and a loaded video's ScrubBar
could appear in the Audio panel (or its picture show through) just because
some file happened to be loaded, regardless of kind. Fixed two ways: the
Video/Audio segment buttons now call a new pause-playback callback
(video_player::VideoPlayer::pause()) before select-media-source, and
AppWindow::media-ready (video-loaded && loaded-media-source == media-source) gates the mpv underlay/ScrubBar/SpeedSlider/Audio
placeholder so they only activate once the actually-loaded file's kind
matches the visible panel. loaded-media-source is set in
open_selected_media, the single choke point both the Open dialog and the
post-recording auto-load go through. Two independent player "slots" (each
source remembering its own loaded file/position) was considered and
rejected as unnecessarily large for the actual complaint — the one shared
mpv instance never caused problems the previous UI just failed to gate
against.
Sentence card/list and Ctrl+A also gated by panel_content_ready
Once playback controls were gated by media-ready above, the same
complaint showed up one layer up: switching to a not-yet-loaded Audio
panel left the Video source's current-sentence card and sentence list
sitting on screen untouched, and Ctrl+A would still analyze that stale
sentence — the "Validated: cue-based features never depended on video"
decision above had made this a deliberate, tested guarantee (switching
source never touches cues), which now reads as the bug rather than the
fix. Revised: main.rs's panel_content_ready (same media-source != Audio || media-ready condition as the Slint gate) decides what the
sentence card/list display, blanking it to an empty PlayerState's
placeholder in on_select_media_source when the newly-selected panel
isn't ready, and restoring the real one when switching back to a source
that is. The Ctrl+A handler checks the same condition before reading
current_cue_index, so it reports "No sentence is currently in focus"
rather than reusing the other source's cache entry. PlayerState.cues
itself is never touched — only what's displayed/analyzed — so cue
navigation's source-independence (the e2e_sentence_navigation.rs
guarantee) still holds unchanged; only the two Slint-facing consumers
that read the current cue for on-screen display now also check which
panel is showing it.
CI: PR checks and .deb release automation
Pull requests against master run .github/workflows/ci.yml: fmt +
clippy (scripts/check.sh), the test suite (scripts/test.sh), and a
release build, as three separate jobs so failures are easy to tell apart.
.github/workflows/release-deb.yml builds and publishes a .deb as a
GitHub Release whenever master's workspace Cargo.toml changes — in
practice, every merged PR, since versioning bumps on every commit. A
check-version job guards against re-publishing a version that already
has a release (e.g. a Cargo.toml change that touched something other
than the version field). Packaging uses cargo-deb, configured via
[package.metadata.deb] in crates/app/Cargo.toml; runtime Depends
are left at the default $auto so dpkg-shlibdeps derives them from the
built binary's actual shared-library links instead of being hand-maintained.
Hebrew pronunciation: native ort inference, not the Ollama prompt
Ollama's own pronunciation field is unreliable for Hebrew even with a
Hebrew-capable model — small LLMs mistransliterate niqud/dagesh
distinctions (e.g. שכב → "shkach" instead of "sha-khav"), and re-feeding
niqud text through the LLM wouldn't fix this: BPE tokenization splits
Hebrew combining diacritics unpredictably, so the same unreliability just
moves one step later. Instead crates/niqud's OnnxNiqudClient runs
Phonikud's niqud model
directly via ort (ONNX Runtime bindings), and a deterministic Rust
table (transliterate.rs) converts the resulting niqud text to a
hyphenated Latin guide — no further LLM call. Gated automatically by
contains_hebrew (Unicode block U+0590–U+05FF); other languages are
untouched. Ollama still handles translation (a real semantic task) and
its pronunciation guess is kept as a fallback if no niqud model is
configured, loading fails, or the word counts don't align
(tracing::warn, never a hard failure).
Native Rust, not a Python subprocess. An earlier version shelled out
to a Python/phonikud-onnx CLI wrapper; it worked but accumulated real
operational hackiness (a venv whose activation state depends on whatever
shell happens to launch trango, on top of CPU-pinning/offline-mode
workarounds). Reimplementing natively turned out tractable because the
model's I/O contract and the dicta-il/dictabert-large-char-menaked
tokenizer were both fully reverse-engineered during that first
implementation: despite being stored in HuggingFace's "WordPiece"
format, the tokenizer is actually character-level (its
pre_tokenizer splits into individual characters first, so no subword
merging ever happens) — a flat char→id vocab parsed straight from
tokenizer.json is enough, no tokenizers crate dependency needed.
decode.rs ports phonikud_onnx's Python reconstruction loop
(argmax over nikud_logits/shin_logits, threshold over
additional_logits's stress/vocal-shva/prefix classifiers) directly.
Pitfalls found in the model's output that both the decode loop and the
transliteration table depend on: beyond standard nikud/dagesh/shin-dot
marks, the model also emits a | (U+007C) after prefix letters
(ו/ב/כ/ל/מ/ש) marking a morpheme boundary, and a meteg (U+05BD) combined
with shva distinguishes vocal shva ("e", pronounced) from silent shva —
both undocumented in Phonikud's own API but load-bearing for correct
syllabification.
Build vs. runtime linking. ort's default download-binaries
feature fetches a prebuilt ONNX Runtime binary at compile time over the
network — unacceptable for offline/CI builds. crates/niqud/Cargo.toml
instead uses load-dynamic (loads libonnxruntime.so at runtime) plus
api-23: the crate's default feature set requests API 24, which hangs
indefinitely (not a clean error) against Ubuntu's apt-packaged
libonnxruntime1.23 — api-23 works correctly against that same package,
confirmed by comparing its output against Python onnxruntime's for
identical input (matching to a few significant digits; a newer runtime
like pip's onnxruntime 1.27 matches exactly).
No ORT_DYLIB_PATH needed for a normal install: crates/app/Cargo.toml
depends on libonnxruntime1.23 directly ($auto/dpkg-shlibdeps can't
detect it, since load-dynamic means no link-time ELF reference exists
for it to find), and crates/niqud/src/dylib.rs scans the usual
Debian/Ubuntu library directories at runtime for a match — see
ort for the hang-avoidance details this needed.
Model/tokenizer files are still a manual download (accepted tradeoff,
not automatable the way the library dependency is — too large to bundle
in the .deb) — see docs/src/usage/word-analysis.md.
GPU checked, CPU kept deliberately. onnxruntime's CUDA provider
silently falls back to CPU if system cuDNN is missing (no hard error —
easy to miss). Measured explicitly on real hardware (RTX 5070 Ti):
inference is already ~16ms on plain CPU for this int8 model, so GPU
wouldn't help. OnnxNiqudClient requests CPUExecutionProvider
explicitly (not a silent default).
Hebrew prefix particles: a parts breakdown, not split top-level entries
Real use surfaced two problems with Hebrew's single-letter prefix
particles (ו/ה/ב/כ/ל/מ/ש, written attached to the following word with no
space, e.g. לסרטים = ל + סרטים). A first attempt asked Ollama to always
split such a word into two separate top-level word entries (own
"word"/"translation"/"pronunciation" each). That was wrong on two
counts:
- Not how it sounds. A prefixed word is pronounced as one fused unit in speech (e.g. "לסרטים" as "le-sratim"), not two separate sounds — but the user wants exactly that fused pronunciation, matching what's actually heard, not a per-morpheme guess.
- Broke niqud alignment.
apply_niqud_pronunciation's word-count check compares Ollama's word list against niqud's own, which only ever splits on whitespace — so splitting a prefixed word into two Ollama entries mismatched niqud's one, and the mismatch fallback (keep Ollama's own pronunciation guess) applied far more often than it should have.
Fixed by keeping "word"/"pronunciation" as the whole combined form
(restoring niqud's 1:1 alignment as a side effect — no changes needed to
the niqud pipeline itself) and moving the morpheme breakdown into a new
optional "parts" array on each WordEntry (word-analysis/src/ entry.rs's WordPart, #[serde(default, skip_serializing_if = "Vec::is_empty")] so it's absent from JSON for the overwhelming
majority of words that have nothing to break down). ollama.rs's
HEBREW_PREFIX_GUIDANCE (gated on a contains_hebrew check duplicated
from niqud rather than adding a crate dependency for one predicate)
asks for this with a concrete worked example. The Ctrl+A popup
(WordAnalysisRow's parts-label) shows the breakdown as a small
second line under the translation, e.g. "ל = to · סרטים = movies", only
when non-empty.
Hebrew prefix particles: merging by niqud's boundaries, not by exact text
The prompt guidance above isn't followed consistently — real captured output for one sentence correctly fused one prefixed word but still split two others into separate top-level entries in the same response. An earlier fix compared Ollama's and niqud's word lists via an LCS match on exact text equality, correcting pronunciation wherever both sides matched verbatim. That can't fix a split word: a split fragment's text ("ו", "אמר") never equals the fused token niqud returns ("ואמר"), so it stayed both visually split and mispronounced.
hebrew_word_merge::merge_by_niqud_boundaries (crates/app/src/ hebrew_word_merge.rs) fixes this instead by trusting niqud's
whitespace-only tokenization as the word boundary, and growing a
window of consecutive Ollama entries (smallest first) until their
concatenated text matches niqud's current word — merging whichever
entries were consumed into one WordEntry, joining their translations
with a space and rebuilding parts from them. Runs once, inside
apply_niqud_pronunciation, before the analysis is cached — the Ctrl+A
popup and cache file only ever see the already-reconciled result.
Hebrew word analysis: niqud's word list feeds the Ollama prompt, not the other way around
Even with the merge above, Ollama's own word count kept drifting from
niqud's in real use (e.g. logged as ollama_words=31 niqud_words=30 on
a real subtitle line) — asking a token-based LLM to reproduce an exact
word count/order for a sentence it segments itself is inherently
unreliable, no matter how the prompt wording is tuned.
word_analysis::analyze_sentence (crates/app/src/word_analysis.rs)
now calls niqud before Ollama for a Hebrew sentence, and passes its
whitespace-split words to OllamaClient::analyze_words (crates/ word-analysis/src/ollama.rs) as a fixed JSON array the model fills in
translation/pronunciation/parts for — never asking it to decide
word boundaries itself. merge_by_niqud_boundaries still runs
afterward as a safety net for the rarer case where Ollama's response
doesn't match the given list either. Non-Hebrew sentences are
unaffected — they still use analyze_sentence's free-text prompt,
since there's no niqud tokenization to pre-split them with.