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

@@ -12,11 +12,17 @@ const STATUS_TIMESTAMP = {
// GET /api/words
router.get('/', async (req, res, next) => {
try {
const { status, titel_de, search, limit = 50, offset = 0 } = req.query;
const { status, titel_de, search, dom_pos, level, themenfeld_id, has_conc_m,
limit = 50, offset = 0 } = req.query;
const params = [Math.min(parseInt(limit), 500), parseInt(offset)];
const conditions = [];
if (status) { conditions.push(`w.status = $${params.length + 1}`); params.push(status); }
if (titel_de) { conditions.push(`lower(w.titel_de) = lower($${params.length + 1})`); params.push(titel_de); }
if (status) { conditions.push(`w.status = $${params.length + 1}`); params.push(status); }
if (titel_de) { conditions.push(`lower(w.titel_de) = lower($${params.length + 1})`); params.push(titel_de); }
if (dom_pos) { conditions.push(`w.dom_pos = $${params.length + 1}`); params.push(dom_pos); }
if (level) { conditions.push(`w.level = $${params.length + 1}`); params.push(level); }
if (themenfeld_id) { conditions.push(`w.themenfeld_id = $${params.length + 1}`); params.push(themenfeld_id); }
if (has_conc_m === 'true') conditions.push(`w.conc_m IS NOT NULL`);
if (has_conc_m === 'false') conditions.push(`w.conc_m IS NULL`);
if (search) {
const p = `%${search.toLowerCase()}%`;
conditions.push(`(lower(w.titel_de) LIKE $${params.length + 1} OR lower(w.titel_en) LIKE $${params.length + 1} OR lower(w.titel_sv) LIKE $${params.length + 1})`);
@@ -26,12 +32,14 @@ router.get('/', async (req, res, next) => {
const result = await query(
`SELECT w.*,
COALESCE(json_agg(DISTINCT p.id) FILTER (WHERE p.id IS NOT NULL), '[]') AS picture_ids,
COALESCE(json_agg(DISTINCT c.id) FILTER (WHERE c.id IS NOT NULL), '[]') AS category_ids
COALESCE(json_agg(DISTINCT c.id) FILTER (WHERE c.id IS NOT NULL), '[]') AS category_ids,
COUNT(DISTINCT wp2.picture_id)::int AS picture_count
FROM words w
LEFT JOIN word_pictures wp ON wp.word_id = w.id
LEFT JOIN pictures p ON p.id = wp.picture_id
LEFT JOIN word_categories wc ON wc.word_id = w.id
LEFT JOIN categories c ON c.id = wc.category_id
LEFT JOIN word_pictures wp ON wp.word_id = w.id
LEFT JOIN pictures p ON p.id = wp.picture_id
LEFT JOIN word_categories wc ON wc.word_id = w.id
LEFT JOIN categories c ON c.id = wc.category_id
LEFT JOIN word_pictures wp2 ON wp2.word_id = w.id
${where}
GROUP BY w.id
ORDER BY w.created_at DESC
@@ -69,7 +77,8 @@ router.post('/', async (req, res, next) => {
router.patch('/:id', async (req, res, next) => {
try {
const allowed = ['titel_de', 'titel_en', 'titel_sv', 'status',
'difficulty_level', 'requested_at', 'published_at', 'blocked_at'];
'difficulty_level', 'requested_at', 'published_at', 'blocked_at',
'conc_m', 'dom_pos', 'level', 'themenfeld_id'];
const fields = Object.keys(req.body).filter(k => allowed.includes(k));
if (!fields.length) return res.status(400).json({ error: 'No valid fields provided' });
@@ -117,12 +126,14 @@ router.get('/:id', async (req, res, next) => {
const result = await query(
`SELECT w.*,
COALESCE(json_agg(DISTINCT p.id) FILTER (WHERE p.id IS NOT NULL), '[]') AS picture_ids,
COALESCE(json_agg(DISTINCT c.id) FILTER (WHERE c.id IS NOT NULL), '[]') AS category_ids
COALESCE(json_agg(DISTINCT c.id) FILTER (WHERE c.id IS NOT NULL), '[]') AS category_ids,
COUNT(DISTINCT wp2.picture_id)::int AS picture_count
FROM words w
LEFT JOIN word_pictures wp ON wp.word_id = w.id
LEFT JOIN pictures p ON p.id = wp.picture_id
LEFT JOIN word_categories wc ON wc.word_id = w.id
LEFT JOIN categories c ON c.id = wc.category_id
LEFT JOIN word_pictures wp ON wp.word_id = w.id
LEFT JOIN pictures p ON p.id = wp.picture_id
LEFT JOIN word_categories wc ON wc.word_id = w.id
LEFT JOIN categories c ON c.id = wc.category_id
LEFT JOIN word_pictures wp2 ON wp2.word_id = w.id
WHERE w.id = $1
GROUP BY w.id`,
[req.params.id]