Skip to content

Commit 8409bb4

Browse files
eiler2005claude
andcommitted
Write down the card composition rules
EPIC-25 gains §4a as the canonical spec: the three slots a complete card fills, where each gloss actually lives, and why every reader has to go through stored_russian and usable_example rather than reading the fields directly. Both defects behind those helpers - a predicate disagreeing about where a translation was written, and a generation prompt stored as an example - were invisible until they reached a learner, so the reasoning is recorded next to the rule. AGENTS.md carries the invariant, architecture.md records that enrichment runs off the delivery path so a slow model cannot delay a slot, and the user guide says plainly that curated content is never overwritten. The consistency suite now asserts the spec exists and that the scheduler job count in the diagram matches build_scheduler - it claimed three long after there were five. Also refreshed the architecture header: ADRs 0002-0012, not 0002-0008. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent e7b82a3 commit 8409bb4

7 files changed

Lines changed: 118 additions & 12 deletions

File tree

‎AGENTS.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,13 @@ destructive action is high.
109109
- **Persistent-keyboard taps arrive as plain text.** `quick_action_for` must
110110
stay ahead of every free-text capture path in `on_free_text`, or a button
111111
label gets stored as a vocabulary item.
112+
- **A card carries form, meaning and use**: the phrase, a Russian gloss, an
113+
English gloss, and an example containing the phrase. Read the Russian one
114+
with `word_cards.stored_russian` and the example with
115+
`word_cards.usable_example` — a translation can live in `meaning`,
116+
`explanation`, or `metadata_json`, and any predicate that disagrees makes
117+
the backfill loop forever. Enrichment only fills blanks; it never overwrites
118+
a curated gloss. See EPIC-25 §4a.
112119
- **Never hand a JSON Schema to the model.** `user_prompt` lists the fields
113120
plainly instead: given a schema, models answer with its envelope
114121
(`{"description": ..., "properties": {...}}`) rather than an instance, and
@@ -162,7 +169,7 @@ FluentLoop/
162169
├── src/fluentloop/ Python package — bot, db, ai, llm, learning engine.
163170
│ └── seeds/ Shipped seed data (starter word bank JSONL).
164171
├── ansible/ Deploy playbooks (placeholder for future deployment epic).
165-
└── tests/ Pytest suite (29 modules, 359+ tests).
172+
└── tests/ Pytest suite (29 modules, 363+ tests).
166173
```
167174

168175
## Verification commands

‎CHANGELOG.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@ All notable changes to FluentLoop are recorded here. Format follows
3939
English gloss, and an example containing the phrase. Missing pieces are
4040
generated once per item and never overwrite curated content; newly added
4141
words are enriched at add time and the confirmation shows the finished card.
42-
`scripts/enrich_word_cards.py` backfills an existing base.
42+
`scripts/enrich_word_cards.py` backfills an existing base. The composition
43+
rules are written down as EPIC-25 §4a and asserted by the consistency suite.
44+
- Examples that were really generation prompts ("Use 'x' in a realistic tech
45+
workplace sentence") are recognised, hidden, and replaced.
4346
- The keyboard collapses after each tap and lays out three buttons per row, so
4447
it no longer occupies half a phone screen. `/keyboard` removes it entirely
4548
and restores it; the hide message lists the commands that still work.

‎docs/architecture.md‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# Architecture
22

3-
> **Status:** v0.2 — MVP foundation (EPIC-01..14), learning-engine roadmap
4-
> (EPIC-16..21), and EPIC-22/23 extensions are shipped. ADRs 0002-0008 are
5-
> Accepted. Schema specifics
3+
> **Status:** v0.3 — MVP foundation (EPIC-01..14), learning-engine roadmap
4+
> (EPIC-16..21), and EPIC-22..25 extensions are shipped. ADRs 0002-0012 are
5+
> Accepted (0009 reserved). Schema specifics
66
> for individual epics live in those epic files.
77
88
The PRD deliberately keeps tech choices out of itself. This document is the
@@ -19,7 +19,8 @@ decisions as ADRs in [`adr/`](adr/).
1919
dev and degraded-AI failure modes.
2020
- **SQLite** via SQLAlchemy 2.x, single-file DB, mounted from host.
2121
- **APScheduler** in-process for daily reminders, overnight pre-gen
22-
(ADR-0004), and daily SQLite backups.
22+
(ADR-0004), daily SQLite backups, the weekly digest, and the per-user
23+
vocabulary tick (ADR-0012).
2324
- **Long polling** against Telegram (no webhook, no public ports).
2425
- **Telegram workspace maintenance** syncs Bot API commands, refreshes the
2526
pinned Help topic, and safely removes only identifiable bot-authored stale
@@ -55,10 +56,12 @@ container:
5556
└────────────────────────────────────────────────────────────────────────┘
5657
│
5758
▼
58-
APScheduler (in-process, three jobs)
59+
APScheduler (in-process, five jobs)
5960
├─ Daily reminder (User.reminder_time)
6061
├─ Overnight pre-gen (PRE_GEN_HOUR=3)
61-
└─ Daily SQLite backup (BACKUP_HOUR=4, 14d retention)
62+
├─ Daily SQLite backup (BACKUP_HOUR=4, 14d retention)
63+
├─ Weekly summary (Sun 18:00)
64+
└─ vocab_loop_tick (every minute, per-user local slots)
6265
```
6366

6467
Daily-loop data flow — what happens between bedtime and the morning's
@@ -94,7 +97,7 @@ Daily-loop data flow — what happens between bedtime and the morning's
9497
One Docker container (`python:3.11-slim`) running:
9598

9699
- the Telethon long-poll loop,
97-
- APScheduler with three cron-style jobs,
100+
- APScheduler with five cron-style jobs,
98101
- the SQLite database on `/app/data` (host-mounted),
99102
- session files on `/app/data/sessions/`,
100103
- daily backups on `/app/data/backups/` (14-day rotation),
@@ -135,6 +138,11 @@ No public ports. No webhook. No external orchestrator. Restarts are safe
135138
- **A persistent reply keyboard** (Cards / Review / Lesson / My words / Add
136139
words / Quiz / Stop) is installed by `/start`. Taps arrive as plain text, so
137140
`handlers.quick_action_for` must run before every free-text capture path.
141+
- **Card content is filled once, off the delivery path.**
142+
`word_cards.enrich_item` adds a Russian gloss, an English gloss and an
143+
example when they are missing, at add time or via
144+
`scripts/enrich_word_cards.py`. The minute tick never calls it, so a slow or
145+
dead LLM cannot delay a slot. Composition rules live in EPIC-25 §4a.
138146
- **A quiz is a sequence of deliveries**, one `vocab_deliveries` row per
139147
question, with the `seq=0` claim as the idempotency lock for the set. Rows
140148
stay `claimed` until answered, which is what makes `/quiz` resumable. See

‎docs/features/EPIC-25-daily-vocabulary-loop.md‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ Commit and deploy are still explicit user-approved gates.
2727

2828
## Validation Evidence
2929

30-
- Local gate: `pytest -q` -> `359 passed`; `ruff check src tests scripts`,
30+
- Local gate: `pytest -q` -> `363 passed`; `ruff check src tests scripts`,
3131
`python scripts/secret_scan.py`, and `git diff --check` clean.
3232
- Migration `0004_epic25` verified idempotent and reversible against a fresh
3333
SQLite file, and applied on the VPS (`alembic_version = 0004_epic25`).
@@ -162,6 +162,56 @@ in batches, dry-run by default. Words the learner types in are enriched at
162162
add time and the confirmation shows the finished card, so a wrong translation
163163
is visible immediately.
164164

165+
### 4a. Card composition rules
166+
167+
The canonical spec for what a card is and how one gets built. `word_cards.py`
168+
implements it; `tests/test_epic25_word_cards.py` enforces it.
169+
170+
**A complete card carries three things.** Nation splits knowing a word into
171+
form, meaning and use, and a card missing any of them is easy to read past:
172+
173+
| Slot | Field | Rule |
174+
|---|---|---|
175+
| Form | `LearningItem.text` | The phrase itself, never rewritten. |
176+
| Meaning | Russian gloss | On the headline - the line the eye stops on. |
177+
| Meaning | English gloss | Under it, so recognition does not stop at the translation. |
178+
| Use | one example | Must contain the phrase, not describe it. |
179+
180+
**Where each gloss lives.** Items arrive from several sources with the two
181+
text fields already used in different ways, so there is no fixed column:
182+
183+
- `english_definition()` returns whichever of `meaning` / `explanation` has no
184+
Cyrillic; `russian_definition()` returns whichever has some.
185+
- `enrich_item` writes a new Russian gloss into whichever field is free, and
186+
into `metadata_json["russian"]` when both are taken.
187+
- **Every reader must use `stored_russian()`**, which looks in all three.
188+
`needs_enrichment` once checked only the two fields, so metadata-stored
189+
translations looked permanently missing and the backfill re-processed the
190+
same rows on every run.
191+
192+
**Never overwrite what is already there.** A generated gloss is worth less
193+
than a curated or bank-supplied one, so enrichment only fills blanks. The one
194+
exception is an example that is not an example - see below.
195+
196+
**An example must be a sentence, not an instruction.** Seeded items carried
197+
`"Use 'x' in a realistic tech workplace sentence"` - the generation prompt
198+
written to the database instead of its output. `is_instruction_not_example()`
199+
recognises those; `usable_example()` is what cards and `needs_enrichment` read,
200+
so such a string counts as missing and is replaced on the next pass.
201+
202+
**Language.** The card shows both languages because nothing is being tested
203+
there. Prompts that *ask* something stay English-only; see below.
204+
205+
**When enrichment runs.** Once per item, never in a loop:
206+
207+
- at add time for a word the learner typed in, with the finished card echoed
208+
back so a bad translation is visible immediately;
209+
- via `scripts/enrich_word_cards.py` for an existing base, in batches, dry run
210+
by default;
211+
- never during a slot delivery - the tick must not depend on the LLM.
212+
213+
A missing or failing model degrades to a bare card, never to a failed add.
214+
165215
### 5. Content and language
166216

167217
Distractors are pre-baked into the in-repo word bank, so the common path costs

‎docs/user-guide.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,12 @@ learning-quality report. `/stats` говорит "сколько практик
209209
содержит саму фразу, а не пересказывает её.
210210

211211
Если чего-то не хватает — перевода, примера — бот дописывает недостающее сам
212-
при добавлении слова. Уже написанное вручную он не трогает.
212+
при добавлении слова. Уже написанное вручную он не трогает: сгенерированное
213+
определение хуже выверенного.
214+
215+
Пример обязан содержать саму фразу. Если в базе вместо примера оказалась
216+
инструкция вроде «Use 'x' in a sentence» — такое бот считает пропуском и
217+
заменит настоящим предложением.
213218

214219
### Дневной дрилл
215220

‎tests/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ and stubs the Telegram client and AI providers — no real network calls.
5858
| `test_epic25_wordbank.py` | Word-bank validation, deterministic starter selection, and idempotent seeding. |
5959
| `test_epic25_onboarding.py` | The `/setup` wizard state machine end to end. |
6060
| `test_epic25_qwen_provider.py` | Provider selection, model routing, gateway attribution, and DeepSeek back-compat. |
61-
| `test_epic25_word_cards.py` | Card enrichment: what counts as missing, curated content never overwritten, wrong-language glosses refused, and unwrapping a schema-shaped model answer. |
61+
| `test_epic25_word_cards.py` | The EPIC-25 §4a card rules: what counts as missing, curated content never overwritten, wrong-language glosses refused, a leaked prompt rejected as an example, and unwrapping a schema-shaped model answer. |
6262
| `test_docs_consistency.py` | Documentation claims checked against the code: keyboard buttons, both command catalogs, quiz slots and sizes, slot defaults, practice groups, and the SAVEPOINT rule. |
6363
| `test_bot_upload_documents.py` | Telegram document-upload handler: text decoding, oversize rejection, friendly errors. |
6464
| `test_seed_demo_data.py` | Idempotency and shape of `scripts/seed_demo_data.py`. |

‎tests/test_docs_consistency.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,36 @@ def test_savepoint_rule_is_recorded(docs) -> None:
9494

9595
assert "SAVEPOINT" in docs["adr12"]
9696
assert "begin_nested" in docs["adr12"]
97+
98+
99+
def test_card_rules_are_written_down(docs) -> None:
100+
"""The card spec is non-obvious enough that losing it costs a bug.
101+
102+
Both defects it records - a predicate disagreeing about where the Russian
103+
gloss lives, and a generation prompt stored as an example - were invisible
104+
until they reached a learner.
105+
"""
106+
107+
epic = docs["epic"]
108+
assert "Card composition rules" in epic
109+
for helper in ("stored_russian", "usable_example", "enrich_item"):
110+
assert helper in epic, f"{helper} undocumented"
111+
# The rule that keeps enrichment off the delivery path.
112+
assert "word_cards" in docs["arch"]
113+
assert "stored_russian" in docs["agents"]
114+
115+
116+
def test_scheduler_job_count_is_current(docs, settings) -> None:
117+
"""The diagram claimed three jobs long after there were five."""
118+
119+
from fluentloop.db.session import make_engine, make_session_factory
120+
from fluentloop.scheduler import build_scheduler
121+
122+
class _Client:
123+
pass
124+
125+
factory = make_session_factory(make_engine("sqlite:///:memory:"))
126+
scheduler = build_scheduler(settings, factory, client=_Client())
127+
128+
assert len(scheduler.get_jobs()) == 5
129+
assert "five jobs" in docs["arch"]

0 commit comments

Comments
 (0)