diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fb3c02..388c096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] - 2026-07-11 + +### Added + +- Learn subject-plugin contract v1.0: eight tutor verbs (overview, progress, advice, story, lesson, practice, record, doctor) with schema-valid --json and contractual exit codes +- Tutor engine: 3-module French curriculum (5 lessons, 9 items, 18 exercises), per-learner XDG state with atomic writes and resume +- Story ladder: 11 graded-reader stories (A1-C1) with glossaries and comprehension exercises, plus dev fixtures; CI validates all content against the cited story schema +- Cited contract schemas + validator from learn-cli (contract v1.0, provenance ledger in docs/contract-provenance.md) + ## [0.4.1] - 2026-07-10 ### Changed diff --git a/README.md b/README.md index 2267c1b..d32eb1c 100644 --- a/README.md +++ b/README.md @@ -30,26 +30,60 @@ resolve to the root doc entry. ## CLI +french-cli is an **LLM-free tutor engine** that implements the +[learn subject-plugin contract](https://github.com/agentculture/learn-cli): it +owns the committed French content (stories, lessons, exercises) and each +learner's mastery state, resolves what to teach next, and emits structured +teaching **directives**. The driving agent (or human) does the conversational +tutoring the directive describes, grades `pass|partial|fail`, and writes the +result back with `record`. french never converses, grades free text, or computes +scores — learn-cli's motivation layer does the scoring. + +### Tutor verbs (the contract surface) + +| Verb | What it does | Payload `kind` | +|------|--------------|----------------| +| `french overview` | Subject self-description: modules + content counts. | `subject_overview` | +| `french progress` | The learner's mastery, counters, and next step. | `progress` | +| `french advice` | Deterministic study advice from stored state. | `advice` | +| `french story list \| read ` | Graded stories + a reading directive. | `story_list` / `story_read` | +| `french lesson start \| next \| repeat` | Teaching directives from the curriculum. | `lesson_directive` | +| `french practice []` | A batch of exercises to run (no scope = review). | `practice_directive` | +| `french record --item --result …` | Write back one graded outcome; updates mastery. | `record_ack` | +| `french doctor` | Self-check + the pinned contract version. | `subject_doctor` | + +### Agent-first verbs + | Verb | What it does | |------|--------------| | `french whoami` | Report this agent's nick, version, backend, and model from `culture.yaml`. | | `french learn` | Print a structured self-teaching prompt. | | `french explain ` | Markdown docs for any noun/verb path. | -| `french overview` | Read-only descriptive snapshot of the agent. | -| `french doctor` | Check the agent-identity invariants (prompt-file-present, backend-consistency). | | `french cli overview` | Describe the CLI surface itself. | Every command supports `--json`. Results go to stdout, errors/diagnostics to stderr (never mixed). Exit codes: `0` success, `1` user error, `2` environment error, `3+` reserved. -## Status +### Learner state + +Learner-scoped verbs take `--learner ` (default: `$FRENCH_CLI_LEARNER` or the +OS user; the resolved id is echoed back). State persists one JSON file per +learner under `$FRENCH_CLI_LEARN_HOME` (or `$XDG_DATA_HOME/french_cli/learn`, or +`~/.local/share/french_cli/learn`), written atomically and resumed across +sessions. + +## Content + +Stories are committed JSON files under `content/stories/*.json` (flat, one file +per story, `filename == id`), each validated against the shared `story` schema in +CI (`tests/test_story_content.py`). The starter set ships `dev-`prefixed dev +stories; the full graded ladder is authored separately. The curriculum (modules, +lessons, items, exercises) lives as structured data in +`french/tutor/curriculum.py`. -**The French-tutor domain is not implemented yet.** The scaffold — identity, -skill kit, CI, and the agent-first CLI contract — is in place, but every verb -today is generic introspection (`whoami`, `learn`, `explain`, `overview`, -`doctor`, `cli overview`). Progress tracking, advice, stories, and written / -spoken practice are still to be built. +The contract schemas + validator are **cited (cite-don't-import)** into +`french/contract_cite/` — see [`docs/contract-provenance.md`](docs/contract-provenance.md). See [`CLAUDE.md`](CLAUDE.md) for the architecture and the full conventions (version-bump-every-PR, the `cicd` PR lane, deploy setup). diff --git a/content/stories/dev-cafe.json b/content/stories/dev-cafe.json new file mode 100644 index 0000000..b7263e3 --- /dev/null +++ b/content/stories/dev-cafe.json @@ -0,0 +1,66 @@ +{ + "schema_version": "1.0", + "kind": "story", + "id": "dev-cafe", + "subject": "french", + "title": "Un cafe, s'il vous plait", + "level": "beginner", + "level_detail": "A1", + "language": "fr", + "summary": "Paul orders a coffee and a croissant, then pays at a small cafe.", + "body": "Paul entre dans un petit cafe. Il regarde le menu.\n\n« Bonjour ! Je voudrais un cafe et un croissant, s'il vous plait. »\n\nLe serveur sourit. « Tres bien. Ca fait trois euros cinquante. »\n\nPaul paie et s'assoit pres de la fenetre. Le cafe est chaud et le croissant est delicieux.", + "glossary": [ + { + "term": "je voudrais", + "definition": "I would like", + "note": "The polite way to order; softer than « je veux »." + }, + { + "term": "ca fait trois euros cinquante", + "definition": "that comes to three euros fifty" + }, + { + "term": "pres de la fenetre", + "definition": "near the window" + } + ], + "exercises": [ + { + "id": "dev-cafe-q1", + "type": "multiple_choice", + "item_id": "fr.food.commander", + "prompt": "Qu'est-ce que Paul commande ?", + "choices": [ + "Un cafe et un croissant", + "Un the et une tarte", + "Un jus et un pain" + ], + "answer": "Un cafe et un croissant" + }, + { + "id": "dev-cafe-q2", + "type": "multiple_choice", + "item_id": "fr.numbers.prix", + "prompt": "Combien Paul paie-t-il ?", + "choices": [ + "Deux euros", + "Trois euros cinquante", + "Dix euros" + ], + "answer": "Trois euros cinquante" + }, + { + "id": "dev-cafe-q3", + "type": "cloze", + "item_id": "fr.food.commander", + "prompt": "« Je ___ un cafe, s'il vous plait. »", + "answer": "voudrais" + } + ], + "audio": null, + "source": { + "generator": "hand-authored", + "reviewed_by": "french-cli t4", + "generated_at": "2026-07-11" + } +} diff --git a/content/stories/dev-directions.json b/content/stories/dev-directions.json new file mode 100644 index 0000000..81b0e06 --- /dev/null +++ b/content/stories/dev-directions.json @@ -0,0 +1,66 @@ +{ + "schema_version": "1.0", + "kind": "story", + "id": "dev-directions", + "subject": "french", + "title": "Ou est la gare ?", + "level": "intermediate", + "level_detail": "A2", + "language": "fr", + "summary": "Lost in town, Hugo asks a passer-by for the way to the station.", + "body": "Hugo est perdu. Il cherche la gare.\n\nIl arrete une dame dans la rue. « Pardon, madame. Ou est la gare, s'il vous plait ? »\n\n« C'est facile », repond la dame. « Continuez tout droit, puis tournez a gauche au coin. La gare est a cinq minutes a pied. »\n\n« Merci beaucoup ! » dit Hugo. Il tourne a gauche et trouve la gare tout de suite.", + "glossary": [ + { + "term": "je cherche", + "definition": "I am looking for", + "note": "From « chercher »; « je cherche la gare »." + }, + { + "term": "tout droit", + "definition": "straight ahead" + }, + { + "term": "tournez a gauche", + "definition": "turn left", + "note": "« a droite » is to the right." + }, + { + "term": "a cinq minutes a pied", + "definition": "five minutes away on foot" + } + ], + "exercises": [ + { + "id": "dev-directions-q1", + "type": "multiple_choice", + "item_id": "fr.ville.directions", + "prompt": "Qu'est-ce que Hugo cherche ?", + "choices": [ + "La gare", + "Le marche", + "Le cafe" + ], + "answer": "La gare" + }, + { + "id": "dev-directions-q2", + "type": "cloze", + "item_id": "fr.ville.directions", + "prompt": "Continuez tout droit, puis tournez a ___ au coin.", + "answer": "gauche" + }, + { + "id": "dev-directions-q3", + "type": "short_answer", + "item_id": "fr.ville.directions", + "prompt": "La gare est a combien de minutes a pied ?", + "rubric": "Passes with « cinq minutes » (or « 5 minutes »); anything else is fail." + } + ], + "audio": null, + "source": { + "generator": "hand-authored", + "reviewed_by": "french-cli t4", + "generated_at": "2026-07-11" + } +} diff --git a/content/stories/dev-marche.json b/content/stories/dev-marche.json new file mode 100644 index 0000000..26b154a --- /dev/null +++ b/content/stories/dev-marche.json @@ -0,0 +1,78 @@ +{ + "schema_version": "1.0", + "kind": "story", + "id": "dev-marche", + "subject": "french", + "title": "Au marche du samedi", + "level": "beginner", + "level_detail": "A1", + "language": "fr", + "summary": "Claire buys apples, bread, and cheese at the Saturday market.", + "body": "Le samedi matin, Claire va au marche.\n\nElle achete un kilo de pommes rouges. « Bonjour, madame ! Un kilo de pommes, s'il vous plait. » Le vendeur dit : « Voila, ca fait deux euros. »\n\nEnsuite, Claire achete une baguette et un fromage de chevre. Elle aime beaucoup le fromage.\n\nA midi, elle rentre a la maison et prepare un dejeuner simple : du pain, du fromage et une pomme.", + "glossary": [ + { + "term": "le marche", + "definition": "the market", + "note": "« un marche en plein air » is an open-air market." + }, + { + "term": "un kilo de pommes", + "definition": "a kilo of apples", + "note": "Quantities take « de »: un kilo de, une tranche de." + }, + { + "term": "le fromage de chevre", + "definition": "goat cheese" + }, + { + "term": "elle rentre a la maison", + "definition": "she goes back home" + } + ], + "exercises": [ + { + "id": "dev-marche-q1", + "type": "multiple_choice", + "item_id": "fr.food.marche", + "prompt": "Quand est-ce que Claire va au marche ?", + "choices": [ + "Le samedi matin", + "Le dimanche soir", + "Le lundi a midi" + ], + "answer": "Le samedi matin" + }, + { + "id": "dev-marche-q2", + "type": "multiple_choice", + "item_id": "fr.numbers.prix", + "prompt": "Combien coutent les pommes ?", + "choices": [ + "Deux euros", + "Quatre euros", + "Dix euros" + ], + "answer": "Deux euros" + }, + { + "id": "dev-marche-q3", + "type": "cloze", + "item_id": "fr.food.marche", + "prompt": "Claire achete une baguette et un ___ de chevre.", + "answer": "fromage" + }, + { + "id": "dev-marche-q4", + "type": "short_answer", + "item_id": "fr.food.marche", + "prompt": "Qu'est-ce que Claire mange a midi ?", + "rubric": "Any answer naming bread, cheese, and/or an apple (du pain, du fromage, une pomme) passes; one item is partial." + } + ], + "audio": null, + "source": { + "generator": "hand-authored", + "reviewed_by": "french-cli t4", + "generated_at": "2026-07-11" + } +} diff --git a/content/stories/fr-a1-la-rentree.json b/content/stories/fr-a1-la-rentree.json new file mode 100644 index 0000000..5557f3c --- /dev/null +++ b/content/stories/fr-a1-la-rentree.json @@ -0,0 +1,81 @@ +{ + "schema_version": "1.0", + "kind": "story", + "id": "fr-a1-la-rentree", + "subject": "french", + "title": "La rentrée de Nina", + "level": "beginner", + "level_detail": "A1", + "language": "fr", + "summary": "It is Nina's first day back at school, and she is a little nervous about her new teacher.", + "body": "Aujourd'hui, c'est la rentrée. Nina a huit ans et elle est un peu nerveuse.\n\nLe matin, elle se réveille tôt. Elle prend son petit-déjeuner : du pain, du beurre et un chocolat chaud. Sa mère prépare son cartable avec des cahiers, des crayons et une nouvelle trousse bleue.\n\n« Tu es prête, Nina ? » demande sa mère. « Oui, mais j'ai un peu peur, » répond Nina. « Il y a une nouvelle maîtresse cette année. »\n\nÀ l'école, Nina retrouve ses amis dans la cour. Ils sont contents de se revoir après les vacances d'été. La maîtresse s'appelle Madame Dubois. Elle sourit et dit : « Bonjour, les enfants ! Bienvenue dans la classe de CE2. »\n\nPendant la journée, les élèves apprennent leurs nouveaux noms, rangent leurs affaires et écoutent une histoire. À midi, Nina mange à la cantine avec sa meilleure amie, Léa.\n\nLe soir, Nina raconte sa journée à ses parents. « La maîtresse est très gentille, » dit-elle. « Et j'ai une nouvelle amie dans ma classe ! »", + "glossary": [ + { + "term": "la rentrée", + "definition": "back-to-school (day), the start of the school year" + }, + { + "term": "le cartable", + "definition": "the school bag" + }, + { + "term": "la trousse", + "definition": "the pencil case" + }, + { + "term": "la maîtresse", + "definition": "the (female) teacher", + "note": "Used for a primary-school teacher; le maître for a man." + }, + { + "term": "la cour", + "definition": "the schoolyard" + }, + { + "term": "la cantine", + "definition": "the school canteen" + }, + { + "term": "avoir peur", + "definition": "to be scared/afraid" + } + ], + "exercises": [ + { + "id": "fr-a1-la-rentree-q1", + "type": "multiple_choice", + "item_id": "fr.story.fr-a1-la-rentree.1", + "prompt": "Quel âge a Nina ?", + "choices": ["Six ans", "Huit ans", "Dix ans"], + "answer": "Huit ans" + }, + { + "id": "fr-a1-la-rentree-q2", + "type": "multiple_choice", + "item_id": "fr.story.fr-a1-la-rentree.2", + "prompt": "Comment s'appelle la maîtresse de Nina ?", + "choices": ["Madame Dubois", "Madame Martin", "Madame Petit"], + "answer": "Madame Dubois" + }, + { + "id": "fr-a1-la-rentree-q3", + "type": "true_false", + "item_id": "fr.story.fr-a1-la-rentree.3", + "prompt": "Nina n'a pas d'amis à l'école.", + "answer": "false" + }, + { + "id": "fr-a1-la-rentree-q4", + "type": "open", + "item_id": "fr.story.fr-a1-la-rentree.4", + "prompt": "Pourquoi Nina a-t-elle un peu peur le matin de la rentrée ?", + "rubric": "Passes if the answer mentions the new teacher or general first-day nerves; partial if it just says 'she is scared' with no reason." + } + ], + "audio": null, + "source": { + "generator": "hand-authored", + "reviewed_by": "learn-cli t7a", + "generated_at": "2026-07-11" + } +} diff --git a/content/stories/fr-a1-le-marche.json b/content/stories/fr-a1-le-marche.json new file mode 100644 index 0000000..a43cade --- /dev/null +++ b/content/stories/fr-a1-le-marche.json @@ -0,0 +1,83 @@ +{ + "schema_version": "1.0", + "kind": "story", + "id": "fr-a1-le-marche", + "subject": "french", + "title": "Le marché du mercredi", + "level": "beginner", + "level_detail": "A1", + "language": "fr", + "summary": "Léo goes to the Wednesday market in Lyon with his grandmother and practices talking to the vendors.", + "body": "Léo habite à Lyon avec sa famille. Le mercredi, il n'y a pas d'école, alors Léo va au marché avec sa grand-mère.\n\nLe marché est grand et coloré. Il y a des fruits, des légumes, du poisson et des fleurs. « Regarde ces tomates, Léo ! » dit sa grand-mère. « Elles sont rouges et bien mûres. »\n\nLéo aime parler avec les vendeurs. Il dit : « Bonjour, monsieur. Je voudrais un kilo de carottes, s'il vous plaît. » Le vendeur répond : « Voilà, mon garçon. Et avec ça ? » Léo réfléchit un moment. « Aussi des oignons, s'il vous plaît. »\n\nSa grand-mère achète du poulet et des herbes fraîches. Elle va préparer une soupe pour le dîner.\n\nAvant de rentrer, ils s'arrêtent devant un stand de fleurs. La grand-mère de Léo choisit un joli bouquet jaune pour la table de la cuisine.\n\nÀ la maison, Léo aide sa grand-mère à laver les légumes. Il est fier d'avoir parlé français avec les vendeurs, tout seul.", + "glossary": [ + { + "term": "le marché", + "definition": "the market" + }, + { + "term": "la grand-mère", + "definition": "the grandmother" + }, + { + "term": "bien mûres", + "definition": "nice and ripe", + "note": "mûr(e) = ripe; agrees in gender/number with the noun." + }, + { + "term": "je voudrais", + "definition": "I would like", + "note": "The polite conditional of vouloir, used for requests." + }, + { + "term": "s'il vous plaît", + "definition": "please (formal)" + }, + { + "term": "les herbes fraîches", + "definition": "fresh herbs" + }, + { + "term": "fier / fière", + "definition": "proud", + "note": "fier de + infinitive = proud to have done something." + } + ], + "exercises": [ + { + "id": "fr-a1-le-marche-q1", + "type": "multiple_choice", + "item_id": "fr.story.fr-a1-le-marche.1", + "prompt": "Quel jour Léo va-t-il au marché avec sa grand-mère ?", + "choices": ["Le mercredi", "Le dimanche", "Le vendredi"], + "answer": "Le mercredi" + }, + { + "id": "fr-a1-le-marche-q2", + "type": "multiple_choice", + "item_id": "fr.story.fr-a1-le-marche.2", + "prompt": "Qu'est-ce que la grand-mère de Léo va préparer avec le poulet ?", + "choices": ["Une salade", "Une soupe", "Un gâteau"], + "answer": "Une soupe" + }, + { + "id": "fr-a1-le-marche-q3", + "type": "cloze", + "item_id": "fr.story.fr-a1-le-marche.3", + "prompt": "Léo dit au vendeur : « Je voudrais un kilo de ___, s'il vous plaît. »", + "answer": "carottes" + }, + { + "id": "fr-a1-le-marche-q4", + "type": "open", + "item_id": "fr.story.fr-a1-le-marche.4", + "prompt": "Pourquoi Léo est-il fier à la fin de l'histoire ?", + "rubric": "Passes if the answer names that he spoke French with the vendors by himself/alone; partial if it only says he went to the market." + } + ], + "audio": null, + "source": { + "generator": "hand-authored", + "reviewed_by": "learn-cli t7a", + "generated_at": "2026-07-11" + } +} diff --git a/content/stories/fr-a2-le-cafe-du-coin.json b/content/stories/fr-a2-le-cafe-du-coin.json new file mode 100644 index 0000000..03e4215 --- /dev/null +++ b/content/stories/fr-a2-le-cafe-du-coin.json @@ -0,0 +1,84 @@ +{ + "schema_version": "1.0", + "kind": "story", + "id": "fr-a2-le-cafe-du-coin", + "subject": "french", + "title": "Un café entre amis", + "level": "beginner", + "level_detail": "A2", + "language": "fr", + "summary": "Amina and Chloé catch up over coffee after Chloé's move to Marseille, and remember a past trip gone wrong.", + "body": "Samedi dernier, Amina a retrouvé son amie Chloé dans un petit café du quartier. Elles ne se sont pas vues depuis deux mois, parce que Chloé a déménagé à Marseille pour son nouveau travail.\n\n« Alors, comment s'est passé ton déménagement ? » demande Amina en s'asseyant à la table.\n\n« C'était fatigant, » répond Chloé, « mais je suis contente. Mon appartement est petit, mais il a un beau balcon avec vue sur la mer. »\n\nLe serveur arrive et leur demande ce qu'elles veulent boire. Amina commande un café crème, et Chloé choisit un thé à la menthe. Elles discutent pendant des heures : le nouveau travail de Chloé, les amis communs, et les vacances qu'elles veulent prendre ensemble cet été.\n\n« Tu te souviens de notre voyage en Espagne ? » dit Chloé en riant. « On a raté le train et on a dû dormir à la gare ! »\n\n« Comment oublier ça ? » répond Amina. « On devrait recommencer, mais sans rater le train cette fois. »\n\nAvant de partir, elles échangent leurs nouvelles adresses et promettent de s'écrire plus souvent. Amina rentre chez elle avec le sourire ; retrouver une vieille amie, même après une longue absence, lui a fait beaucoup de bien.", + "glossary": [ + { + "term": "retrouver quelqu'un", + "definition": "to meet up with someone (again)" + }, + { + "term": "déménager", + "definition": "to move (house)" + }, + { + "term": "fatigant(e)", + "definition": "tiring" + }, + { + "term": "le balcon", + "definition": "the balcony" + }, + { + "term": "commander", + "definition": "to order (food/drink)" + }, + { + "term": "rater le train", + "definition": "to miss the train" + }, + { + "term": "se souvenir de", + "definition": "to remember" + }, + { + "term": "faire du bien", + "definition": "to do (someone) good, to feel good" + } + ], + "exercises": [ + { + "id": "fr-a2-le-cafe-du-coin-q1", + "type": "multiple_choice", + "item_id": "fr.story.fr-a2-le-cafe-du-coin.1", + "prompt": "Pourquoi Amina et Chloé ne se sont-elles pas vues depuis deux mois ?", + "choices": ["Chloé a déménagé à Marseille", "Amina était malade", "Chloé était en vacances"], + "answer": "Chloé a déménagé à Marseille" + }, + { + "id": "fr-a2-le-cafe-du-coin-q2", + "type": "multiple_choice", + "item_id": "fr.story.fr-a2-le-cafe-du-coin.2", + "prompt": "Qu'est-ce que Chloé commande à boire ?", + "choices": ["Un café crème", "Un thé à la menthe", "Un chocolat chaud"], + "answer": "Un thé à la menthe" + }, + { + "id": "fr-a2-le-cafe-du-coin-q3", + "type": "short_answer", + "item_id": "fr.story.fr-a2-le-cafe-du-coin.3", + "prompt": "Que s'est-il passé pendant leur voyage en Espagne ?", + "rubric": "Passes if the answer mentions missing the train and having to sleep at the station; partial if it only mentions one of the two." + }, + { + "id": "fr-a2-le-cafe-du-coin-q4", + "type": "discussion", + "item_id": "fr.story.fr-a2-le-cafe-du-coin.4", + "prompt": "Amina dit que retrouver une vieille amie lui a fait du bien. Raconte une fois où tu as retrouvé un ami après une longue absence.", + "rubric": "Passes if the learner produces a coherent short personal narrative in French connected to the theme of reunion; partial if the answer is a single word or off-topic." + } + ], + "audio": null, + "source": { + "generator": "hand-authored", + "reviewed_by": "learn-cli t7a", + "generated_at": "2026-07-11" + } +} diff --git a/content/stories/fr-a2-le-weekend-a-la-montagne.json b/content/stories/fr-a2-le-weekend-a-la-montagne.json new file mode 100644 index 0000000..10076c5 --- /dev/null +++ b/content/stories/fr-a2-le-weekend-a-la-montagne.json @@ -0,0 +1,84 @@ +{ + "schema_version": "1.0", + "kind": "story", + "id": "fr-a2-le-weekend-a-la-montagne", + "subject": "french", + "title": "Un week-end à la montagne", + "level": "beginner", + "level_detail": "A2", + "language": "fr", + "summary": "The Bernard family spends a snowy February weekend at a mountain chalet, skiing and building a snowman.", + "body": "Pour les vacances de février, la famille Bernard part à la montagne. Ils louent un petit chalet en bois près des pistes de ski.\n\nLe premier jour, il neige beaucoup. Les enfants, Tom et Julie, sont très excités. « On peut faire un bonhomme de neige ? » demande Julie. « Bien sûr, » répond leur père, « mais d'abord, il faut s'habiller chaudement ! »\n\nAprès le petit-déjeuner, toute la famille met des bottes, des gants et des bonnets. Ils sortent et construisent un grand bonhomme de neige avec une carotte pour le nez.\n\nLe lendemain, ils vont skier. Tom, qui a seulement six ans, apprend avec un moniteur. Julie, plus âgée, descend déjà les pistes bleues avec sa mère. À midi, ils mangent une fondue savoyarde dans un petit restaurant en bois, en haut de la montagne. C'est délicieux, mais très riche !\n\nLe soir, fatigués mais heureux, ils se réchauffent devant la cheminée du chalet. Le père raconte des histoires, et la mère prépare du chocolat chaud pour tout le monde.\n\nLe dernier jour, avant de rentrer à Paris, ils font une dernière descente en luge. Julie tombe dans la neige et rit aux éclats. « On revient l'année prochaine ? » demande-t-elle dans la voiture. « Promis, » dit sa mère en souriant.", + "glossary": [ + { + "term": "le chalet", + "definition": "the mountain cabin/chalet" + }, + { + "term": "les pistes (de ski)", + "definition": "the ski slopes" + }, + { + "term": "un bonhomme de neige", + "definition": "a snowman" + }, + { + "term": "s'habiller chaudement", + "definition": "to dress warmly" + }, + { + "term": "le moniteur / la monitrice", + "definition": "the ski instructor" + }, + { + "term": "la fondue savoyarde", + "definition": "cheese fondue (from the Savoie region)" + }, + { + "term": "se réchauffer", + "definition": "to warm oneself up" + }, + { + "term": "la luge", + "definition": "the sledge/sled" + } + ], + "exercises": [ + { + "id": "fr-a2-le-weekend-a-la-montagne-q1", + "type": "multiple_choice", + "item_id": "fr.story.fr-a2-le-weekend-a-la-montagne.1", + "prompt": "Où part la famille Bernard pendant les vacances de février ?", + "choices": ["À la mer", "À la montagne", "À la campagne"], + "answer": "À la montagne" + }, + { + "id": "fr-a2-le-weekend-a-la-montagne-q2", + "type": "multiple_choice", + "item_id": "fr.story.fr-a2-le-weekend-a-la-montagne.2", + "prompt": "Qui apprend à skier avec un moniteur ?", + "choices": ["Julie", "Tom", "Leur père"], + "answer": "Tom" + }, + { + "id": "fr-a2-le-weekend-a-la-montagne-q3", + "type": "true_false", + "item_id": "fr.story.fr-a2-le-weekend-a-la-montagne.3", + "prompt": "La famille mange une pizza en haut de la montagne.", + "answer": "false" + }, + { + "id": "fr-a2-le-weekend-a-la-montagne-q4", + "type": "open", + "item_id": "fr.story.fr-a2-le-weekend-a-la-montagne.4", + "prompt": "Décris ce que la famille fait le soir dans le chalet.", + "rubric": "Passes if the answer mentions warming up by the fireplace, the father telling stories, and the mother making hot chocolate; partial if only one detail is given." + } + ], + "audio": null, + "source": { + "generator": "hand-authored", + "reviewed_by": "learn-cli t7a", + "generated_at": "2026-07-11" + } +} diff --git a/content/stories/fr-b1-entretien-embauche.json b/content/stories/fr-b1-entretien-embauche.json new file mode 100644 index 0000000..9b41cbe --- /dev/null +++ b/content/stories/fr-b1-entretien-embauche.json @@ -0,0 +1,96 @@ +{ + "schema_version": "1.0", + "kind": "story", + "id": "fr-b1-entretien-embauche", + "subject": "french", + "title": "Un entretien d'embauche", + "level": "intermediate", + "level_detail": "B1", + "language": "fr", + "summary": "Thomas navigates his first job interview, including an unexpected question about resolving conflict.", + "body": "Thomas attend depuis dix minutes dans la salle d'accueil de l'entreprise. Il vérifie une dernière fois son CV et relit les questions qu'il a préparées. C'est son premier entretien depuis qu'il a terminé ses études, et il est plutôt nerveux.\n\nUne femme sort d'un bureau et l'appelle : « Monsieur Lefebvre ? Vous pouvez entrer. » Il s'agit de Madame Rousseau, la responsable des ressources humaines.\n\n« Alors, parlez-moi un peu de vous, » commence-t-elle en souriant. Thomas explique son parcours : ses études en marketing, son stage dans une agence de communication, et son intérêt pour les réseaux sociaux.\n\n« Pourquoi voulez-vous travailler chez nous ? » demande Madame Rousseau. Thomas répond qu'il admire les campagnes publicitaires de l'entreprise, et qu'il aimerait contribuer à des projets créatifs tout en apprenant auprès d'une équipe expérimentée.\n\nElle lui pose ensuite une question à laquelle il ne s'attendait pas : « Racontez-moi une situation où vous avez dû résoudre un conflit. » Thomas hésite un instant, puis raconte comment il a réussi à calmer un client mécontent pendant son stage, en l'écoutant attentivement avant de proposer une solution.\n\nL'entretien dure presque quarante-cinq minutes. À la fin, Madame Rousseau explique que l'entreprise contactera les candidats retenus dans les deux semaines. Thomas la remercie chaleureusement et sort du bureau, le cœur battant.\n\nDans la rue, il envoie immédiatement un message à sa mère : « Je crois que ça s'est bien passé ! » Une semaine plus tard, il reçoit un appel : l'entreprise lui propose le poste. Toute cette nervosité, finalement, en valait la peine.", + "glossary": [ + { + "term": "un entretien d'embauche", + "definition": "a job interview" + }, + { + "term": "la salle d'accueil", + "definition": "the reception/waiting area" + }, + { + "term": "les ressources humaines", + "definition": "human resources" + }, + { + "term": "le parcours", + "definition": "one's background, career path" + }, + { + "term": "un stage", + "definition": "an internship" + }, + { + "term": "une campagne publicitaire", + "definition": "an advertising campaign" + }, + { + "term": "résoudre un conflit", + "definition": "to resolve a conflict" + }, + { + "term": "mécontent(e)", + "definition": "unhappy, displeased" + }, + { + "term": "retenu(e)", + "definition": "selected, shortlisted" + }, + { + "term": "en valoir la peine", + "definition": "to be worth it" + } + ], + "exercises": [ + { + "id": "fr-b1-entretien-embauche-q1", + "type": "multiple_choice", + "item_id": "fr.story.fr-b1-entretien-embauche.1", + "prompt": "Quel est le métier de Madame Rousseau ?", + "choices": ["Responsable des ressources humaines", "Directrice marketing", "Avocate"], + "answer": "Responsable des ressources humaines" + }, + { + "id": "fr-b1-entretien-embauche-q2", + "type": "multiple_choice", + "item_id": "fr.story.fr-b1-entretien-embauche.2", + "prompt": "Quelle question surprend Thomas pendant l'entretien ?", + "choices": [ + "Une question sur la résolution de conflit", + "Une question sur son salaire souhaité", + "Une question sur ses loisirs" + ], + "answer": "Une question sur la résolution de conflit" + }, + { + "id": "fr-b1-entretien-embauche-q3", + "type": "cloze", + "item_id": "fr.story.fr-b1-entretien-embauche.3", + "prompt": "Thomas raconte comment il a calmé un client ___ pendant son stage.", + "answer": "mécontent" + }, + { + "id": "fr-b1-entretien-embauche-q4", + "type": "open", + "item_id": "fr.story.fr-b1-entretien-embauche.4", + "prompt": "Que ferais-tu si on te posait une question à laquelle tu ne t'attends pas pendant un entretien ?", + "rubric": "Passes if the learner gives a coherent personal strategy in French (e.g. pausing to think, asking for clarification, giving a concrete example); partial if the answer is a single word." + } + ], + "audio": null, + "source": { + "generator": "hand-authored", + "reviewed_by": "learn-cli t7a", + "generated_at": "2026-07-11" + } +} diff --git a/content/stories/fr-b1-la-fete-des-voisins.json b/content/stories/fr-b1-la-fete-des-voisins.json new file mode 100644 index 0000000..cba74a6 --- /dev/null +++ b/content/stories/fr-b1-la-fete-des-voisins.json @@ -0,0 +1,93 @@ +{ + "schema_version": "1.0", + "kind": "story", + "id": "fr-b1-la-fete-des-voisins", + "subject": "french", + "title": "La fête des voisins", + "level": "intermediate", + "level_detail": "B1", + "language": "fr", + "summary": "Farida organizes her Paris building's annual Neighbours' Day and discovers she barely knew the people living around her.", + "body": "Chaque année, le dernier vendredi de mai, la France célèbre la fête des voisins. Ce jour-là, les habitants d'un même immeuble ou d'une même rue se retrouvent dans la cour ou sur le trottoir pour partager un repas ensemble.\n\nCette année, c'est Farida qui organise l'événement dans son immeuble parisien. Elle affiche une invitation dans le hall : « Venez avec un plat, une boisson, et surtout votre bonne humeur ! »\n\nLe soir venu, les tables se remplissent peu à peu. Monsieur Petit, le voisin du cinquième étage que personne ne connaît vraiment, apporte une tarte aux pommes faite maison. La famille Nguyen, arrivée récemment dans l'immeuble, propose des rouleaux de printemps qui ont beaucoup de succès.\n\nAu début, les conversations restent polies et un peu formelles. Mais après quelques verres de vin et beaucoup de rires, l'ambiance se détend. Farida découvre que Monsieur Petit a été professeur de musique pendant trente ans, et qu'il joue encore du piano tous les dimanches. Les enfants, eux, courent entre les tables et organisent leurs propres jeux dans la cour.\n\nÀ la fin de la soirée, plusieurs voisins échangent leurs numéros de téléphone. « On devrait faire ça plus souvent, » dit Madame Nguyen en aidant à ranger les chaises. Farida est d'accord : avant cette soirée, elle connaissait à peine le nom de ses voisins ; maintenant, elle a l'impression d'avoir une vraie petite communauté autour d'elle.\n\nLa fête des voisins, née d'une initiative simple il y a plus de vingt ans, continue chaque année de rapprocher des gens qui, autrement, ne se seraient peut-être jamais parlé.", + "glossary": [ + { + "term": "la fête des voisins", + "definition": "Neighbours' Day, a French community event held annually", + "note": "Held on the last Friday of May since the late 1990s." + }, + { + "term": "l'immeuble (m.)", + "definition": "the apartment building" + }, + { + "term": "afficher", + "definition": "to post/put up (a notice)" + }, + { + "term": "la bonne humeur", + "definition": "good mood, cheerfulness" + }, + { + "term": "fait(e) maison", + "definition": "homemade" + }, + { + "term": "les rouleaux de printemps", + "definition": "spring rolls" + }, + { + "term": "se détendre", + "definition": "to relax, to loosen up" + }, + { + "term": "à peine", + "definition": "barely, hardly" + }, + { + "term": "rapprocher", + "definition": "to bring closer together" + }, + { + "term": "une initiative", + "definition": "an initiative" + } + ], + "exercises": [ + { + "id": "fr-b1-la-fete-des-voisins-q1", + "type": "multiple_choice", + "item_id": "fr.story.fr-b1-la-fete-des-voisins.1", + "prompt": "Quand a lieu la fête des voisins ?", + "choices": ["Le dernier vendredi de mai", "Le premier janvier", "Le 14 juillet"], + "answer": "Le dernier vendredi de mai" + }, + { + "id": "fr-b1-la-fete-des-voisins-q2", + "type": "multiple_choice", + "item_id": "fr.story.fr-b1-la-fete-des-voisins.2", + "prompt": "Qui apporte une tarte aux pommes faite maison ?", + "choices": ["Monsieur Petit", "Farida", "Madame Nguyen"], + "answer": "Monsieur Petit" + }, + { + "id": "fr-b1-la-fete-des-voisins-q3", + "type": "short_answer", + "item_id": "fr.story.fr-b1-la-fete-des-voisins.3", + "prompt": "Qu'est-ce que Farida apprend sur Monsieur Petit pendant la soirée ?", + "rubric": "Passes if the answer mentions he was a music teacher for thirty years and still plays piano on Sundays; partial if only one of the two facts is given." + }, + { + "id": "fr-b1-la-fete-des-voisins-q4", + "type": "discussion", + "item_id": "fr.story.fr-b1-la-fete-des-voisins.4", + "prompt": "Selon le texte, pourquoi la fête des voisins est-elle importante pour une communauté ? Ton quartier organise-t-il un événement similaire ?", + "rubric": "Passes if the response connects the event to building community between strangers and offers at least a brief reflection on the learner's own context; partial if it only summarizes the story." + } + ], + "audio": null, + "source": { + "generator": "hand-authored", + "reviewed_by": "learn-cli t7a", + "generated_at": "2026-07-11" + } +} diff --git a/content/stories/fr-b1-la-recherche-appartement.json b/content/stories/fr-b1-la-recherche-appartement.json new file mode 100644 index 0000000..10fd5ea --- /dev/null +++ b/content/stories/fr-b1-la-recherche-appartement.json @@ -0,0 +1,100 @@ +{ + "schema_version": "1.0", + "kind": "story", + "id": "fr-b1-la-recherche-appartement", + "subject": "french", + "title": "La recherche d'appartement", + "level": "intermediate", + "level_detail": "B1", + "language": "fr", + "summary": "Camille weighs a small city-centre studio against a bigger, farther apartment before choosing where to live in Bordeaux.", + "body": "Depuis qu'elle a trouvé un nouvel emploi à Bordeaux, Camille cherche un appartement. Ce n'est pas facile : les loyers ont beaucoup augmenté ces dernières années, et il faut souvent visiter plusieurs logements avant d'en trouver un qui convienne.\n\nCe matin, elle visite un studio dans le centre-ville. « Il est un peu petit, » pense-t-elle en entrant, « mais la lumière est agréable et il y a une petite terrasse. » Le propriétaire, un homme âgé plutôt sympathique, lui explique que le loyer comprend les charges, mais pas internet.\n\nL'après-midi, Camille visite un deuxième appartement, plus grand, mais situé loin du centre. Bien qu'il soit spacieux et lumineux, le trajet jusqu'au travail prendrait presque quarante minutes en tramway. Camille hésite : faut-il privilégier l'espace ou la proximité ?\n\nSon amie Sarah, qui vit à Bordeaux depuis longtemps, lui conseille de choisir le studio du centre-ville. « Tu passeras moins de temps dans les transports, et le quartier est vivant : il y a des cafés, un marché, et le tramway juste en bas de la rue. »\n\nAprès réflexion, Camille décide de suivre ce conseil. Elle rappelle le propriétaire du studio et lui annonce qu'elle est intéressée. Il lui demande de préparer un dossier avec ses trois derniers bulletins de salaire et une lettre de son employeur.\n\nUne semaine plus tard, Camille reçoit enfin les clés de son nouveau chez-elle. Elle est soulagée : la recherche d'appartement, aussi stressante soit-elle, est enfin terminée.", + "glossary": [ + { + "term": "le loyer", + "definition": "the rent" + }, + { + "term": "les charges", + "definition": "utility/service charges (often included in a French rent)" + }, + { + "term": "le propriétaire", + "definition": "the landlord" + }, + { + "term": "spacieux / spacieuse", + "definition": "spacious" + }, + { + "term": "le trajet", + "definition": "the commute, the journey" + }, + { + "term": "privilégier", + "definition": "to favor, to prioritize" + }, + { + "term": "conseiller", + "definition": "to advise" + }, + { + "term": "un dossier", + "definition": "an application file" + }, + { + "term": "le bulletin de salaire", + "definition": "the payslip" + }, + { + "term": "soulagé(e)", + "definition": "relieved" + } + ], + "exercises": [ + { + "id": "fr-b1-la-recherche-appartement-q1", + "type": "multiple_choice", + "item_id": "fr.story.fr-b1-la-recherche-appartement.1", + "prompt": "Pourquoi Camille cherche-t-elle un appartement ?", + "choices": [ + "Elle a trouvé un nouvel emploi à Bordeaux", + "Elle veut vivre avec des amis", + "Son appartement actuel a brûlé" + ], + "answer": "Elle a trouvé un nouvel emploi à Bordeaux" + }, + { + "id": "fr-b1-la-recherche-appartement-q2", + "type": "multiple_choice", + "item_id": "fr.story.fr-b1-la-recherche-appartement.2", + "prompt": "Quel appartement Camille choisit-elle finalement ?", + "choices": [ + "Le studio du centre-ville", + "Le grand appartement loin du centre", + "Aucun des deux" + ], + "answer": "Le studio du centre-ville" + }, + { + "id": "fr-b1-la-recherche-appartement-q3", + "type": "short_answer", + "item_id": "fr.story.fr-b1-la-recherche-appartement.3", + "prompt": "Que doit préparer Camille pour le propriétaire du studio ?", + "rubric": "Passes if the answer mentions the last three payslips (bulletins de salaire) and a letter from her employer; partial if only one document is named." + }, + { + "id": "fr-b1-la-recherche-appartement-q4", + "type": "discussion", + "item_id": "fr.story.fr-b1-la-recherche-appartement.4", + "prompt": "Camille hésite entre l'espace et la proximité du travail. Si tu devais choisir, que privilégierais-tu, et pourquoi ?", + "rubric": "Passes if the learner gives a reasoned personal preference in French that engages with the space-vs-commute trade-off; partial if it only picks one side without a reason." + } + ], + "audio": null, + "source": { + "generator": "hand-authored", + "reviewed_by": "learn-cli t7a", + "generated_at": "2026-07-11" + } +} diff --git a/content/stories/fr-b1-le-covoiturage.json b/content/stories/fr-b1-le-covoiturage.json new file mode 100644 index 0000000..f9a93e9 --- /dev/null +++ b/content/stories/fr-b1-le-covoiturage.json @@ -0,0 +1,96 @@ +{ + "schema_version": "1.0", + "kind": "story", + "id": "fr-b1-le-covoiturage", + "subject": "french", + "title": "Le covoiturage", + "level": "intermediate", + "level_detail": "B1", + "language": "fr", + "summary": "Julien starts carpooling to save money and ends up gaining unexpected friendships along the way.", + "body": "Pour économiser de l'argent et réduire son impact sur l'environnement, Julien a décidé d'essayer le covoiturage pour aller travailler. Chaque matin, il prend sa voiture, mais désormais avec trois autres passagers qu'il ne connaissait pas avant de s'inscrire sur une application.\n\nLe premier jour, l'ambiance est un peu timide. Personne ne parle vraiment, et Julien se demande s'il a fait le bon choix. Mais dès la deuxième semaine, les choses changent. Ines, une passagère qui travaille dans le même quartier que lui, propose d'apporter des viennoiseries le vendredi matin. Marc, un autre passager, raconte des histoires drôles sur son travail dans un hôpital.\n\nAu fil des semaines, ce trajet quotidien devient un moment agréable de la journée. Ils discutent de tout : de politique, de leurs enfants, des séries qu'ils regardent le soir. Julien réalise qu'il a fait des économies importantes sur l'essence, mais aussi qu'il a gagné quelque chose d'inattendu : de nouveaux amis.\n\nUn matin, la voiture tombe en panne sur l'autoroute. Au lieu de paniquer, le petit groupe reste calme. Ines appelle une dépanneuse pendant que Marc plaisante pour détendre l'atmosphère. Ils arrivent au travail en retard, mais ensemble, et cette mésaventure devient une histoire qu'ils racontent encore des mois plus tard.\n\nAujourd'hui, Julien encourage tous ses collègues à essayer le covoiturage. « On pense économiser de l'essence, » dit-il, « mais on gagne bien plus que ça. »", + "glossary": [ + { + "term": "le covoiturage", + "definition": "carpooling" + }, + { + "term": "économiser", + "definition": "to save (money)" + }, + { + "term": "s'inscrire", + "definition": "to sign up" + }, + { + "term": "timide", + "definition": "shy" + }, + { + "term": "une viennoiserie", + "definition": "a pastry (croissant, pain au chocolat, etc.)" + }, + { + "term": "au fil des semaines", + "definition": "as the weeks go by" + }, + { + "term": "l'essence (f.)", + "definition": "gasoline/petrol" + }, + { + "term": "tomber en panne", + "definition": "to break down (a vehicle)" + }, + { + "term": "une dépanneuse", + "definition": "a tow truck" + }, + { + "term": "une mésaventure", + "definition": "a mishap" + } + ], + "exercises": [ + { + "id": "fr-b1-le-covoiturage-q1", + "type": "multiple_choice", + "item_id": "fr.story.fr-b1-le-covoiturage.1", + "prompt": "Pourquoi Julien essaie-t-il le covoiturage ?", + "choices": [ + "Pour économiser de l'argent et réduire son impact environnemental", + "Parce que sa voiture est en panne", + "Parce que son travail est trop loin" + ], + "answer": "Pour économiser de l'argent et réduire son impact environnemental" + }, + { + "id": "fr-b1-le-covoiturage-q2", + "type": "multiple_choice", + "item_id": "fr.story.fr-b1-le-covoiturage.2", + "prompt": "Que se passe-t-il un matin sur l'autoroute ?", + "choices": ["La voiture tombe en panne", "Ils ont un accident", "Ils se perdent"], + "answer": "La voiture tombe en panne" + }, + { + "id": "fr-b1-le-covoiturage-q3", + "type": "true_false", + "item_id": "fr.story.fr-b1-le-covoiturage.3", + "prompt": "Après la panne, le groupe panique et se dispute.", + "answer": "false" + }, + { + "id": "fr-b1-le-covoiturage-q4", + "type": "open", + "item_id": "fr.story.fr-b1-le-covoiturage.4", + "prompt": "Selon Julien, qu'est-ce que le covoiturage lui a apporté en plus des économies d'essence ?", + "rubric": "Passes if the answer names new friendships / social connection with the other passengers; partial if it only restates the fuel savings." + } + ], + "audio": null, + "source": { + "generator": "hand-authored", + "reviewed_by": "learn-cli t7a", + "generated_at": "2026-07-11" + } +} diff --git a/content/stories/fr-b2-la-transition-ecologique.json b/content/stories/fr-b2-la-transition-ecologique.json new file mode 100644 index 0000000..1421c0a --- /dev/null +++ b/content/stories/fr-b2-la-transition-ecologique.json @@ -0,0 +1,104 @@ +{ + "schema_version": "1.0", + "kind": "story", + "id": "fr-b2-la-transition-ecologique", + "subject": "french", + "title": "La transition écologique, vue d'un petit village", + "level": "advanced", + "level_detail": "B2", + "language": "fr", + "summary": "A profile of a small Limousin village that rebuilt its grocery store as a cooperative and added solar panels, without any ideological framing.", + "body": "Saint-Martin-des-Bois, un village de huit cents habitants niché au cœur du Limousin, n'a rien d'exceptionnel au premier regard. Pourtant, depuis cinq ans, il est devenu, presque malgré lui, un symbole local de la transition écologique.\n\nTout a commencé lorsque le conseil municipal, confronté à la fermeture de la dernière épicerie du village, a décidé de réagir plutôt que de subir ce déclin. Au lieu d'attendre qu'un investisseur extérieur rachète le local, les habitants ont créé une coopérative. Chacun pouvait devenir sociétaire pour une somme modeste, et l'épicerie a rouvert quelques mois plus tard, proposant en priorité des produits locaux et de saison.\n\nCe premier succès a donné des idées. Une association de bénévoles a installé des panneaux solaires sur le toit de la mairie et de l'école. Le maire, initialement sceptique quant à la rentabilité du projet, admet aujourd'hui que les économies réalisées sur la facture d'électricité ont dépassé ses attentes les plus optimistes.\n\nCertes, tout ne s'est pas déroulé sans difficulté. Convaincre les agriculteurs les plus âgés d'adopter des pratiques moins intensives, par exemple, a demandé des années de discussions patientes, souvent autour d'un verre au café du village. Certains, encore aujourd'hui, restent réticents, arguant que ces changements profitent surtout aux « citadins venus s'installer à la campagne ».\n\nCe qui frappe pourtant l'observateur extérieur, c'est l'absence de discours idéologique dans la démarche des habitants de Saint-Martin-des-Bois. Personne, ici, ne parle de sauver la planète en de grandes phrases. On évoque plutôt la volonté de garder le village vivant, de transmettre quelque chose à la génération suivante, et de ne plus dépendre entièrement de décisions prises loin, dans des bureaux qui ignorent tout des réalités locales.\n\nÀ l'heure où la transition écologique reste souvent perçue comme une contrainte imposée d'en haut, l'exemple de ce petit village rappelle qu'elle peut aussi naître, modestement, d'une volonté locale de préserver un mode de vie.", + "glossary": [ + { + "term": "niché(e)", + "definition": "nestled, tucked away" + }, + { + "term": "le conseil municipal", + "definition": "the town council" + }, + { + "term": "l'épicerie (f.)", + "definition": "the grocery store" + }, + { + "term": "une coopérative", + "definition": "a cooperative" + }, + { + "term": "un sociétaire", + "definition": "a shareholder/member (of a cooperative)" + }, + { + "term": "sceptique", + "definition": "skeptical" + }, + { + "term": "la rentabilité", + "definition": "profitability" + }, + { + "term": "réticent(e)", + "definition": "reluctant" + }, + { + "term": "un citadin / une citadine", + "definition": "a city-dweller" + }, + { + "term": "une contrainte", + "definition": "a constraint, an imposed burden" + }, + { + "term": "préserver", + "definition": "to preserve" + } + ], + "exercises": [ + { + "id": "fr-b2-la-transition-ecologique-q1", + "type": "multiple_choice", + "item_id": "fr.story.fr-b2-la-transition-ecologique.1", + "prompt": "Pourquoi le conseil municipal de Saint-Martin-des-Bois a-t-il créé une coopérative ?", + "choices": [ + "Pour rouvrir la dernière épicerie du village", + "Pour construire une nouvelle école", + "Pour attirer des touristes" + ], + "answer": "Pour rouvrir la dernière épicerie du village" + }, + { + "id": "fr-b2-la-transition-ecologique-q2", + "type": "multiple_choice", + "item_id": "fr.story.fr-b2-la-transition-ecologique.2", + "prompt": "Qu'est-ce que le maire admet à propos des panneaux solaires ?", + "choices": [ + "Que les économies ont dépassé ses attentes", + "Que le projet a été un échec", + "Que le coût était trop élevé" + ], + "answer": "Que les économies ont dépassé ses attentes" + }, + { + "id": "fr-b2-la-transition-ecologique-q3", + "type": "short_answer", + "item_id": "fr.story.fr-b2-la-transition-ecologique.3", + "prompt": "Pourquoi certains agriculteurs âgés restent-ils réticents face à ces changements ?", + "rubric": "Passes if the answer mentions the belief that these changes mainly benefit city-dwellers who moved to the countryside; partial if it only says 'they disagree' without the reason." + }, + { + "id": "fr-b2-la-transition-ecologique-q4", + "type": "discussion", + "item_id": "fr.story.fr-b2-la-transition-ecologique.4", + "prompt": "L'article insiste sur l'absence de « discours idéologique » dans la démarche du village. Penses-tu que ce soit une force ou une limite pour la transition écologique en général ?", + "rubric": "Passes if the learner engages with the tension between pragmatic local action and broader ideological/political framing, and takes a defensible position; partial if it only summarizes the village's approach without answering the question." + } + ], + "audio": null, + "source": { + "generator": "hand-authored", + "reviewed_by": "learn-cli t7a", + "generated_at": "2026-07-11" + } +} diff --git a/content/stories/fr-b2-le-teletravail.json b/content/stories/fr-b2-le-teletravail.json new file mode 100644 index 0000000..9cbefd3 --- /dev/null +++ b/content/stories/fr-b2-le-teletravail.json @@ -0,0 +1,100 @@ +{ + "schema_version": "1.0", + "kind": "story", + "id": "fr-b2-le-teletravail", + "subject": "french", + "title": "Le télétravail, une révolution silencieuse", + "level": "advanced", + "level_detail": "B2", + "language": "fr", + "summary": "An essay-style piece weighing the promises and hidden costs of remote work, and the hybrid compromise many companies now choose.", + "body": "Il y a dix ans, proposer de travailler depuis son salon aurait semblé, pour la plupart des entreprises françaises, tout à fait saugrenu. Aujourd'hui, le télétravail s'est imposé comme une pratique courante, bouleversant profondément notre rapport au travail et à l'espace urbain.\n\nPour ses partisans, les avantages sont évidents. Fini les heures perdues dans les transports en commun bondés ; fini le stress de devoir arriver à l'heure malgré une grève ou un embouteillage. De nombreux salariés affirment être plus productifs chez eux, à l'abri des interruptions constantes qui rythment souvent la vie de bureau. Certains y voient même une meilleure conciliation entre vie professionnelle et vie personnelle, un équilibre longtemps considéré comme inaccessible.\n\nPourtant, il serait naïf de croire que cette transformation ne comporte aucun inconvénient. Plusieurs managers s'inquiètent que le télétravail n'affaiblisse la cohésion des équipes : sans les échanges informels de la machine à café, les liens entre collègues se distendent, et les jeunes recrues peinent parfois à s'intégrer pleinement à la culture de l'entreprise. D'autres soulignent le risque, bien réel, que la frontière entre vie privée et vie professionnelle ne s'efface complètement, certains salariés se surprenant à répondre à des e-mails tard le soir.\n\nFace à ces constats contradictoires, de nombreuses entreprises optent désormais pour un modèle hybride, alternant jours au bureau et jours à domicile. Cette solution, bien qu'imparfaite, semble pour l'instant représenter le compromis le plus largement accepté.\n\nReste une question, plus profonde, que peu osent poser franchement : et si le télétravail n'était que la première étape d'une remise en cause bien plus vaste de l'organisation même du travail salarié, telle que nous la connaissons depuis plus d'un siècle ?", + "glossary": [ + { + "term": "saugrenu(e)", + "definition": "absurd, preposterous" + }, + { + "term": "bouleverser", + "definition": "to upend, to profoundly disrupt" + }, + { + "term": "bondé(e)", + "definition": "packed, crowded (transport)" + }, + { + "term": "un embouteillage", + "definition": "a traffic jam" + }, + { + "term": "la conciliation (vie pro/perso)", + "definition": "work-life balance" + }, + { + "term": "la cohésion", + "definition": "cohesion, team unity" + }, + { + "term": "se distendre", + "definition": "to loosen, to become strained (of ties or relationships)" + }, + { + "term": "peiner à", + "definition": "to struggle to (do something)" + }, + { + "term": "s'effacer", + "definition": "to fade away, to disappear" + }, + { + "term": "un modèle hybride", + "definition": "a hybrid model" + }, + { + "term": "une remise en cause", + "definition": "a questioning, a challenging (of an established idea)" + } + ], + "exercises": [ + { + "id": "fr-b2-le-teletravail-q1", + "type": "multiple_choice", + "item_id": "fr.story.fr-b2-le-teletravail.1", + "prompt": "Selon le texte, quel est un avantage souvent cité du télétravail ?", + "choices": ["Moins de temps perdu dans les transports", "Un salaire plus élevé", "Moins de réunions"], + "answer": "Moins de temps perdu dans les transports" + }, + { + "id": "fr-b2-le-teletravail-q2", + "type": "multiple_choice", + "item_id": "fr.story.fr-b2-le-teletravail.2", + "prompt": "Quelle inquiétude certains managers expriment-ils ?", + "choices": [ + "Que la cohésion des équipes s'affaiblisse", + "Que les salariés gagnent trop d'argent", + "Que les bureaux deviennent trop grands" + ], + "answer": "Que la cohésion des équipes s'affaiblisse" + }, + { + "id": "fr-b2-le-teletravail-q3", + "type": "short_answer", + "item_id": "fr.story.fr-b2-le-teletravail.3", + "prompt": "Quel compromis de nombreuses entreprises adoptent-elles face à ce débat ?", + "rubric": "Passes if the answer names the hybrid model alternating office days and remote days; partial if it only says 'a compromise' without naming it." + }, + { + "id": "fr-b2-le-teletravail-q4", + "type": "discussion", + "item_id": "fr.story.fr-b2-le-teletravail.4", + "prompt": "L'auteur termine par une question sur une remise en cause plus vaste de l'organisation du travail salarié. Qu'en penses-tu : le télétravail est-il une simple adaptation, ou le début d'un changement plus profond ?", + "rubric": "Passes if the learner takes and defends a position with at least one supporting reason, engaging with the article's closing question; partial if it restates the question without taking a position." + } + ], + "audio": null, + "source": { + "generator": "hand-authored", + "reviewed_by": "learn-cli t7a", + "generated_at": "2026-07-11" + } +} diff --git a/content/stories/fr-c1-le-vieux-libraire.json b/content/stories/fr-c1-le-vieux-libraire.json new file mode 100644 index 0000000..9e9fab9 --- /dev/null +++ b/content/stories/fr-c1-le-vieux-libraire.json @@ -0,0 +1,104 @@ +{ + "schema_version": "1.0", + "kind": "story", + "id": "fr-c1-le-vieux-libraire", + "subject": "french", + "title": "Le vieux libraire", + "level": "advanced", + "level_detail": "C1", + "language": "fr", + "summary": "A literary short story about an old bookseller's uncanny gift for recommending the book a stranger truly needs, not the one she asked for.", + "body": "Monsieur Aubert tenait sa librairie depuis si longtemps que personne, dans le quartier, ne se souvenait d'un temps où elle n'avait pas existé. Coincée entre une pharmacie et un magasin de vêtements qui changeait d'enseigne tous les deux ans, la petite boutique semblait échapper au temps, avec sa devanture en bois vert écaillé et son odeur tenace de papier vieilli.\n\nCela faisait quarante-trois ans que Monsieur Aubert vendait des livres, et il aurait été bien en peine de dire combien il en avait recommandés à ses clients au fil des décennies. Il avait ce don, rare, de deviner en quelques mots à peine échangés ce qu'une personne cherchait vraiment à lire — non pas ce qu'elle demandait, mais ce dont, sans le savoir elle-même, elle avait besoin.\n\nUn après-midi de novembre, alors que la pluie tambourinait contre la vitrine, une jeune femme entra, visiblement égarée plus qu'intéressée par les livres. Elle errait entre les rayonnages sans but précis, effleurant les tranches du bout des doigts.\n\n« Vous cherchez quelque chose de particulier ? » demanda-t-il, sans lever complètement les yeux du roman qu'il était en train de réparer.\n\n« Pas vraiment, » répondit-elle après un silence. « Je viens de perdre mon emploi. Je ne sais pas très bien ce que je fais ici, à vrai dire. »\n\nMonsieur Aubert hocha la tête, comme si cette réponse ne le surprenait guère. Il se leva, disparut un instant entre deux étagères, et revint avec un livre à la couverture usée, dont le titre avait presque disparu.\n\n« Celui-ci, » dit-il simplement, en le lui tendant. « Ce n'est pas ce que vous cherchiez. Mais je crois que c'est ce dont vous avez besoin, aujourd'hui. »\n\nElle le regarda, surprise par cette assurance tranquille, puis accepta le livre sans poser de questions.\n\nDes années plus tard, devenue elle-même écrivaine, elle raconterait souvent cette scène dans les interviews, sans jamais réussir à expliquer tout à fait pourquoi ce vieil homme, ce jour-là, avait vu juste. Quant à Monsieur Aubert, il continua, jusqu'à sa retraite, à faire ce qu'il avait toujours fait : ouvrir sa boutique chaque matin, et attendre, avec une patience inlassable, que les bonnes personnes croisent les bons livres.", + "glossary": [ + { + "term": "coincé(e)", + "definition": "wedged, squeezed (between two things)" + }, + { + "term": "une devanture", + "definition": "a shop front" + }, + { + "term": "écaillé(e)", + "definition": "chipped, peeling (of paint)" + }, + { + "term": "tenace", + "definition": "stubborn, persistent (of a smell)" + }, + { + "term": "être en peine de", + "definition": "to be at a loss to (do something)" + }, + { + "term": "un rayonnage", + "definition": "a bookshelf, a shelving unit" + }, + { + "term": "effleurer", + "definition": "to lightly touch, to brush against" + }, + { + "term": "égaré(e)", + "definition": "lost, disoriented" + }, + { + "term": "hocher la tête", + "definition": "to nod" + }, + { + "term": "tendre (un objet)", + "definition": "to hold out (an object)" + }, + { + "term": "voir juste", + "definition": "to be right, to have judged correctly" + }, + { + "term": "inlassable", + "definition": "tireless, untiring" + } + ], + "exercises": [ + { + "id": "fr-c1-le-vieux-libraire-q1", + "type": "multiple_choice", + "item_id": "fr.story.fr-c1-le-vieux-libraire.1", + "prompt": "Depuis combien de temps Monsieur Aubert tient-il sa librairie ?", + "choices": ["Quarante-trois ans", "Dix ans", "Vingt ans"], + "answer": "Quarante-trois ans" + }, + { + "id": "fr-c1-le-vieux-libraire-q2", + "type": "multiple_choice", + "item_id": "fr.story.fr-c1-le-vieux-libraire.2", + "prompt": "Pourquoi la jeune femme entre-t-elle dans la librairie ?", + "choices": [ + "Elle vient de perdre son emploi et se sent perdue", + "Elle cherche un livre précis pour un cadeau", + "Elle s'abrite de la pluie uniquement" + ], + "answer": "Elle vient de perdre son emploi et se sent perdue" + }, + { + "id": "fr-c1-le-vieux-libraire-q3", + "type": "short_answer", + "item_id": "fr.story.fr-c1-le-vieux-libraire.3", + "prompt": "Que fait Monsieur Aubert avant de donner un livre à la jeune femme ?", + "rubric": "Passes if the answer mentions he goes and selects a specific worn/old book from between the shelves rather than asking more questions; partial if it just says 'he gives her a book'." + }, + { + "id": "fr-c1-le-vieux-libraire-q4", + "type": "discussion", + "item_id": "fr.story.fr-c1-le-vieux-libraire.4", + "prompt": "Le texte ne révèle jamais ce que contenait le livre offert par Monsieur Aubert. Pourquoi, selon toi, l'auteur fait-il ce choix ? Qu'est-ce que cela ajoute à l'histoire ?", + "rubric": "Passes if the learner reflects on the literary effect of withholding the title (mystery, focus on the human moment rather than the object, room for the reader's imagination) rather than simply guessing a specific title; partial if the answer only guesses a title without reflecting on the choice." + } + ], + "audio": null, + "source": { + "generator": "hand-authored", + "reviewed_by": "learn-cli t7a", + "generated_at": "2026-07-11" + } +} diff --git a/docs/contract-provenance.md b/docs/contract-provenance.md new file mode 100644 index 0000000..dee2558 --- /dev/null +++ b/docs/contract-provenance.md @@ -0,0 +1,59 @@ +# Cited contract provenance + +french-cli implements the **learn subject-plugin contract** but is not a +dependency of learn-cli: it is a *subject* that learn-cli hosts as an external +`--json` runtime. Its runtime dependencies stay `[]` and it must never +`import learn`. So the machine-readable half of the contract — the JSON Schemas +and the stdlib validator — is **cited (cite-don't-import)** into +`french/contract_cite/`, the same way `.claude/skills/` is vendored from +guildmaster (see [`skill-sources.md`](skill-sources.md)). + +Each consumer owns its copy; french-cli validates its own tutor-verb `--json` +payloads against these schemas in CI (`tests/test_contract_conformance.py`), +which is exactly the conformance gate learn-cli's registry (t3) runs against the +subject as a subprocess. + +## What was cited + +| Cited file | Upstream source | Kind | Notes | +|------------|-----------------|------|-------| +| `french/contract_cite/_validate.py` | `learn/contract/_validate.py` | **verbatim** | Stdlib-only JSON-Schema validator (no `jsonschema`). Header carries the provenance stamp. Do not edit the body. | +| `french/contract_cite/schemas/*.json` (11 files) | `learn/contract/schemas/*.json` | **verbatim** | `overview`, `progress`, `advice`, `story`, `story_list`, `story_read`, `lesson`, `practice`, `record`, `doctor`, `error`. | +| `french/contract_cite/__init__.py` | `learn/contract/__init__.py` | **adapted** | Package name (`learn.contract` → `french.contract_cite`) and docstrings only; the public API (`validate`, `load_schema`, `list_schemas`, `CONTRACT_VERSION`, `SCHEMA_NAMES`, `MASTERY_LEVELS`, `RESULTS`, `STORY_LEVELS`) is preserved. | + +## Provenance stamp + +- **Source repo:** `agentculture/learn-cli` +- **Contract:** subject-plugin contract **v1.0** (`docs/specs/subject-plugin-contract.md`) +- **Source commit:** `ed81b87` (2026-07-11) +- **Cited on:** 2026-07-11 +- **Cited by:** french-cli (task t4 of the learn uplift) + +## Re-sync procedure + +The contract is authoritative for a `major.minor` version. When learn-cli bumps +it, re-sync the citation deterministically: + +```bash +# From the french-cli repo root, with a learn-cli checkout at ../learn-cli: +rm -rf french/contract_cite/schemas +cp -R ../learn-cli/learn/contract/schemas french/contract_cite/schemas +cp ../learn-cli/learn/contract/_validate.py french/contract_cite/_validate.py + +# Re-apply the two headers (this doc + the _validate.py provenance stamp) and +# bump the "source commit" / "cited on" lines. The __init__.py is adapted, not +# copied — reconcile its API by hand if the upstream API changed. +``` + +`french doctor`'s `contract-schemas-pinned` check verifies the cited schema set +still matches `SCHEMA_NAMES`, and `tests/test_contract_conformance.py` fails the +build if a payload drifts out of the pinned version — so a stale or partial +re-sync is caught in CI, not at a learner's runtime. + +## What is *not* cited + +learn-cli's registry, conformance-gate runner, motivation layer (scores / +streaks / review queues), and cross-subject learner profile stay in learn-cli. +french-cli reports **raw observations only** (`result`, optional +`correct`/`total`, `duration_seconds`) and never computes a score — the +`record` ack's `recorded` object structurally forbids `score`/`grade`/`points`. diff --git a/docs/stories.md b/docs/stories.md new file mode 100644 index 0000000..425e62e --- /dev/null +++ b/docs/stories.md @@ -0,0 +1,167 @@ +# The French graded-reader story ladder + +`content/stories/*.json` is french-cli's committed story content: short, +self-contained graded readers a learner reads through the tutor's `story` +verbs. Each file is one story, validated against the shared story schema that +`learn-cli` ships at `learn/contract/schemas/story.json` (contract version +`1.0`) — the same schema culture-guide's scenario stories validate against, so +a `story read` payload can never drift from what the CLI, the MCP tool, and the +web page all show. + +This document is the reproducible pipeline: how the ladder was produced, how +to validate a story, how to add another one, and the checklist a human +reviews before a story merges. + +## The ladder + +11 stories across the three schema-level rungs (`beginner` / `intermediate` / +`advanced`), with a CEFR `level_detail` tag for finer grading: + +| id | level | level_detail | title | words | +|----|-------|---------------|-------|------:| +| `fr-a1-le-marche` | beginner | A1 | Le marché du mercredi | 169 | +| `fr-a1-la-rentree` | beginner | A1 | La rentrée de Nina | 175 | +| `fr-a2-le-cafe-du-coin` | beginner | A2 | Un café entre amis | 211 | +| `fr-a2-le-weekend-a-la-montagne` | beginner | A2 | Un week-end à la montagne | 217 | +| `fr-b1-la-recherche-appartement` | intermediate | B1 | La recherche d'appartement | 244 | +| `fr-b1-le-covoiturage` | intermediate | B1 | Le covoiturage | 232 | +| `fr-b1-la-fete-des-voisins` | intermediate | B1 | La fête des voisins | 266 | +| `fr-b1-entretien-embauche` | intermediate | B1 | Un entretien d'embauche | 262 | +| `fr-b2-le-teletravail` | advanced | B2 | Le télétravail, une révolution silencieuse | 277 | +| `fr-b2-la-transition-ecologique` | advanced | B2 | La transition écologique, vue d'un petit village | 312 | +| `fr-c1-le-vieux-libraire` | advanced | C1 | Le vieux libraire | 368 | + +Word count scales with level (150-220 for A1/A2, 230-270 for B1, 275-370 for +B2/C1), each story carries 3-5 glossary entries per 100 words of body text, +and 4 comprehension exercises mixing `multiple_choice` with an +open-ended type (`open`, `discussion`, or `short_answer`) plus one of +`true_false`/`cloze` — never comprehension-by-recognition alone. + +## Filename / id convention + +The directory is **flat**: `content/stories/.json`, where `` is also +the story's `id` field (so a broken symlink between filename and content is +structurally impossible — a glob test can diff the two). Ids follow +`fr--`, e.g. `fr-b1-le-covoiturage.json` holds +`"id": "fr-b1-le-covoiturage"`. Exercise ids extend the story id +(`fr-b1-le-covoiturage-q1`); `item_id` — the curriculum item an exercise +evidences, per the schema — is namespaced `fr.story..` (e.g. +`fr.story.fr-b1-le-covoiturage.1`) so results recorded against one story's +exercises never collide with another's in a learner's cross-story progress. + +## Reproducible pipeline + +### Batch drafting with cloudai-cli — pending availability + +The intended pipeline is a `cloudai-cli` batch draft pass, one invocation per +target `(level_detail, theme)` pair, followed by the human-review checklist +below before any draft is committed: + +```bash +# INTENDED command — cloudai-cli was not installed/reachable in this +# environment (`command -v cloudai` failed), so this batch step is pending +# cloudai availability, not yet exercised. Batch drafts still land as +# candidate JSON that a human reviews and edits before commit — cloudai only +# accelerates the first draft, it does not replace review. +cloudai run \ + --task "Write a French graded-reader short story matching learn-cli's story \ +schema (schema_version 1.0, kind story, subject french). Level: \ +(). Theme: . 150-400 words scaling with level, everyday \ +or literary French matching the level, self-contained, no copyrighted text. \ +Emit valid JSON: id, subject, title, level, level_detail, language=fr, \ +summary, body, glossary (3-5 entries per 100 words, term+definition+optional \ +note), exercises (>=3, mixing multiple_choice with an open-ended type, \ +item_id namespaced fr.story..), audio: null, source.generator." \ + --output content/stories/.json +``` + +Until `cloudai` is on `PATH` in an authoring environment, every story in this +ladder was **hand-authored directly to the target JSON shape** (`generator: +"hand-authored"` in each file's `source` block), following the same schema +constraints the prompt above encodes. When `cloudai` becomes available, run it +per new story, then apply the same review checklist — a cloudai draft is a +first draft, not a merge-ready file. + +### Validating a story + +french-cli has no runtime dependency on `learn-cli`'s validator (the contract +schema lives there, read-only, as the cross-subject reference); a story is +validated by calling the validator from a `learn-cli` checkout during +authoring, and again by `learn-cli`'s own CI (a glob test over every subject +repo's `content/stories/*.json`) after this repo merges: + +```bash +cd +uv run python -c " +import json, sys +from learn.contract import validate + +path = sys.argv[1] +data = json.load(open(path)) +errors = validate(data, 'story') +print('OK' if not errors else '\n'.join(errors)) +" +``` + +`validate(instance, schema_name)` returns an empty list on success; every +story in this ladder currently returns `[]`. + +### How to add a story + +1. Pick a level (`beginner`/`intermediate`/`advanced`) and a CEFR + `level_detail` (`A1`-`C1`+) that keeps the ladder's rungs populated — + don't stack every new story onto one rung. +2. Draft via `cloudai` (once available) or hand-author directly to the shape + above; target word count for the chosen level (150-220 beginner, 230-270 + intermediate, 275-370+ advanced) and 3-5 glossary entries per 100 words. +3. Write >=3 comprehension exercises tied to specifics in the body (not + generic questions answerable without reading), mixing `multiple_choice` + with at least one open-ended type; namespace `item_id` as + `fr.story..`. +4. Save as `content/stories/.json`, filename matching `id` exactly. +5. Run the validation snippet above against a `learn-cli` checkout. +6. Run the human-review checklist below before committing. +7. `markdownlint-cli2 "**/*.md" "#node_modules" "#.claude/skills"` still needs + to pass if this file changed. + +## Human-review checklist + +A human (the operator) reviews samples at merge, not just the schema +validator. Check, per story: + +- **Level-appropriateness** — does the grammar, tense range, and vocabulary + actually match the CEFR tag? A1 stays in the present tense with short + sentences; B1 can carry passé composé, imparfait, and simple subordinate + clauses; B2/C1 can carry subjunctive, idiomatic phrasing, and longer + sentences without becoming impenetrable. A story that reads harder or + easier than its `level_detail` needs the tag or the text fixed. +- **Glossary accuracy** — does each `term` actually appear in the `body` in + that form (or an obviously inflected form), is the `definition` correct and + concise, and does an optional `note` add real value (grammar point, + cultural aside) rather than repeat the definition? Flag any glossary entry + for a word most learners at that level would already know — it's noise. +- **Exercise answerability** — can every exercise actually be answered from + the body alone, without outside knowledge? For `multiple_choice`, are the + distractors plausible but unambiguously wrong, and does exactly one + `choices` entry match `answer` verbatim? For open/`discussion`/`short_answer` + items, does the `rubric` state what passes vs. partial in a way a grader + (human or agent) could apply consistently? +- **Cultural naturalness** — would a native speaker recognize the scene, + dialogue, and register as authentic rather than a translated fixture (stiff + phrasing, non-idiomatic word order, foreign-holiday assumptions)? Dialogue + should sound like something a person would actually say out loud. +- **Originality** — the story must be original prose, not adapted from a + copyrighted source (published graded readers, textbooks, song lyrics, + etc.). + +## Schema reference + +The schema this ladder validates against lives at +`learn/contract/schemas/story.json` in the `learn-cli` repo (read-only from +french-cli's perspective — french-cli does not vendor or copy it). Required +fields: `schema_version` (`"1.0"`), `kind` (`"story"`), `id`, `subject`, +`title`, `level`, `body`, `glossary`, `exercises`. `level_detail`, `language`, +`summary`, `audio`, and `source` are optional but used by every story in this +ladder for full downstream fidelity (CEFR tagging, BCP-47 language tag, +catalog teaser, the reserved audio slot set to `null`, and generation +provenance). diff --git a/french/cli/__init__.py b/french/cli/__init__.py index 944dacf..283b6bd 100644 --- a/french/cli/__init__.py +++ b/french/cli/__init__.py @@ -62,11 +62,17 @@ def _argv_has_json(argv: list[str] | None) -> bool: def _build_parser() -> argparse.ArgumentParser: + from french.cli._commands import advice as _advice_cmd from french.cli._commands import cli as _cli_group from french.cli._commands import doctor as _doctor_cmd from french.cli._commands import explain as _explain_cmd from french.cli._commands import learn as _learn_cmd + from french.cli._commands import lesson as _lesson_cmd from french.cli._commands import overview as _overview_cmd + from french.cli._commands import practice as _practice_cmd + from french.cli._commands import progress as _progress_cmd + from french.cli._commands import record as _record_cmd + from french.cli._commands import story as _story_cmd from french.cli._commands import whoami as _whoami_cmd # prog is the installed console script (`french`), not the distribution @@ -85,15 +91,20 @@ def _build_parser() -> argparse.ArgumentParser: # through _CliArgumentParser too. sub = parser.add_subparsers(dest="command", parser_class=_CliArgumentParser) + # Agent-first introspection verbs. _whoami_cmd.register(sub) _learn_cmd.register(sub) _explain_cmd.register(sub) + _cli_group.register(sub) + # The eight subject-plugin (tutor) verbs, all top-level. _overview_cmd.register(sub) + _progress_cmd.register(sub) + _advice_cmd.register(sub) + _story_cmd.register(sub) + _lesson_cmd.register(sub) + _practice_cmd.register(sub) + _record_cmd.register(sub) _doctor_cmd.register(sub) - _cli_group.register(sub) - # Register your own noun groups here: - # from french.cli._commands import my_noun as _my_noun_group - # _my_noun_group.register(sub) return parser diff --git a/french/cli/_commands/_tutor.py b/french/cli/_commands/_tutor.py new file mode 100644 index 0000000..09b6d86 --- /dev/null +++ b/french/cli/_commands/_tutor.py @@ -0,0 +1,56 @@ +"""Shared helpers for the tutor verbs (learner resolution, state, emit). + +Every learner-scoped tutor verb (``progress``, ``advice``, ``lesson``, +``practice``, ``record``, ``story read``) resolves a learner id, loads that +learner's state (turning a corrupt/future state file into a clean environment +error), and emits a JSON payload on ``--json``. These helpers keep that plumbing +in one place so the command modules stay thin and the contract conventions — +``--learner`` everywhere, errors as ``{code, message, remediation}`` — hold +uniformly. +""" + +from __future__ import annotations + +import argparse +from typing import Any + +from french.cli._errors import EXIT_ENV_ERROR, CliError +from french.cli._output import emit_result +from french.tutor import state + + +def add_json(p: argparse.ArgumentParser) -> None: + p.add_argument("--json", action="store_true", help="Emit structured JSON.") + + +def add_learner(p: argparse.ArgumentParser) -> None: + p.add_argument( + "--learner", + help="Learner id (human or agent). Default: $FRENCH_CLI_LEARNER or the OS user.", + ) + + +def resolve_learner(args: argparse.Namespace) -> str: + return state.resolve_learner(getattr(args, "learner", None)) + + +def load_state(learner: str) -> dict[str, Any]: + """Load a learner's state, mapping a bad state file to an env error.""" + try: + return state.load(learner) + except state.StateError as exc: + raise CliError( + code=EXIT_ENV_ERROR, + message=str(exc), + remediation="fix or remove the state file under $FRENCH_CLI_LEARN_HOME " + "(or $XDG_DATA_HOME/french_cli/learn)", + ) from exc + + +def emit(payload: dict[str, Any], args: argparse.Namespace, *, text: str | None = None) -> int: + """Emit ``payload`` as JSON, or ``text`` (falling back to the payload) in text mode.""" + if getattr(args, "json", False): + emit_result(payload, json_mode=True) + else: + emit_result(text if text is not None else payload, json_mode=False) + return 0 diff --git a/french/cli/_commands/advice.py b/french/cli/_commands/advice.py new file mode 100644 index 0000000..365580c --- /dev/null +++ b/french/cli/_commands/advice.py @@ -0,0 +1,46 @@ +"""``french advice`` — deterministic study advice from stored state. + +Read-only. Emits the contract ``advice`` payload: what to shore up and why, each +entry with a runnable command. A pure function of the learner's mastery/history +(the "tutor's opinion" a driver can relay verbatim); may be empty for a +brand-new learner — here we seed a single "start here" entry instead. +""" + +from __future__ import annotations + +import argparse + +from french.cli._commands import _tutor +from french.tutor import engine + + +def cmd_advice(args: argparse.Namespace) -> int: + learner = _tutor.resolve_learner(args) + st = _tutor.load_state(learner) + payload = engine.advice_payload(st, learner) + return _tutor.emit(payload, args, text=_render(payload)) + + +def _render(payload: dict) -> str: + lines = [f"# Advice — {payload['learner']} ({payload['subject']})", ""] + if not payload["advice"]: + lines.append("(no advice yet — start a lesson)") + return "\n".join(lines) + for entry in payload["advice"]: + focus = entry.get("focus") or "general" + lines.append(f"- [{focus}] {entry['suggestion']}") + if entry.get("reason"): + lines.append(f" why: {entry['reason']}") + if entry.get("command"): + lines.append(f" -> {entry['command']}") + return "\n".join(lines) + + +def register(sub: argparse._SubParsersAction) -> None: + p = sub.add_parser( + "advice", + help="Deterministic study advice from stored state (contract: advice).", + ) + _tutor.add_json(p) + _tutor.add_learner(p) + p.set_defaults(func=cmd_advice, json=False) diff --git a/french/cli/_commands/doctor.py b/french/cli/_commands/doctor.py index 9eb07b9..9dbd5c9 100644 --- a/french/cli/_commands/doctor.py +++ b/french/cli/_commands/doctor.py @@ -1,28 +1,27 @@ -"""``french doctor`` — check the agent-identity invariants. - -Mirrors the two invariants ``steward doctor`` verifies for a mesh agent: - -* **prompt-file-present** — the repo declares an agent in ``culture.yaml`` and - has the matching prompt file on disk; -* **backend-consistency** — the declared ``backend`` matches the prompt file - (``claude`` → ``CLAUDE.md``, ``colleague`` → ``AGENTS.colleague.md``, - ``acp`` → ``AGENTS.md``, ``gemini`` → ``GEMINI.md``). - -Plus a **skills-present** check (the vendored ``.claude/skills/`` kit). Read-only. - -Reports the rubric-shaped contract -``{healthy, checks: [{id, passed, severity, message, remediation}]}`` so the -agent-first rubric's bundle 7 passes. When run from a wheel install (no -``culture.yaml`` alongside the package), it reports a single info check and -exits 0 — there is nothing to diagnose. +"""``french doctor`` — self-check + contract pin (contract: subject_doctor). + +Reconciled surface: keeps the mesh agent-identity checks the template chassis +verified (``prompt-file-present`` / ``backend-consistency`` and ``skills-present``, +mirroring ``steward doctor``) **and** adds the subject-plugin contract's checks +and pin. The JSON payload carries the contract-required fields +(``schema_version``, ``kind: subject_doctor``, ``subject``, ``contract_version``) +alongside the ``healthy`` + ``checks`` shape the agent-first rubric asserts. + +``learn subject doctor`` (learn-cli's conformance gate) reads ``contract_version`` +first, then validates the other seven verbs against that version's schemas. Exit +0 when healthy, 2 when not (an error-severity check failed). """ from __future__ import annotations import argparse +import os +import tempfile from french.cli._commands.whoami import find_culture_yaml, read_agent_fields from french.cli._output import emit_result +from french.contract_cite import CONTRACT_VERSION, SCHEMA_NAMES, list_schemas +from french.tutor import state, stories, subject # backend → required prompt file (the backend-consistency mapping). _PROMPT_FILE = { @@ -33,29 +32,27 @@ } -def _diagnose() -> dict[str, object]: +def _identity_checks() -> list[dict[str, object]]: + """The mesh agent-identity checks (steward doctor's invariants).""" cfg = find_culture_yaml() if cfg is None: - check = { - "id": "source_checkout", - "passed": True, - "severity": "info", - "message": "no culture.yaml found alongside the package; identity checks skipped", - "remediation": "", - } - return {"healthy": True, "checks": [check]} - + return [ + { + "id": "source-checkout", + "passed": True, + "severity": "info", + "message": "no culture.yaml found alongside the package; identity checks skipped", + "remediation": "", + } + ] root = cfg.parent - fields = read_agent_fields() - backend = fields["backend"] + backend = read_agent_fields()["backend"] checks: list[dict[str, object]] = [] - - # 1. backend-consistency: the prompt file for the declared backend exists. expected = _PROMPT_FILE.get(backend) if expected is None: checks.append( { - "id": "backend_consistency", + "id": "backend-consistency", "passed": False, "severity": "error", "message": f"unknown backend '{backend}' in culture.yaml", @@ -66,59 +63,145 @@ def _diagnose() -> dict[str, object]: present = (root / expected).is_file() checks.append( { - "id": "prompt_file_present", + "id": "prompt-file-present", "passed": present, "severity": "error", - "message": ( - f"backend '{backend}' requires {expected} — " - + ("present" if present else "missing") - ), + "message": f"backend '{backend}' requires {expected} — " + + ("present" if present else "missing"), "remediation": "" if present else f"create {expected} at the repo root", } ) - - # 2. skills-present: the vendored skill kit is on disk. skills_dir = root / ".claude" / "skills" has_skills = skills_dir.is_dir() and any(skills_dir.iterdir()) checks.append( { - "id": "skills_present", + "id": "skills-present", "passed": has_skills, "severity": "warning", - "message": ( - ".claude/skills/ vendored" if has_skills else ".claude/skills/ missing or empty" - ), - "remediation": ( - "" if has_skills else "vendor the skill kit (see docs/skill-sources.md)" - ), + "message": ".claude/skills/ vendored" if has_skills else ".claude/skills/ missing", + "remediation": "" if has_skills else "vendor the skill kit (see docs/skill-sources.md)", } ) + return checks + - healthy = all(c["passed"] for c in checks) +def _content_check() -> dict[str, object]: + """content-store-present — story files load and validate against story.json.""" + files = stories.story_files() + if not files: + return { + "id": "content-store-present", + "passed": True, + "severity": "warning", + "message": "no story files found under content/stories/ yet", + "remediation": "add graded stories at content/stories/*.json", + } + invalid: list[str] = [] + for path in files: + try: + story = stories.load_story(path) + except Exception: # noqa: BLE001 - any parse failure is an invalid file + invalid.append(path.name) + continue + if stories.validate_story(story) or path.stem != story.get("id"): + invalid.append(path.name) + passed = not invalid + return { + "id": "content-store-present", + "passed": passed, + "severity": "error", + "message": ( + f"{len(files)} story file(s) load and validate" + if passed + else f"invalid story file(s): {', '.join(invalid)}" + ), + "remediation": ( + "" + if passed + else "each story must validate against story.json and have " "filename == id" + ), + } + + +def _state_writable_check() -> dict[str, object]: + """learner-state-writable — the XDG state dir accepts an atomic write.""" + target = state.state_dir() + try: + target.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(target), prefix=".doctor-", suffix=".tmp") + os.close(fd) + os.unlink(tmp) + passed = True + message = f"learner-state dir is writable ({target})" + remediation = "" + except OSError as exc: + passed = False + message = f"learner-state dir is not writable ({target}): {exc}" + remediation = "set $FRENCH_CLI_LEARN_HOME to a writable directory" + return { + "id": "learner-state-writable", + "passed": passed, + "severity": "error", + "message": message, + "remediation": remediation, + } + + +def _contract_pin_check() -> dict[str, object]: + """contract-schemas-pinned — the cited schema set matches the pinned version.""" + on_disk = sorted(list_schemas()) + passed = on_disk == sorted(SCHEMA_NAMES) + return { + "id": "contract-schemas-pinned", + "passed": passed, + "severity": "warning", + "message": ( + f"subject pins contract {CONTRACT_VERSION} ({len(on_disk)} schemas cited)" + if passed + else "cited schema set does not match the pinned contract" + ), + "remediation": "" if passed else "re-sync french/contract_cite from learn-cli", + } + + +def _diagnose() -> dict[str, object]: + checks = _identity_checks() + checks += [_content_check(), _state_writable_check(), _contract_pin_check()] + # healthy = no error-severity check failed (contract rule). + healthy = not any(c["severity"] == "error" and not c["passed"] for c in checks) return {"healthy": healthy, "checks": checks} def cmd_doctor(args: argparse.Namespace) -> int: report = _diagnose() + payload = { + "schema_version": CONTRACT_VERSION, + "kind": "subject_doctor", + "subject": subject.SUBJECT_ID, + "contract_version": CONTRACT_VERSION, + "healthy": report["healthy"], + "checks": report["checks"], + } json_mode = bool(getattr(args, "json", False)) if json_mode: - emit_result(report, json_mode=True) + emit_result(payload, json_mode=True) else: - status = "healthy" if report["healthy"] else "unhealthy" - lines = [f"french-cli doctor: {status}", ""] - for check in report["checks"]: + status = "healthy" if payload["healthy"] else "unhealthy" + lines = [f"french-cli doctor: {status} (contract {CONTRACT_VERSION})", ""] + for check in payload["checks"]: mark = "ok" if check["passed"] else "FAIL" lines.append(f"[{mark}] {check['id']}: {check['message']}") if not check["passed"] and check["remediation"]: lines.append(f" hint: {check['remediation']}") emit_result("\n".join(lines), json_mode=False) - return 0 if report["healthy"] else 1 + # Contract: exit 0 healthy, 2 (environment error) when not. + return 0 if payload["healthy"] else 2 def register(sub: argparse._SubParsersAction) -> None: p = sub.add_parser( "doctor", - help="Check the agent-identity invariants (prompt-file-present, backend-consistency).", + help="Self-check + contract pin (identity, content, state, schemas).", ) p.add_argument("--json", action="store_true", help="Emit structured JSON.") p.set_defaults(func=cmd_doctor) diff --git a/french/cli/_commands/learn.py b/french/cli/_commands/learn.py index f40673a..4c8516e 100644 --- a/french/cli/_commands/learn.py +++ b/french/cli/_commands/learn.py @@ -1,7 +1,9 @@ """``french learn`` — the learnability affordance. -Prints a structured self-teaching prompt. Must satisfy the agent-first rubric: ->=200 chars and mention purpose, command map, exit codes, --json, and explain. +Prints a structured self-teaching prompt for an agent operating the CLI. Must +satisfy the agent-first rubric: >=200 chars and mention purpose, command map, +exit codes, --json, and explain. Also the front door to the tutor surface: it +documents the eight subject-plugin verbs the driver drives. """ from __future__ import annotations @@ -16,20 +18,38 @@ Purpose ------- -Track progress, get an overview, get advice, read stories, and learn & practice -French (written and spoken) online from your phone. Built on an agent-first CLI -(cited from the teken `python-cli` reference) with a Culture mesh identity -(culture.yaml + AGENTS.colleague.md) and the guildmaster skill kit under -.claude/skills/. - -Commands --------- - french whoami Identity from culture.yaml. - french learn This self-teaching prompt. - french explain ... Markdown docs for any noun/verb path. - french overview Descriptive snapshot of the agent. - french doctor Check the agent-identity invariants. - french cli overview Describe the CLI surface itself. +An LLM-free tutor engine that implements the learn subject-plugin contract: +french owns the committed French content (stories, lessons, exercises) and each +learner's mastery state, resolves what to teach next, and emits structured +teaching DIRECTIVES. You — the driving agent — do the conversational tutoring +the directive describes (present, explain, quiz), grade pass|partial|fail, and +write the result back with `record`. french never converses, grades free text, +or computes scores; learn-cli's motivation layer does the scoring. + +Commands — tutor verbs (the contract surface) +--------------------------------------------- + french overview Subject self-description: modules + content counts. + french progress The learner's mastery, counters, and next step. + french advice Deterministic study advice from stored state. + french story list|read Graded stories + a reading directive. + french lesson start|next|repeat Teaching directives from the curriculum. + french practice [] A batch of exercises to run (no scope = review). + french record --item --result pass|partial|fail Write back one outcome. + french doctor Self-check + the pinned contract version. + +Agent-first verbs +----------------- + french whoami Identity from culture.yaml. + french learn This self-teaching prompt. + french explain Markdown docs for any noun/verb path. + french cli overview Describe the CLI surface itself. + +Learner + state +--------------- +Pass --learner on learner-scoped verbs (default: $FRENCH_CLI_LEARNER or the +OS user; the resolved id is echoed back). State persists per learner under +$FRENCH_CLI_LEARN_HOME (or $XDG_DATA_HOME/french_cli/learn) and resumes across +sessions. Machine-readable output ----------------------- @@ -39,13 +59,14 @@ Exit-code policy ---------------- 0 success - 1 user-input error (bad flag, bad path, missing arg) - 2 environment / setup error + 1 user-input error (bad flag, unknown item/story, missing arg) + 2 environment / setup error (unwritable state, unhealthy doctor) 3+ reserved More detail ----------- french explain french + french explain lesson """ @@ -55,14 +76,24 @@ def _as_json_payload() -> dict[str, object]: "tool": "french-cli", "command": "french", "version": __version__, - "purpose": "A private French tutor: track progress, read stories, and " - "practice French written and spoken.", + "purpose": "An LLM-free French tutor implementing the learn subject-plugin " + "contract: it emits teaching directives and records graded outcomes; the " + "driving agent does the tutoring.", "commands": [ + {"path": ["overview"], "summary": "Subject self-description (subject_overview)."}, + {"path": ["progress"], "summary": "Learner mastery, counters, next step."}, + {"path": ["advice"], "summary": "Deterministic study advice from state."}, + {"path": ["story", "list"], "summary": "Story summaries for the catalog."}, + {"path": ["story", "read"], "summary": "One story + a reading directive."}, + {"path": ["lesson", "start"], "summary": "A lesson directive (start)."}, + {"path": ["lesson", "next"], "summary": "Continue from mastery state."}, + {"path": ["lesson", "repeat"], "summary": "Re-issue a lesson; --harder raises it."}, + {"path": ["practice"], "summary": "A batch of exercises to run."}, + {"path": ["record"], "summary": "Write back a graded outcome; updates mastery."}, + {"path": ["doctor"], "summary": "Self-check + contract pin."}, {"path": ["whoami"], "summary": "Identity probe from culture.yaml."}, - {"path": ["learn"], "summary": "Self-teaching prompt."}, + {"path": ["learn"], "summary": "This self-teaching prompt."}, {"path": ["explain"], "summary": "Markdown docs by path."}, - {"path": ["overview"], "summary": "Descriptive snapshot of the agent."}, - {"path": ["doctor"], "summary": "Check the agent-identity invariants."}, {"path": ["cli", "overview"], "summary": "Describe the CLI surface."}, ], "exit_codes": { diff --git a/french/cli/_commands/lesson.py b/french/cli/_commands/lesson.py new file mode 100644 index 0000000..0af8cb6 --- /dev/null +++ b/french/cli/_commands/lesson.py @@ -0,0 +1,157 @@ +"""``french lesson`` — start / next / repeat. + +The subject resolves *what* to teach; the directive tells the driver *how*: + +* ``lesson start []`` — a specific lesson (by lesson id, module id, or + item id); first exposure lifts its items to ``introduced``. +* ``lesson next`` — continue from mastery state (the first not-yet-mastered + item's lesson). +* ``lesson repeat [] [--harder]`` — re-issue a lesson; ``--harder`` bumps its + integer difficulty rung (the never-ending-progression hook). + +Learner-scoped; ``start``/``next`` set the current position and lift items. +Bare ``french lesson`` defaults to ``lesson next``. +""" + +from __future__ import annotations + +import argparse + +from french.cli._commands import _tutor +from french.cli._errors import EXIT_USER_ERROR, CliError +from french.tutor import curriculum, engine, state + + +def _resolve_target(token: str) -> curriculum.LessonTarget: + try: + return curriculum.resolve_lesson_target(token) + except KeyError as exc: + raise CliError( + code=EXIT_USER_ERROR, + message=f"no lesson matches '{token}'", + remediation="valid targets: " + ", ".join(curriculum.valid_lesson_targets()), + ) from exc + + +def _lift_and_position(st: dict, target: curriculum.LessonTarget) -> None: + for item in target.lesson.items: + state.touch_item(st, item.id) + first = target.lesson.items[0] + state.set_current(st, target.module.id, first.id) + + +def cmd_lesson_start(args: argparse.Namespace) -> int: + learner = _tutor.resolve_learner(args) + st = _tutor.load_state(learner) + if args.target: + target = _resolve_target(args.target) + else: + target = _next_target(st) + _lift_and_position(st, target) + state.save(st) + payload = engine.lesson_payload( + learner, mode="start", module=target.module, lesson=target.lesson, difficulty=1 + ) + return _tutor.emit(payload, args, text=_render(payload)) + + +def cmd_lesson_next(args: argparse.Namespace) -> int: + learner = _tutor.resolve_learner(args) + st = _tutor.load_state(learner) + target = _next_target(st) + _lift_and_position(st, target) + state.save(st) + payload = engine.lesson_payload( + learner, mode="next", module=target.module, lesson=target.lesson, difficulty=1 + ) + return _tutor.emit(payload, args, text=_render(payload)) + + +def cmd_lesson_repeat(args: argparse.Namespace) -> int: + learner = _tutor.resolve_learner(args) + st = _tutor.load_state(learner) + if args.lesson_id: + target = _resolve_target(args.lesson_id) + else: + # Repeat the learner's current lesson, or the next one if none set. + target = _current_target(st) or _next_target(st) + if args.harder: + difficulty = state.bump_repeat(st, target.lesson.id) + else: + difficulty = state.repeat_difficulty(st, target.lesson.id) + state.save(st) + payload = engine.lesson_payload( + learner, mode="repeat", module=target.module, lesson=target.lesson, difficulty=difficulty + ) + return _tutor.emit(payload, args, text=_render(payload)) + + +def _next_target(st: dict) -> curriculum.LessonTarget: + nxt = engine.recommend_next(st) + token = nxt.get("item_id") or nxt.get("module_id") or "" + if token: + try: + return curriculum.resolve_lesson_target(token) + except KeyError: + pass + # done / no token → fall back to the first lesson (maintenance repeat). + module = curriculum.MODULES[0] + return curriculum.LessonTarget(module=module, lesson=module.lessons[0]) + + +def _current_target(st: dict) -> curriculum.LessonTarget | None: + current = st.get("current") or {} + item_id = current.get("item_id") + if not item_id: + return None + hit = curriculum.find_item(item_id) + if hit is None: + return None + return curriculum.LessonTarget(module=hit[0], lesson=hit[1]) + + +def _render(payload: dict) -> str: + lesson = payload["lesson"] + lines = [ + f"# Lesson [{payload['mode']}] — {lesson['title']} (rung {lesson['difficulty']})", + "", + ] + for item in lesson["items"]: + lines.append(f"## {item['label']} [{item['id']}]") + lines += [f"- {pt}" for pt in item.get("points", [])] + lines.append("") + lines.append("## Directive") + lines += [f"- {step}" for step in payload["directive"]["instructions"]] + return "\n".join(lines).rstrip() + + +def register(sub: argparse._SubParsersAction) -> None: + p = sub.add_parser( + "lesson", + help="Start / continue / repeat a lesson (contract: lesson_directive).", + ) + _tutor.add_json(p) + _tutor.add_learner(p) + # Bare `french lesson` continues from mastery state. + p.set_defaults(func=cmd_lesson_next, json=False) + noun = p.add_subparsers(dest="lesson_command", parser_class=type(p)) + + st = noun.add_parser( + "start", help="Start a lesson (by lesson/module/item id, or the next one)." + ) + st.add_argument("target", nargs="?", help="A lesson id, module id, or item id.") + _tutor.add_json(st) + _tutor.add_learner(st) + st.set_defaults(func=cmd_lesson_start) + + nx = noun.add_parser("next", help="Continue from mastery state.") + _tutor.add_json(nx) + _tutor.add_learner(nx) + nx.set_defaults(func=cmd_lesson_next) + + rp = noun.add_parser("repeat", help="Re-issue a completed lesson; --harder raises the rung.") + rp.add_argument("lesson_id", nargs="?", help="Lesson to repeat (default: current/next).") + rp.add_argument("--harder", action="store_true", help="Increment the difficulty rung.") + _tutor.add_json(rp) + _tutor.add_learner(rp) + rp.set_defaults(func=cmd_lesson_repeat) diff --git a/french/cli/_commands/overview.py b/french/cli/_commands/overview.py index a82c6fa..034feb5 100644 --- a/french/cli/_commands/overview.py +++ b/french/cli/_commands/overview.py @@ -1,13 +1,16 @@ -"""``french overview`` — read-only descriptive snapshot of the agent. - -Describes the agent to an agent reader: identity (from culture.yaml), the verb -surface, and the sibling-pattern artifacts this template carries. The shared -section/render helpers here are reused by the ``cli`` noun's ``overview`` (see -:mod:`french.cli._commands.cli`). - -Descriptive verbs never hard-fail on a missing target path — an optional -positional ``target`` is accepted and ignored (overview describes this agent, -not an external target), so ``overview `` still exits 0. +"""``french overview`` — the subject's self-description (contract: subject_overview). + +Reconciled surface: this verb is both the agent-first descriptive snapshot *and* +the contract's ``overview`` payload. Because contract payloads are open, the +JSON output carries the contract-required fields (``schema_version``, ``kind: +subject_overview``, ``subject``, ``display_name``, ``description``, ``modules``, +``content``) **and** keeps the ``sections`` key the agent-first rubric checks — +one payload satisfying both. + +Learner-independent and side-effect free, so the static web face can build one +sub-page per module straight from it. Descriptive verbs never hard-fail on a +missing target path: an optional ``target`` positional is accepted and ignored, +so ``overview `` still exits 0. """ from __future__ import annotations @@ -16,38 +19,51 @@ from french.cli._commands.whoami import report from french.cli._output import emit_result - -_ARTIFACTS = [ - "culture.yaml + AGENTS.colleague.md — mesh identity (suffix + backend)", - ".claude/skills/ — the canonical guildmaster skill kit (cite-don't-import)", - "docs/skill-sources.md — skill provenance ledger", - "pyproject.toml + .github/workflows/ — buildable, deployable package baseline", -] +from french.tutor import curriculum, engine, stories _VERBS = [ - "whoami — identity probe (nick, version, backend, model)", - "learn — structured self-teaching prompt", - "explain — markdown docs for a topic", - "overview — this descriptive snapshot", - "doctor — check the agent-identity invariants", + "overview — this subject self-description (contract: subject_overview)", + "progress — the learner's mastery, counters, and next step", + "advice — deterministic study advice from stored state", + "story list|read — graded stories + reading directive", + "lesson start|next|repeat — teaching directives from the curriculum", + "practice [] — a batch of exercises to run", + "record — write back a graded outcome; updates mastery", + "doctor — self-check + contract pin", + "whoami / learn / explain / cli overview — agent-first introspection", ] +def _module_items() -> list[str]: + return [f"{m.id} — {m.title} ({m.level})" for m in curriculum.MODULES] + + def agent_sections() -> list[dict[str, object]]: - """Sections describing the agent (used by the global verb).""" + """Sections describing the subject + agent (rubric `sections` + text render).""" ident = report() + counts = curriculum.counts() return [ { "title": "Identity", "items": [ + f"subject: {engine.subject.SUBJECT_ID}", f"nick: {ident['nick']}", f"version: {ident['version']}", f"backend: {ident['backend']}", f"model: {ident['model']}", ], }, + {"title": "Modules", "items": _module_items()}, + { + "title": "Content", + "items": [ + f"stories: {stories.story_count()}", + f"lessons: {counts['lessons']}", + f"items: {counts['items']}", + f"exercises: {counts['exercises']}", + ], + }, {"title": "Verbs", "items": list(_VERBS)}, - {"title": "Sibling-pattern artifacts", "items": list(_ARTIFACTS)}, ] @@ -88,24 +104,29 @@ def emit_overview(subject: str, sections: list[dict[str, object]], *, json_mode: def cmd_overview(args: argparse.Namespace) -> int: # `target` is accepted for rubric compatibility (descriptive verbs must not - # hard-fail on a missing path) but overview describes this agent itself. - emit_overview( - "french-cli", - agent_sections(), - json_mode=bool(getattr(args, "json", False)), - ) + # hard-fail on a missing path) but overview describes this subject itself. + json_mode = bool(getattr(args, "json", False)) + if json_mode: + # Contract subject_overview payload, extended (open payload) with the + # `sections` key the agent-first rubric asserts — one payload, both + # contracts satisfied. + payload = engine.overview_payload() + payload["sections"] = agent_sections() + emit_result(payload, json_mode=True) + else: + emit_result(render_text("french-cli", agent_sections()), json_mode=False) return 0 def register(sub: argparse._SubParsersAction) -> None: p = sub.add_parser( "overview", - help="Read-only descriptive snapshot of the agent (identity, verbs, artifacts).", + help="Subject self-description: modules, content counts, verbs (subject_overview).", ) p.add_argument( "target", nargs="?", - help="Ignored — overview always describes this agent itself. Accepted so a " + help="Ignored — overview always describes this subject itself. Accepted so a " "stray path argument never hard-fails.", ) p.add_argument("--json", action="store_true", help="Emit structured JSON.") diff --git a/french/cli/_commands/practice.py b/french/cli/_commands/practice.py new file mode 100644 index 0000000..648e48d --- /dev/null +++ b/french/cli/_commands/practice.py @@ -0,0 +1,78 @@ +"""``french practice []`` — a batch of exercises to run. + +``scope`` may be an item id, a module id, or a lesson id; with no scope (or +``review``) the subject picks the learner's weakest touched items — its +within-subject review mode. Read-only (grading and write-back happen via +``record``). Emits the contract ``practice_directive`` payload. +""" + +from __future__ import annotations + +import argparse + +from french.cli._commands import _tutor +from french.cli._errors import EXIT_USER_ERROR, CliError +from french.tutor import curriculum, engine + +_REVIEW_LIMIT = 6 + + +def cmd_practice(args: argparse.Namespace) -> int: + learner = _tutor.resolve_learner(args) + st = _tutor.load_state(learner) + scope_arg = args.scope + if scope_arg is None or scope_arg == "review": + resolved_scope, exercises = _review_exercises(st) + else: + hit = curriculum.exercises_for_scope(scope_arg) + if hit is None: + raise CliError( + code=EXIT_USER_ERROR, + message=f"no practice scope matches '{scope_arg}'", + remediation="pass an item id, a module id, a lesson id, or 'review'; " + "see 'french overview --json' for the module/item map", + ) + resolved_scope, exercise_tuple = hit + exercises = [engine._exercise_dict(ex) for ex in exercise_tuple] + payload = engine.practice_payload(learner, scope=resolved_scope, exercises=exercises) + return _tutor.emit(payload, args, text=_render(payload)) + + +def _review_exercises(st: dict) -> tuple[str, list[dict]]: + """Weakest touched items' exercises; fall back to the first item when new.""" + exercises: list[dict] = [] + for weak in engine.weak_items(st): + hit = curriculum.find_item(weak["item_id"]) + if hit is not None: + exercises.extend(engine._exercise_dict(ex) for ex in hit[2].exercises) + if len(exercises) >= _REVIEW_LIMIT: + break + if not exercises: + first = curriculum.all_items()[0][2] + exercises = [engine._exercise_dict(ex) for ex in first.exercises] + return "review", exercises[:_REVIEW_LIMIT] + + +def _render(payload: dict) -> str: + lines = [f"# Practice — {payload['scope']} ({payload['learner']})", ""] + for ex in payload["exercises"]: + lines.append(f"- [{ex['type']}] {ex['prompt']}") + lines += ["", "## Directive"] + lines += [f"- {step}" for step in payload["directive"]["instructions"]] + return "\n".join(lines) + + +def register(sub: argparse._SubParsersAction) -> None: + p = sub.add_parser( + "practice", + help="Get a batch of exercises to run (contract: practice_directive).", + ) + p.add_argument( + "scope", + nargs="?", + default=None, + help="An item id, module id, lesson id, or 'review' (default: weakest touched items).", + ) + _tutor.add_json(p) + _tutor.add_learner(p) + p.set_defaults(func=cmd_practice, json=False) diff --git a/french/cli/_commands/progress.py b/french/cli/_commands/progress.py new file mode 100644 index 0000000..4cad8f0 --- /dev/null +++ b/french/cli/_commands/progress.py @@ -0,0 +1,47 @@ +"""``french progress`` — where the learner stands in this subject. + +Read-only. Emits the contract ``progress`` payload: per-item mastery on the +shared ladder, counters, weak items, and the subject's own within-subject next +recommendation. No LLM, no network — a pure function of stored state. +""" + +from __future__ import annotations + +import argparse + +from french.cli._commands import _tutor +from french.tutor import engine + + +def cmd_progress(args: argparse.Namespace) -> int: + learner = _tutor.resolve_learner(args) + st = _tutor.load_state(learner) + payload = engine.progress_payload(st, learner) + text = _render(payload) + return _tutor.emit(payload, args, text=text) + + +def _render(payload: dict) -> str: + nxt = payload["next"] + lines = [ + f"# Progress — {payload['learner']} ({payload['subject']})", + "", + f"items: mastered {payload['items_mastered']}/{payload['items_total']} " + f"(touched {payload['items_touched']})", + f"next: {nxt['text']}", + f" -> {nxt['command']}", + ] + if payload["weak"]: + lines += ["", "weak items:"] + lines += [f" - {w['item_id']} ({w['mastery']})" for w in payload["weak"]] + return "\n".join(lines) + + +def register(sub: argparse._SubParsersAction) -> None: + p = sub.add_parser( + "progress", + help="Show the learner's mastery, counters, and next step (contract: progress).", + ) + _tutor.add_json(p) + _tutor.add_learner(p) + p.set_defaults(func=cmd_progress, json=False) diff --git a/french/cli/_commands/record.py b/french/cli/_commands/record.py new file mode 100644 index 0000000..cf8fd72 --- /dev/null +++ b/french/cli/_commands/record.py @@ -0,0 +1,101 @@ +"""``french record`` — the driver's write-back (the motivation layer's input). + +The driver reports one graded outcome; the subject appends the raw result to its +history, updates the item's mastery on the ladder (inferred from ``--result`` +unless ``--mastery`` is given; inference never regresses), and acks with the +contract ``record_ack`` payload. The ``recorded`` object is raw observations +only — it structurally forbids score/grade/points; learn-cli computes scores. + + french record --learner ori --item fr.numbers.prix --activity practice \\ + --exercise prix-1 --result pass --correct 1 --total 1 --json +""" + +from __future__ import annotations + +import argparse + +from french.cli._commands import _tutor +from french.cli._errors import EXIT_USER_ERROR, CliError +from french.contract_cite import MASTERY_LEVELS, RESULTS +from french.tutor import engine, state + + +def cmd_record(args: argparse.Namespace) -> int: + learner = _tutor.resolve_learner(args) + st = _tutor.load_state(learner) + try: + recorded = state.record_result( + st, + item_id=args.item, + activity=args.activity, + result=args.result, + exercise_id=args.exercise, + story_id=args.story, + lesson_id=args.lesson_id, + correct=args.correct, + total=args.total, + duration_seconds=args.duration_seconds, + notes=args.notes, + mastery=args.mastery, + ) + except ValueError as exc: + raise CliError( + code=EXIT_USER_ERROR, + message=str(exc), + remediation=f"result in {{{','.join(RESULTS)}}}; " + f"mastery in {{{','.join(MASTERY_LEVELS)}}}", + ) from exc + state.save(st) + level = state.mastery_of(st, args.item) + payload = engine.record_ack_payload( + learner, + recorded=recorded, + item_id=args.item, + level=level, + next_step=engine.recommend_next(st), + ) + text = ( + f"recorded {args.item} -> {args.result} (mastery {level})\n" + f"next: {payload['next']['command']}" + ) + return _tutor.emit(payload, args, text=text) + + +def register(sub: argparse._SubParsersAction) -> None: + p = sub.add_parser( + "record", + help="Write back a graded outcome; updates mastery (contract: record_ack).", + ) + p.add_argument("--item", required=True, help="The curriculum item id this result evidences.") + p.add_argument( + "--result", + required=True, + choices=RESULTS, + help="The graded outcome: pass | partial | fail.", + ) + p.add_argument( + "--activity", + default="practice", + choices=state.ACTIVITIES, + help="Which activity produced the result (default: practice).", + ) + p.add_argument("--exercise", help="The exercise id, when the result is for one exercise.") + p.add_argument("--story", help="The story id, when the result came from a story.") + p.add_argument("--lesson-id", dest="lesson_id", help="The lesson id, for a lesson result.") + p.add_argument("--correct", type=int, help="Raw count of correct responses (optional).") + p.add_argument("--total", type=int, help="Raw count of graded responses (optional, >=1).") + p.add_argument( + "--duration-seconds", + dest="duration_seconds", + type=float, + help="Time spent, in seconds (optional).", + ) + p.add_argument("--notes", help="Free-text notes from the driver (optional).") + p.add_argument( + "--mastery", + choices=MASTERY_LEVELS, + help="Set mastery explicitly (else inferred from --result, never regressing).", + ) + _tutor.add_json(p) + _tutor.add_learner(p) + p.set_defaults(func=cmd_record, json=False) diff --git a/french/cli/_commands/story.py b/french/cli/_commands/story.py new file mode 100644 index 0000000..1e49d4e --- /dev/null +++ b/french/cli/_commands/story.py @@ -0,0 +1,105 @@ +"""``french story`` — the shared content surface (list + read). + +* ``story list [--level ]`` — level-tagged summaries for the catalog + (learner-independent, so the static site can build from it). +* ``story read `` — the full committed story wrapped in a teaching directive + (present paragraph-at-a-time, use the glossary on demand, run the comprehension + exercises, record each result). Learner-scoped: reading updates the learner's + current position. An unknown id exits 1 with the error shape. + +Bare ``french story`` defaults to ``story list``. +""" + +from __future__ import annotations + +import argparse + +from french.cli._commands import _tutor +from french.cli._errors import EXIT_USER_ERROR, CliError +from french.tutor import curriculum, engine, state, stories, subject + + +def cmd_story_list(args: argparse.Namespace) -> int: + level = getattr(args, "level", None) + payload = engine.story_list_payload(level) + return _tutor.emit(payload, args, text=_render_list(payload)) + + +def _render_list(payload: dict) -> str: + lines = [f"# Stories ({payload['subject']})", ""] + if not payload["stories"]: + lines.append("(no stories found)") + return "\n".join(lines) + for s in payload["stories"]: + detail = f" [{s['level_detail']}]" if s.get("level_detail") else "" + lines.append(f"- {s['id']} — {s['title']} ({s['level']}{detail}, {s['exercises']} ex)") + return "\n".join(lines) + + +def cmd_story_read(args: argparse.Namespace) -> int: + story = stories.read_story(args.story_id) + if story is None: + raise CliError( + code=EXIT_USER_ERROR, + message=f"no story matches '{args.story_id}'", + remediation=f"run '{subject.COMMAND} story list --json' to see valid story ids", + ) + learner = _tutor.resolve_learner(args) + st = _tutor.load_state(learner) + _set_position(st, story) + state.save(st) + payload = engine.story_read_payload(learner, story) + return _tutor.emit(payload, args, text=_render_read(payload)) + + +def _set_position(st: dict, story: dict) -> None: + """Mark the story's first exercise item as the learner's current position.""" + exercises = story.get("exercises", []) or [] + if not exercises: + return + item_id = exercises[0].get("item_id") + if not item_id: + return + hit = curriculum.find_item(item_id) + if hit is not None: + state.set_current(st, hit[0].id, item_id) + else: + st["current"] = {"item_id": item_id} + + +def _render_read(payload: dict) -> str: + story = payload["story"] + lines = [ + f"# {story['title']} [{story.get('level_detail', story['level'])}]", + "", + story["body"], + ] + if story.get("glossary"): + lines += ["", "## Glossary"] + lines += [f"- {g['term']}: {g['definition']}" for g in story["glossary"]] + lines += ["", "## Directive"] + lines += [f"- {step}" for step in payload["directive"]["instructions"]] + return "\n".join(lines) + + +def register(sub: argparse._SubParsersAction) -> None: + p = sub.add_parser( + "story", + help="Read graded stories (see 'french story list').", + ) + _tutor.add_json(p) + _tutor.add_learner(p) + # Bare `french story` lists. + p.set_defaults(func=cmd_story_list, json=False, level=None) + noun = p.add_subparsers(dest="story_command", parser_class=type(p)) + + ls = noun.add_parser("list", help="List story summaries (contract: story_list).") + ls.add_argument("--level", choices=("beginner", "intermediate", "advanced"), default=None) + _tutor.add_json(ls) + ls.set_defaults(func=cmd_story_list) + + rd = noun.add_parser("read", help="Read one story with a teaching directive (story_read).") + rd.add_argument("story_id", help="The story id (filename stem under content/stories/).") + _tutor.add_json(rd) + _tutor.add_learner(rd) + rd.set_defaults(func=cmd_story_read) diff --git a/french/contract_cite/__init__.py b/french/contract_cite/__init__.py new file mode 100644 index 0000000..eb0c100 --- /dev/null +++ b/french/contract_cite/__init__.py @@ -0,0 +1,101 @@ +"""The subject-plugin contract, CITED (cite-don't-import) into french-cli. + +.. provenance:: + + * source repo: agentculture/learn-cli + * source paths: learn/contract/__init__.py (adapted) + learn/contract/schemas/*.json + (verbatim) + learn/contract/_validate.py (verbatim) + * contract: subject-plugin contract v1.0 + * source commit: ed81b87 (2026-07-11) + * cited on: 2026-07-11 + * cited by: french-cli (task t4 of the learn uplift) + +french-cli is a *subject* hosted by learn-cli, not a dependency of it: its +runtime deps stay ``[]`` and it must never import ``learn``. So the machine- +readable half of the contract (the schemas + the stdlib validator) is copied in +here. french-cli validates its own ``--json`` payloads against these schemas in +CI (``tests/test_contract_conformance.py``), which is exactly the conformance +gate learn-cli's registry (t3) runs against the subject as a subprocess. + +The ``__init__`` here is *adapted* from ``learn.contract`` (package name + +docstrings), while ``_validate.py`` and ``schemas/*.json`` are *verbatim* +citations. See ``docs/contract-provenance.md`` for the ledger and re-sync +procedure. Keep these in lockstep with the upstream contract version. +""" + +from __future__ import annotations + +import json +from importlib import resources +from typing import Any + +from french.contract_cite._validate import validate as _validate_instance + +#: The contract version this package ships. Payloads carry it as +#: ``schema_version``; the subject pins it via ``doctor``'s ``contract_version``. +CONTRACT_VERSION = "1.0" + +#: Ordered mastery ladder every subject reports per item. Index = how +#: well-understood; inference never regresses down this ladder. +MASTERY_LEVELS: tuple[str, ...] = ("unknown", "introduced", "practiced", "mastered") + +#: Raw result vocabulary the driver records back. Subjects report these — never +#: numeric scores; learn-cli's motivation layer computes scores from the ledger. +RESULTS: tuple[str, ...] = ("pass", "partial", "fail") + +#: Difficulty ladder for stories (graded readers and narrative scenarios). +STORY_LEVELS: tuple[str, ...] = ("beginner", "intermediate", "advanced") + +#: Every schema shipped by this contract version (stem of ``schemas/*.json``). +SCHEMA_NAMES: tuple[str, ...] = ( + "overview", + "progress", + "advice", + "story", + "story_list", + "story_read", + "lesson", + "practice", + "record", + "doctor", + "error", +) + + +def _schemas_root() -> Any: + return resources.files(__package__).joinpath("schemas") + + +def list_schemas() -> tuple[str, ...]: + """The schema names present on disk, sorted (should equal SCHEMA_NAMES).""" + root = _schemas_root() + return tuple( + sorted( + entry.name[: -len(".json")] for entry in root.iterdir() if entry.name.endswith(".json") + ) + ) + + +def load_schema(name: str) -> dict[str, Any]: + """Load a contract schema by name (e.g. ``"story"``). + + Raises :class:`KeyError` for a name outside :data:`SCHEMA_NAMES` so callers + fail loudly on typos rather than reading an unshipped file. + """ + if name not in SCHEMA_NAMES: + raise KeyError(f"unknown contract schema '{name}' (valid: {', '.join(SCHEMA_NAMES)})") + text = _schemas_root().joinpath(f"{name}.json").read_text(encoding="utf-8") + return json.loads(text) + + +def _loader(ref: str) -> dict[str, Any] | None: + """Resolve a sibling-file ``$ref`` (``story.json``) to its schema.""" + stem = ref[: -len(".json")] if ref.endswith(".json") else ref + if stem in SCHEMA_NAMES: + return load_schema(stem) + return None + + +def validate(instance: Any, schema_name: str) -> list[str]: + """Validate a payload against a named contract schema; [] means valid.""" + return _validate_instance(instance, load_schema(schema_name), loader=_loader) diff --git a/french/contract_cite/_validate.py b/french/contract_cite/_validate.py new file mode 100644 index 0000000..6f7d30b --- /dev/null +++ b/french/contract_cite/_validate.py @@ -0,0 +1,245 @@ +"""Minimal stdlib JSON-Schema validator for the subject-plugin contract. + +.. provenance:: + + CITED (cite-don't-import) from learn-cli, verbatim. + + * source repo: agentculture/learn-cli + * source path: learn/contract/_validate.py + * contract: subject-plugin contract v1.0 + * source commit: ed81b87 (2026-07-11) + * cited on: 2026-07-11 + * cited by: french-cli (task t4 of the learn uplift) + + french-cli's runtime dependencies stay ``[]`` and it must not depend on + learn-cli, so this validator is copied in rather than imported. Keep it in + sync with the upstream contract when the version bumps; see + ``docs/contract-provenance.md`` for the re-sync procedure. Do NOT edit the + body — this is a verbatim citation. + +The runtime package carries zero third-party dependencies, so contract payloads +are validated with this module instead of the ``jsonschema`` package. It +implements exactly the keyword subset the shipped schemas use — pinned by +:data:`SUPPORTED_KEYWORDS` and guarded by a test that walks every schema with +:func:`unsupported_keywords`, so a schema can never quietly rely on a keyword +this validator ignores. + +Semantics follow JSON Schema draft 2020-12 for the supported subset: + +* ``type`` — a name or list of names; ``integer``/``number`` exclude booleans + (Python ``bool`` is an ``int`` subclass, JSON's is not). +* ``$ref`` — two forms only: local ``#/$defs/`` pointers, and sibling + schema files (``story.json``) resolved through the ``loader`` callable. +* ``pattern`` — an unanchored regex search, per the spec. +* ``additionalProperties`` — when a schema object, it validates the values of + properties not named in ``properties`` (used for mastery maps). +""" + +from __future__ import annotations + +import re +from typing import Any, Callable + +# A loader maps a sibling-file ref ("story.json") to its parsed schema, or None. +Loader = Callable[[str], "dict[str, Any] | None"] + +SUPPORTED_KEYWORDS = frozenset( + { + # validation + "type", + "enum", + "const", + "pattern", + "required", + "properties", + "additionalProperties", + "items", + "minItems", + "minimum", + "minLength", + # combinators / references + "anyOf", + "not", + "$ref", + "$defs", + # annotations (ignored by validation) + "$schema", + "$id", + "title", + "description", + "examples", + "default", + "deprecated", + } +) + +_SIMPLE_TYPES: dict[str, type | tuple[type, ...]] = { + "string": str, + "object": dict, + "array": list, + "null": type(None), +} + + +def _matches_type(value: Any, name: str) -> bool: + if name == "boolean": + return isinstance(value, bool) + if name == "integer": + return isinstance(value, int) and not isinstance(value, bool) + if name == "number": + return isinstance(value, (int, float)) and not isinstance(value, bool) + expected = _SIMPLE_TYPES.get(name) + return expected is not None and isinstance(value, expected) + + +def validate( + instance: Any, + schema: dict[str, Any], + *, + loader: Loader | None = None, +) -> list[str]: + """Validate ``instance`` against ``schema``; return error strings ([] = valid). + + Each error names the failing JSON path (``$.stories[0].level``) and the + violated rule, so a conformance gate can surface it as a remediation. + """ + errors: list[str] = [] + _check(instance, schema, schema, "$", errors, loader) + return errors + + +def _subcheck( + value: Any, + schema: dict[str, Any], + root: dict[str, Any], + path: str, + loader: Loader | None, +) -> list[str]: + sub: list[str] = [] + _check(value, schema, root, path, sub, loader) + return sub + + +def _resolve_ref( + ref: str, + root: dict[str, Any], + loader: Loader | None, +) -> tuple[dict[str, Any], dict[str, Any]] | str: + """Resolve ``$ref`` → ``(schema, new_root)``, or an error string.""" + if ref.startswith("#/$defs/"): + name = ref[len("#/$defs/") :] + target = root.get("$defs", {}).get(name) + if target is None: + return f"unresolvable local $ref '{ref}'" + return target, root + loaded = loader(ref) if loader is not None else None + if loaded is None: + return f"unresolvable $ref '{ref}' (no loader for sibling schema files)" + return loaded, loaded + + +def _check( # noqa: C901 - one dispatch table over the supported keywords + value: Any, + schema: dict[str, Any], + root: dict[str, Any], + path: str, + errors: list[str], + loader: Loader | None, +) -> None: + ref = schema.get("$ref") + if ref is not None: + resolved = _resolve_ref(ref, root, loader) + if isinstance(resolved, str): + errors.append(f"{path}: {resolved}") + else: + target, new_root = resolved + _check(value, target, new_root, path, errors, loader) + + type_rule = schema.get("type") + if type_rule is not None: + names = type_rule if isinstance(type_rule, list) else [type_rule] + if not any(_matches_type(value, n) for n in names): + errors.append(f"{path}: expected type {'|'.join(names)}") + return # structure is wrong; per-keyword checks below would misfire + + if "enum" in schema and value not in schema["enum"]: + errors.append(f"{path}: {value!r} is not one of {schema['enum']}") + if "const" in schema and value != schema["const"]: + errors.append(f"{path}: expected constant {schema['const']!r}") + + if isinstance(value, str): + pattern = schema.get("pattern") + if pattern is not None and re.search(pattern, value) is None: + errors.append(f"{path}: {value!r} does not match pattern {pattern!r}") + min_length = schema.get("minLength") + if min_length is not None and len(value) < min_length: + errors.append(f"{path}: shorter than minLength {min_length}") + + if isinstance(value, (int, float)) and not isinstance(value, bool): + minimum = schema.get("minimum") + if minimum is not None and value < minimum: + errors.append(f"{path}: {value} is below minimum {minimum}") + + if isinstance(value, dict): + for key in schema.get("required", []): + if key not in value: + errors.append(f"{path}: missing required property '{key}'") + properties = schema.get("properties", {}) + for key, subschema in properties.items(): + if key in value: + _check(value[key], subschema, root, f"{path}.{key}", errors, loader) + additional = schema.get("additionalProperties") + if isinstance(additional, dict): + for key, item in value.items(): + if key not in properties: + _check(item, additional, root, f"{path}.{key}", errors, loader) + elif additional is False: + for key in value: + if key not in properties: + errors.append(f"{path}: additional property '{key}' is not allowed") + + if isinstance(value, list): + min_items = schema.get("minItems") + if min_items is not None and len(value) < min_items: + errors.append(f"{path}: fewer than minItems {min_items}") + items = schema.get("items") + if items is not None: + for i, element in enumerate(value): + _check(element, items, root, f"{path}[{i}]", errors, loader) + + any_of = schema.get("anyOf") + if any_of is not None: + if all(_subcheck(value, option, root, path, loader) for option in any_of): + errors.append(f"{path}: does not match any allowed variant (anyOf)") + + negated = schema.get("not") + if negated is not None: + if not _subcheck(value, negated, root, path, loader): + errors.append(f"{path}: matches a forbidden shape (not)") + + +def unsupported_keywords(schema: dict[str, Any]) -> set[str]: + """Every keyword in ``schema`` (recursively) this validator does not implement. + + The contract test suite asserts this returns an empty set for every shipped + schema — the guard that keeps schemas within the validated subset. + """ + found: set[str] = set() + _walk_keywords(schema, found) + return found + + +def _walk_keywords(schema: dict[str, Any], found: set[str]) -> None: + for key, value in schema.items(): + if key not in SUPPORTED_KEYWORDS: + found.add(key) + if key in ("properties", "$defs") and isinstance(value, dict): + for sub in value.values(): + if isinstance(sub, dict): + _walk_keywords(sub, found) + elif key in ("items", "not", "additionalProperties") and isinstance(value, dict): + _walk_keywords(value, found) + elif key == "anyOf" and isinstance(value, list): + for sub in value: + if isinstance(sub, dict): + _walk_keywords(sub, found) diff --git a/french/contract_cite/schemas/advice.json b/french/contract_cite/schemas/advice.json new file mode 100644 index 0000000..0f4579d --- /dev/null +++ b/french/contract_cite/schemas/advice.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agentculture.org/learn/contract/1.0/advice.json", + "title": "Advice payload", + "description": "Emitted by ` advice --json [--learner ]`. Deterministic study advice derived from the learner's recorded state — what to shore up and why, each with a runnable command. No LLM: advice is a pure function of stored mastery/history.", + "type": "object", + "required": ["schema_version", "kind", "subject", "learner", "advice"], + "properties": { + "schema_version": { "type": "string", "pattern": "^1\\.[0-9]+$" }, + "kind": { "const": "advice" }, + "subject": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "learner": { "type": "string", "minLength": 1 }, + "advice": { + "type": "array", + "description": "May be empty for a brand-new learner; each entry is actionable.", + "items": { + "type": "object", + "required": ["suggestion"], + "properties": { + "focus": { + "type": "string", + "description": "The item or module id the advice targets ('' for general advice)." + }, + "reason": { "type": "string" }, + "suggestion": { "type": "string", "minLength": 1 }, + "command": { + "type": "string", + "description": "A runnable subject-CLI command that acts on the suggestion." + } + } + } + } + } +} diff --git a/french/contract_cite/schemas/doctor.json b/french/contract_cite/schemas/doctor.json new file mode 100644 index 0000000..88d6355 --- /dev/null +++ b/french/contract_cite/schemas/doctor.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agentculture.org/learn/contract/1.0/doctor.json", + "title": "Subject doctor payload", + "description": "Emitted by ` doctor --json`. The subject's self-check plus its contract pin: `contract_version` declares which contract version this subject implements — the field `learn subject doctor` reads before validating any other payload. A subject that drifts fails its own CI, not learn-cli's runtime. Exit code follows health: 0 healthy, 2 unhealthy.", + "type": "object", + "required": ["schema_version", "kind", "subject", "contract_version", "healthy", "checks"], + "properties": { + "schema_version": { "type": "string", "pattern": "^1\\.[0-9]+$" }, + "kind": { "const": "subject_doctor" }, + "subject": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "contract_version": { + "type": "string", + "pattern": "^1\\.[0-9]+$", + "description": "The subject-plugin contract version this subject pins and satisfies." + }, + "healthy": { + "type": "boolean", + "description": "False when any severity=error check failed." + }, + "checks": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id", "passed", "severity", "message"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" }, + "passed": { "type": "boolean" }, + "severity": { "enum": ["error", "warning", "info"] }, + "message": { "type": "string", "minLength": 1 }, + "remediation": { "type": "string" } + } + } + } + } +} diff --git a/french/contract_cite/schemas/error.json b/french/contract_cite/schemas/error.json new file mode 100644 index 0000000..c8ddae7 --- /dev/null +++ b/french/contract_cite/schemas/error.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agentculture.org/learn/contract/1.0/error.json", + "title": "Error payload (stderr)", + "description": "The structured error every subject emits to STDERR in --json mode on any failure — the CliError shape {code, message, remediation} already emitted by the template chassis. Text mode renders as 'error: ' + 'hint: '. This shape predates the contract and carries no schema_version. Exit code equals `code`: 1 user error, 2 environment error.", + "type": "object", + "required": ["code", "message", "remediation"], + "properties": { + "code": { + "type": "integer", + "minimum": 1, + "description": "The process exit code: 1 user error, 2 environment error, 3+ reserved." + }, + "message": { "type": "string", "minLength": 1 }, + "remediation": { + "type": "string", + "description": "Actionable hint for the driving agent; may be empty but must be present." + } + } +} diff --git a/french/contract_cite/schemas/lesson.json b/french/contract_cite/schemas/lesson.json new file mode 100644 index 0000000..4f4d074 --- /dev/null +++ b/french/contract_cite/schemas/lesson.json @@ -0,0 +1,80 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agentculture.org/learn/contract/1.0/lesson.json", + "title": "Lesson directive payload", + "description": "Emitted by ` lesson start [] --json`, `lesson next --json`, and `lesson repeat [] [--harder] --json`. The subject resolves WHAT to teach (content items + objectives, from committed content and the learner's mastery); the directive tells the driver HOW to teach it. `repeat --harder` re-issues a completed lesson at a higher difficulty rung — the never-ending-progression hook.", + "type": "object", + "required": ["schema_version", "kind", "subject", "learner", "mode", "lesson", "directive"], + "properties": { + "schema_version": { "type": "string", "pattern": "^1\\.[0-9]+$" }, + "kind": { "const": "lesson_directive" }, + "subject": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "learner": { "type": "string", "minLength": 1 }, + "mode": { + "enum": ["start", "next", "repeat"], + "description": "Which lesson subverb produced this directive." + }, + "lesson": { + "type": "object", + "required": ["id", "title", "items"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" }, + "module_id": { "type": "string" }, + "title": { "type": "string", "minLength": 1 }, + "level": { "enum": ["beginner", "intermediate", "advanced"] }, + "difficulty": { + "type": "integer", + "minimum": 1, + "description": "Repeat rung: 1 = first pass; `lesson repeat --harder` increments it." + }, + "objectives": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "items": { + "type": "array", + "minItems": 1, + "description": "The curriculum items this lesson covers — their ids are what `record --item` expects back.", + "items": { + "type": "object", + "required": ["id", "label"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" }, + "label": { "type": "string", "minLength": 1 }, + "points": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "description": "The teachable points the driver must explain and check." + }, + "body": { + "type": "string", + "description": "Optional prose content for the item (markdown)." + } + } + } + } + } + }, + "directive": { "$ref": "#/$defs/directive" } + }, + "$defs": { + "directive": { + "type": "object", + "description": "Structured instructions to the driving agent (the directive pattern).", + "required": ["instructions", "record_with"], + "properties": { + "persona": { "type": "string" }, + "instructions": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + }, + "record_with": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + } + } + } + } +} diff --git a/french/contract_cite/schemas/overview.json b/french/contract_cite/schemas/overview.json new file mode 100644 index 0000000..3b6126b --- /dev/null +++ b/french/contract_cite/schemas/overview.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agentculture.org/learn/contract/1.0/overview.json", + "title": "Subject overview payload", + "description": "Emitted by ` overview --json`. The subject's self-description: identity, modules (one site sub-page per module), and content counts. Learner-independent and side-effect free.", + "type": "object", + "required": ["schema_version", "kind", "subject", "display_name", "description", "modules"], + "properties": { + "schema_version": { + "type": "string", + "pattern": "^1\\.[0-9]+$", + "description": "Contract version family 1.x this payload conforms to." + }, + "kind": { "const": "subject_overview" }, + "subject": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "The subject's registry id (e.g. 'french')." + }, + "display_name": { "type": "string", "minLength": 1 }, + "tagline": { "type": "string" }, + "description": { "type": "string", "minLength": 1 }, + "language": { + "type": "string", + "description": "BCP-47 tag of the language being taught, when the subject is a language." + }, + "modules": { + "type": "array", + "minItems": 1, + "description": "Ordered course modules; the web face renders one sub-page per module.", + "items": { + "type": "object", + "required": ["id", "title", "summary"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" }, + "title": { "type": "string", "minLength": 1 }, + "summary": { "type": "string", "minLength": 1 }, + "level": { "enum": ["beginner", "intermediate", "advanced"] } + } + } + }, + "content": { + "type": "object", + "description": "Counts of committed content, for catalog display and content gates.", + "properties": { + "stories": { "type": "integer", "minimum": 0 }, + "lessons": { "type": "integer", "minimum": 0 }, + "exercises": { "type": "integer", "minimum": 0 } + } + } + } +} diff --git a/french/contract_cite/schemas/practice.json b/french/contract_cite/schemas/practice.json new file mode 100644 index 0000000..e2ce2e7 --- /dev/null +++ b/french/contract_cite/schemas/practice.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agentculture.org/learn/contract/1.0/practice.json", + "title": "Practice directive payload", + "description": "Emitted by ` practice [] --json [--learner ]`. A batch of exercises for the driver to run conversationally, grade (pass|partial|fail against answer/rubric), and record. With no scope argument the subject picks the weakest touched items — its within-subject review mode.", + "type": "object", + "required": ["schema_version", "kind", "subject", "learner", "scope", "exercises", "directive"], + "properties": { + "schema_version": { "type": "string", "pattern": "^1\\.[0-9]+$" }, + "kind": { "const": "practice_directive" }, + "subject": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "learner": { "type": "string", "minLength": 1 }, + "scope": { + "type": "string", + "minLength": 1, + "description": "What is being practiced: an item id, a module id, or 'review'." + }, + "exercises": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/exercise" } + }, + "directive": { "$ref": "#/$defs/directive" } + }, + "$defs": { + "exercise": { + "type": "object", + "required": ["id", "type", "prompt"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" }, + "type": { + "enum": [ + "multiple_choice", + "true_false", + "cloze", + "short_answer", + "translation", + "open", + "discussion" + ] + }, + "item_id": { + "type": "string", + "description": "The curriculum item this exercise evidences — the id `record --item` expects." + }, + "prompt": { "type": "string", "minLength": 1 }, + "choices": { + "type": "array", + "minItems": 2, + "items": { "type": "string" } + }, + "answer": { "type": "string" }, + "rubric": { "type": "string" } + } + }, + "directive": { + "type": "object", + "description": "Structured instructions to the driving agent (the directive pattern).", + "required": ["instructions", "record_with"], + "properties": { + "persona": { "type": "string" }, + "instructions": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + }, + "record_with": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + } + } + } + } +} diff --git a/french/contract_cite/schemas/progress.json b/french/contract_cite/schemas/progress.json new file mode 100644 index 0000000..718ea5f --- /dev/null +++ b/french/contract_cite/schemas/progress.json @@ -0,0 +1,85 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agentculture.org/learn/contract/1.0/progress.json", + "title": "Progress payload", + "description": "Emitted by ` progress --json [--learner ]`. The learner's standing inside this subject: per-item mastery, counters, and the subject's own next-step recommendation. Read-only.", + "type": "object", + "required": [ + "schema_version", + "kind", + "subject", + "learner", + "items_total", + "items_touched", + "items_mastered", + "mastery", + "next" + ], + "properties": { + "schema_version": { "type": "string", "pattern": "^1\\.[0-9]+$" }, + "kind": { "const": "progress" }, + "subject": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "learner": { + "type": "string", + "minLength": 1, + "description": "The resolved learner id — always echoed, even when defaulted." + }, + "started_at": { "type": "string", "description": "ISO-8601 timestamp." }, + "last_seen_at": { "type": "string", "description": "ISO-8601 timestamp." }, + "current": { + "type": ["object", "null"], + "description": "Where the learner is right now; null before any activity.", + "properties": { + "module_id": { "type": "string" }, + "item_id": { "type": "string" } + } + }, + "items_total": { "type": "integer", "minimum": 0 }, + "items_touched": { "type": "integer", "minimum": 0 }, + "items_mastered": { "type": "integer", "minimum": 0 }, + "completed": { + "type": "array", + "items": { "type": "string" }, + "description": "Item ids the learner has completed (mastered at least once)." + }, + "mastery": { + "type": "object", + "description": "item_id → mastery level for every touched item.", + "additionalProperties": { "enum": ["unknown", "introduced", "practiced", "mastered"] } + }, + "weak": { + "type": "array", + "description": "Touched-but-not-mastered items, for review prompts.", + "items": { + "type": "object", + "required": ["item_id", "mastery"], + "properties": { + "item_id": { "type": "string", "minLength": 1 }, + "mastery": { "enum": ["unknown", "introduced", "practiced"] } + } + } + }, + "next": { "$ref": "#/$defs/recommendation" } + }, + "$defs": { + "recommendation": { + "type": "object", + "description": "The subject's recommended next step within itself. Cross-subject what-next is learn-cli's motivation layer, not the subject's.", + "required": ["done", "text", "command"], + "properties": { + "done": { + "type": "boolean", + "description": "True when every authored item is mastered (learn-cli then switches the learner to maintenance + depth mode)." + }, + "module_id": { "type": "string" }, + "item_id": { "type": "string" }, + "text": { "type": "string", "minLength": 1 }, + "command": { + "type": "string", + "minLength": 1, + "description": "A runnable subject-CLI command implementing the recommendation." + } + } + } + } +} diff --git a/french/contract_cite/schemas/record.json b/french/contract_cite/schemas/record.json new file mode 100644 index 0000000..480257a --- /dev/null +++ b/french/contract_cite/schemas/record.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agentculture.org/learn/contract/1.0/record.json", + "title": "Record acknowledgement payload", + "description": "Emitted by ` record --item --result pass|partial|fail [...] --json`. The driver's write-back after grading: the subject appends the raw result to its history, updates the item's mastery on its ladder, and acks with the normalized raw-result object. That `recorded` object is the exact record learn-cli appends to its cross-subject ledger — the sole input to the motivation layer's deterministic scores/streaks/review-queues. Subjects MUST NOT compute scores: the recorded object structurally forbids score-like fields.", + "type": "object", + "required": ["schema_version", "kind", "subject", "learner", "recorded", "mastery", "next"], + "properties": { + "schema_version": { "type": "string", "pattern": "^1\\.[0-9]+$" }, + "kind": { "const": "record_ack" }, + "subject": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "learner": { "type": "string", "minLength": 1 }, + "recorded": { + "type": "object", + "description": "The normalized raw result, as persisted. Raw observations only — never derived numbers.", + "required": ["item_id", "activity", "result", "at"], + "not": { + "anyOf": [ + { "required": ["score"] }, + { "required": ["grade"] }, + { "required": ["points"] } + ] + }, + "properties": { + "item_id": { "type": "string", "minLength": 1 }, + "activity": { + "enum": ["lesson", "practice", "story"], + "description": "Which activity produced this result." + }, + "exercise_id": { "type": "string" }, + "story_id": { "type": "string" }, + "lesson_id": { "type": "string" }, + "result": { "enum": ["pass", "partial", "fail"] }, + "correct": { + "type": "integer", + "minimum": 0, + "description": "Raw count of correct responses, when countable." + }, + "total": { + "type": "integer", + "minimum": 1, + "description": "Raw count of graded responses, when countable." + }, + "duration_seconds": { "type": "number", "minimum": 0 }, + "notes": { "type": "string" }, + "at": { "type": "string", "minLength": 1, "description": "ISO-8601 timestamp." } + } + }, + "mastery": { + "type": "object", + "description": "The item's mastery after applying this record (inferred from result unless set explicitly; never regresses on inference).", + "required": ["item_id", "level"], + "properties": { + "item_id": { "type": "string", "minLength": 1 }, + "level": { "enum": ["unknown", "introduced", "practiced", "mastered"] } + } + }, + "next": { "$ref": "#/$defs/recommendation" } + }, + "$defs": { + "recommendation": { + "type": "object", + "description": "The subject's recommended next step within itself, refreshed after the write.", + "required": ["done", "text", "command"], + "properties": { + "done": { "type": "boolean" }, + "module_id": { "type": "string" }, + "item_id": { "type": "string" }, + "text": { "type": "string", "minLength": 1 }, + "command": { "type": "string", "minLength": 1 } + } + } + } +} diff --git a/french/contract_cite/schemas/story.json b/french/contract_cite/schemas/story.json new file mode 100644 index 0000000..8ef2d24 --- /dev/null +++ b/french/contract_cite/schemas/story.json @@ -0,0 +1,131 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agentculture.org/learn/contract/1.0/story.json", + "title": "Story content schema", + "description": "The shared story shape across all subjects (spec c29): the unit of committed learning content. Language subjects use it for graded-reader ladders (beginner → advanced); culture-guide uses it for narrative scenarios. Stories live as committed content files in each subject repo and are embedded verbatim in `story read` payloads. Each subject repo validates its story files against this schema in CI.", + "type": "object", + "required": [ + "schema_version", + "kind", + "id", + "subject", + "title", + "level", + "body", + "glossary", + "exercises" + ], + "properties": { + "schema_version": { "type": "string", "pattern": "^1\\.[0-9]+$" }, + "kind": { "const": "story" }, + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]*$", + "description": "Stable story id, unique within the subject (e.g. 'fr-beg-le-marche-du-samedi')." + }, + "subject": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "title": { "type": "string", "minLength": 1 }, + "level": { + "enum": ["beginner", "intermediate", "advanced"], + "description": "Coarse difficulty rung of the graded ladder." + }, + "level_detail": { + "type": "string", + "description": "Finer grading: a CEFR tag ('A1', 'B2') for languages, 'scenario' for culture-guide narratives." + }, + "language": { + "type": "string", + "description": "BCP-47 tag of the body text ('fr', 'es', 'en')." + }, + "summary": { + "type": "string", + "description": "One-line teaser, safe to show signed-out visitors in the catalog." + }, + "body": { + "type": "string", + "minLength": 1, + "description": "The story text. Markdown; paragraphs separated by blank lines." + }, + "glossary": { + "type": "array", + "description": "Glossary/annotations: terms from the body the learner may not know. May be empty for advanced content.", + "items": { + "type": "object", + "required": ["term", "definition"], + "properties": { + "term": { "type": "string", "minLength": 1 }, + "definition": { "type": "string", "minLength": 1 }, + "note": { + "type": "string", + "description": "Optional annotation: usage, grammar, or cultural context." + } + } + } + }, + "exercises": { + "type": "array", + "minItems": 1, + "description": "Comprehension exercises over the body; the driver runs and grades them, then records results.", + "items": { "$ref": "#/$defs/exercise" } + }, + "audio": { + "type": ["object", "null"], + "description": "RESERVED for the spoken-practice follow-up (TTS/STT). Producers set null or omit until the audio pipeline exists; consumers must tolerate both.", + "properties": { + "url": { "type": "string" }, + "format": { "type": "string" }, + "voice": { "type": "string" }, + "duration_seconds": { "type": "number", "minimum": 0 } + } + }, + "source": { + "type": "object", + "description": "Provenance for the batch-generation pipeline: how this story was produced and who reviewed it.", + "properties": { + "generator": { "type": "string" }, + "model": { "type": "string" }, + "reviewed_by": { "type": "string" }, + "generated_at": { "type": "string" } + } + } + }, + "$defs": { + "exercise": { + "type": "object", + "required": ["id", "type", "prompt"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" }, + "type": { + "enum": [ + "multiple_choice", + "true_false", + "cloze", + "short_answer", + "translation", + "open", + "discussion" + ] + }, + "item_id": { + "type": "string", + "description": "The curriculum item this exercise evidences — the id `record --item` expects." + }, + "prompt": { "type": "string", "minLength": 1 }, + "choices": { + "type": "array", + "minItems": 2, + "items": { "type": "string" }, + "description": "Required in spirit for multiple_choice; unused elsewhere." + }, + "answer": { + "type": "string", + "description": "The expected answer, when one is checkable verbatim." + }, + "rubric": { + "type": "string", + "description": "Grading guidance for open-ended exercises: what passes, what is partial." + } + } + } + } +} diff --git a/french/contract_cite/schemas/story_list.json b/french/contract_cite/schemas/story_list.json new file mode 100644 index 0000000..f707242 --- /dev/null +++ b/french/contract_cite/schemas/story_list.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agentculture.org/learn/contract/1.0/story_list.json", + "title": "Story list payload", + "description": "Emitted by ` story list --json [--level ] [--module ]`. Story summaries only — bodies come from `story read`. Learner-independent, so the static web catalog can be built from it.", + "type": "object", + "required": ["schema_version", "kind", "subject", "stories"], + "properties": { + "schema_version": { "type": "string", "pattern": "^1\\.[0-9]+$" }, + "kind": { "const": "story_list" }, + "subject": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "stories": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "title", "level"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" }, + "title": { "type": "string", "minLength": 1 }, + "level": { "enum": ["beginner", "intermediate", "advanced"] }, + "level_detail": { "type": "string" }, + "language": { "type": "string" }, + "summary": { "type": "string" }, + "exercises": { + "type": "integer", + "minimum": 0, + "description": "Count of comprehension exercises in the story." + } + } + } + } + } +} diff --git a/french/contract_cite/schemas/story_read.json b/french/contract_cite/schemas/story_read.json new file mode 100644 index 0000000..1756e19 --- /dev/null +++ b/french/contract_cite/schemas/story_read.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agentculture.org/learn/contract/1.0/story_read.json", + "title": "Story read payload", + "description": "Emitted by ` story read --json [--learner ]`. Wraps one full story (the shared story.json schema, verbatim) in a teaching directive: how the driver should present it, use the glossary, run the comprehension exercises, and record results. Reading may update the learner's current position.", + "type": "object", + "required": ["schema_version", "kind", "subject", "learner", "story", "directive"], + "properties": { + "schema_version": { "type": "string", "pattern": "^1\\.[0-9]+$" }, + "kind": { "const": "story_read" }, + "subject": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "learner": { "type": "string", "minLength": 1 }, + "story": { + "$ref": "story.json", + "description": "The full story content object, exactly as committed in the subject repo." + }, + "directive": { "$ref": "#/$defs/directive" } + }, + "$defs": { + "directive": { + "type": "object", + "description": "Structured instructions to the driving agent (the directive pattern): the subject stays LLM-free; the driver does the conversational tutoring and writes results back via `record`.", + "required": ["instructions", "record_with"], + "properties": { + "persona": { "type": "string" }, + "instructions": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + }, + "record_with": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 }, + "description": "Concrete runnable `record` command lines for writing outcomes back." + } + } + } + } +} diff --git a/french/explain/catalog.py b/french/explain/catalog.py index e6b8837..48e9329 100644 --- a/french/explain/catalog.py +++ b/french/explain/catalog.py @@ -3,9 +3,8 @@ Each entry is verbatim markdown. Keys are command-path tuples. The empty tuple and both spellings of the tool's name — ``("french",)`` (the installed console script) and ``("french-cli",)`` (the distribution / repo / mesh nick) — resolve -to the root entry. The agent-first rubric resolves the tool's own name from -``[project.scripts]`` and requires ``explain `` to work; the -distribution alias is kept because that is the name on PyPI and in the mesh. +to the root entry. Every registered command path has an entry here +(``test_every_registered_path_has_catalog_entry`` enforces it). Keep bodies self-contained: an agent reading one entry should get enough context without chaining reads. @@ -16,20 +15,31 @@ _ROOT = """\ # french -A private French tutor: track progress, get an overview, get advice, read -stories, and learn & practice French (written and spoken) online from your -phone. +A private, LLM-free French tutor implementing the learn subject-plugin contract. +It owns the committed French content (stories, lessons, exercises) and each +learner's mastery state, resolves what to teach next, and emits structured +teaching directives. The driving agent (or human) does the conversational +tutoring and writes graded outcomes back with `record`. Installed command: `french`. PyPI package and repo: `french-cli`. Mesh nick: `french-cli`. -## Verbs +## Tutor verbs (the contract surface) + +- `french overview` — subject self-description: modules + content counts. +- `french progress` — the learner's mastery, counters, and next step. +- `french advice` — deterministic study advice from stored state. +- `french story list|read ` — graded stories + a reading directive. +- `french lesson start|next|repeat` — teaching directives from the curriculum. +- `french practice []` — a batch of exercises to run (no scope = review). +- `french record --item --result pass|partial|fail` — write back one outcome. +- `french doctor` — self-check + the pinned contract version. + +## Agent-first verbs - `french whoami` — identity probe from `culture.yaml`. - `french learn` — structured self-teaching prompt. - `french explain ` — markdown docs for any noun/verb. -- `french overview` — descriptive snapshot of the agent. -- `french doctor` — check the agent-identity invariants. - `french cli overview` — describe the CLI surface. ## Exit-code policy @@ -41,8 +51,8 @@ ## See also -- `french explain whoami` -- `french explain doctor` +- `french explain lesson` +- `french explain record` """ _WHOAMI = """\ @@ -60,8 +70,9 @@ _LEARN = """\ # french learn -Prints a structured self-teaching prompt covering purpose, command map, -exit-code policy, `--json` support, and the `explain` pointer. +Prints a structured self-teaching prompt for an agent operating the CLI: the +tutor purpose, the eight subject-plugin verbs, the learner/state model, exit-code +policy, `--json` support, and the `explain` pointer. ## Usage @@ -81,16 +92,21 @@ ## Usage french explain french - french explain whoami + french explain lesson french explain --json """ _OVERVIEW = """\ # french overview -Read-only descriptive snapshot of the agent: identity (from `culture.yaml`), the -verb surface, and the sibling-pattern artifacts the template carries. Accepts an -ignored `target` so a stray path never hard-fails. +The subject's self-description (contract `subject_overview`): identity, the +ordered course modules (the web face renders one sub-page per module), and +content counts. Learner-independent and side-effect free. + +The `--json` payload carries the contract fields (`schema_version`, `kind`, +`subject`, `display_name`, `description`, `modules`, `content`) plus the +`sections` key the agent-first rubric checks. Accepts an ignored `target` so a +stray path never hard-fails. ## Usage @@ -98,12 +114,181 @@ french overview --json """ +_PROGRESS = """\ +# french progress + +Where the learner stands in this subject (contract `progress`): per-item mastery +on the ladder `unknown → introduced → practiced → mastered`, the counters +`items_total`/`items_touched`/`items_mastered`, weak items, and the subject's own +`next` recommendation. Read-only — a pure function of stored state. + +## Usage + + french progress + french progress --learner ori --json +""" + +_ADVICE = """\ +# french advice + +Deterministic study advice derived from stored state (contract `advice`): what to +shore up and why, each entry with a runnable command. No LLM. May seed a single +"start here" entry for a brand-new learner. + +## Usage + + french advice + french advice --learner ori --json +""" + +_STORY = """\ +# french story + +The shared content surface. + +- `french story list [--level beginner|intermediate|advanced]` — level-tagged + summaries for the catalog (contract `story_list`, learner-independent). +- `french story read ` — the full committed story wrapped in a teaching + directive (contract `story_read`): present paragraph-at-a-time, use the + glossary on demand, run the comprehension exercises, record each result. + Learner-scoped; an unknown id exits 1. + +Bare `french story` lists. + +## Usage + + french story list --json + french story read dev-cafe --learner ori --json +""" + +_STORY_LIST = """\ +# french story list + +Level-tagged story summaries (id, title, level, exercise count) for the catalog +— contract `story_list`. Learner-independent, so the static web catalog builds +from it. Filter with `--level`. + +## Usage + + french story list + french story list --level beginner --json +""" + +_STORY_READ = """\ +# french story read + +Returns one full committed story (the shared `story` schema, verbatim) wrapped in +a teaching directive — contract `story_read`. Learner-scoped: reading updates the +learner's current position. An unknown story id exits 1 with the error shape. + +## Usage + + french story read dev-cafe + french story read dev-cafe --learner ori --json +""" + +_LESSON = """\ +# french lesson + +Start / continue / repeat a lesson (contract `lesson_directive`). The subject +resolves *what* to teach; the directive tells the driver *how*. + +- `french lesson start []` — a lesson by lesson id, module id, or item + id (first exposure lifts its items to `introduced`). +- `french lesson next` — continue from mastery state. +- `french lesson repeat [] [--harder]` — re-issue a lesson; `--harder` + increments its integer difficulty rung (never-ending progression). + +Bare `french lesson` continues from mastery state. + +## Usage + + french lesson start l.greetings --json + french lesson next --learner ori --json + french lesson repeat l.numbers --harder --json +""" + +_LESSON_START = """\ +# french lesson start [] + +Emit a lesson directive for a specific lesson — resolved from a lesson id, a +module id (its first lesson), or an item id (the lesson containing it). With no +target, starts the next lesson from mastery state. First exposure lifts the +lesson's items to `introduced` and sets the current position. + +## Usage + + french lesson start l.greetings --json + french lesson start premiers-pas --learner ori --json +""" + +_LESSON_NEXT = """\ +# french lesson next + +Emit the lesson directive for the first not-yet-mastered item's lesson — +continuing from the learner's mastery state. + +## Usage + + french lesson next + french lesson next --learner ori --json +""" + +_LESSON_REPEAT = """\ +# french lesson repeat [] [--harder] + +Re-issue a lesson (default: the learner's current or next lesson). `--harder` +increments the lesson's integer difficulty rung and raises the directive's bar — +the repeatable-lessons half of never-ending progression. + +## Usage + + french lesson repeat l.numbers --json + french lesson repeat l.numbers --harder --learner ori --json +""" + +_PRACTICE = """\ +# french practice [] + +A batch of exercises for the driver to run, grade `pass|partial|fail` against the +answer/rubric, and record — contract `practice_directive`. `scope` may be an item +id, a module id, or a lesson id; with no scope (or `review`) the subject picks the +learner's weakest touched items. + +## Usage + + french practice fr.numbers.prix --json + french practice --learner ori --json # review the weakest items +""" + +_RECORD = """\ +# french record --item --result pass|partial|fail + +The driver's write-back after grading (contract `record_ack`). The subject +appends the raw result to history, updates the item's mastery (inferred from +`--result` unless `--mastery` is given; inference never regresses), and acks with +the normalized `recorded` object — raw observations only, never a score. + +Flags: `--activity lesson|practice|story` (default practice), `--exercise `, +`--story `, `--lesson-id `, `--correct N`, `--total N`, +`--duration-seconds F`, `--notes ...`, `--mastery `. + +## Usage + + french record --item fr.numbers.prix --result pass --json + french record --learner ori --item fr.numbers.prix --activity practice \\ + --exercise prix-1 --result partial --correct 1 --total 2 --json +""" + _DOCTOR = """\ # french doctor -Checks the agent-identity invariants `steward doctor` verifies: -prompt-file-present and backend-consistency (`colleague` → `AGENTS.colleague.md`), plus a -skills-present check. Exits 1 when unhealthy. +Self-check + contract pin (contract `subject_doctor`). Keeps the mesh +agent-identity checks (`prompt-file-present`, `backend-consistency` → +`colleague` requires `AGENTS.colleague.md`, `skills-present`) and adds the +subject checks: `content-store-present` (stories validate), `learner-state-writable` +(the XDG state dir), and `contract-schemas-pinned`. Emits `contract_version`. +Exit 0 healthy, 2 unhealthy. ## Usage @@ -115,7 +300,7 @@ # french cli Noun group for CLI-surface introspection. `cli overview` describes the CLI -itself (distinct from the global `overview`, which describes the agent). +itself (distinct from the global `overview`, which describes the subject). ## Usage @@ -133,6 +318,17 @@ ("learn",): _LEARN, ("explain",): _EXPLAIN, ("overview",): _OVERVIEW, + ("progress",): _PROGRESS, + ("advice",): _ADVICE, + ("story",): _STORY, + ("story", "list"): _STORY_LIST, + ("story", "read"): _STORY_READ, + ("lesson",): _LESSON, + ("lesson", "start"): _LESSON_START, + ("lesson", "next"): _LESSON_NEXT, + ("lesson", "repeat"): _LESSON_REPEAT, + ("practice",): _PRACTICE, + ("record",): _RECORD, ("doctor",): _DOCTOR, ("cli",): _CLI, ("cli", "overview"): _CLI, diff --git a/french/tutor/__init__.py b/french/tutor/__init__.py new file mode 100644 index 0000000..8626fcd --- /dev/null +++ b/french/tutor/__init__.py @@ -0,0 +1,23 @@ +"""The French tutor engine — the subject-plugin contract implementation. + +This subpackage is the LLM-free engine behind french-cli's eight tutor verbs. It +owns committed content and per-learner state, resolves what to teach next, and +emits structured teaching directives; the driving agent (or human) does the +conversational tutoring and writes results back via ``record``. + +Layers: + +* :mod:`~french.tutor.subject` — the French-specific identity knobs (the first + thing a sibling language tutor swaps). +* :mod:`~french.tutor.curriculum` — committed modules / lessons / items / + exercises. +* :mod:`~french.tutor.stories` — discovery + loading of ``content/stories/``. +* :mod:`~french.tutor.state` — per-learner mastery + history, XDG-pathed, atomic. +* :mod:`~french.tutor.engine` — deterministic payload builders for every verb. + +The contract schemas + validator live cited in :mod:`french.contract_cite`. +""" + +from __future__ import annotations + +__all__ = ["curriculum", "engine", "state", "stories", "subject"] diff --git a/french/tutor/curriculum.py b/french/tutor/curriculum.py new file mode 100644 index 0000000..b7800a8 --- /dev/null +++ b/french/tutor/curriculum.py @@ -0,0 +1,508 @@ +"""The committed French curriculum — modules, lessons, items, exercises. + +This is the *content* half of the LLM-free tutor engine (the other half is +per-learner state). It is French-specific data only: the surrounding engine, +state, and CLI never hard-code course content, so a sibling language tutor swaps +this module (plus :mod:`french.tutor.subject` and ``content/stories/``) and +inherits the whole contract implementation unchanged. + +Shape (mirrors culture-guide's proven ``curriculum`` layout — structured Python +data, path-addressable): + +* a course is an ordered tuple of :class:`Module` (the web face renders one + sub-page per module); +* each module holds ordered :class:`Lesson` groups; each lesson holds ordered + :class:`Item` (curriculum items — the join key across lessons, practice, + stories, ``record``, and ``progress.mastery``); +* each item carries the teachable ``points`` a driver must explain and check, + plus its practice :class:`Exercise` batch. + +Item ids are namespaced ``fr..`` and match the contract id pattern +``^[a-z0-9][a-z0-9._-]*$``. +""" + +from __future__ import annotations + +from typing import NamedTuple + + +class Exercise(NamedTuple): + """One checkable/gradable exercise (same shape as the contract exercise).""" + + id: str + type: str # multiple_choice|true_false|cloze|short_answer|translation|open|discussion + item_id: str + prompt: str + choices: tuple[str, ...] = () + answer: str = "" + rubric: str = "" + + +class Item(NamedTuple): + """One curriculum item: a stable id, a label, teachable points, exercises.""" + + id: str + label: str + points: tuple[str, ...] + exercises: tuple[Exercise, ...] + body: str = "" + + +class Lesson(NamedTuple): + """An ordered group of items taught together as one lesson directive.""" + + id: str + title: str + objectives: tuple[str, ...] + items: tuple[Item, ...] + + +class Module(NamedTuple): + """A course module. ``level`` is the coarse contract rung; CEFR lives in stories.""" + + id: str + title: str + summary: str + level: str # beginner|intermediate|advanced + lessons: tuple[Lesson, ...] + + +# --------------------------------------------------------------------------- +# Module 1 — Premiers pas (beginner / A1) +# --------------------------------------------------------------------------- +_M1 = Module( + id="premiers-pas", + title="Premiers pas", + summary="Greetings, introductions, numbers, and prices — the survival core.", + level="beginner", + lessons=( + Lesson( + id="l.greetings", + title="Salutations et presentations", + objectives=( + "Greet and take leave at the right time of day.", + "Introduce yourself and ask someone's name.", + ), + items=( + Item( + id="fr.greetings.bonjour", + label="Greetings and politeness", + points=( + "Bonjour by day, bonsoir in the evening; salut is informal.", + "Politeness markers: s'il vous plait, merci, de rien, pardon.", + "« Ca va ? » asks how are you; reply « Ca va bien » or « Pas mal ».", + ), + exercises=( + Exercise( + id="bonjour-1", + type="translation", + item_id="fr.greetings.bonjour", + prompt="Say in French: 'Hello, how are you?'", + answer="Bonjour, ca va ?", + ), + Exercise( + id="bonjour-2", + type="multiple_choice", + item_id="fr.greetings.bonjour", + prompt="Quelle salutation convient le soir ?", + choices=("Bonjour", "Bonsoir", "Salut"), + answer="Bonsoir", + ), + ), + ), + Item( + id="fr.greetings.presentations", + label="Introducing yourself", + points=( + "« Je m'appelle ... » gives your name; « Comment vous " + "appelez-vous ? » asks it.", + "« Enchante(e) » = pleased to meet you (agree the -e if feminine).", + "« Je suis ... » + role/origin: je suis anglais, je suis etudiant.", + ), + exercises=( + Exercise( + id="presentations-1", + type="cloze", + item_id="fr.greetings.presentations", + prompt="« Comment vous ___-vous ? » — « Je m'appelle Claire. »", + answer="appelez", + ), + Exercise( + id="presentations-2", + type="open", + item_id="fr.greetings.presentations", + prompt="Introduce yourself in three short French sentences " + "(name, origin, one detail).", + rubric="Passes with a well-formed « je m'appelle » plus « je " + "suis »/« je viens de »; partial if one sentence is malformed.", + ), + ), + ), + ), + ), + Lesson( + id="l.numbers", + title="Nombres et prix", + objectives=( + "Count aloud from 0 to 20.", + "Ask for and understand prices in euros.", + ), + items=( + Item( + id="fr.numbers.compter", + label="Counting 0-20", + points=( + "0-16 are single words; 17-19 compose: dix-sept, dix-huit, dix-neuf.", + "Learn 0-20 by ear first — they recur in prices, times, and dates.", + "Liaison: « deux euros » links the x — /do.z‿o.ro/.", + ), + exercises=( + Exercise( + id="compter-1", + type="translation", + item_id="fr.numbers.compter", + prompt="Count aloud from 11 to 15 in French.", + answer="onze, douze, treize, quatorze, quinze", + ), + Exercise( + id="compter-2", + type="multiple_choice", + item_id="fr.numbers.compter", + prompt="Quel nombre est « quatorze » ?", + choices=("12", "14", "40"), + answer="14", + ), + ), + ), + Item( + id="fr.numbers.prix", + label="Prices in euros", + points=( + "« C'est combien ? » and « Ca fait combien ? » both ask the price.", + "Prices read 'X euros Y': quatre euros cinquante — never 'euros et'.", + "Round it off politely: « Ca fait dix euros. » — « Voila, merci ! »", + ), + exercises=( + Exercise( + id="prix-1", + type="translation", + item_id="fr.numbers.prix", + prompt="Say in French: 'That comes to seven euros fifty.'", + answer="Ca fait sept euros cinquante.", + ), + Exercise( + id="prix-2", + type="cloze", + item_id="fr.numbers.prix", + prompt="« C'est ___ ? » — « Deux euros, madame. »", + answer="combien", + ), + ), + ), + ), + ), + ), +) + +# --------------------------------------------------------------------------- +# Module 2 — La vie quotidienne (intermediate / A2) +# --------------------------------------------------------------------------- +_M2 = Module( + id="la-vie-quotidienne", + title="La vie quotidienne", + summary="Food and the market, ordering at a cafe, and daily-routine verbs.", + level="intermediate", + lessons=( + Lesson( + id="l.food", + title="Au marche et au cafe", + objectives=( + "Name common foods and buy them by quantity.", + "Order politely at a cafe and ask for the bill.", + ), + items=( + Item( + id="fr.food.marche", + label="Market and food vocabulary", + points=( + "Staples: le pain, le fromage, les pommes, une baguette.", + "Quantities take de: un kilo de pommes, une tranche de jambon.", + "Some = du / de la / des: du pain, de la confiture, des oeufs.", + ), + exercises=( + Exercise( + id="marche-1", + type="multiple_choice", + item_id="fr.food.marche", + prompt="Comment dit-on 'goat cheese' ?", + choices=( + "le fromage de chevre", + "le pain de campagne", + "la pomme de terre", + ), + answer="le fromage de chevre", + ), + Exercise( + id="marche-2", + type="cloze", + item_id="fr.food.marche", + prompt="Claire achete un ___ de pommes.", + answer="kilo", + ), + ), + ), + Item( + id="fr.food.commander", + label="Ordering at a cafe", + points=( + "« Je voudrais ..., s'il vous plait » is the polite order frame.", + "« Un cafe » is an espresso; « un cafe au lait » comes with milk.", + "« L'addition, s'il vous plait » asks for the bill.", + ), + exercises=( + Exercise( + id="commander-1", + type="translation", + item_id="fr.food.commander", + prompt="Order politely: 'I would like a coffee and a " + "croissant, please.'", + answer="Je voudrais un cafe et un croissant, s'il vous plait.", + ), + Exercise( + id="commander-2", + type="open", + item_id="fr.food.commander", + prompt="Role-play ordering two items at a cafe and asking " + "for the bill.", + rubric="Passes with a polite « je voudrais » order plus " + "« l'addition »; partial if the bill request is missing.", + ), + ), + ), + ), + ), + Lesson( + id="l.routine", + title="La journee", + objectives=( + "Describe a daily routine with reflexive verbs.", + "Say how often you do things.", + ), + items=( + Item( + id="fr.routine.journee", + label="Daily-routine verbs", + points=( + "Reflexives: je me leve, je me lave, je m'habille.", + "Time of day: le matin, l'apres-midi, le soir.", + "Frequency: tous les jours, souvent, parfois, ne ... jamais.", + ), + exercises=( + Exercise( + id="journee-1", + type="cloze", + item_id="fr.routine.journee", + prompt="Le matin, je ___ leve a sept heures.", + answer="me", + ), + Exercise( + id="journee-2", + type="short_answer", + item_id="fr.routine.journee", + prompt="Decris ta matinee en deux phrases.", + rubric="Passes with two well-formed reflexive-verb sentences; " + "one is partial.", + ), + ), + ), + ), + ), + ), +) + +# --------------------------------------------------------------------------- +# Module 3 — En ville (advanced rung of this ladder / A2) +# --------------------------------------------------------------------------- +_M3 = Module( + id="en-ville", + title="En ville", + summary="Getting around town: asking directions and taking public transport.", + level="advanced", + lessons=( + Lesson( + id="l.directions", + title="Demander son chemin et prendre les transports", + objectives=( + "Ask for and follow simple directions.", + "Buy a ticket and use public transport.", + ), + items=( + Item( + id="fr.ville.directions", + label="Asking for directions", + points=( + "« Pardon, ou est ... ? » / « Je cherche la gare. »", + "Directions: tout droit, a gauche, a droite, jusqu'au coin.", + "« C'est loin ? » — « Non, c'est a cinq minutes a pied. »", + ), + exercises=( + Exercise( + id="directions-1", + type="translation", + item_id="fr.ville.directions", + prompt="Ask politely: 'Excuse me, where is the station?'", + answer="Pardon, ou est la gare ?", + ), + Exercise( + id="directions-2", + type="multiple_choice", + item_id="fr.ville.directions", + prompt="'Turn left' se dit :", + choices=("tout droit", "a gauche", "a droite"), + answer="a gauche", + ), + ), + ), + Item( + id="fr.ville.transport", + label="Public transport", + points=( + "Prendre le metro / le bus / le train; un ticket, un carnet.", + "« Quel bus va a ... ? » — « Le 27 va au centre. »", + "« Un aller-retour pour Lyon, s'il vous plait. »", + ), + exercises=( + Exercise( + id="transport-1", + type="cloze", + item_id="fr.ville.transport", + prompt="Je prends le ___ pour aller au travail.", + answer="metro", + ), + Exercise( + id="transport-2", + type="open", + item_id="fr.ville.transport", + prompt="Buy a round-trip train ticket to Lyon and ask what " + "time it leaves.", + rubric="Passes with a polite « aller-retour » request plus a " + "time question (« a quelle heure »); partial if one is missing.", + ), + ), + ), + ), + ), + ), +) + +#: The course, in order. The web face renders one sub-page per module. +MODULES: tuple[Module, ...] = (_M1, _M2, _M3) + + +# --------------------------------------------------------------------------- +# Indices + lookups (built once, module-level) +# --------------------------------------------------------------------------- +def all_lessons() -> tuple[tuple[Module, Lesson], ...]: + """Every (module, lesson) pair in course order.""" + return tuple((m, lesson) for m in MODULES for lesson in m.lessons) + + +def all_items() -> tuple[tuple[Module, Lesson, Item], ...]: + """Every (module, lesson, item) triple in course order.""" + return tuple((m, lesson, item) for m, lesson in all_lessons() for item in lesson.items) + + +def all_item_ids() -> tuple[str, ...]: + """Stable item ids in course order (the mastery-map / join keys).""" + return tuple(item.id for _, _, item in all_items()) + + +def all_exercises() -> tuple[Exercise, ...]: + """Every practice exercise across the curriculum, in course order.""" + return tuple(ex for _, _, item in all_items() for ex in item.exercises) + + +_ITEM_INDEX: dict[str, tuple[Module, Lesson, Item]] = { + item.id: (m, lesson, item) for m, lesson, item in all_items() +} +_LESSON_INDEX: dict[str, tuple[Module, Lesson]] = { + lesson.id: (m, lesson) for m, lesson in all_lessons() +} +_MODULE_INDEX: dict[str, Module] = {m.id: m for m in MODULES} + + +def find_item(item_id: str) -> tuple[Module, Lesson, Item] | None: + return _ITEM_INDEX.get(item_id) + + +def find_lesson(lesson_id: str) -> tuple[Module, Lesson] | None: + return _LESSON_INDEX.get(lesson_id) + + +def find_module(module_id: str) -> Module | None: + return _MODULE_INDEX.get(module_id) + + +class LessonTarget(NamedTuple): + """A resolved lesson-verb target: the module + the lesson to teach.""" + + module: Module + lesson: Lesson + + +def resolve_lesson_target(token: str) -> LessonTarget: + """Resolve a lesson token — a lesson id, a module id, or an item id. + + A module id resolves to its first lesson; an item id resolves to the lesson + that contains it. Raises :class:`KeyError` with the token if nothing matches + (the CLI turns that into a ``CliError`` listing valid targets). + """ + needle = token.strip() + hit = find_lesson(needle) + if hit is not None: + return LessonTarget(module=hit[0], lesson=hit[1]) + module = find_module(needle) + if module is not None: + return LessonTarget(module=module, lesson=module.lessons[0]) + item_hit = find_item(needle) + if item_hit is not None: + return LessonTarget(module=item_hit[0], lesson=item_hit[1]) + raise KeyError(token) + + +def valid_lesson_targets() -> list[str]: + """All accepted lesson tokens (module ids + lesson ids), for error hints.""" + return [m.id for m in MODULES] + [lesson.id for _, lesson in all_lessons()] + + +def exercises_for_scope(scope: str) -> tuple[str, tuple[Exercise, ...]] | None: + """Resolve a practice ``scope`` to ``(resolved_scope, exercises)``. + + ``scope`` may be an item id (that item's exercises), a module id (all its + items' exercises), or a lesson id (that lesson's exercises). Returns ``None`` + when nothing matches (the caller raises a ``CliError``). + """ + item_hit = find_item(scope) + if item_hit is not None: + return item_hit[2].id, item_hit[2].exercises + module = find_module(scope) + if module is not None: + exercises = tuple( + ex for lesson in module.lessons for it in lesson.items for ex in it.exercises + ) + return module.id, exercises + lesson_hit = find_lesson(scope) + if lesson_hit is not None: + exercises = tuple(ex for it in lesson_hit[1].items for ex in it.exercises) + return lesson_hit[1].id, exercises + return None + + +def counts() -> dict[str, int]: + """Deterministic content counts for the overview payload.""" + return { + "modules": len(MODULES), + "lessons": len(all_lessons()), + "items": len(all_item_ids()), + "exercises": len(all_exercises()), + } diff --git a/french/tutor/engine.py b/french/tutor/engine.py new file mode 100644 index 0000000..8929940 --- /dev/null +++ b/french/tutor/engine.py @@ -0,0 +1,367 @@ +"""Payload builders — assemble every contract payload from state + content. + +This is the deterministic, LLM-free core: given the curriculum, the committed +stories, and a learner's stored state, it produces the exact ``--json`` shapes +the subject-plugin contract defines (``overview``, ``progress``, ``advice``, +``lesson``, ``practice``, ``record`` ack, ``story list``/``read``). No content +strings are hard-coded here — the French-ness comes from +:mod:`french.tutor.subject`, :mod:`french.tutor.curriculum`, and the story +files — so a sibling language tutor reuses this module verbatim. + +The driver-facing directive *instructions* are language-neutral (the +French-specific voice is in ``subject.PERSONA``), which keeps a sibling-language +port to a token swap of ``subject`` + ``curriculum`` + the story files. +""" + +from __future__ import annotations + +from typing import Any + +from french.contract_cite import CONTRACT_VERSION +from french.tutor import curriculum, state, stories, subject + +_CMD = subject.COMMAND + + +# --------------------------------------------------------------------------- +# Small helpers +# --------------------------------------------------------------------------- +def _exercise_dict(ex: curriculum.Exercise) -> dict[str, Any]: + out: dict[str, Any] = { + "id": ex.id, + "type": ex.type, + "item_id": ex.item_id, + "prompt": ex.prompt, + } + if ex.choices: + out["choices"] = list(ex.choices) + if ex.answer: + out["answer"] = ex.answer + if ex.rubric: + out["rubric"] = ex.rubric + return out + + +def _record_line(learner: str, item_id: str, activity: str, *, exercise: str | None = None) -> str: + line = f"{_CMD} record --learner {learner} --item {item_id} --activity {activity}" + if exercise: + line += f" --exercise {exercise}" + return line + " --result pass|partial|fail --json" + + +# --------------------------------------------------------------------------- +# Recommendation (the within-subject next step) +# --------------------------------------------------------------------------- +def recommend_next(st: dict[str, Any]) -> dict[str, Any]: + """First not-yet-mastered item in course order → a runnable next step. + + Untouched item → start its lesson; touched-but-not-mastered → practice it. + When everything authored is mastered, ``done`` flips true and the step is a + maintenance/review one (learn-cli then drives depth mode). + """ + for module, lesson, item in curriculum.all_items(): + level = state.mastery_of(st, item.id) + if state.mastery_ordinal(level) >= 3: + continue + if state.mastery_ordinal(level) == 0: + return { + "done": False, + "module_id": module.id, + "item_id": item.id, + "text": f"start '{item.label}' in {module.title}", + "command": f"{_CMD} lesson start {lesson.id} --json", + } + return { + "done": False, + "module_id": module.id, + "item_id": item.id, + "text": f"practice '{item.label}' in {module.title} to reach mastered", + "command": f"{_CMD} practice {item.id} --json", + } + return { + "done": True, + "text": "every item is mastered — review decayed items and try harder repeats", + "command": f"{_CMD} practice review --json", + } + + +def weak_items(st: dict[str, Any]) -> list[dict[str, str]]: + """Touched-but-not-mastered items, weakest first (course order tiebreak).""" + out: list[dict[str, str]] = [] + for item_id in curriculum.all_item_ids(): + level = st["mastery"].get(item_id) + if level is not None and state.mastery_ordinal(level) < 3: + out.append({"item_id": item_id, "mastery": level}) + out.sort(key=lambda w: state.mastery_ordinal(w["mastery"])) + return out + + +# --------------------------------------------------------------------------- +# overview (subject_overview) +# --------------------------------------------------------------------------- +def overview_payload() -> dict[str, Any]: + modules = [ + { + "id": m.id, + "title": m.title, + "summary": m.summary, + "level": m.level, + } + for m in curriculum.MODULES + ] + content_counts = curriculum.counts() + return { + "schema_version": CONTRACT_VERSION, + "kind": "subject_overview", + "subject": subject.SUBJECT_ID, + "display_name": subject.DISPLAY_NAME, + "tagline": subject.TAGLINE, + "description": subject.DESCRIPTION, + "language": subject.LANGUAGE, + "modules": modules, + "content": { + "stories": stories.story_count(), + "lessons": content_counts["lessons"], + "exercises": content_counts["exercises"] + _story_exercise_count(), + }, + } + + +def _story_exercise_count() -> int: + total = 0 + for summary in stories.list_summaries(): + total += int(summary.get("exercises", 0) or 0) + return total + + +# --------------------------------------------------------------------------- +# progress +# --------------------------------------------------------------------------- +def progress_payload(st: dict[str, Any], learner: str) -> dict[str, Any]: + mastery = dict(st["mastery"]) + mastered = [iid for iid, lvl in mastery.items() if state.mastery_ordinal(lvl) >= 3] + payload: dict[str, Any] = { + "schema_version": CONTRACT_VERSION, + "kind": "progress", + "subject": subject.SUBJECT_ID, + "learner": learner, + "started_at": st.get("started_at", ""), + "last_seen_at": st.get("last_seen_at", ""), + "current": st.get("current"), + "items_total": len(curriculum.all_item_ids()), + "items_touched": len(mastery), + "items_mastered": len(mastered), + "completed": sorted(mastered), + "mastery": mastery, + "weak": weak_items(st), + "next": recommend_next(st), + } + return payload + + +# --------------------------------------------------------------------------- +# advice +# --------------------------------------------------------------------------- +def advice_payload(st: dict[str, Any], learner: str) -> dict[str, Any]: + advice: list[dict[str, Any]] = [] + if not st["mastery"]: + nxt = recommend_next(st) + advice.append( + { + "focus": nxt.get("item_id", ""), + "reason": "You haven't started this subject yet.", + "suggestion": nxt["text"].replace("start '", "Begin with '"), + "command": nxt["command"], + } + ) + else: + for weak in weak_items(st)[:3]: + module, _lesson, item = curriculum.find_item(weak["item_id"]) or (None, None, None) + label = item.label if item is not None else weak["item_id"] + advice.append( + { + "focus": weak["item_id"], + "reason": f"'{label}' is at '{weak['mastery']}', not yet mastered.", + "suggestion": f"Run a short practice pass on {label} before moving on.", + "command": f"{_CMD} practice {weak['item_id']} --json", + } + ) + if not advice: # everything touched is mastered + advice.append( + { + "focus": "", + "reason": "Every touched item is mastered.", + "suggestion": "Review decayed items and try a harder lesson repeat " + "or a fresh story.", + "command": f"{_CMD} practice review --json", + } + ) + return { + "schema_version": CONTRACT_VERSION, + "kind": "advice", + "subject": subject.SUBJECT_ID, + "learner": learner, + "advice": advice, + } + + +# --------------------------------------------------------------------------- +# lesson (lesson_directive) +# --------------------------------------------------------------------------- +def _lesson_items_payload(lesson: curriculum.Lesson) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + for item in lesson.items: + entry: dict[str, Any] = {"id": item.id, "label": item.label, "points": list(item.points)} + if item.body: + entry["body"] = item.body + items.append(entry) + return items + + +def lesson_payload( + learner: str, + *, + mode: str, + module: curriculum.Module, + lesson: curriculum.Lesson, + difficulty: int, +) -> dict[str, Any]: + record_lines = [_record_line(learner, item.id, "lesson") for item in lesson.items] + record_lines.append(f"{_CMD} progress --learner {learner} --json") + instructions = [ + "Teach one item at a time; explain each teachable point with a short " + "spoken exchange the learner completes.", + "Ask a brief comprehension check after each point and adapt the pace to " "the answers.", + "Finish with a short role-play that uses the whole lesson in context.", + "Grade each check pass|partial|fail and record every result before ending " "the session.", + ] + if difficulty > 1: + instructions.insert( + 0, + f"This is repeat rung {difficulty}: raise the bar — less English, faster " + "pace, and demand fuller answers than the first pass.", + ) + return { + "schema_version": CONTRACT_VERSION, + "kind": "lesson_directive", + "subject": subject.SUBJECT_ID, + "learner": learner, + "mode": mode, + "lesson": { + "id": lesson.id, + "module_id": module.id, + "title": lesson.title, + "level": module.level, + "difficulty": difficulty, + "objectives": list(lesson.objectives), + "items": _lesson_items_payload(lesson), + }, + "directive": { + "persona": subject.PERSONA, + "instructions": instructions, + "record_with": record_lines, + }, + } + + +# --------------------------------------------------------------------------- +# practice (practice_directive) +# --------------------------------------------------------------------------- +def practice_payload( + learner: str, + *, + scope: str, + exercises: list[dict[str, Any]], +) -> dict[str, Any]: + first = exercises[0] + example = _record_line( + learner, first.get("item_id", scope), "practice", exercise=first.get("id") + ) + return { + "schema_version": CONTRACT_VERSION, + "kind": "practice_directive", + "subject": subject.SUBJECT_ID, + "learner": learner, + "scope": scope, + "exercises": exercises, + "directive": { + "persona": subject.PERSONA, + "instructions": [ + "Run the exercises one at a time, conversationally — do not dump " + "them as a list.", + "Grade pass|partial|fail against the answer or rubric; explain any " + "miss briefly, in context.", + "Record each result immediately after grading it.", + ], + "record_with": [example], + }, + } + + +# --------------------------------------------------------------------------- +# record ack (record_ack) +# --------------------------------------------------------------------------- +def record_ack_payload( + learner: str, + *, + recorded: dict[str, Any], + item_id: str, + level: str, + next_step: dict[str, Any], +) -> dict[str, Any]: + return { + "schema_version": CONTRACT_VERSION, + "kind": "record_ack", + "subject": subject.SUBJECT_ID, + "learner": learner, + "recorded": recorded, + "mastery": {"item_id": item_id, "level": level}, + "next": next_step, + } + + +# --------------------------------------------------------------------------- +# story list / read +# --------------------------------------------------------------------------- +def story_list_payload(level: str | None = None) -> dict[str, Any]: + return { + "schema_version": CONTRACT_VERSION, + "kind": "story_list", + "subject": subject.SUBJECT_ID, + "stories": stories.list_summaries(level), + } + + +def story_read_payload(learner: str, story: dict[str, Any]) -> dict[str, Any]: + exercises = story.get("exercises", []) or [] + record_lines: list[str] = [] + if exercises: + first = exercises[0] + item_id = first.get("item_id") or story.get("id", "") + record_lines.append( + f"{_CMD} record --learner {learner} --item {item_id} " + f"--exercise {first.get('id', '')} --activity story --result pass|partial|fail --json" + ) + else: + record_lines.append( + f"{_CMD} record --learner {learner} --item {story.get('id', '')} " + "--activity story --result pass|partial|fail --json" + ) + return { + "schema_version": CONTRACT_VERSION, + "kind": "story_read", + "subject": subject.SUBJECT_ID, + "learner": learner, + "story": story, + "directive": { + "persona": subject.PERSONA, + "instructions": [ + "Present the story one paragraph at a time; let the learner attempt " + "it before translating.", + "Use the glossary entries when the learner is stuck; do not " "pre-translate.", + "Run each comprehension exercise conversationally, grade " + "pass|partial|fail, and record every result.", + ], + "record_with": record_lines, + }, + } diff --git a/french/tutor/state.py b/french/tutor/state.py new file mode 100644 index 0000000..e455a57 --- /dev/null +++ b/french/tutor/state.py @@ -0,0 +1,343 @@ +"""Per-learner tutor state — one JSON file per learner, XDG-pathed, atomic. + +The subject CLI owns the learner's mastery ladder and raw-result history *within +this subject*; learn-cli owns the cross-subject ledger and all scoring. This +module is that subject-side store. It is pure logic — no argparse, no printing; +the CLI layer calls it and renders. + +Design (carried over from culture-guide's proven ``teach.state``, generalized to +the contract): + +* **XDG path resolution** — ``$FRENCH_CLI_LEARN_HOME`` > + ``$XDG_DATA_HOME/french_cli/learn`` > ``~/.local/share/french_cli/learn``. +* **Atomic writes** — write a temp file in the same dir, then ``os.replace`` + (atomic on POSIX), so two driver processes can never leave a half-written + file. +* **Tolerant-but-typed loads** — missing fields fall back to defaults (an older + file still loads); a *future* ``state_version``, a learner-id mismatch, or a + wrong-typed field is refused with :class:`StateError` (the CLI turns it into a + clean environment error) rather than crashing deeper. +* **Mastery never regresses on inference** — ``fail → introduced``, + ``partial → practiced``, ``pass → mastered``; an explicit ``mastery`` overrides. + +Nothing here is French-specific: the store keys off ``subject.SUBJECT_ID`` and +the item ids the curriculum defines, so a sibling language tutor reuses it as-is. +""" + +from __future__ import annotations + +import getpass +import hashlib +import json +import os +import re +import tempfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from french.contract_cite import MASTERY_LEVELS, RESULTS +from french.tutor import subject + +#: Internal on-disk state-file version (distinct from the contract's payload +#: ``schema_version``). Bumped only when this file's own shape changes. +STATE_VERSION = 1 + +#: Activities a record may report (matches the contract ``recorded.activity``). +ACTIVITIES: tuple[str, ...] = ("lesson", "practice", "story") + +# A raw result implies *at least* this mastery level when --mastery is omitted. +# The "pass" key trips bandit B105 (reads it as a password); it is a result +# label, not a secret. +_RESULT_TO_MASTERY = { # nosec B105 + "fail": "introduced", + "partial": "practiced", + "pass": "mastered", +} + +#: Env var a learner can set to avoid passing --learner every call. +_LEARNER_ENV = "FRENCH_CLI_LEARNER" + +#: Env var overriding the state directory (highest precedence, per the contract). +_HOME_ENV = "FRENCH_CLI_LEARN_HOME" + +_SAFE_ID = re.compile(r"[^A-Za-z0-9._-]") + + +class StateError(Exception): + """Raised on an unreadable / future-version / inconsistent state file.""" + + +# --------------------------------------------------------------------------- +# Identity + path resolution +# --------------------------------------------------------------------------- +def resolve_learner(explicit: str | None = None) -> str: + """Learner id: ``--learner`` > ``$FRENCH_CLI_LEARNER`` > OS user > ``default``. + + The contract requires the resolved id to be echoed in every payload's + ``learner`` field, even when defaulted; callers pass the return value through. + The learner may be a human or an agent. + """ + candidate = explicit or os.environ.get(_LEARNER_ENV) + if not candidate: + try: + candidate = getpass.getuser() + except Exception: # noqa: BLE001 - getuser can raise on odd environments + candidate = "default" + return candidate.strip() or "default" + + +def state_dir() -> Path: + """Data dir: ``$FRENCH_CLI_LEARN_HOME`` > ``$XDG_DATA_HOME/french_cli/learn`` + > ``~/.local/share/french_cli/learn``.""" + override = os.environ.get(_HOME_ENV) + if override: + return Path(override).expanduser() + xdg = os.environ.get("XDG_DATA_HOME") + base = Path(xdg).expanduser() if xdg else Path.home() / ".local" / "share" + return base / "french_cli" / "learn" + + +def _safe_filename(learner: str) -> str: + """A filesystem-safe, collision-free stem for a learner id. + + A plain sanitized id can collide (``a/b`` and ``a_b`` both → ``a_b``). When + sanitization changes the id, a short content hash of the *original* is + appended so distinct learners never share a file. Already-safe ids keep + their readable name. + """ + safe = _SAFE_ID.sub("_", learner) + if safe != learner or not safe: + digest = hashlib.sha256(learner.encode("utf-8")).hexdigest()[:8] + safe = f"{safe or 'id'}-{digest}" + return safe + + +def state_path(learner: str) -> Path: + return state_dir() / f"{_safe_filename(learner)}.json" + + +# --------------------------------------------------------------------------- +# Time (injectable for tests) +# --------------------------------------------------------------------------- +def now_iso(now: datetime | None = None) -> str: + return (now or datetime.now(timezone.utc)).isoformat() + + +# --------------------------------------------------------------------------- +# Construction / load / save +# --------------------------------------------------------------------------- +def new_state(learner: str, *, now: datetime | None = None) -> dict[str, Any]: + stamp = now_iso(now) + return { + "state_version": STATE_VERSION, + "subject": subject.SUBJECT_ID, + "learner": learner, + "started_at": stamp, + "last_seen_at": stamp, + "current": None, + "mastery": {}, + "history": [], + "repeats": {}, + } + + +# Expected JSON type per field — a present field of the wrong type means a +# malformed (but parseable) file, refused cleanly rather than crashed on later. +_FIELD_TYPES: dict[str, type | tuple[type, ...]] = { + "started_at": str, + "last_seen_at": str, + "current": (dict, type(None)), + "mastery": dict, + "history": list, + "repeats": dict, +} + + +def _coerce(raw: dict[str, Any], learner: str) -> dict[str, Any]: + """Typed, identity-safe projection of ``raw`` onto the current schema.""" + embedded = raw.get("learner") + if embedded is not None and embedded != learner: + raise StateError(f"state file for '{learner}' carries a different learner ({embedded!r})") + base = new_state(learner) + for key, expected in _FIELD_TYPES.items(): + if key in raw: + if not isinstance(raw[key], expected): + raise StateError(f"state field '{key}' has the wrong type") + base[key] = raw[key] + base["learner"] = learner + base["subject"] = subject.SUBJECT_ID + base["state_version"] = STATE_VERSION + return base + + +def load(learner: str) -> dict[str, Any]: + """Load a learner's state, or a fresh default if none exists. + + Raises :class:`StateError` on a corrupt file or a *newer* state version. + """ + path = state_path(learner) + if not path.is_file(): + return new_state(learner) + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + raise StateError(f"cannot read state file {path}: {exc}") from exc + if not isinstance(raw, dict): + raise StateError(f"state file {path} is not a JSON object") + version = raw.get("state_version", STATE_VERSION) + if isinstance(version, int) and version > STATE_VERSION: + raise StateError( + f"state file {path} has state_version {version}, " + f"newer than this CLI supports ({STATE_VERSION})" + ) + return _coerce(raw, learner) + + +def save(state: dict[str, Any], *, now: datetime | None = None) -> Path: + """Atomically persist ``state``; stamps ``last_seen_at``. Returns the path.""" + state["last_seen_at"] = now_iso(now) + state["state_version"] = STATE_VERSION + path = state_path(state["learner"]) + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".tmp-", suffix=".json") + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + json.dump(state, fh, ensure_ascii=False, indent=2, sort_keys=True) + fh.write("\n") + os.replace(tmp, path) # atomic on POSIX + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + return path + + +def exists(learner: str) -> bool: + return state_path(learner).is_file() + + +def delete(learner: str) -> bool: + """Remove a learner's state file. Returns True if a file was removed.""" + path = state_path(learner) + if path.is_file(): + path.unlink() + return True + return False + + +# --------------------------------------------------------------------------- +# Mastery +# --------------------------------------------------------------------------- +def mastery_ordinal(level: str) -> int: + try: + return MASTERY_LEVELS.index(level) + except ValueError: + return 0 + + +def mastery_of(state: dict[str, Any], item_id: str) -> str: + return state["mastery"].get(item_id, "unknown") + + +def set_mastery(state: dict[str, Any], item_id: str, level: str) -> None: + if level not in MASTERY_LEVELS: + raise ValueError(f"unknown mastery level '{level}'") + state["mastery"][item_id] = level + + +def infer_mastery(result: str) -> str: + """The mastery level a raw result implies (before the never-regress rule).""" + return _RESULT_TO_MASTERY[result] + + +def touch_item(state: dict[str, Any], item_id: str) -> None: + """First exposure lifts an untouched item to at least ``introduced``.""" + if item_id not in state["mastery"]: + state["mastery"][item_id] = "introduced" + + +def set_current(state: dict[str, Any], module_id: str, item_id: str) -> None: + state["current"] = {"module_id": module_id, "item_id": item_id} + + +def bump_repeat(state: dict[str, Any], lesson_id: str) -> int: + """Increment the harder-repeat count and return the new difficulty rung.""" + count = int(state["repeats"].get(lesson_id, 0)) + 1 + state["repeats"][lesson_id] = count + return 1 + count + + +def repeat_difficulty(state: dict[str, Any], lesson_id: str) -> int: + """Current difficulty rung for a lesson (1 = first pass, +1 per harder repeat).""" + return 1 + int(state["repeats"].get(lesson_id, 0)) + + +# --------------------------------------------------------------------------- +# The write-back (record) +# --------------------------------------------------------------------------- +def record_result( + state: dict[str, Any], + *, + item_id: str, + activity: str, + result: str, + exercise_id: str | None = None, + story_id: str | None = None, + lesson_id: str | None = None, + correct: int | None = None, + total: int | None = None, + duration_seconds: float | None = None, + notes: str | None = None, + mastery: str | None = None, + now: datetime | None = None, +) -> dict[str, Any]: + """Append the raw result to history and update the item's mastery. + + Returns the normalized ``recorded`` object (the contract's scoring input): + raw observations only — never a score/grade/points. Mastery is inferred from + ``result`` unless ``mastery`` is given, and inference never regresses. + """ + if result not in RESULTS: + raise ValueError(f"result must be one of {', '.join(RESULTS)}") + if activity not in ACTIVITIES: + raise ValueError(f"activity must be one of {', '.join(ACTIVITIES)}") + if mastery is not None and mastery not in MASTERY_LEVELS: + raise ValueError(f"mastery must be one of {', '.join(MASTERY_LEVELS)}") + if total is not None and total < 1: + raise ValueError("total must be >= 1 when given") + if correct is not None and correct < 0: + raise ValueError("correct must be >= 0 when given") + + recorded: dict[str, Any] = { + "item_id": item_id, + "activity": activity, + "result": result, + "at": now_iso(now), + } + if exercise_id: + recorded["exercise_id"] = exercise_id + if story_id: + recorded["story_id"] = story_id + if lesson_id: + recorded["lesson_id"] = lesson_id + if correct is not None: + recorded["correct"] = correct + if total is not None: + recorded["total"] = total + if duration_seconds is not None: + recorded["duration_seconds"] = duration_seconds + if notes: + recorded["notes"] = notes + + state["history"].append(recorded) + + if mastery is not None: + set_mastery(state, item_id, mastery) + else: + inferred = infer_mastery(result) + if mastery_ordinal(inferred) >= mastery_ordinal(mastery_of(state, item_id)): + set_mastery(state, item_id, inferred) + return recorded diff --git a/french/tutor/stories.py b/french/tutor/stories.py new file mode 100644 index 0000000..a217862 --- /dev/null +++ b/french/tutor/stories.py @@ -0,0 +1,122 @@ +"""Committed story content — discovery, loading, and validation. + +Stories are flat JSON files under ``content/stories/`` (pinned decision: +``filename == story id``), each conforming to the shared contract ``story`` +schema. This module globs that directory (no hard-coded filenames or counts, so +the ladder can grow underneath it), loads stories, and builds the ``story list`` +/ ``story read`` payload bodies. It is subject-agnostic — the French-ness lives +entirely in the JSON files. + +Content-dir resolution (first that exists wins): + +1. ``$FRENCH_CLI_CONTENT_DIR`` — explicit override (tests, alternate content); +2. the repo-root ``content/stories/`` walking up from this file (source checkout); +3. the packaged ``french/_content/stories/`` (the wheel force-includes it). +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +from french.contract_cite import validate + +_CONTENT_ENV = "FRENCH_CLI_CONTENT_DIR" + + +def _candidate_dirs() -> list[Path]: + dirs: list[Path] = [] + override = os.environ.get(_CONTENT_ENV) + if override: + dirs.append(Path(override).expanduser()) + here = Path(__file__).resolve() + for parent in here.parents: + dirs.append(parent / "content" / "stories") + # Packaged copy next to the installed package (wheel force-include target). + dirs.append(here.parent.parent / "_content" / "stories") + return dirs + + +def content_dir() -> Path | None: + """The first existing story-content directory, or ``None`` if none exists.""" + for candidate in _candidate_dirs(): + if candidate.is_dir(): + return candidate + return None + + +def story_files() -> list[Path]: + """Every ``*.json`` story file, sorted by filename. Empty if no content dir.""" + root = content_dir() + if root is None: + return [] + return sorted(root.glob("*.json")) + + +def load_story(path: Path) -> dict[str, Any]: + """Parse one story file to a dict. Raises on unreadable / non-object JSON.""" + raw = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValueError(f"{path.name}: story file is not a JSON object") + return raw + + +def validate_story(story: dict[str, Any]) -> list[str]: + """Validate a story object against the cited ``story`` schema ([] = valid).""" + return validate(story, "story") + + +def _summary(story: dict[str, Any]) -> dict[str, Any]: + out: dict[str, Any] = { + "id": story.get("id", ""), + "title": story.get("title", ""), + "level": story.get("level", ""), + "exercises": len(story.get("exercises", []) or []), + } + for optional in ("level_detail", "language", "summary"): + if optional in story: + out[optional] = story[optional] + return out + + +def list_summaries(level: str | None = None) -> list[dict[str, Any]]: + """Story summaries for the catalog, optionally filtered by coarse ``level``. + + Malformed files are skipped rather than crashing the whole listing; the + content-validation CI test is what fails the build on a bad file. + """ + out: list[dict[str, Any]] = [] + for path in story_files(): + try: + story = load_story(path) + except (json.JSONDecodeError, ValueError, OSError): + continue + if level is not None and story.get("level") != level: + continue + out.append(_summary(story)) + return out + + +def read_story(story_id: str) -> dict[str, Any] | None: + """Return the full committed story object for ``story_id``, or ``None``. + + Lookup is by filename stem (the pinned ``filename == id`` rule), so it is a + direct file open, not a directory scan. + """ + root = content_dir() + if root is None: + return None + path = root / f"{story_id}.json" + if not path.is_file(): + return None + try: + return load_story(path) + except (json.JSONDecodeError, ValueError, OSError): + return None + + +def story_count() -> int: + """Number of story files (best-effort; 0 when no content dir).""" + return len(story_files()) diff --git a/french/tutor/subject.py b/french/tutor/subject.py new file mode 100644 index 0000000..857eed2 --- /dev/null +++ b/french/tutor/subject.py @@ -0,0 +1,51 @@ +"""Subject identity — the French-specific knobs. + +This module is the *first* thing a sibling language tutor (e.g. spanish-cli) +rewrites when porting french-cli: everything here is language-specific, and +nothing else in :mod:`french.tutor` hard-codes "French", "fr", or the persona. +The contract plumbing (``state``, ``engine``, ``stories``, ``contract_cite``, +and the CLI command chassis) is subject-agnostic and ports unchanged. + +Port checklist for spanish-cli (see ``docs/contract-provenance.md`` for the full +token map): + +* ``SUBJECT_ID`` ``"french"`` → ``"spanish"`` +* ``COMMAND`` ``"french"`` → ``"spanish"`` (the console script on PATH) +* ``DISPLAY_NAME`` / ``LANGUAGE`` / ``TAGLINE`` / ``DESCRIPTION`` / ``PERSONA`` +* the whole of :mod:`french.tutor.curriculum` (module/lesson/item content) +* the ``content/stories/*.json`` files +""" + +from __future__ import annotations + +#: The subject's registry id (matches ``^[a-z][a-z0-9-]*$``). The value learn-cli +#: keys this subject on, and the ``subject`` field of every contract payload. +SUBJECT_ID = "french" + +#: The installed console script on PATH. Embedded verbatim in every ``command`` +#: and ``record_with`` line a directive hands the driver, so it must be runnable. +COMMAND = "french" + +#: Human-facing display name (the web face's course title). +DISPLAY_NAME = "French" + +#: BCP-47 tag of the language being taught. +LANGUAGE = "fr" + +#: One-line hook for the catalog. +TAGLINE = "Written and spoken French through graded stories, lessons, and practice." + +#: Longer self-description for ``overview``. +DESCRIPTION = ( + "A graded-reader French tutor: greeting-to-getting-around lessons, market " + "and cafe stories, and spaced practice from A1 upward. The CLI is LLM-free " + "— it resolves what to teach and emits structured teaching directives; an " + "agent or human driver does the conversational tutoring over --json." +) + +#: The tutor persona a directive hands the driving agent (verb-agnostic voice). +PERSONA = ( + "You are a patient, practical French tutor. Teach in short spoken exchanges, " + "start from what the learner already knows, and lean less on English as they " + "improve. Encourage, correct gently, and keep every turn usable out loud." +) diff --git a/pyproject.toml b/pyproject.toml index 90c4a7a..1bb3900 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "french-cli" -version = "0.4.1" +version = "0.5.0" description = "Agent + CLI that turns Claude into a private French tutor: track progress, get an overview, get advice, read stories, and learn & practice French (written and spoken) online from your phone." readme = "README.md" license = "Apache-2.0" @@ -29,6 +29,13 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["french"] +# Ship the committed story content inside the wheel so the installed CLI can +# resolve `content/stories/*.json` without the repo checked out. The loader in +# french/tutor/stories.py falls back to this packaged copy (french/_content/ +# stories) when the repo-root content/ dir is absent. +[tool.hatch.build.targets.wheel.force-include] +"content" = "french/_content" + [dependency-groups] dev = [ "pytest>=8.0", diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0281d3f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,36 @@ +"""Shared test fixtures. + +Every test runs with ``$FRENCH_CLI_LEARN_HOME`` pointed at a fresh temp dir, so +no test reads or writes the developer's real learner state under +``~/.local/share/french_cli/learn``. Function-scoped, so each test starts from +an empty state store. +""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest + +from french.cli import main + + +@pytest.fixture(autouse=True) +def learn_home(tmp_path, monkeypatch) -> str: + """Isolate per-learner state in a temp dir for every test.""" + home = tmp_path / "learn-home" + monkeypatch.setenv("FRENCH_CLI_LEARN_HOME", str(home)) + # Guard against a stray learner env leaking a real id into a test. + monkeypatch.delenv("FRENCH_CLI_LEARNER", raising=False) + return str(home) + + +def run_json(capsys: pytest.CaptureFixture[str], argv: list[str]) -> tuple[int, Any]: + """Run a verb with --json, returning ``(exit_code, parsed_stdout)``. + + stderr is left in the capture buffer for the caller to inspect if needed. + """ + rc = main(argv) + out = capsys.readouterr().out + return rc, (json.loads(out) if out.strip() else None) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4fa8c9e..b46cd9a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -134,3 +134,28 @@ def test_every_catalog_path_resolves(capsys: pytest.CaptureFixture[str]) -> None rc = main(["explain", *path]) assert rc == 0, f"explain {' '.join(path)} failed" capsys.readouterr() + + +def _registered_paths() -> list[tuple[str, ...]]: + """Every command path registered in the argparse tree (nouns + subverbs).""" + import argparse + + from french.cli import _build_parser + + def walk(parser, prefix: tuple[str, ...]) -> list[tuple[str, ...]]: + found: list[tuple[str, ...]] = [] + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + for name, subparser in action.choices.items(): + found.append(prefix + (name,)) + found.extend(walk(subparser, prefix + (name,))) + return found + + return walk(_build_parser(), ()) + + +def test_every_registered_path_has_catalog_entry() -> None: + """Every registered noun/verb path must have an explain catalog entry.""" + catalog = set(known_paths()) + missing = [p for p in _registered_paths() if p not in catalog] + assert missing == [], f"registered paths missing an explain entry: {missing}" diff --git a/tests/test_cli_introspection.py b/tests/test_cli_introspection.py index 67a5a10..5e75c6e 100644 --- a/tests/test_cli_introspection.py +++ b/tests/test_cli_introspection.py @@ -23,7 +23,10 @@ def test_overview_json_shape(capsys: pytest.CaptureFixture[str]) -> None: rc = main(["overview", "--json"]) assert rc == 0 payload = json.loads(capsys.readouterr().out) - assert payload["subject"] == "french-cli" + # Reconciled: the contract subject id is `french`, and the agent-first + # rubric's `sections` key is kept alongside it (open payload). + assert payload["subject"] == "french" + assert payload["kind"] == "subject_overview" assert isinstance(payload["sections"], list) assert payload["sections"] @@ -76,13 +79,13 @@ def test_cli_overview_unknown_flag_structured_error( def test_doctor_text(capsys: pytest.CaptureFixture[str]) -> None: rc = main(["doctor"]) - assert rc in (0, 1) + assert rc in (0, 2) # contract: 0 healthy, 2 unhealthy assert "french-cli doctor" in capsys.readouterr().out def test_doctor_json_shape(capsys: pytest.CaptureFixture[str]) -> None: rc = main(["doctor", "--json"]) - assert rc in (0, 1) + assert rc in (0, 2) payload = json.loads(capsys.readouterr().out) assert isinstance(payload["healthy"], bool) assert isinstance(payload["checks"], list) diff --git a/tests/test_contract_conformance.py b/tests/test_contract_conformance.py new file mode 100644 index 0000000..8c59c2f --- /dev/null +++ b/tests/test_contract_conformance.py @@ -0,0 +1,210 @@ +"""Contract conformance — every tutor verb emits a schema-valid --json payload. + +This is the subject-side mirror of learn-cli's ``learn subject doctor`` gate: it +spawns each of the eight verbs, parses ``--json`` stdout, and validates it +against the cited contract schema (``french/contract_cite``). It also checks the +contractual exit codes (0 success, 1 user error, 2 environment error) and the +``schema_version`` / ``contract_version`` pins. +""" + +from __future__ import annotations + +import json + +import pytest + +from french.cli import main +from french.contract_cite import CONTRACT_VERSION, validate +from tests.conftest import run_json + +LEARNER = "conformance-bot" + + +def _valid(payload: dict, schema: str) -> None: + errors = validate(payload, schema) + assert errors == [], f"{schema} payload invalid: {errors}" + + +# --- the eight verbs, each schema-valid ------------------------------------ + + +def test_overview_conforms(capsys: pytest.CaptureFixture[str]) -> None: + rc, payload = run_json(capsys, ["overview", "--json"]) + assert rc == 0 + _valid(payload, "overview") + assert payload["schema_version"] == CONTRACT_VERSION + assert payload["kind"] == "subject_overview" + assert payload["subject"] == "french" + # Reconciled: still carries the agent-first rubric's `sections` key. + assert isinstance(payload["sections"], list) and payload["sections"] + + +def test_progress_conforms_new_learner(capsys: pytest.CaptureFixture[str]) -> None: + rc, payload = run_json(capsys, ["progress", "--learner", LEARNER, "--json"]) + assert rc == 0 + _valid(payload, "progress") + assert payload["learner"] == LEARNER + assert payload["items_touched"] == 0 + assert payload["next"]["done"] is False + + +def test_advice_conforms(capsys: pytest.CaptureFixture[str]) -> None: + rc, payload = run_json(capsys, ["advice", "--learner", LEARNER, "--json"]) + assert rc == 0 + _valid(payload, "advice") + + +def test_story_list_conforms(capsys: pytest.CaptureFixture[str]) -> None: + rc, payload = run_json(capsys, ["story", "list", "--json"]) + assert rc == 0 + _valid(payload, "story_list") + assert payload["stories"], "dev stories must be discoverable" + + +def test_story_read_conforms(capsys: pytest.CaptureFixture[str]) -> None: + rc, payload = run_json(capsys, ["story", "read", "dev-cafe", "--learner", LEARNER, "--json"]) + assert rc == 0 + _valid(payload, "story_read") + _valid(payload["story"], "story") # embedded story is itself valid content + + +def test_lesson_start_conforms(capsys: pytest.CaptureFixture[str]) -> None: + rc, payload = run_json( + capsys, ["lesson", "start", "l.greetings", "--learner", LEARNER, "--json"] + ) + assert rc == 0 + _valid(payload, "lesson") + assert payload["mode"] == "start" + assert payload["lesson"]["difficulty"] == 1 + + +def test_lesson_next_conforms(capsys: pytest.CaptureFixture[str]) -> None: + rc, payload = run_json(capsys, ["lesson", "next", "--learner", LEARNER, "--json"]) + assert rc == 0 + _valid(payload, "lesson") + assert payload["mode"] == "next" + + +def test_lesson_repeat_harder_conforms(capsys: pytest.CaptureFixture[str]) -> None: + main(["lesson", "start", "l.numbers", "--learner", LEARNER, "--json"]) + capsys.readouterr() + rc, payload = run_json( + capsys, ["lesson", "repeat", "l.numbers", "--harder", "--learner", LEARNER, "--json"] + ) + assert rc == 0 + _valid(payload, "lesson") + assert payload["mode"] == "repeat" + assert payload["lesson"]["difficulty"] == 2 + + +def test_practice_scoped_conforms(capsys: pytest.CaptureFixture[str]) -> None: + rc, payload = run_json(capsys, ["practice", "fr.numbers.prix", "--learner", LEARNER, "--json"]) + assert rc == 0 + _valid(payload, "practice") + assert payload["scope"] == "fr.numbers.prix" + + +def test_practice_review_conforms(capsys: pytest.CaptureFixture[str]) -> None: + rc, payload = run_json(capsys, ["practice", "--learner", LEARNER, "--json"]) + assert rc == 0 + _valid(payload, "practice") + assert payload["scope"] == "review" + + +def test_record_conforms(capsys: pytest.CaptureFixture[str]) -> None: + rc, payload = run_json( + capsys, + [ + "record", + "--learner", + LEARNER, + "--item", + "fr.numbers.prix", + "--activity", + "practice", + "--exercise", + "prix-1", + "--result", + "pass", + "--correct", + "1", + "--total", + "1", + "--duration-seconds", + "38", + "--json", + ], + ) + assert rc == 0 + _valid(payload, "record") + assert payload["recorded"]["item_id"] == "fr.numbers.prix" + assert payload["mastery"]["level"] == "mastered" + + +def test_doctor_conforms(capsys: pytest.CaptureFixture[str]) -> None: + rc, payload = run_json(capsys, ["doctor", "--json"]) + assert rc == 0 # healthy repo + _valid(payload, "doctor") + assert payload["contract_version"] == CONTRACT_VERSION + assert payload["kind"] == "subject_doctor" + + +# --- exit codes + error shape ---------------------------------------------- + + +def test_unknown_story_is_user_error(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["story", "read", "fr-does-not-exist", "--json"]) + assert rc == 1 # user error + err = capsys.readouterr().err + payload = json.loads(err) + _valid(payload, "error") + assert payload["code"] == 1 + + +def test_unknown_practice_scope_is_user_error(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["practice", "no-such-scope", "--json"]) + assert rc == 1 + payload = json.loads(capsys.readouterr().err) + _valid(payload, "error") + + +def test_bad_state_file_is_environment_error( + capsys: pytest.CaptureFixture[str], learn_home +) -> None: + """A future-version state file is refused as an environment (exit 2) error.""" + import pathlib + + home = pathlib.Path(learn_home) + home.mkdir(parents=True, exist_ok=True) + (home / "brokenlearner.json").write_text( + json.dumps({"state_version": 999, "learner": "brokenlearner"}), encoding="utf-8" + ) + rc = main(["progress", "--learner", "brokenlearner", "--json"]) + assert rc == 2 # environment error + payload = json.loads(capsys.readouterr().err) + _valid(payload, "error") + assert payload["code"] == 2 + + +def test_record_recorded_forbids_score_fields(capsys: pytest.CaptureFixture[str]) -> None: + """The recorded object structurally rejects score/grade/points (contract §1).""" + rc, payload = run_json( + capsys, + [ + "record", + "--learner", + LEARNER, + "--item", + "fr.greetings.bonjour", + "--result", + "pass", + "--json", + ], + ) + assert rc == 0 + recorded = payload["recorded"] + assert "score" not in recorded and "grade" not in recorded and "points" not in recorded + # And injecting one makes the schema reject it (proves the `not` clause bites). + recorded_with_score = dict(recorded, score=99) + bad = dict(payload, recorded=recorded_with_score) + assert validate(bad, "record") != [] diff --git a/tests/test_story_content.py b/tests/test_story_content.py new file mode 100644 index 0000000..dac1d98 --- /dev/null +++ b/tests/test_story_content.py @@ -0,0 +1,52 @@ +"""Committed story content validates against the contract story schema. + +Globs ``content/stories/*.json`` (no hard-coded filenames or counts, so t7a's +real ladder validates here as it lands) and validates each file against the +cited ``story`` schema, plus the pinned ``filename == id`` rule. +""" + +from __future__ import annotations + +import json + +import pytest + +from french.contract_cite import validate +from french.tutor import stories + +_FILES = stories.story_files() + + +def test_content_dir_exists() -> None: + assert stories.content_dir() is not None, "content/stories/ must exist" + + +def test_at_least_one_story() -> None: + assert _FILES, "expected at least one story file under content/stories/" + + +@pytest.mark.parametrize("path", _FILES, ids=[p.name for p in _FILES]) +def test_story_file_valid(path) -> None: + story = stories.load_story(path) + errors = validate(story, "story") + assert errors == [], f"{path.name} invalid: {errors}" + + +@pytest.mark.parametrize("path", _FILES, ids=[p.name for p in _FILES]) +def test_filename_equals_id(path) -> None: + story = json.loads(path.read_text(encoding="utf-8")) + assert path.stem == story.get("id"), f"{path.name}: filename must equal story id" + + +@pytest.mark.parametrize("path", _FILES, ids=[p.name for p in _FILES]) +def test_story_schema_version_and_subject(path) -> None: + story = json.loads(path.read_text(encoding="utf-8")) + assert story["schema_version"] == "1.0" + assert story["subject"] == "french" + + +def test_dev_stories_present_and_prefixed() -> None: + """The dev fixtures ship and are prefixed 'dev-' so they never collide with t7a.""" + ids = {p.stem for p in _FILES} + dev_ids = {i for i in ids if i.startswith("dev-")} + assert dev_ids, "expected dev-prefixed starter stories" diff --git a/tests/test_tutor_cli.py b/tests/test_tutor_cli.py new file mode 100644 index 0000000..9abab78 --- /dev/null +++ b/tests/test_tutor_cli.py @@ -0,0 +1,133 @@ +"""Behavioural tests for the tutor verbs (exit codes, state effects, streams).""" + +from __future__ import annotations + +import pytest + +from french.cli import main +from tests.conftest import run_json + +LEARNER = "cli-bot" + + +def test_record_updates_progress(capsys: pytest.CaptureFixture[str]) -> None: + # Record a pass, then progress must reflect it as mastered + completed. + main( + [ + "record", + "--learner", + LEARNER, + "--item", + "fr.greetings.bonjour", + "--result", + "pass", + "--json", + ] + ) + capsys.readouterr() + rc, progress = run_json(capsys, ["progress", "--learner", LEARNER, "--json"]) + assert rc == 0 + assert progress["items_touched"] == 1 + assert progress["items_mastered"] == 1 + assert progress["mastery"]["fr.greetings.bonjour"] == "mastered" + assert "fr.greetings.bonjour" in progress["completed"] + + +def test_lesson_start_lifts_items_and_sets_current(capsys: pytest.CaptureFixture[str]) -> None: + main(["lesson", "start", "l.greetings", "--learner", LEARNER, "--json"]) + capsys.readouterr() + rc, progress = run_json(capsys, ["progress", "--learner", LEARNER, "--json"]) + assert rc == 0 + # Both greetings items were lifted to at least introduced. + assert progress["mastery"]["fr.greetings.bonjour"] == "introduced" + assert progress["mastery"]["fr.greetings.presentations"] == "introduced" + assert progress["current"]["module_id"] == "premiers-pas" + + +def test_advice_targets_weak_item(capsys: pytest.CaptureFixture[str]) -> None: + main( + [ + "record", + "--learner", + LEARNER, + "--item", + "fr.numbers.prix", + "--result", + "partial", + "--json", + ] + ) + capsys.readouterr() + rc, advice = run_json(capsys, ["advice", "--learner", LEARNER, "--json"]) + assert rc == 0 + focuses = [a.get("focus") for a in advice["advice"]] + assert "fr.numbers.prix" in focuses + + +def test_progress_next_recommends_practice_for_touched(capsys: pytest.CaptureFixture[str]) -> None: + main( + [ + "record", + "--learner", + LEARNER, + "--item", + "fr.greetings.bonjour", + "--result", + "partial", + "--json", + ] + ) + capsys.readouterr() + rc, progress = run_json(capsys, ["progress", "--learner", LEARNER, "--json"]) + assert rc == 0 + assert progress["next"]["item_id"] == "fr.greetings.bonjour" + assert "practice" in progress["next"]["command"] + + +def test_learner_default_is_echoed(capsys: pytest.CaptureFixture[str], monkeypatch) -> None: + monkeypatch.setenv("FRENCH_CLI_LEARNER", "envy") + rc, progress = run_json(capsys, ["progress", "--json"]) + assert rc == 0 + assert progress["learner"] == "envy" + + +def test_record_requires_item(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exc: + main(["record", "--result", "pass", "--json"]) + assert exc.value.code == 1 + err = capsys.readouterr().err + assert err.strip(), "argparse error must route through the structured contract" + + +def test_errors_go_to_stderr_not_stdout(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["story", "read", "nope", "--json"]) + assert rc == 1 + captured = capsys.readouterr() + assert captured.out == "", "error payload must not leak to stdout" + assert captured.err.strip() + + +def test_repeat_without_harder_keeps_rung(capsys: pytest.CaptureFixture[str]) -> None: + main(["lesson", "start", "l.numbers", "--learner", LEARNER, "--json"]) + capsys.readouterr() + rc, payload = run_json( + capsys, ["lesson", "repeat", "l.numbers", "--learner", LEARNER, "--json"] + ) + assert rc == 0 + assert payload["lesson"]["difficulty"] == 1 + + +def test_story_read_sets_position(capsys: pytest.CaptureFixture[str]) -> None: + main(["story", "read", "dev-cafe", "--learner", LEARNER, "--json"]) + capsys.readouterr() + rc, progress = run_json(capsys, ["progress", "--learner", LEARNER, "--json"]) + assert rc == 0 + assert progress["current"] is not None + assert progress["current"]["item_id"] == "fr.food.commander" + + +def test_text_mode_is_human_readable(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["progress", "--learner", LEARNER]) + assert rc == 0 + out = capsys.readouterr().out + assert out.startswith("# Progress") diff --git a/tests/test_tutor_state.py b/tests/test_tutor_state.py new file mode 100644 index 0000000..9dbe473 --- /dev/null +++ b/tests/test_tutor_state.py @@ -0,0 +1,190 @@ +"""Learner-state persistence, XDG path resolution, and mastery inference.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from french.tutor import state + +# --- path resolution -------------------------------------------------------- + + +def test_home_env_wins(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("FRENCH_CLI_LEARN_HOME", str(tmp_path / "explicit")) + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg")) + assert state.state_dir() == tmp_path / "explicit" + + +def test_xdg_fallback(monkeypatch, tmp_path) -> None: + monkeypatch.delenv("FRENCH_CLI_LEARN_HOME", raising=False) + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg")) + assert state.state_dir() == tmp_path / "xdg" / "french_cli" / "learn" + + +def test_home_default(monkeypatch, tmp_path) -> None: + monkeypatch.delenv("FRENCH_CLI_LEARN_HOME", raising=False) + monkeypatch.delenv("XDG_DATA_HOME", raising=False) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + assert state.state_dir() == tmp_path / ".local" / "share" / "french_cli" / "learn" + + +def test_learner_resolution_precedence(monkeypatch) -> None: + monkeypatch.setenv("FRENCH_CLI_LEARNER", "env-user") + assert state.resolve_learner("explicit") == "explicit" # flag wins + assert state.resolve_learner(None) == "env-user" # then env + monkeypatch.delenv("FRENCH_CLI_LEARNER", raising=False) + assert state.resolve_learner(None) # falls back to OS user, never empty + + +# --- persistence round-trip ------------------------------------------------- + + +def test_save_load_round_trip() -> None: + st = state.new_state("ori") + state.record_result(st, item_id="fr.greetings.bonjour", activity="lesson", result="pass") + state.set_current(st, "premiers-pas", "fr.greetings.bonjour") + path = state.save(st) + assert path.is_file() + + reloaded = state.load("ori") + assert reloaded["learner"] == "ori" + assert reloaded["mastery"]["fr.greetings.bonjour"] == "mastered" + assert reloaded["current"] == {"module_id": "premiers-pas", "item_id": "fr.greetings.bonjour"} + assert len(reloaded["history"]) == 1 + + +def test_resume_across_sessions() -> None: + st = state.load("resumer") # fresh + state.record_result(st, item_id="fr.numbers.prix", activity="practice", result="partial") + state.save(st) + # A brand new load (new "session") sees the persisted mastery. + again = state.load("resumer") + assert again["mastery"]["fr.numbers.prix"] == "practiced" + + +def test_atomic_write_leaves_no_temp_files() -> None: + st = state.new_state("clean") + state.save(st) + leftovers = list(state.state_dir().glob(".tmp-*")) + assert leftovers == [] + + +def test_load_missing_returns_fresh() -> None: + st = state.load("never-seen") + assert st["mastery"] == {} + assert st["history"] == [] + assert st["current"] is None + + +def test_future_version_refused() -> None: + st = state.new_state("future") + path = state.save(st) + raw = json.loads(path.read_text()) + raw["state_version"] = 999 + path.write_text(json.dumps(raw), encoding="utf-8") + with pytest.raises(state.StateError): + state.load("future") + + +def test_learner_mismatch_refused() -> None: + st = state.new_state("owner") + path = state.save(st) + raw = json.loads(path.read_text()) + raw["learner"] = "someone-else" + path.write_text(json.dumps(raw), encoding="utf-8") + with pytest.raises(state.StateError): + state.load("owner") + + +# --- mastery inference (never regressing) ---------------------------------- + + +def test_inference_mapping() -> None: + assert state.infer_mastery("fail") == "introduced" + assert state.infer_mastery("partial") == "practiced" + assert state.infer_mastery("pass") == "mastered" + + +def test_inference_never_regresses() -> None: + st = state.new_state("ladder") + state.record_result(st, item_id="x", activity="practice", result="pass") + assert state.mastery_of(st, "x") == "mastered" + # A later fail must NOT drop mastery back down. + state.record_result(st, item_id="x", activity="practice", result="fail") + assert state.mastery_of(st, "x") == "mastered" + # History still records every raw result, though. + assert len(st["history"]) == 2 + + +def test_inference_climbs() -> None: + st = state.new_state("climb") + state.record_result(st, item_id="y", activity="practice", result="fail") + assert state.mastery_of(st, "y") == "introduced" + state.record_result(st, item_id="y", activity="practice", result="partial") + assert state.mastery_of(st, "y") == "practiced" + state.record_result(st, item_id="y", activity="practice", result="pass") + assert state.mastery_of(st, "y") == "mastered" + + +def test_explicit_mastery_overrides_inference() -> None: + st = state.new_state("explicit") + state.record_result(st, item_id="z", activity="practice", result="fail", mastery="mastered") + assert state.mastery_of(st, "z") == "mastered" + + +def test_touch_lifts_to_introduced() -> None: + st = state.new_state("touch") + state.touch_item(st, "fresh") + assert state.mastery_of(st, "fresh") == "introduced" + # Touching an already-practiced item does not knock it back down. + state.set_mastery(st, "adv", "practiced") + state.touch_item(st, "adv") + assert state.mastery_of(st, "adv") == "practiced" + + +def test_recorded_object_is_raw_only() -> None: + st = state.new_state("raw") + recorded = state.record_result( + st, + item_id="fr.numbers.prix", + activity="practice", + result="partial", + exercise_id="prix-1", + correct=1, + total=2, + duration_seconds=30.0, + notes="hesitated on cinquante", + ) + assert set(recorded) == { + "item_id", + "activity", + "result", + "at", + "exercise_id", + "correct", + "total", + "duration_seconds", + "notes", + } + for forbidden in ("score", "grade", "points"): + assert forbidden not in recorded + + +def test_repeat_difficulty_bumps() -> None: + st = state.new_state("rep") + assert state.repeat_difficulty(st, "l.numbers") == 1 + assert state.bump_repeat(st, "l.numbers") == 2 + assert state.repeat_difficulty(st, "l.numbers") == 2 + + +def test_bad_inputs_raise_value_error() -> None: + st = state.new_state("bad") + with pytest.raises(ValueError): + state.record_result(st, item_id="a", activity="practice", result="excellent") + with pytest.raises(ValueError): + state.record_result(st, item_id="a", activity="dancing", result="pass") + with pytest.raises(ValueError): + state.record_result(st, item_id="a", activity="practice", result="pass", total=0)