Generation guards: cards with repeated wording never ship

A generated Czech literature deck reached a phone with three identical
matching definitions — the card could not be solved. The pipeline only
deduped matching terms (and ordering items), so repeated definitions and
options sailed through.

- parseDeck now rejects any card whose options, items, terms or definitions
  repeat (case-insensitive), through the same drop-or-repair path as the
  ordering giveaway guard: the card is dropped, or a deck left with too few
  cards gets one repair round with a clear reason.
- The matching dedupe covers definitions too, and both it and the ordering
  one only trim when the card still meets the schema minimum afterwards —
  a shorter card would fail validation on the next read and take the whole
  deck down with it (latent data-loss bug).
- The generation prompt states the uniqueness rule.
- phase-1 suite: a stub Ollama returns a deck with a repeated-definition
  matching card (the reported shape), a repeated-option card and a
  salvageable one; asserts the first two are dropped, the third is trimmed
  to three pairs and the good cards ship (43 checks).
- README: decision 51.

Server-side only (lib/ai.ts, lib/prompts.ts) — no client bundle change, no
SW bump.
This commit is contained in:
Martin Dvořák 2026-10-12 05:37:03 +00:00
commit f202653017
4 changed files with 177 additions and 22 deletions

View file

@ -374,5 +374,107 @@ async function openSettings(page) {
}
}
/* 9. generation guards: cards with repeated wording never ship ------- */
{
check("guard: stub port free", !(await portInUse(11998)));
// Mirrors the deck that reached a phone: three terms, one shared definition.
const repeated = "Jedna z hlavních postav v knížkách lidového čtení";
const deckReply = {
title: "Lidové čtení",
subject: "Literatura",
language: "Czech",
summary: "Deck for the repeated-wording guards.",
activities: [
{ type: "flashcard", topic: "Postavy", front: "Kdo je Bruncvík?", back: "Rytíř se lvem ve znaku.", explanation: "x" },
{ type: "flashcard", topic: "Postavy", front: "Kdo je Štilfrid?", back: "Bruncvíkův otec.", explanation: "x" },
{ type: "multiple_choice", topic: "Postavy", question: "Kdo je hlavní postava?", options: ["Bruncvík", "Paleček", "Štilfrid", "Karel"], correctIndex: 0, explanation: "x" },
{ type: "multiple_choice", topic: "Postavy", question: "Kdo patří k lidovému čtení?", options: ["Paleček", "Paleček", "Bruncvík", "Štilfrid"], correctIndex: 2, explanation: "x" },
{
type: "matching",
topic: "Postavy",
instruction: "Přiřaď postavy k jejich popisu.",
pairs: [
{ term: "Bruncvík", definition: "Rytíř se lvem ve znaku" },
{ term: "Štilfrid", definition: "Bruncvíkův otec" },
{ term: "Paleček", definition: "Chytrý sluha" },
],
explanation: "x",
},
{
type: "matching",
topic: "Postavy",
instruction: "Přiřaď postavy k jejich zařazení.",
pairs: [
{ term: "Bruncvík", definition: repeated },
{ term: "Štilfrid", definition: repeated },
{ term: "Paleček", definition: repeated },
],
explanation: "x",
},
{
type: "matching",
topic: "Postavy",
instruction: "Přiřaď postavy k vlastnostem.",
pairs: [
{ term: "A", definition: "statečný" },
{ term: "B", definition: "statečný" },
{ term: "C", definition: "moudrý" },
{ term: "D", definition: "věrný" },
],
explanation: "x",
},
],
};
const stub = http.createServer((request, response) => {
if (request.url?.startsWith("/api/tags")) {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ models: [{ name: "stub", model: "stub" }] }));
return;
}
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ message: { content: JSON.stringify(deckReply) } }));
});
await new Promise((resolve) => stub.listen(11998, "127.0.0.1", resolve));
const app = spawnApp(3210, {
OLLAMA_BASE_URL: "http://127.0.0.1:11998",
OLLAMA_AUTH: "local",
OLLAMA_VERIFY_KEY: "false",
});
try {
const up = await waitForHttp("http://127.0.0.1:3210/api/health");
check("guard: stub app is up", up);
if (up) {
const response = await fetch("http://127.0.0.1:3210/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: "Lidové čtení: Bruncvík, Štilfrid a Paleček.", difficulty: "easy", count: 6 }),
});
const events = (await response.text())
.trim()
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line));
const deck = events.find((event) => event.type === "deck")?.deck;
const activities = deck?.activities ?? [];
const matchings = activities.filter((activity) => activity.type === "matching");
const choices = activities.filter((activity) => activity.type === "multiple_choice");
check("guard: the good cards still ship", activities.length === 5, `got ${activities.length}: ${activities.map((a) => a.type).join(",")}`);
check("guard: repeated-option card dropped", choices.length === 1 && new Set(choices[0].options).size === 4);
check("guard: repeated-definition card dropped", matchings.length === 2, `matchings: ${matchings.length}`);
check(
"guard: every matching card keeps its definitions distinct",
matchings.every((activity) => new Set(activity.pairs.map((pair) => pair.definition)).size === activity.pairs.length),
);
const salvaged = matchings.find((activity) => activity.pairs.some((pair) => pair.term === "A"));
check("guard: a salvageable card is trimmed, not dropped", salvaged?.pairs.length === 3, `pairs: ${salvaged?.pairs?.length}`);
}
} finally {
app.stop();
stub.close();
}
}
await browser.close();
finish();