Prompt-Inventar P1–P18: 22 Templates, Slot-System, Seed-Skript, Index-Fix

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 16:40:29 +00:00
parent 13bc7867cf
commit dc14bcaa2a
26 changed files with 1267 additions and 1 deletions

148
scripts/seed-prompts.mjs Normal file
View File

@@ -0,0 +1,148 @@
#!/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.`);

View File

@@ -127,7 +127,10 @@ const TABLES = [
enm('model_adapter', ['seedance', 'veo', 'sora', 'wan']), // null = kein Adapter
bool('aktiv', { default: true }),
],
indexes: [{ key: 'uq_key_version', type: 'unique', columns: ['key', 'version'] }],
// model_adapter gehört in den Index: P8 hat 5 Zeilen (Kern + 4 Adapter) je Version.
// MariaDB erlaubt mehrere NULLs im Unique-Index Eindeutigkeit der Kern-Zeilen
// (model_adapter=null) sichert das Seed-Skript (einziger Schreiber).
indexes: [{ key: 'uq_key_adapter_version', type: 'unique', columns: ['key', 'model_adapter', 'version'] }],
},
{
id: 'categories', name: 'Categories', rowSecurity: true,