updating documentation

This commit is contained in:
lila 2026-04-11 21:32:13 +02:00
parent bc7977463e
commit dd6c2b0118
2 changed files with 39 additions and 21 deletions

View file

@ -83,6 +83,9 @@ touches the database. A service never reads `req.body`. A model never knows what
model. Documented coupling acknowledged with a comment.
- Problem 7: Gloss join could multiply question rows. Schema allowed multiple glosses per term per language, so the left join would duplicate rows. Fixed by tightening the unique constraint.
- Problem 8: Model leaked quiz semantics. Return fields were named prompt / answer, baking HTTP-layer concepts into the database layer. Renamed to neutral field names.
- Problem 9: AnswerResult wasn't self-contained. Frontend needed selectedOptionId to render feedback but the schema didn't include it (reasoning was "client already knows"). Discovered during frontend work; added the field.
- Problem 10: Distractor could duplicate the correct answer text. Different terms can share the same translation. Fixed with ne(translations.text, excludeText) in the query.
- Problem 11: TypeScript strict mode flagged Fisher-Yates shuffle array access. noUncheckedIndexedAccess treats result[i] as T | undefined. Fixed with non-null assertion and temp variable pattern.
---
@ -116,6 +119,15 @@ touches the database. A service never reads `req.body`. A model never knows what
- One gloss per term per language. The unique constraint on term_glosses was tightened from (term_id, language_code, text) to (term_id, language_code) to prevent the left join from multiplying question rows. Revisit if multiple glosses per language are ever needed (e.g. register or domain variants).
- Model returns neutral field names, not quiz semantics. getGameTerms returns sourceText / targetText / sourceGloss rather than prompt / answer / gloss. Quiz semantics are applied in the service layer. Keeps the model reusable for non-quiz features.
- Asymmetric difficulty filter. Difficulty is filtered on the target (answer) side only. A word can be A2 in Italian but B1 in English, and what matters is the difficulty of the word being learned.
- optionId as integer 0-3, not UUID. Options only need uniqueness within a single question; cheating prevented by shuffling, not opaque IDs.
- questionId and sessionId as UUIDs. Globally unique, opaque, natural Valkey keys when storage moves later.
- gloss is string | null rather than optional, for predictable shape on the frontend.
- GameSessionStore stores only the answer key (questionId → correctOptionId). Minimal payload for easy Valkey migration.
- All GameSessionStore methods are async even for the in-memory implementation, so the service layer is already written for Valkey.
- Distractors fetched per-question (N+1 queries). Correct shape for the problem; 10 queries on local Postgres is negligible latency.
- No fallback logic for insufficient distractors. Data volumes are sufficient; strict query throws if something is genuinely broken.
- Distractor query excludes both the correct term ID and the correct answer text, preventing duplicate options from different terms with the same translation.
- Submit-before-send flow on frontend: user selects, then confirms. Prevents misclicks.
---
@ -157,7 +169,7 @@ as a potential constraint for the distractor algorithm at high difficulty.
## API Schemas (packages/shared)
### `GameRequestSchema` (implemented)
### `GameRequestSchema`
```typescript
{
@ -169,14 +181,11 @@ as a potential constraint for the distractor algorithm at high difficulty.
}
```
### Planned schemas (not yet implemented)
```text
QuizQuestion — prompt, optional gloss, 4 options (no correct answer)
QuizOption — optionId + text
AnswerSubmission — questionId + selectedOptionId
AnswerResult — correct boolean, correctOptionId, selectedOptionId
```
AnswerOption: { optionId: number (0-3), text: string }
GameQuestion: { questionId: uuid, prompt: string, gloss: string | null, options: AnswerOption[4] }
GameSession: { sessionId: uuid, questions: GameQuestion[] }
AnswerSubmission: { sessionId: uuid, questionId: uuid, selectedOptionId: number (0-3) }
AnswerResult: { questionId: uuid, isCorrect: boolean, correctOptionId: number (0-3), selectedOptionId: number (0-3) }
---
@ -236,27 +245,36 @@ packages/db/src/
- [x] Gloss left join implemented
- [x] Model return type uses neutral field names (sourceText, targetText, sourceGloss)
- [x] Schema: gloss unique constraint tightened to one gloss per term per language
- [x] Zod schemas defined: AnswerOption, GameQuestion, GameSession, AnswerSubmission, AnswerResult
- [x] getDistractors model implemented with POS/difficulty/language/excludeTermId/excludeText filters
- [x] createGameSession service: fetches terms, fetches distractors per question, shuffles options, stores session, returns GameSession
- [x] evaluateAnswer service: looks up session, compares submitted optionId to stored correct answer, returns AnswerResult
- [x] GameSessionStore interface + InMemoryGameSessionStore (Map-backed, swappable to Valkey)
- [x] POST /api/v1/game/answer endpoint wired (route, controller, service)
- [x] selectedOptionId added to AnswerResult (discovered during frontend work)
- [x] Minimal frontend: /play route with settings UI, QuestionCard, OptionButton, ScoreScreen
- [x] Vite proxy configured for dev
---
## Roadmap Ahead
### Step 1 — Learn SQL fundamentals (in progress)
### Step 1 — Learn SQL fundamentals - done
Concepts needed: SELECT, FROM, JOIN, WHERE, LIMIT.
Resources: sqlzoo.net or Khan Academy SQL section.
Required before: implementing the double join for source language prompt.
### Step 2 — Complete the model layer
### Step 2 — Complete the model layer - done
- Double join on `translations` — once for source language (prompt), once for target language (answer)
- `GlossModel.getGloss(termId, languageCode)` — fetch gloss if available
### Step 3 — Define remaining Zod schemas
### Step 3 — Define remaining Zod schemas - done
- `QuizQuestion`, `QuizOption`, `AnswerSubmission`, `AnswerResult` in `packages/shared`
### Step 4 — Complete the service layer
### Step 4 — Complete the service layer - done
- `QuizService.buildSession()` — assemble raw rows into `QuizQuestion[]`
- Generate `questionId` per question
@ -266,7 +284,7 @@ Required before: implementing the double join for source language prompt.
- Shuffle options so correct answer is not always in same position
- `QuizService.evaluateAnswer()` — validate correctness, return `AnswerResult`
### Step 5 — Implement answer endpoint
### Step 5 — Implement answer endpoint - done
- `POST /api/v1/game/answer` route, controller, service method
@ -295,10 +313,10 @@ Required before: implementing the double join for source language prompt.
- **Distractor algorithm:** when Italian C2 has only 242 terms, should the difficulty
filter fall back gracefully or return an error? Decision needed before implementing
`buildSession()`.
`buildSession()`. => resolved
- **Session statefulness:** game loop is currently stateless (fetch all questions upfront).
Confirm this is still the intended MVP approach before building `buildSession()`.
Confirm this is still the intended MVP approach before building `buildSession()`. => resolved
- **Glosses can leak answers:** some WordNet glosses contain the target-language
word in the definition text (e.g. "Padre" appearing in the English gloss for
"father"). Address during the post-MVP data enrichment pass — either clean the
glosses, replace them with custom definitions, or filter at the service layer.
glosses, replace them with custom definitions, or filter at the service layer. => resolved

View file

@ -32,10 +32,10 @@ Done when: `GET /api/decks/1/terms?limit=10` returns 10 terms from a specific de
[x] Write `scripts/build_decks.ts` (reads external CEFR lists, matches to DB, creates decks)
[x] Run `pnpm db:seed` → populates terms
[x] Run `pnpm db:build-deck` → creates curated decks
[ ] Define Zod response schemas in `packages/shared`
[ ] Implement `DeckRepository.getTerms(deckId, limit, offset)`
[ ] Implement `QuizService.attachDistractors(terms)` — same POS, server-side, no duplicates
[ ] Implement `GET /language-pairs`, `GET /decks`, `GET /decks/:id/terms` endpoints
[x] Define Zod response schemas in `packages/shared`
[x] Implement `DeckRepository.getTerms(deckId, limit, offset)` => no decks needed anymore
[x] Implement `QuizService.attachDistractors(terms)` — same POS, server-side, no duplicates
[x] Implement `GET /language-pairs`, `GET /decks`, `GET /decks/:id/terms` endpoints => no language pairs, not needed anymore
[ ] Unit tests for `QuizService` (correct POS filtering, never includes the answer)
[ ] update decisions.md