149 lines
7.4 KiB
JavaScript
149 lines
7.4 KiB
JavaScript
#!/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.`);
|