From cc1a82f9b5f45f27a1774a604cb619630a25008b Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sat, 11 Jul 2026 10:52:40 +0300 Subject: [PATCH] =?UTF-8?q?feat(content):=20pick-the-right-word=20cloze=20?= =?UTF-8?q?exercises=20across=20the=20story=20ladder=20(t11,=20contract=20?= =?UTF-8?q?=C2=A73.6.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add one pick-the-right-word cloze exercise (contract v1.0 §3.6.1's second `cloze` variant: text + blanks, checkable without a driver) to each of the 11 graded-reader stories, covering all five CEFR rungs (A1-C1) and all three coarse levels. Each exercise's text is lifted near-verbatim from its story's body so it stays answerable from the reading alone, with level-graded distractors (same-word-class confusions for A1/A2; gender/number agreement and idiom traps for B1; subjunctive-mood and lexical-register contrasts for B2; a deviner/devenir near-homophone reading trap for C1). Every new exercise reuses an existing exercise's item_id from its own story, so results accrue to already-tracked curriculum items; no existing item_id or exercise changed. Re-sync french/contract_cite/{schemas/{story,practice}.json,_validate.py} from learn-cli (source commit cdac077) to pick up the text/blanks fields and the new cloze_blank def the variant needs — purely additive, contract stays 1.0. `learn subject doctor french --json` (built from the learn-cli uplift branch) reports healthy with the new cloze-items check: "11 pick-the-right- word cloze item(s) verified: well-formed blanks". Bumped 0.5.0 -> 0.6.0 per the version-bump-every-PR convention. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz --- CHANGELOG.md | 27 ++ content/stories/fr-a1-la-rentree.json | 11 + content/stories/fr-a1-le-marche.json | 11 + content/stories/fr-a2-le-cafe-du-coin.json | 11 + .../fr-a2-le-weekend-a-la-montagne.json | 11 + content/stories/fr-b1-entretien-embauche.json | 15 ++ .../stories/fr-b1-la-fete-des-voisins.json | 19 ++ .../fr-b1-la-recherche-appartement.json | 19 ++ content/stories/fr-b1-le-covoiturage.json | 19 ++ .../fr-b2-la-transition-ecologique.json | 19 ++ content/stories/fr-b2-le-teletravail.json | 19 ++ content/stories/fr-c1-le-vieux-libraire.json | 19 ++ docs/contract-provenance.md | 9 +- french/contract_cite/_validate.py | 250 ++++++++++++------ french/contract_cite/schemas/practice.json | 35 ++- french/contract_cite/schemas/story.json | 33 +++ pyproject.toml | 2 +- uv.lock | 2 +- 18 files changed, 451 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 388c096..3be6ccd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ 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.6.0] - 2026-07-11 + +### Added + +- **Pick-the-right-word cloze exercises** (contract v1.0 §3.6.1, the two-variant + `cloze` type) across the full story ladder: one new exercise per story (11 + total, each named `q5` after its story's existing four), spanning all five + CEFR rungs (A1 through C1) and all three coarse levels. Every new exercise + carries `text` (a passage lifted near-verbatim from the story body, so it + stays answerable from the reading alone) and `blanks`, with level-appropriate + distractors: same-word-class confusions for A1/A2 (units of measure, flavor + nouns); gender/number agreement traps for B1 (`spacieux`/`spacieuse`, + `retenus`/`retenue`); mood and tense contrasts, including the subjunctive + triggered by `s'inquiéter que`, for B2; and a near-homophone reading trap + (`deviner`/`devenir`) for the C1 literary story. Each new exercise reuses an + existing exercise's `item_id` from its own story — no existing `item_id` or + exercise was touched, so learner mastery on those items carries over intact. + +### Changed + +- **Re-synced `french/contract_cite/schemas/{story,practice}.json` and + `_validate.py` from learn-cli** (source commit `cdac077`) to pick up the + `text`/`blanks` exercise fields and the new `cloze_blank` def added for the + pick-the-right-word variant. Purely additive — the contract stays `1.0`, and + every payload that predates these fields keeps validating unchanged; see the + updated stamp in `docs/contract-provenance.md`. + ## [0.5.0] - 2026-07-11 ### Added diff --git a/content/stories/fr-a1-la-rentree.json b/content/stories/fr-a1-la-rentree.json index 5557f3c..2a750c2 100644 --- a/content/stories/fr-a1-la-rentree.json +++ b/content/stories/fr-a1-la-rentree.json @@ -70,6 +70,17 @@ "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." + }, + { + "id": "fr-a1-la-rentree-q5", + "type": "cloze", + "item_id": "fr.story.fr-a1-la-rentree.2", + "prompt": "Fill in each blank with the right word.", + "text": "« Il y a une nouvelle {{enseignante}} cette année, » dit Nina.", + "answer": "maîtresse", + "blanks": [ + { "id": "enseignante", "options": ["maîtresse", "maître", "professeur"], "answer": "maîtresse" } + ] } ], "audio": null, diff --git a/content/stories/fr-a1-le-marche.json b/content/stories/fr-a1-le-marche.json index a43cade..792c98a 100644 --- a/content/stories/fr-a1-le-marche.json +++ b/content/stories/fr-a1-le-marche.json @@ -72,6 +72,17 @@ "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." + }, + { + "id": "fr-a1-le-marche-q5", + "type": "cloze", + "item_id": "fr.story.fr-a1-le-marche.3", + "prompt": "Fill in each blank with the right word.", + "text": "Léo dit au vendeur : « Bonjour, monsieur. Je voudrais un {{unit}} de carottes, s'il vous plaît. »", + "answer": "kilo", + "blanks": [ + { "id": "unit", "options": ["kilo", "litre", "mètre"], "answer": "kilo" } + ] } ], "audio": null, diff --git a/content/stories/fr-a2-le-cafe-du-coin.json b/content/stories/fr-a2-le-cafe-du-coin.json index 03e4215..8b9761b 100644 --- a/content/stories/fr-a2-le-cafe-du-coin.json +++ b/content/stories/fr-a2-le-cafe-du-coin.json @@ -73,6 +73,17 @@ "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." + }, + { + "id": "fr-a2-le-cafe-du-coin-q5", + "type": "cloze", + "item_id": "fr.story.fr-a2-le-cafe-du-coin.2", + "prompt": "Fill in each blank with the right word.", + "text": "Chloé choisit un thé à la {{gout}}.", + "answer": "menthe", + "blanks": [ + { "id": "gout", "options": ["menthe", "citron", "vanille"], "answer": "menthe" } + ] } ], "audio": null, diff --git a/content/stories/fr-a2-le-weekend-a-la-montagne.json b/content/stories/fr-a2-le-weekend-a-la-montagne.json index 10076c5..262df9d 100644 --- a/content/stories/fr-a2-le-weekend-a-la-montagne.json +++ b/content/stories/fr-a2-le-weekend-a-la-montagne.json @@ -73,6 +73,17 @@ "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." + }, + { + "id": "fr-a2-le-weekend-a-la-montagne-q5", + "type": "cloze", + "item_id": "fr.story.fr-a2-le-weekend-a-la-montagne.1", + "prompt": "Fill in each blank with the right word.", + "text": "Ils sortent et construisent un grand {{construction}} de neige avec une carotte pour le nez.", + "answer": "bonhomme", + "blanks": [ + { "id": "construction", "options": ["bonhomme", "château", "bateau"], "answer": "bonhomme" } + ] } ], "audio": null, diff --git a/content/stories/fr-b1-entretien-embauche.json b/content/stories/fr-b1-entretien-embauche.json index 9b41cbe..8d4a126 100644 --- a/content/stories/fr-b1-entretien-embauche.json +++ b/content/stories/fr-b1-entretien-embauche.json @@ -85,6 +85,21 @@ "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." + }, + { + "id": "fr-b1-entretien-embauche-q5", + "type": "cloze", + "item_id": "fr.story.fr-b1-entretien-embauche.3", + "prompt": "Fill in each blank with the right word.", + "text": "L'entreprise contactera les candidats {{participe}} dans les deux semaines. Toute cette nervosité, finalement, en valait la {{mot}}.", + "blanks": [ + { + "id": "participe", + "options": ["retenus", "retenues", "retenu"], + "answer": "retenus" + }, + { "id": "mot", "options": ["peine", "douleur", "peur"], "answer": "peine" } + ] } ], "audio": null, diff --git a/content/stories/fr-b1-la-fete-des-voisins.json b/content/stories/fr-b1-la-fete-des-voisins.json index cba74a6..3ac634e 100644 --- a/content/stories/fr-b1-la-fete-des-voisins.json +++ b/content/stories/fr-b1-la-fete-des-voisins.json @@ -82,6 +82,25 @@ "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." + }, + { + "id": "fr-b1-la-fete-des-voisins-q5", + "type": "cloze", + "item_id": "fr.story.fr-b1-la-fete-des-voisins.4", + "prompt": "Fill in each blank with the right word.", + "text": "Après quelques verres de vin et beaucoup de rires, l'ambiance se {{verbe}}. Avant cette soirée, elle connaissait {{adverbe}} le nom de ses voisins.", + "blanks": [ + { + "id": "verbe", + "options": ["détend", "détendent", "détends"], + "answer": "détend" + }, + { + "id": "adverbe", + "options": ["à peine", "déjà", "toujours"], + "answer": "à peine" + } + ] } ], "audio": null, diff --git a/content/stories/fr-b1-la-recherche-appartement.json b/content/stories/fr-b1-la-recherche-appartement.json index 10fd5ea..d70b46c 100644 --- a/content/stories/fr-b1-la-recherche-appartement.json +++ b/content/stories/fr-b1-la-recherche-appartement.json @@ -89,6 +89,25 @@ "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." + }, + { + "id": "fr-b1-la-recherche-appartement-q5", + "type": "cloze", + "item_id": "fr.story.fr-b1-la-recherche-appartement.2", + "prompt": "Fill in each blank with the right word.", + "text": "Le propriétaire explique que le loyer comprend les {{mot}}, mais pas internet. Bien qu'il soit {{adj}} et lumineux, le trajet jusqu'au travail prendrait presque quarante minutes.", + "blanks": [ + { + "id": "mot", + "options": ["charges", "frais", "taxes"], + "answer": "charges" + }, + { + "id": "adj", + "options": ["spacieux", "spacieuse", "spacieuses"], + "answer": "spacieux" + } + ] } ], "audio": null, diff --git a/content/stories/fr-b1-le-covoiturage.json b/content/stories/fr-b1-le-covoiturage.json index f9a93e9..6722a37 100644 --- a/content/stories/fr-b1-le-covoiturage.json +++ b/content/stories/fr-b1-le-covoiturage.json @@ -85,6 +85,25 @@ "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." + }, + { + "id": "fr-b1-le-covoiturage-q5", + "type": "cloze", + "item_id": "fr.story.fr-b1-le-covoiturage.2", + "prompt": "Fill in each blank with the right word.", + "text": "Un matin, la voiture tombe en {{mot}} sur l'autoroute. Ines appelle une {{mot2}} pendant que Marc plaisante pour détendre l'atmosphère.", + "blanks": [ + { + "id": "mot", + "options": ["panne", "accident", "retard"], + "answer": "panne" + }, + { + "id": "mot2", + "options": ["dépanneuse", "ambulance", "police"], + "answer": "dépanneuse" + } + ] } ], "audio": null, diff --git a/content/stories/fr-b2-la-transition-ecologique.json b/content/stories/fr-b2-la-transition-ecologique.json index 1421c0a..d33f47c 100644 --- a/content/stories/fr-b2-la-transition-ecologique.json +++ b/content/stories/fr-b2-la-transition-ecologique.json @@ -93,6 +93,25 @@ "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." + }, + { + "id": "fr-b2-la-transition-ecologique-q5", + "type": "cloze", + "item_id": "fr.story.fr-b2-la-transition-ecologique.2", + "prompt": "Fill in each blank with the right word.", + "text": "Le maire, initialement {{attitude}} quant à la rentabilité du projet, admet aujourd'hui que les économies réalisées sur la facture d'électricité ont {{verbe}} ses attentes les plus optimistes.", + "blanks": [ + { + "id": "attitude", + "options": ["sceptique", "optimiste", "confiant"], + "answer": "sceptique" + }, + { + "id": "verbe", + "options": ["dépassé", "respecté", "déçu"], + "answer": "dépassé" + } + ] } ], "audio": null, diff --git a/content/stories/fr-b2-le-teletravail.json b/content/stories/fr-b2-le-teletravail.json index 9cbefd3..df1fadb 100644 --- a/content/stories/fr-b2-le-teletravail.json +++ b/content/stories/fr-b2-le-teletravail.json @@ -89,6 +89,25 @@ "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." + }, + { + "id": "fr-b2-le-teletravail-q5", + "type": "cloze", + "item_id": "fr.story.fr-b2-le-teletravail.2", + "prompt": "Fill in each blank with the right word.", + "text": "Plusieurs managers s'inquiètent que le télétravail n'{{verbe}} la cohésion des équipes, et que la frontière entre vie privée et vie professionnelle ne {{verbe2}} complètement.", + "blanks": [ + { + "id": "verbe", + "options": ["affaiblisse", "affaiblit", "affaiblira"], + "answer": "affaiblisse" + }, + { + "id": "verbe2", + "options": ["s'efface", "s'effacent", "s'effaçait"], + "answer": "s'efface" + } + ] } ], "audio": null, diff --git a/content/stories/fr-c1-le-vieux-libraire.json b/content/stories/fr-c1-le-vieux-libraire.json index 9e9fab9..c7d0d25 100644 --- a/content/stories/fr-c1-le-vieux-libraire.json +++ b/content/stories/fr-c1-le-vieux-libraire.json @@ -93,6 +93,25 @@ "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." + }, + { + "id": "fr-c1-le-vieux-libraire-q5", + "type": "cloze", + "item_id": "fr.story.fr-c1-le-vieux-libraire.2", + "prompt": "Fill in each blank with the right word.", + "text": "Il avait ce don, rare, de {{verbe}} en quelques mots à peine échangés ce qu'une personne {{verbe2}} vraiment à lire.", + "blanks": [ + { + "id": "verbe", + "options": ["deviner", "décider", "devenir"], + "answer": "deviner" + }, + { + "id": "verbe2", + "options": ["cherchait", "demandait", "voulait"], + "answer": "cherchait" + } + ] } ], "audio": null, diff --git a/docs/contract-provenance.md b/docs/contract-provenance.md index dee2558..9f5b49c 100644 --- a/docs/contract-provenance.md +++ b/docs/contract-provenance.md @@ -25,9 +25,14 @@ subject as a subprocess. - **Source repo:** `agentculture/learn-cli` - **Contract:** subject-plugin contract **v1.0** (`docs/specs/subject-plugin-contract.md`) -- **Source commit:** `ed81b87` (2026-07-11) +- **Source commit:** `cdac077` (2026-07-11) - **Cited on:** 2026-07-11 -- **Cited by:** french-cli (task t4 of the learn uplift) +- **Cited by:** french-cli (task t4 of the learn uplift; re-synced by task t11 to + pick up the pick-the-right-word cloze variant fields — `text`/`blanks` on the + exercise def and the new `cloze_blank` def — added to `story.json` and + `practice.json`, contract §3.6.1. Purely additive: contract version stays + `1.0`, and every payload that predates these fields keeps validating + unchanged.) ## Re-sync procedure diff --git a/french/contract_cite/_validate.py b/french/contract_cite/_validate.py index 6f7d30b..83d95cb 100644 --- a/french/contract_cite/_validate.py +++ b/french/contract_cite/_validate.py @@ -7,9 +7,10 @@ * source repo: agentculture/learn-cli * source path: learn/contract/_validate.py * contract: subject-plugin contract v1.0 - * source commit: ed81b87 (2026-07-11) + * source commit: cdac077 (2026-07-11) * cited on: 2026-07-11 - * cited by: french-cli (task t4 of the learn uplift) + * cited by: french-cli (task t11 of the learn uplift — re-sync for + the pick-the-right-word cloze variant, contract §3.6.1) 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 @@ -43,6 +44,11 @@ # A loader maps a sibling-file ref ("story.json") to its parsed schema, or None. Loader = Callable[[str], "dict[str, Any] | None"] +#: The ``$defs`` keyword name (draft 2020-12's local-definitions container), +#: named once since it recurs across the keyword set, the local-$ref resolver, +#: and the keyword walker. +_DEFS = "$defs" + SUPPORTED_KEYWORDS = frozenset( { # validation @@ -61,7 +67,7 @@ "anyOf", "not", "$ref", - "$defs", + _DEFS, # annotations (ignored by validation) "$schema", "$id", @@ -126,9 +132,9 @@ def _resolve_ref( 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 ref.startswith(f"#/{_DEFS}/"): + name = ref[len(f"#/{_DEFS}/") :] + target = root.get(_DEFS, {}).get(name) if target is None: return f"unresolvable local $ref '{ref}'" return target, root @@ -138,7 +144,7 @@ def _resolve_ref( return loaded, loaded -def _check( # noqa: C901 - one dispatch table over the supported keywords +def _check_ref( value: Any, schema: dict[str, Any], root: dict[str, Any], @@ -146,76 +152,151 @@ def _check( # noqa: C901 - one dispatch table over the supported keywords errors: list[str], loader: Loader | None, ) -> None: + """Resolve and recurse through ``$ref``, if present.""" 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) - + if ref is None: + return + 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) + + +def _check_type(value: Any, schema: dict[str, Any], path: str, errors: list[str]) -> bool: + """Check ``type``; return True when it mismatches (caller should stop early).""" 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 type_rule is None: + return False + names = type_rule if isinstance(type_rule, list) else [type_rule] + if any(_matches_type(value, n) for n in names): + return False + errors.append(f"{path}: expected type {'|'.join(names)}") + return True + +def _check_enum_const(value: Any, schema: dict[str, Any], path: str, errors: list[str]) -> None: 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) +def _check_string(value: Any, schema: dict[str, Any], path: str, errors: list[str]) -> None: + if not isinstance(value, str): + return + 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}") + + +def _check_numeric(value: Any, schema: dict[str, Any], path: str, errors: list[str]) -> None: + if not (isinstance(value, (int, float)) and not isinstance(value, bool)): + return + minimum = schema.get("minimum") + if minimum is not None and value < minimum: + errors.append(f"{path}: {value} is below minimum {minimum}") + + +def _check_object( + value: Any, + schema: dict[str, Any], + root: dict[str, Any], + path: str, + errors: list[str], + loader: Loader | None, +) -> None: + if not isinstance(value, dict): + return + 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") + + +def _check_array( + value: Any, + schema: dict[str, Any], + root: dict[str, Any], + path: str, + errors: list[str], + loader: Loader | None, +) -> None: + if not isinstance(value, list): + return + 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) + + +def _check_any_of( + value: Any, + schema: dict[str, Any], + root: dict[str, Any], + path: str, + errors: list[str], + loader: Loader | None, +) -> None: 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)") + if any_of is None: + return + if all(_subcheck(value, option, root, path, loader) for option in any_of): + errors.append(f"{path}: does not match any allowed variant (anyOf)") + +def _check_not( + value: Any, + schema: dict[str, Any], + root: dict[str, Any], + path: str, + errors: list[str], + loader: Loader | None, +) -> None: 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)") + if negated is not None and not _subcheck(value, negated, root, path, loader): + errors.append(f"{path}: matches a forbidden shape (not)") + + +def _check( + value: Any, + schema: dict[str, Any], + root: dict[str, Any], + path: str, + errors: list[str], + loader: Loader | None, +) -> None: + _check_ref(value, schema, root, path, errors, loader) + + if _check_type(value, schema, path, errors): + return # structure is wrong; per-keyword checks below would misfire + + _check_enum_const(value, schema, path, errors) + _check_string(value, schema, path, errors) + _check_numeric(value, schema, path, errors) + _check_object(value, schema, root, path, errors, loader) + _check_array(value, schema, root, path, errors, loader) + _check_any_of(value, schema, root, path, errors, loader) + _check_not(value, schema, root, path, errors, loader) def unsupported_keywords(schema: dict[str, Any]) -> set[str]: @@ -229,17 +310,36 @@ def unsupported_keywords(schema: dict[str, Any]) -> set[str]: return found +def _walk_keyword_map(value: Any, found: set[str]) -> None: + """Recurse into a keyword whose value is a name → subschema map.""" + if not isinstance(value, dict): + return + for sub in value.values(): + if isinstance(sub, dict): + _walk_keywords(sub, found) + + +def _walk_keyword_list(value: Any, found: set[str]) -> None: + """Recurse into a keyword whose value is a list of subschemas.""" + if not isinstance(value, list): + return + for sub in value: + if isinstance(sub, dict): + _walk_keywords(sub, found) + + +def _walk_keyword_value(key: str, value: Any, found: set[str]) -> None: + """Recurse into ``value`` per the shape its keyword ``key`` implies.""" + if key in ("properties", _DEFS): + _walk_keyword_map(value, found) + elif key in ("items", "not", "additionalProperties") and isinstance(value, dict): + _walk_keywords(value, found) + elif key == "anyOf": + _walk_keyword_list(value, 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) + _walk_keyword_value(key, value, found) diff --git a/french/contract_cite/schemas/practice.json b/french/contract_cite/schemas/practice.json index e2ce2e7..6ea6aa4 100644 --- a/french/contract_cite/schemas/practice.json +++ b/french/contract_cite/schemas/practice.json @@ -50,7 +50,40 @@ "items": { "type": "string" } }, "answer": { "type": "string" }, - "rubric": { "type": "string" } + "rubric": { "type": "string" }, + "text": { + "type": "string", + "minLength": 1, + "description": "For the pick-the-right-word cloze variant ONLY: the passage with each blank marked as a `{{blank_id}}` placeholder, one per entry in `blanks`. Omit for a single-blank free-text cloze exercise (use `prompt` with the blank written as `___`, plus a top-level `answer`, exactly as contract 1.0 always allowed) and for every non-cloze type." + }, + "blanks": { + "type": "array", + "minItems": 1, + "description": "For the pick-the-right-word cloze variant ONLY: one entry per `{{blank_id}}` placeholder in `text`, always present together with `text`. Absent for a single-blank free-text cloze exercise and for every non-cloze type.", + "items": { "$ref": "#/$defs/cloze_blank" } + } + } + }, + "cloze_blank": { + "type": "object", + "required": ["id", "options", "answer"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]*$", + "description": "Matches one `{{id}}` placeholder in the exercise's `text`; unique within the exercise." + }, + "options": { + "type": "array", + "minItems": 2, + "items": { "type": "string", "minLength": 1 }, + "description": "The words the reader picks from for this blank: the correct word plus one or more distractors. Must include `answer`." + }, + "answer": { + "type": "string", + "minLength": 1, + "description": "The correct option for this blank; must be one of `options`." + } } }, "directive": { diff --git a/french/contract_cite/schemas/story.json b/french/contract_cite/schemas/story.json index 8ef2d24..ea7ca91 100644 --- a/french/contract_cite/schemas/story.json +++ b/french/contract_cite/schemas/story.json @@ -124,6 +124,39 @@ "rubric": { "type": "string", "description": "Grading guidance for open-ended exercises: what passes, what is partial." + }, + "text": { + "type": "string", + "minLength": 1, + "description": "For the pick-the-right-word cloze variant ONLY: the passage with each blank marked as a `{{blank_id}}` placeholder, one per entry in `blanks`. Omit for a single-blank free-text cloze exercise (use `prompt` with the blank written as `___`, plus a top-level `answer`, exactly as contract 1.0 always allowed) and for every non-cloze type." + }, + "blanks": { + "type": "array", + "minItems": 1, + "description": "For the pick-the-right-word cloze variant ONLY: one entry per `{{blank_id}}` placeholder in `text`, always present together with `text`. Absent for a single-blank free-text cloze exercise and for every non-cloze type.", + "items": { "$ref": "#/$defs/cloze_blank" } + } + } + }, + "cloze_blank": { + "type": "object", + "required": ["id", "options", "answer"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]*$", + "description": "Matches one `{{id}}` placeholder in the exercise's `text`; unique within the exercise." + }, + "options": { + "type": "array", + "minItems": 2, + "items": { "type": "string", "minLength": 1 }, + "description": "The words the reader picks from for this blank: the correct word plus one or more distractors. Must include `answer`." + }, + "answer": { + "type": "string", + "minLength": 1, + "description": "The correct option for this blank; must be one of `options`." } } } diff --git a/pyproject.toml b/pyproject.toml index 1bb3900..adc7173 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "french-cli" -version = "0.5.0" +version = "0.6.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" diff --git a/uv.lock b/uv.lock index abae0d5..3d97707 100644 --- a/uv.lock +++ b/uv.lock @@ -179,7 +179,7 @@ wheels = [ [[package]] name = "french-cli" -version = "0.4.1" +version = "0.6.0" source = { editable = "." } [package.dev-dependencies]