Lesarten are real words from a dictionary in the shared database, not letter swaps - #456
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
There are correctness issues in the generation-load lifecycle (stale generations not actually dropped as documented) and input validation edge cases, plus future-dated provenance/migration headers that should be corrected before merge.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR changes the Lesarten feature from “generated letter swaps” to “real-word readings” backed by a dictionary stored in the shared PostgreSQL database, adding a public GET /lesarten?text=… endpoint plus an admin-gated generation-switched loader and the tooling/docs/tests around it.
Changes:
- Add
core/lesarten(look-alike classes, bucketing key, ranking) and a new API router providingGET /lesarten+ admin load endpoints. - Add migration
0028and SQLAlchemy repository/models forlesart_forms+lesart_dictionary, with generation switching for atomic vocabulary updates. - Replace the frontend’s local Lesarten generator with API-backed results and update provenance/docs/changelog accordingly.
File summaries
| File | Description |
|---|---|
| tools/lesarten/sync.py | Admin tool to build and load the vocabulary into the API in batches with generation switching. |
| tools/lesarten/expand.py | Local dictionary expansion (Hunspell affix layer) to produce word forms for loading. |
| tools/lesarten/init.py | Package docstring describing the tools’ responsibilities and invariants. |
| tests/test_lesarten_expand.py | Unit tests for Hunspell expansion behavior and filtering rules. |
| tests/test_lesarten_core.py | Unit tests for look-alike symmetry, TS↔Python table parity, bucketing and ranking. |
| tests/test_api_public_surface.py | Marks the new Lesarten GET routes as public surface. |
| tests/test_api_lesarten.py | API harness tests covering load/read/replace/gating/validation for Lesarten. |
| docs/reference/werkzeuge.md | Documents the new tools/lesarten workflow and constraints (German internal docs). |
| docs/reference/quellen-und-rechte.md | Records the licensing/provenance doctrine for copyleft wordlists as server data. |
| docs/reference/glossar.md | Adds glossary entry for “Lesart” and updates the index. |
| docs/reference/frontend-stack.md | Updates the route description for /lesen/vergleichen to reflect real-word readings. |
| docs/reference/datenablage.md | Documents the “server data under copyleft” storage rule and public endpoint constraints. |
| data/corpora/igerman98/SOURCE.md | Adds provenance record for igerman98 and the rationale for server-side use. |
| data/corpora/igerman98/fetch_igerman98.py | Fetch script with pinned commit + SHA256 verification for the dictionary bytes. |
| core/lesarten/init.py | Core logic for look-alike bucketing and ranking of candidate readings. |
| core/database/repositories.py | Adds LesartRepository for dictionary metadata and form generation switching. |
| core/database/models.py | Adds ORM models LesartForm and LesartDictionary. |
| core/database/init.py | Exposes the new Lesart models/repository from the core.database package. |
| CHANGELOG.md | Adds an Unreleased entry describing the new real-word Lesarten behavior and DB-backed vocabulary. |
| app/src/sections/vergleichen/VergleichenView.tsx | Switches Lesarten UI from local generation to API-backed readings with loading/error states. |
| app/src/locales/de/vergleichen.ts | Updates German UI strings for the new semantics (real words, dictionary metadata, states). |
| app/src/lib/lesarten.ts | Reduces to the LOOKALIKES table (no longer generates readings locally). |
| app/src/lib/lesarten.test.ts | Updates tests to cover table symmetry and constraints after removing the generator. |
| app/src/lib/api/types.ts | Adds TypeScript types for GET /lesarten response payloads. |
| app/src/lib/api/endpoints.ts | Adds getLesarten() endpoint wrapper. |
| app/public/llms.txt | Documents the new endpoint in the LLM-readable public surface listing. |
| app/prerender/lesen/vergleichen.html | Updates prerendered static content to match the new wording/semantics. |
| api/schemas.py | Adds Pydantic schemas for Lesarten read/load endpoints and payloads. |
| api/routers/lesarten.py | New router implementing public read + admin-gated generation load/commit/drop endpoints. |
| api/routers/init.py | Registers the new Lesarten router. |
| api/main.py | Includes the Lesarten router in the FastAPI app. |
| alembic/versions/0028_lesart_forms.py | Migration creating lesart_forms and lesart_dictionary. |
Review details
- Files reviewed: 32/32 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Review round addressed in the last push:
|
…ing docstring Review round on #456: begin_generation now deletes every non-live generation before handing out the next number (the docstring promised it, the code did not do it); GET /lesarten answers 422 for text that is blank after trimming; the rank_readings docstring names the real tie order (fewer differing letters, then alphabetical).
There was a problem hiding this comment.
🟡 Changes recommended
core/lesarten currently treats typed s as always confusable with f, which conflicts with the codebase’s long-ſ vs word-final round-s shaping and can yield incorrect readings (e.g., “das” bucketing with “daf”).
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (3)
Previously missed (2) — in code that hasn't changed since the last review.
tools/lesarten/sync.py:62
- The 409 "already live" fast-path is detected via a broad substring check ("409" in the exception text). Since request_json raises SystemExit with a formatted message that also embeds the response body, this can mis-detect unrelated failures (e.g. a 400 whose detail mentions "409"). Match the status marker instead so only an actual HTTP 409 triggers the no-op path.
core/lesarten/init.py:45 - The LOOKALIKES graph currently treats typed
sas always confusable withf. But shaping distinguishes long-ſ vs round-s: a word-finalsshapes to Schluss-s and should not be bucketed withf(core/shaping.py makessround at the end of a letter run). As-is,lesart_key("das")will share a bucket with"daf", potentially returning false readings for final-s words.
"t": ("l", "f"),
"l": ("t",),
"f": ("s", "h", "t"),
"h": ("f",),
"s": ("f",),
core/database/repositories.py:151
- Similarly, dropping non-live generations via
gen != gencan devolve into a full table scan. Using two bounded deletes (< genand> gen) better matches the PK layout (gen-first) and avoids scanning the live generation when it’s the only one present.
meta.forms = forms
meta.sha256 = sha256
meta.updated_at = now
await self.session.execute(delete(LesartForm).where(LesartForm.gen != gen))
await self.session.flush()
- Files reviewed: 32/32 changed files
- Comments generated: 1
- Review effort level: Lite
… letter swaps Owner feedback: a reading must be an existing word, and the vocabulary belongs in PostgreSQL like everything else. GET /lesarten?text=… answers the vocabulary words that differ from the guess by look-alike letters alone (core/lesarten: the look-alike classes make a bucket key, one indexed lookup; ranked by summed pair distance, bank words first on a tie). Migration 0028 adds lesart_forms + lesart_dictionary; tools.lesarten expands the igerman98/frami dictionary (one affix layer, ≈ 720 000 letter-only forms) ∪ the quiz bank and loads it generation-wise through the admin API. The GPL dictionary bytes stay gitignored — SOURCE.md, quellen-und-rechte.md §5 and datenablage.md §1 record the licence reasoning (use without conditions, duties only on redistribution, which a handful of words per query is not). The page shows the real words with every differing letter marked; lib/lesarten.ts keeps only the table, pinned to its Python twin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012TVu1uouGhsjKCEVhWBinX
…ing docstring Review round on #456: begin_generation now deletes every non-live generation before handing out the next number (the docstring promised it, the code did not do it); GET /lesarten answers 422 for text that is blank after trimming; the rank_readings docstring names the real tie order (fewer differing letters, then alphabetical).
c4faeba to
e9a74fd
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Several user-facing and API-contract issues need tightening (generation protocol enforcement, misleading UI/prerender copy, batch cap safety, and future-dated metadata) before this can be safely merged.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
api/routers/lesarten.py:139
POST /lesarten/dictionary/generations/{gen}/commitalso accepts arbitrarygenvalues. Without enforcinggen == (active_gen + 1), callers can commit unexpected generations and bypass the intended begin→load→commit flow.
async def commit_generation(
gen: int, body: LesartGenerationIn, db: AsyncSession = Depends(require_db)
) -> LesartDictionaryOut:
"""Make the generation live and drop every other one."""
repo = LesartRepository(db)
if await repo.count_forms(gen) == 0:
raise HTTPException(status.HTTP_409_CONFLICT, detail=f"generation {gen} holds no forms — nothing to commit")
meta = await repo.commit_generation(gen, body.source, body.sha256)
api/routers/lesarten.py:155
DELETE /lesarten/dictionary/generations/{gen}should also follow the single-open-generation protocol; as written, it allows dropping arbitrary non-live generations, which is unnecessary if onlyactive_gen + 1can exist. Enforcinggen == (active_gen + 1)keeps the protocol tight and predictable.
async def drop_generation(gen: int, db: AsyncSession = Depends(require_db)) -> Response:
"""Abandon a load in progress (the live generation cannot be dropped)."""
repo = LesartRepository(db)
meta = await repo.dictionary()
if meta is not None and gen == meta.active_gen:
raise HTTPException(status.HTTP_409_CONFLICT, detail=f"generation {gen} is live — commit a new one instead")
await repo.drop_generation(gen)
- Files reviewed: 32/32 changed files
- Comments generated: 4
- Review effort level: Lite
gen leads the primary key of lesart_forms; `< live OR > live` lets Postgres answer the common case (only the live generation present) with two empty range scans instead of a full scan over ~700k rows. Same for the commit's sweep.
|
Both sweeps ( |
There was a problem hiding this comment.
🔵 Needs a closer look
There are confirmed functional issues in the new Lesart loading/query path (DB insert strategy for large batches) and in the frontend retry/error-state handling that should be fixed before approval.
Review details
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
core/database/repositories.py:134
add_formspre-queries existing rows usingtuple_(key, word).in_(...). WithLesartFormsInallowing up to 50,000 words, this can exceed Postgres’ bind-parameter limit (2 params per tuple) and fail for larger batches. It also does extra round-trips that can be avoided.
Use a single bulk INSERT with ON CONFLICT DO NOTHING (dedupe in-memory so bank=True wins within the batch).
async def add_forms(self, gen: int, rows: list[tuple[str, str, bool]]) -> int:
"""Insert (key, word, bank) rows into a generation. Rows are deduplicated
here so a repeated batch cannot violate the primary key."""
if not rows:
return 0
unique = {(key, word): bank for key, word, bank in rows}
existing = await self.session.execute(
select(LesartForm.key, LesartForm.word).where(
LesartForm.gen == gen, tuple_(LesartForm.key, LesartForm.word).in_(list(unique.keys()))
)
)
present = {(row[0], row[1]) for row in existing.all()}
payload = [
{"gen": gen, "key": key, "word": word, "bank": bank}
for (key, word), bank in unique.items()
if (key, word) not in present
]
if payload:
await self.session.execute(LesartForm.__table__.insert(), payload)
return len(payload)
app/src/sections/vergleichen/VergleichenView.tsx:128
- After a failed
/lesartenrequest,failedTextis never cleared when starting a new request for the sametext, so the UI can keep showing the error state instead of switching back to the loading spinner while a retry is in flight.
Clear failedText when the effect starts (and optionally on success) so retries show the intended loading state.
useEffect(() => {
if (!text) return undefined;
let cancelled = false;
getLesarten(text)
.then((out) => {
if (!cancelled) setAnswer(out);
})
.catch(() => {
if (!cancelled) setFailedText(text);
});
return () => {
cancelled = true;
};
}, [text]);
- Files reviewed: 32/32 changed files
- Comments generated: 0 new
- Review effort level: Lite
…0 000; the not-yet-loaded note and the crawler paragraph say what happens Review round on #456: /forms and /commit now require gen == live + 1 (the number begin hands out), so no second load can fill the table beside the open one; LesartFormsIn caps a batch at 20 000 pairs (the duplicate check binds two parameters per pair, PostgreSQL stops at 65 535; the tool's batch is 20 000 anyway); dictionaryMissing says the page names no words until the vocabulary is loaded; the prerendered paragraph describes the look-alike list as the catalogue the words are judged by, not as generated readings.
|
Round addressed in the last push:
|
There was a problem hiding this comment.
🟡 Changes recommended
There is at least one confirmed UI correctness issue (stale dictionary note for non-current input) plus an unresolved concurrency risk in generation opening that should be addressed or explicitly mitigated before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
app/src/sections/vergleichen/VergleichenView.tsx:131
dictionaryis derived from the last successful API response even when it belongs to a differenttext, so the dictionary note can show stale info while the current query is still loading or has failed. Gatedictionarythe same wayreadingsis gated (answer.text === text) so the note only reflects the current input.
const readings: LesartReadingOut[] | null = answer && answer.text === text ? answer.readings : null;
const dictionary: LesartDictionaryOut | null | undefined = answer?.dictionary;
const readingsError = failedText === text && readings === null;
api/routers/lesarten.py:84
LesartSwapOutalready supports populating by field name (populate_by_name=True), so using**{"from": ...}is unnecessary and makes this harder to read/maintain. Passfrom_=directly and let Pydantic handle the output aliasing.
LesartReadingOut(
word=r.word,
bank=r.bank,
cost=r.cost,
swaps=[LesartSwapOut(index=s.index, **{"from": s.from_}, to=s.to) for s in r.swaps],
)
- Files reviewed: 33/33 changed files
- Comments generated: 1
- Review effort level: Lite
| async def begin_generation(self) -> int: | ||
| """A fresh generation number, above the live one. Every generation that | ||
| is not live — the rows of an abandoned load — is dropped first, so a | ||
| crashed sync never leaves a second vocabulary sitting in the table.""" | ||
| meta = await self.dictionary() | ||
| live = meta.active_gen if meta else 0 | ||
| await self.session.execute(delete(LesartForm).where(_other_generations(live))) | ||
| return live + 1 |
Owner feedback on the Lesart page (2026-08-30): a reading must be an existing word — „Mnhme" is a letter salad, not a Lesart — and the vocabulary belongs in PostgreSQL like everything else.
What
GET /lesarten?text=…(public, cached like the other reads) answers the vocabulary words that differ from the guess by look-alike letters alone: same length, every differing position a documented pair or chain (core/lesarten: the look-alike table's connected classes make a bucket key —lesart_key("Muhme") == lesart_key("Mühme")— so a query is one indexed lookup; ranking by summed pair distance, the project's own bank words first on a tie, then shorter/alphabetical; the guess itself never). The table is the Python twin ofapp/src/lib/lesarten.tsandtest_lesarten_core.pypins the two together; the page's 32-char cap is pinned toMAX_TEXT_LENtoo.0028addslesart_forms(gen, key, word, bank) +lesart_dictionary(the live generation, source, size, hash).tools.lesarten.syncexpands the igerman98/frami dictionary (tools/lesarten/expand.py: one affix layer, compound-only and needs-affix stems excluded, letter-only forms — 718 667 forms measured) ∪ the quiz bank (641 words, flagged) and loads them through the admin API generation-wise: open (the same content hash as the live build → 409, nothing to do) → batches of 20 000 words, keys computed server-side → commit switches the live generation and drops the old one; an abort drops the half-loaded one. Nothing is committed to the repo butSOURCE.md+fetch_igerman98.py(pinned commit + SHA256s).data/corpora/igerman98/SOURCE.md,quellen-und-rechte.md§5 anddatenablage.md§1 — use without conditions, duties only on redistribution of the list, which a handful of words per query is not; the bytes go into neither the image nor the bundle.lib/lesarten.tskeeps only the table (the local swap generator is gone). llms.txt lists the endpoint; the glossary gets „Lesart".Verification
ruff,pytest1813 passed (new:test_lesarten_core.py,test_lesarten_expand.py,test_api_lesarten.py— load/read/replace/gate/validation on the harness;test_api_public_surface.pylists the two new public reads)./verify-migrationscannot run here (Docker daemon down) — the Migrations job in CI is the check, as agreed before.tsc,eslint,vitest171/171; browser against a scratch proxy that answers/lesartenfrom the locally expanded dictionary with the samecore.lesartencode: „Haus" → Hans · Haut · Hanf · Hais · Häms, „lesen" → lehne · sehen (Wortbank) · tuten …, „xyzq" → the „kein Wort" line; console clean.tools.lesarten.sync --dry-run: 718 667 words (641 from the bank) in 1.9 s.After the merge
The endpoint exists only after the deploy; then the load is one command —
ADMIN_TOKEN=… uv run python -m tools.lesarten.sync(≈ 36 batches; I will ask before touching prod). Until then the page says „Das Wörterbuch ist noch nicht geladen".🤖 Generated with Claude Code
https://claude.ai/code/session_012TVu1uouGhsjKCEVhWBinX