#!/usr/bin/env node /** * BrandLoop – Prompt-Seeding (idempotent, versioniert) * * Liest alle prompts/P*.md (Frontmatter + Body) und synchronisiert sie in die * Appwrite-Tabelle `prompt_templates`: * - (key, model_adapter) ohne Zeile → anlegen mit version 1 * - höchste Version inhaltsgleich → skip (nur aktiv-Flag ggf. angleichen) * - Inhalt geändert → neue Zeile version+1, alte aktiv=false * * Der Body ist das `static_core_md`. Die DB-Spalte `slots` (JSON-String) trägt * { name, slots[], modell_hinweis } – die Tabelle hat bewusst keine name-Spalte. * * Zusätzlich werden die 4 globalen Brand-Regeln aus dem Konzept in `rules` * geseedet (brand_id=null, Match per titel). * * Vorab-Check: Jeder {{SLOT}} im Body muss im Frontmatter deklariert sein und * umgekehrt – sonst Abbruch. */ import { readFileSync, readdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const ENDPOINT = process.env.APPWRITE_ENDPOINT || 'https://appwrite.webklar.com/v1'; const PROJECT = process.env.APPWRITE_PROJECT || '6a5cee34002bb8360c34'; const ENV_FILE = process.env.ENV_FILE || '/home/webklar/apps/.env'; const DB_ID = 'brandloop'; const PROMPTS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'prompts'); const API_KEY = process.env.APPWRITE_API_KEY || readFileSync(ENV_FILE, 'utf8').match(/^BRANDLOOP_APPWRITE_API_KEY=(.+)$/m)?.[1]?.trim(); if (!API_KEY) { console.error('Kein API-Key gefunden.'); process.exit(1); } async function api(method, path, body) { const res = await fetch(`${ENDPOINT}${path}`, { method, headers: { 'X-Appwrite-Project': PROJECT, 'X-Appwrite-Key': API_KEY, 'Content-Type': 'application/json', }, body: body ? JSON.stringify(body) : undefined, }); const json = await res.json().catch(() => ({})); if (!res.ok) throw new Error(`${method} ${path}: HTTP ${res.status} – ${json.message}`); return json; } const limit = (n) => `queries[]=${encodeURIComponent(JSON.stringify({ method: 'limit', values: [n] }))}`; // ---- Prompt-Dateien einlesen ---------------------------------------------- function parseFrontmatter(raw, file) { const m = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); if (!m) throw new Error(`${file}: kein Frontmatter gefunden`); const fm = {}; for (const line of m[1].split('\n')) { const kv = line.match(/^([a-z_]+):\s*(.+)$/); if (!kv) continue; let value = kv[2].trim(); if (value.startsWith('[')) value = value.slice(1, -1).split(',').map(s => s.trim()).filter(Boolean); else if (value === 'true') value = true; else if (value === 'false') value = false; fm[kv[1]] = value; } return { fm, body: m[2].trim() }; } const files = readdirSync(PROMPTS_DIR).filter(f => /^P\d+.*\.md$/.test(f)).sort(); const templates = []; let slotErrors = 0; for (const file of files) { const { fm, body } = parseFrontmatter(readFileSync(join(PROMPTS_DIR, file), 'utf8'), file); const declared = new Set(Array.isArray(fm.slots) ? fm.slots : []); const used = new Set([...body.matchAll(/\{\{([A-Z_]+)\}\}/g)].map(x => x[1])); for (const s of used) if (!declared.has(s)) { console.error(`✗ ${file}: {{${s}}} im Body, aber nicht im Frontmatter deklariert`); slotErrors++; } for (const s of declared) if (!used.has(s)) { console.error(`✗ ${file}: Slot ${s} deklariert, aber nicht im Body verwendet`); slotErrors++; } templates.push({ file, key: fm.key, model_adapter: fm.model_adapter ?? null, aktiv: fm.aktiv !== false, static_core_md: body, slots: JSON.stringify({ name: fm.name, slots: [...declared], modell_hinweis: fm.modell_hinweis ?? null }), }); } if (slotErrors) { console.error(`\nAbbruch: ${slotErrors} Slot-Fehler.`); process.exit(1); } console.log(`${templates.length} Prompt-Dateien gelesen, Slot-Check ok.\n`); // ---- prompt_templates synchronisieren ------------------------------------- const existing = (await api('GET', `/tablesdb/${DB_ID}/tables/prompt_templates/rows?${limit(500)}`)).rows; let created = 0, skipped = 0, superseded = 0; for (const t of templates) { const family = existing.filter(r => r.key === t.key && (r.model_adapter ?? null) === t.model_adapter); const label = t.key + (t.model_adapter ? `/${t.model_adapter}` : ''); const latest = family.sort((a, b) => b.version - a.version)[0]; if (latest && latest.static_core_md === t.static_core_md && latest.slots === t.slots) { if (latest.aktiv !== t.aktiv) { await api('PATCH', `/tablesdb/${DB_ID}/tables/prompt_templates/rows/${latest.$id}`, { data: { aktiv: t.aktiv } }); console.log(`~ ${label} v${latest.version}: aktiv → ${t.aktiv}`); } else { console.log(`= ${label} v${latest.version} (unverändert)`); } skipped++; continue; } const version = latest ? latest.version + 1 : 1; await api('POST', `/tablesdb/${DB_ID}/tables/prompt_templates/rows`, { rowId: 'unique()', data: { key: t.key, version, static_core_md: t.static_core_md, slots: t.slots, model_adapter: t.model_adapter, aktiv: t.aktiv }, }); console.log(`+ ${label} v${version}`); created++; for (const old of family.filter(r => r.aktiv)) { await api('PATCH', `/tablesdb/${DB_ID}/tables/prompt_templates/rows/${old.$id}`, { data: { aktiv: false } }); console.log(` - ${label} v${old.version} → aktiv=false`); superseded++; } } // ---- Globale Regeln seeden ------------------------------------------------- const GLOBAL_RULES = [ { titel: 'Hintergrund clean', sortierung: 1, prompt_text: 'Hintergründe neutral und aufgeräumt halten – kein visuelles Chaos, nichts, was vom Produkt ablenkt. (EN: clean, uncluttered background; nothing that distracts from the product.)' }, { titel: 'Nur Verlangtes zeigen', sortierung: 2, prompt_text: 'Nur zeigen, was die Szene verlangt – nicht mehr, nicht weniger. Keine ungefragten Extra-Elemente, Personen oder Requisiten. (EN: show only what is asked for — no unrequested extra elements, people, or props.)' }, { titel: 'Logo-Schutz', sortierung: 3, prompt_text: 'Das Brand-Logo nie verzerren, beschneiden, umfärben oder neu interpretieren – nur als freigegebenes Asset verwenden, nie generieren lassen. (EN: never distort, crop, recolor, or reinterpret the brand logo; use the released asset only, never generate it.)' }, { titel: 'Referenzen nie kopieren', sortierung: 4, prompt_text: 'Aus Referenzen fremder Inhalte nur beschriebene Merkmale lernen, nie 1:1 kopieren (Urheberrecht). Gesichter echter Personen nur mit dokumentierter Einwilligung – rein KI-generierte Gesichter sind der Standard. (EN: learn described features from references, never copy 1:1; real faces only with documented consent.)' }, ]; const existingRules = (await api('GET', `/tablesdb/${DB_ID}/tables/rules/rows?${limit(100)}`)).rows; let rulesCreated = 0; for (const rule of GLOBAL_RULES) { if (existingRules.some(r => !r.brand_id && r.titel === rule.titel)) { console.log(`= Regel „${rule.titel}" (existiert)`); continue; } await api('POST', `/tablesdb/${DB_ID}/tables/rules/rows`, { rowId: 'unique()', data: { titel: rule.titel, prompt_text: rule.prompt_text, aktiv: true, sortierung: rule.sortierung }, }); console.log(`+ Regel „${rule.titel}"`); rulesCreated++; } console.log(`\nFertig: ${created} Prompt-Version(en) angelegt, ${skipped} unverändert, ${superseded} deaktiviert, ${rulesCreated} Regel(n) angelegt.`);