feat: words-Tabelle – Brysbaert-Import + hierarchische Kategorien + Batch-Anreicherung

- categories: parent_id (self-referential) + 49 Unterkategorien geseedet
- words: neue Spalten conc_m, dom_pos, level, themenfeld_id + unique index titel_en
- enrich_batches + word_generative Tabellen
- src/lib/enrichWords.js: Batch-Anreicherung (DE/SV-Übersetzung, Wortart, CEFR, Themenfeld)
- src/routes/wordGenerative.js: CRUD für KI-Bild-Pipeline
- src/routes/words.js: Filter dom_pos/level/themenfeld_id/has_conc_m + picture_count
- scripts/import-brysbaert.js: CSV-Import-Skript (lokal gegen Prod-DB)
- POST /api/words/enrich-batch als manueller Trigger

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 20:41:52 +02:00
parent 1605d2cdd1
commit 7ba6b7120b
6 changed files with 535 additions and 14 deletions

View File

@@ -771,6 +771,128 @@ async function migrate() {
ON CONFLICT (key) DO NOTHING
`).catch(() => {});
// ── Brysbaert-Erweiterungen ─────────────────────────────────────────────────
// parent_id auf categories (self-referential, Oberkategorie → Unterkategorie)
await query(`ALTER TABLE categories ADD COLUMN IF NOT EXISTS parent_id UUID REFERENCES categories(id) ON DELETE SET NULL`).catch(() => {});
// Unterkategorien seeden. Die bestehenden 22 Einträge sind die Oberkategorien (parent_id = NULL).
const SUBCATEGORY_TAXONOMY = [
// Lebensmittel
['Obst', 'Fruit', 'Frukt', 'Lebensmittel'],
['Gemüse', 'Vegetables', 'Grönsaker', 'Lebensmittel'],
['Fleisch & Fisch', 'Meat & Fish', 'Kött & fisk', 'Lebensmittel'],
['Backwaren & Getreide', 'Baked Goods & Grains', 'Bröd & spannmål', 'Lebensmittel'],
['Milchprodukte', 'Dairy', 'Mejeriprodukter', 'Lebensmittel'],
['Getränke', 'Drinks', 'Drycker', 'Lebensmittel'],
['Gewürze & Kräuter', 'Spices & Herbs', 'Kryddor & örter', 'Lebensmittel'],
['Süßigkeiten & Snacks', 'Sweets & Snacks', 'Sötsaker & snacks', 'Lebensmittel'],
// Tiere
['Haustiere', 'Pets', 'Husdjur', 'Tiere'],
['Wildtiere', 'Wild Animals', 'Vilda djur', 'Tiere'],
['Vögel', 'Birds', 'Fåglar', 'Tiere'],
['Reptilien & Amphibien', 'Reptiles & Amphibians', 'Reptiler & amfibier', 'Tiere'],
['Insekten & Spinnen', 'Insects & Spiders', 'Insekter & spindlar', 'Tiere'],
['Meerestiere', 'Sea Animals', 'Havsdjur', 'Tiere'],
// Körper
['Kopf & Gesicht', 'Head & Face', 'Huvud & ansikte', 'Körper'],
['Rumpf', 'Torso', 'Bål', 'Körper'],
['Arme & Beine', 'Arms & Legs', 'Armar & ben', 'Körper'],
['Innere Organe', 'Internal Organs', 'Inre organ', 'Körper'],
['Körperpflege', 'Personal Care', 'Kroppsvård', 'Körper'],
// Kleidung
['Oberbekleidung', 'Tops & Outerwear', 'Överkläder', 'Kleidung'],
['Unterbekleidung', 'Underwear', 'Underkläder', 'Kleidung'],
['Kopfbedeckung', 'Headwear', 'Huvudbonader', 'Kleidung'],
['Schuhe & Socken', 'Shoes & Socks', 'Skor & strumpor', 'Kleidung'],
['Accessoires', 'Accessories', 'Accessoarer', 'Kleidung'],
// Familie & Menschen
['Familienmitglieder', 'Family Members', 'Familjemedlemmar', 'Familie & Menschen'],
['Berufe & Titel', 'Professions & Titles', 'Yrken & titlar', 'Familie & Menschen'],
['Beziehungen', 'Relationships', 'Relationer', 'Familie & Menschen'],
// Haushalt
['Küchenutensilien', 'Kitchen Utensils', 'Köksredskap', 'Haushalt'],
['Reinigung & Pflege', 'Cleaning & Care', 'Rengöring & vård', 'Haushalt'],
['Verpackung & Behälter', 'Packaging & Containers', 'Förpackningar & behållare','Haushalt'],
// Wohnen & Möbel
['Zimmer & Räume', 'Rooms & Spaces', 'Rum & utrymmen', 'Wohnen & Möbel'],
['Möbel', 'Furniture', 'Möbler', 'Wohnen & Möbel'],
['Beleuchtung & Elektro', 'Lighting & Electronics', 'Belysning & el', 'Wohnen & Möbel'],
// Natur & Pflanzen
['Pflanzen & Blumen', 'Plants & Flowers', 'Växter & blommor', 'Natur & Pflanzen'],
['Bäume & Sträucher', 'Trees & Shrubs', 'Träd & buskar', 'Natur & Pflanzen'],
['Landschaftsmerkmale', 'Landscape Features', 'Landskapsdrag', 'Natur & Pflanzen'],
['Gesteine & Böden', 'Rocks & Soils', 'Stenar & jordar', 'Natur & Pflanzen'],
// Verkehr & Reisen
['Fahrzeuge (Land)', 'Land Vehicles', 'Landfordon', 'Verkehr & Reisen'],
['Fahrzeuge (Wasser & Luft)', 'Water & Air Vehicles', 'Vatten- & luftfordon', 'Verkehr & Reisen'],
['Straße & Infrastruktur', 'Roads & Infrastructure', 'Vägar & infrastruktur', 'Verkehr & Reisen'],
// Stadt & Gebäude
['Gebäude & Orte', 'Buildings & Places', 'Byggnader & platser', 'Stadt & Gebäude'],
['Innenräume & Bereiche', 'Indoor Spaces & Areas', 'Inomhusutrymmen', 'Stadt & Gebäude'],
// Technik & Geräte
['Haushaltsgeräte', 'Household Appliances', 'Hushållsapparater', 'Technik & Geräte'],
['Elektronik & Computer', 'Electronics & Computers', 'Elektronik & datorer', 'Technik & Geräte'],
['Werkzeuge & Maschinen', 'Tools & Machines', 'Verktyg & maskiner', 'Technik & Geräte'],
// Sport & Freizeit
['Sport & Bewegung', 'Sports & Exercise', 'Sport & rörelse', 'Sport & Freizeit'],
['Spiele & Spielzeug', 'Games & Toys', 'Spel & leksaker', 'Sport & Freizeit'],
['Kunst & Musik', 'Arts & Music', 'Konst & musik', 'Sport & Freizeit'],
];
for (const [de, en, sv, parentDe] of SUBCATEGORY_TAXONOMY) {
await query(
`INSERT INTO categories (titel_de, titel_en, titel_sv, status, published_at, parent_id)
SELECT $1, $2, $3, 'published', NOW(),
(SELECT id FROM categories WHERE lower(titel_de) = lower($4) AND parent_id IS NULL LIMIT 1)
WHERE NOT EXISTS (SELECT 1 FROM categories WHERE lower(titel_de) = lower($1))`,
[de, en, sv, parentDe]
).catch(() => {});
}
// Neue Spalten auf words (Brysbaert-Import + Anreicherung)
await query(`ALTER TABLE words ADD COLUMN IF NOT EXISTS conc_m NUMERIC(4,2)`).catch(() => {});
await query(`ALTER TABLE words ADD COLUMN IF NOT EXISTS dom_pos VARCHAR(20)`).catch(() => {});
await query(`ALTER TABLE words ADD COLUMN IF NOT EXISTS level VARCHAR(5)`).catch(() => {});
await query(`ALTER TABLE words ADD COLUMN IF NOT EXISTS themenfeld_id UUID`).catch(() => {});
await query(`ALTER TABLE words ADD CONSTRAINT words_themenfeld_id_fkey FOREIGN KEY (themenfeld_id) REFERENCES categories(id) ON DELETE SET NULL`).catch(() => {});
await query(`ALTER TABLE words DROP CONSTRAINT IF EXISTS words_dom_pos_check`).catch(() => {});
await query(`ALTER TABLE words ADD CONSTRAINT words_dom_pos_check CHECK (dom_pos IN ('noun', 'verb', 'adjective', 'other'))`).catch(() => {});
await query(`ALTER TABLE words DROP CONSTRAINT IF EXISTS words_level_check`).catch(() => {});
await query(`ALTER TABLE words ADD CONSTRAINT words_level_check CHECK (level IN ('A1', 'A2', 'B1'))`).catch(() => {});
// Unique-Index auf titel_en — Voraussetzung für ON CONFLICT im CSV-Import.
// Falls bestehende Duplikate den Index verhindern, muss erst bereinigt werden.
await query(`CREATE UNIQUE INDEX IF NOT EXISTS words_titel_en_key ON words (titel_en)`).catch(() => {});
// enrich_batches — Status-Tracking für Wort-Anreicherungs-Batches (analog category_batches)
await query(`
CREATE TABLE IF NOT EXISTS enrich_batches (
batch_id TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'submitted',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
// word_generative — Pipeline für KI-generierte Wort-Bilder
await query(`
CREATE TABLE IF NOT EXISTS word_generative (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
word_id UUID NOT NULL REFERENCES words(id) ON DELETE CASCADE,
prompt TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'generating', 'generated', 'accepted', 'rejected')),
picture_link TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
await query(`
DROP TRIGGER IF EXISTS word_generative_updated_at ON word_generative;
CREATE TRIGGER word_generative_updated_at
BEFORE UPDATE ON word_generative
FOR EACH ROW EXECUTE FUNCTION update_updated_at()
`);
// ── Migrate old {{uuid}} placeholders → new {{label.w:uuid}} / {{label.o:uuid}} ──
await migratePlaceholders();