implementing phase 3 pipeline: gemini structured output, validation, sqlite staging

Prompt is now a template (fixes the hardcoded en/es leftovers in rules
2, 3, 15, 16, 26, 31). pipeline.ts replaces the pseudocode: wordlist
normalization, skip-already-staged idempotency, batches of 20 against
gemini-3.6-flash with responseSchema, raw responses persisted per batch,
per-entry validation with rejection log, one transaction per word into
db/staging.db. Flags: --langs --pos --max-batches --delay-ms --dry-run.

Smoke run: 40/40 words staged (de+es, one batch each), 0 rejections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lila 2026-08-09 19:04:04 +02:00
parent 303bb9388c
commit 37c978e230
12 changed files with 1477 additions and 66 deletions

View file

@ -0,0 +1,57 @@
import { describe, it, expect } from "vitest";
import {
loadPromptTemplate,
renderPrompt,
type PromptParams,
} from "../promptTemplate.js";
const params: PromptParams = {
sourceLanguage: "es",
pos: "noun",
targetLanguages: ["en", "it", "de", "fr"],
words: ["suelo", "pared"],
};
describe("renderPrompt", () => {
it("substitutes every placeholder from the checked-in template", () => {
const rendered = renderPrompt(loadPromptTemplate(), params);
expect(rendered).toContain('Source language: Spanish ("es")');
expect(rendered).toContain('language must be "es"');
expect(rendered).toContain("Target languages: en, it, de, fr");
expect(rendered).toContain('"en" | "it" | "de" | "fr"');
expect(rendered).toContain("suelo\npared");
expect(rendered).toContain("valid Spanish noun");
expect(rendered).not.toMatch(/\{\{[A-Z_]+\}\}/);
});
it("does not tell the model to exclude a non-source language as target", () => {
const rendered = renderPrompt(loadPromptTemplate(), params);
expect(rendered).toContain(
'Do not include Spanish ("es") as a target_language.',
);
expect(rendered).not.toContain(
"Do not include English as a target_language",
);
});
it("throws when the source language is listed as a target", () => {
expect(() =>
renderPrompt(loadPromptTemplate(), {
...params,
targetLanguages: ["en", "es"],
}),
).toThrow(/exclude the source language/);
});
it("throws on an empty word batch", () => {
expect(() =>
renderPrompt(loadPromptTemplate(), { ...params, words: [] }),
).toThrow(/empty word batch/);
});
it("throws on unreplaced placeholders", () => {
expect(() => renderPrompt("hello {{UNKNOWN_TOKEN}}", params)).toThrow(
/UNKNOWN_TOKEN/,
);
});
});

View file

@ -0,0 +1,54 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, it, expect, afterAll } from "vitest";
import { discoverSourceLists, normalizeWords } from "../sourceLists.js";
describe("normalizeWords", () => {
it("trims whitespace and drops empty lines", () => {
expect(normalizeWords([" Haus ", "", " ", "Tür"])).toEqual([
"Haus",
"Tür",
]);
});
it("dedups while preserving first-occurrence order", () => {
expect(normalizeWords(["centro", "borde", "centro", "suelo"])).toEqual([
"centro",
"borde",
"suelo",
]);
});
});
describe("discoverSourceLists", () => {
const root = mkdtempSync(path.join(tmpdir(), "lila-source-data-"));
afterAll(() => rmSync(root, { recursive: true, force: true }));
it("finds only supported language/pos paths and normalizes their words", () => {
mkdirSync(path.join(root, "de"));
mkdirSync(path.join(root, "es"));
mkdirSync(path.join(root, "german")); // unsupported dir name — ignored
writeFileSync(
path.join(root, "de", "noun"),
"Haus\nTür\nHaus\n\n Tisch \n",
);
writeFileSync(path.join(root, "es", "noun"), "casa\npared\n");
writeFileSync(path.join(root, "es", "nouns"), "ignored\n"); // unsupported pos name
writeFileSync(path.join(root, "german", "noun"), "ignored\n");
writeFileSync(path.join(root, "stray.txt"), "ignored\n");
const lists = discoverSourceLists(root);
expect(lists).toHaveLength(2);
expect(lists[0]).toMatchObject({
sourceLanguage: "de",
pos: "noun",
words: ["Haus", "Tür", "Tisch"],
});
expect(lists[1]).toMatchObject({
sourceLanguage: "es",
pos: "noun",
words: ["casa", "pared"],
});
});
});

View file

@ -0,0 +1,106 @@
import path from "node:path";
import { describe, it, expect, beforeEach } from "vitest";
import type Database from "better-sqlite3";
import {
countStagedRows,
getStagedHeadwords,
openStaging,
stageEntry,
} from "../staging.js";
import type { GeminiWordEntry } from "../validate.js";
const SCHEMA_PATH = path.join(import.meta.dirname, "..", "db", "schema.sql");
const entry: GeminiWordEntry = {
headword: "casa",
language: "es",
pos: "noun",
senses: [
{
sense_index: 0,
difficulty: "easy",
definitions: ["Un edificio para vivir."],
examples: ["Compraron una casa en la ciudad."],
translations: [
{
target_language: "en",
word: "house",
gender: null,
difficulty: "easy",
},
{
target_language: "it",
word: "casa",
gender: "feminine",
difficulty: "easy",
},
{
target_language: "de",
word: "Haus",
gender: "neuter",
difficulty: "easy",
},
{
target_language: "fr",
word: "maison",
gender: "feminine",
difficulty: "easy",
},
],
},
],
};
describe("staging", () => {
let db: Database.Database;
beforeEach(() => {
db = openStaging(":memory:", SCHEMA_PATH);
});
it("creates the schema from db/schema.sql on an empty database", () => {
expect(countStagedRows(db)).toEqual({
words: 0,
senses: 0,
translations: 0,
});
});
it("stages a word with its senses and translations atomically", () => {
expect(stageEntry(db, entry)).toBe("staged");
expect(countStagedRows(db)).toEqual({
words: 1,
senses: 1,
translations: 4,
});
const row = db
.prepare<
[],
{ definitions: string; examples: string }
>(`SELECT definitions, examples FROM senses`)
.get();
expect(JSON.parse(row?.definitions ?? "")).toEqual([
"Un edificio para vivir.",
]);
expect(JSON.parse(row?.examples ?? "")).toEqual([
"Compraron una casa en la ciudad.",
]);
});
it("is idempotent: staging the same word twice writes nothing new", () => {
expect(stageEntry(db, entry)).toBe("staged");
expect(stageEntry(db, entry)).toBe("already-staged");
expect(countStagedRows(db)).toEqual({
words: 1,
senses: 1,
translations: 4,
});
});
it("reports staged headwords per language and pos", () => {
stageEntry(db, entry);
expect(getStagedHeadwords(db, "es", "noun")).toEqual(new Set(["casa"]));
expect(getStagedHeadwords(db, "de", "noun")).toEqual(new Set());
expect(getStagedHeadwords(db, "es", "verb")).toEqual(new Set());
});
});

View file

@ -0,0 +1,285 @@
import { describe, it, expect } from "vitest";
import { validateEntry, type ValidationContext } from "../validate.js";
const ctx: ValidationContext = {
sourceLanguage: "es",
pos: "noun",
targetLanguages: ["en", "it", "de", "fr"],
inputWords: new Set(["casa", "banco"]),
};
type Translation = {
target_language: string;
word: string;
gender: string | null;
difficulty: string;
};
const translations = (): Translation[] => [
{ target_language: "en", word: "house", gender: null, difficulty: "easy" },
{
target_language: "it",
word: "casa",
gender: "feminine",
difficulty: "easy",
},
{ target_language: "de", word: "Haus", gender: "neuter", difficulty: "easy" },
{
target_language: "fr",
word: "maison",
gender: "feminine",
difficulty: "easy",
},
];
const sense = (
overrides: Record<string, unknown> = {},
): Record<string, unknown> => ({
sense_index: 0,
difficulty: "easy",
definitions: ["Un edificio para vivir."],
examples: ["Compraron una casa en la ciudad."],
translations: translations(),
...overrides,
});
const entry = (
overrides: Record<string, unknown> = {},
): Record<string, unknown> => ({
headword: "casa",
language: "es",
pos: "noun",
senses: [sense()],
...overrides,
});
const errorsOf = (raw: unknown): string[] => {
const result = validateEntry(raw, ctx);
return result.status === "invalid" ? result.errors : [];
};
describe("validateEntry", () => {
it("accepts a fully valid entry", () => {
const result = validateEntry(entry(), ctx);
expect(result.status).toBe("valid");
});
it("treats senses: [] as empty (word skipped, not rejected)", () => {
const result = validateEntry(entry({ senses: [] }), ctx);
expect(result).toEqual({ status: "empty", headword: "casa" });
});
it("rejects senses: [] when the rest of the entry is invalid", () => {
const result = validateEntry(entry({ senses: [], language: "en" }), ctx);
expect(result.status).toBe("invalid");
});
it("rejects non-object input", () => {
expect(validateEntry("casa", ctx).status).toBe("invalid");
expect(validateEntry(null, ctx).status).toBe("invalid");
expect(validateEntry([entry()], ctx).status).toBe("invalid");
});
it("rejects a headword that was not in the input batch", () => {
expect(errorsOf(entry({ headword: "perro" }))).toContainEqual(
expect.stringContaining("not in the input batch"),
);
});
it("rejects a wrong source language", () => {
expect(errorsOf(entry({ language: "en" }))).toContainEqual(
expect.stringContaining('language must be "es"'),
);
});
it("rejects a wrong pos", () => {
expect(errorsOf(entry({ pos: "verb" }))).toContainEqual(
expect.stringContaining('pos must be "noun"'),
);
});
it("rejects more than 3 senses", () => {
const senses = [0, 1, 2, 3].map((i) => sense({ sense_index: i }));
expect(errorsOf(entry({ senses }))).toContainEqual(
expect.stringContaining("at most 3"),
);
});
it("rejects non-sequential sense_index", () => {
const senses = [sense({ sense_index: 0 }), sense({ sense_index: 2 })];
expect(errorsOf(entry({ senses }))).toContainEqual(
expect.stringContaining("sense_index must be 1"),
);
});
it("rejects an unknown difficulty", () => {
expect(
errorsOf(entry({ senses: [sense({ difficulty: "intermediate" })] })),
).toContainEqual(expect.stringContaining("difficulty must be one of"));
});
it("rejects empty definitions and examples", () => {
expect(
errorsOf(entry({ senses: [sense({ definitions: [] })] })),
).toContainEqual(
expect.stringContaining("definitions must be a non-empty array"),
);
expect(
errorsOf(entry({ senses: [sense({ examples: [""] })] })),
).toContainEqual(
expect.stringContaining("examples must contain only non-empty strings"),
);
});
it("rejects a non-null gender for English targets", () => {
const bad = translations();
bad[0] = {
target_language: "en",
word: "house",
gender: "feminine",
difficulty: "easy",
};
expect(
errorsOf(entry({ senses: [sense({ translations: bad })] })),
).toContainEqual(
expect.stringContaining('gender must be null for target "en"'),
);
});
it("rejects a null gender for German targets", () => {
const bad = translations();
bad[2] = {
target_language: "de",
word: "Haus",
gender: null,
difficulty: "easy",
};
expect(
errorsOf(entry({ senses: [sense({ translations: bad })] })),
).toContainEqual(expect.stringContaining('for target "de"'));
});
it("rejects neuter for Romance-language targets", () => {
const bad = translations();
bad[3] = {
target_language: "fr",
word: "maison",
gender: "neuter",
difficulty: "easy",
};
expect(
errorsOf(entry({ senses: [sense({ translations: bad })] })),
).toContainEqual(expect.stringContaining('for target "fr"'));
});
it("rejects an invented gender value", () => {
const bad = translations();
bad[2] = {
target_language: "de",
word: "Haus",
gender: "common",
difficulty: "easy",
};
expect(
errorsOf(entry({ senses: [sense({ translations: bad })] })).length,
).toBeGreaterThan(0);
});
it("rejects a missing target language", () => {
const partial = translations().filter((t) => t.target_language !== "fr");
expect(
errorsOf(entry({ senses: [sense({ translations: partial })] })),
).toContainEqual(
expect.stringContaining('missing translation for target language "fr"'),
);
});
it("rejects the source language as a target", () => {
const bad = [
...translations(),
{
target_language: "es",
word: "hogar",
gender: "masculine",
difficulty: "easy",
},
];
expect(
errorsOf(entry({ senses: [sense({ translations: bad })] })),
).toContainEqual(expect.stringContaining("target_language must be one of"));
});
it("rejects more than 2 translations for one target language", () => {
const bad = [
...translations(),
{
target_language: "de",
word: "Gebäude",
gender: "neuter",
difficulty: "medium",
},
{
target_language: "de",
word: "Heim",
gender: "neuter",
difficulty: "medium",
},
];
expect(
errorsOf(entry({ senses: [sense({ translations: bad })] })),
).toContainEqual(
expect.stringContaining(
'more than 2 translations for target language "de"',
),
);
});
it("rejects duplicate translation words for one target language", () => {
const bad = [
...translations(),
{
target_language: "de",
word: "Haus",
gender: "neuter",
difficulty: "medium",
},
];
expect(
errorsOf(entry({ senses: [sense({ translations: bad })] })),
).toContainEqual(
expect.stringContaining(
'duplicate translation word for target language "de"',
),
);
});
it("rejects a translation difficulty below the sense difficulty", () => {
const easyTranslations = translations();
expect(
errorsOf(
entry({
senses: [
sense({ difficulty: "medium", translations: easyTranslations }),
],
}),
),
).toContainEqual(
expect.stringContaining('is lower than sense difficulty "medium"'),
);
});
it("allows a translation difficulty above the sense difficulty", () => {
const harder = translations();
harder[2] = {
target_language: "de",
word: "Geldinstitut",
gender: "neuter",
difficulty: "medium",
};
const result = validateEntry(
entry({ senses: [sense({ translations: harder })] }),
ctx,
);
expect(result.status).toBe("valid");
});
});