diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff9c859 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +.env.* +node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..7c9b0d1 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# BrandLoop (videogen) + +KI-Video-Generierung mit lernender Brand-Knowledge-Base (Elo-Scoring über Attribute). + +- **Prototyp:** [prototyp-app.html](prototyp-app.html) – statische Preview unter `videogen.project.webklar.com` +- **Backend:** selbst gehostetes Appwrite (`https://appwrite.webklar.com/v1`), Projekt **BrandLoop** `6a5cee34002bb8360c34`, Datenbank `brandloop` + +## Datenbank-Setup + +Das komplette Schema (15 Tabellen, Indizes, 4 Storage-Buckets, internes Dev-Team) legt +[scripts/setup-appwrite.mjs](scripts/setup-appwrite.mjs) an – idempotent, kann nach +Plan-Erweiterungen jederzeit erneut laufen: + +```bash +# API-Key liegt als BRANDLOOP_APPWRITE_API_KEY in /home/webklar/apps/.env, dann einfach: +node scripts/setup-appwrite.mjs + +# oder explizit: +APPWRITE_API_KEY=... node scripts/setup-appwrite.mjs +``` + +Der API-Key braucht die Scopes **Databases/Tables (read+write), Storage/Buckets (read+write), Teams (read+write)**. + +### Bewusste Abweichungen vom DB-Plan + +- **Referenzen als indizierte String-Spalten (size 64) statt Relationship-Spalten.** + Appwrite-Relationships sind nicht filter-/indizierbar – der Top-N-Index auf + `attributes` (`brand_id, category_id, status, score DESC`) und alle Listen-Queries + brauchen aber genau das. Many-to-many (`videos.attribute_ids`) ist ein String-Array + (`Query.contains`). IDs mit 64 Zeichen, weil uuid4 = 36 Zeichen. +- **`created_at`-Spalten entfallen** – Appwrite pflegt `$createdAt` automatisch + (Indizes nutzen `$createdAt` direkt, z. B. `score_events`, `jobs`). +- **Enum-Werte ohne Umlaute** (`laeuft` statt `läuft`). +- **S3/Hetzner-Backend für Storage** ist eine *instanzweite* Appwrite-Einstellung + (`_APP_STORAGE_DEVICE`) und betrifft alle Projekte auf dem Server – wird daher + nicht vom Skript gesetzt, sondern muss bewusst am Server konfiguriert werden. + Aktuell: lokales Storage. + +### Rechte-Modell + +- `rules` + `prompt_templates`: Tabellen-Rechte nur für Team `internal` (Dev-Modus) 🔒 +- Alle anderen Tabellen: `rowSecurity` an, keine Tabellen-Rechte – Zeilen bekommen + beim Anlegen die Team-Permission der jeweiligen Brand (Multi-Tenant). +- `consents`-Bucket: zusätzlich Lese-Recht für Team `internal`. +- Append-only per Konvention (Server-Key schreibt): `score_events`, `video_metrics`, `jobs`. + +### Noch offen (laut Plan) + +- Appwrite **Functions**: `elo-update`, `favorit-berechnen`, `ads-metriken-holen`, + `kategorie-durchschnitt`, `archivierung`, `job-dispatcher` +- Stripe-Anbindung (metered billing über `usage_records`) diff --git a/scripts/setup-appwrite.mjs b/scripts/setup-appwrite.mjs new file mode 100644 index 0000000..371ea98 --- /dev/null +++ b/scripts/setup-appwrite.mjs @@ -0,0 +1,373 @@ +#!/usr/bin/env node +/** + * BrandLoop – Appwrite-Schema-Setup (idempotent) + * + * Legt im selbst gehosteten Appwrite (Projekt BrandLoop) die komplette + * Datenbank-Struktur aus dem DB-Plan an: Datenbank, 15 Tabellen (TablesDB), + * Spalten, Indizes, 4 Storage-Buckets und das interne Dev-Team. + * + * Aufruf: + * APPWRITE_API_KEY=... node scripts/setup-appwrite.mjs + * oder Key als BRANDLOOP_APPWRITE_API_KEY in /home/webklar/apps/.env hinterlegen. + * + * Idempotent: bereits existierende Objekte (HTTP 409) werden übersprungen – + * das Skript kann nach Plan-Erweiterungen jederzeit erneut laufen. + * + * Abweichung vom Plan: Fremdschlüssel sind indizierte String-Spalten (size 64) + * statt Appwrite-Relationship-Spalten. Grund: Relationships sind in Appwrite + * nicht filter-/indizierbar – der geforderte Top-N-Index + * (brand, category, status, score DESC) und alle Listen-Queries brauchen aber + * genau das. Many-to-many (videos.attribute_ids) ist ein String-Array + * (Query.contains funktioniert darauf). + */ + +import { readFileSync } from 'node:fs'; + +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'; + +function resolveApiKey() { + if (process.env.APPWRITE_API_KEY) return process.env.APPWRITE_API_KEY; + try { + const env = readFileSync(ENV_FILE, 'utf8'); + const m = env.match(/^BRANDLOOP_APPWRITE_API_KEY=(.+)$/m); + if (m) return m[1].trim(); + } catch { /* .env nicht lesbar – unten Fehlermeldung */ } + console.error(`Kein API-Key: APPWRITE_API_KEY setzen oder BRANDLOOP_APPWRITE_API_KEY in ${ENV_FILE} hinterlegen.`); + process.exit(1); +} +const API_KEY = resolveApiKey(); + +let created = 0, skipped = 0, warned = 0; + +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 text = await res.text(); + let json; + try { json = JSON.parse(text); } catch { json = { message: text }; } + return { status: res.status, json }; +} + +async function ensure(label, method, path, body) { + const { status, json } = await api(method, path, body); + if (status >= 200 && status < 300) { created++; console.log(` + ${label}`); return json; } + if (status === 409) { skipped++; console.log(` = ${label} (existiert)`); return null; } + throw new Error(`${label}: HTTP ${status} – ${json.message}`); +} + +async function tryEnsure(label, method, path, body) { + try { return await ensure(label, method, path, body); } + catch (e) { warned++; console.warn(` ! ${label}: ${e.message}`); return null; } +} + +// ---- Spalten-Helfer ------------------------------------------------------- +const str = (key, size, opts = {}) => ({ type: 'string', body: { key, size, required: false, ...opts } }); +const id = (key, opts = {}) => str(key, 64, opts); // Referenzen: uuid4 = 36 Zeichen, 64 mit Luft +const md = (key, opts = {}) => str(key, 65535, opts); // Markdown-/Prompt-Texte +const jsonCol = (key, size = 16384, opts = {}) => str(key, size, opts); // JSON als String +const int = (key, opts = {}) => ({ type: 'integer', body: { key, required: false, ...opts } }); +const flt = (key, opts = {}) => ({ type: 'float', body: { key, required: false, ...opts } }); +const bool = (key, opts = {}) => ({ type: 'boolean', body: { key, required: false, ...opts } }); +const dt = (key, opts = {}) => ({ type: 'datetime', body: { key, required: false, ...opts } }); +const enm = (key, elements, opts = {}) => ({ type: 'enum', body: { key, elements, required: false, ...opts } }); + +const P_KEYS = Array.from({ length: 18 }, (_, i) => `P${i + 1}`); +const INTERNAL = 'team:internal'; +const internalOnly = [ + `read("${INTERNAL}")`, `create("${INTERNAL}")`, + `update("${INTERNAL}")`, `delete("${INTERNAL}")`, +]; + +// ---- Tabellen-Definitionen (Spalten + Indizes) ---------------------------- +const TABLES = [ + { + id: 'brands', name: 'Brands', rowSecurity: true, + columns: [ + id('team_id'), + str('label_name', 255, { required: true }), + str('produkte', 1024), str('zielgruppe', 1024), + str('brand_worte', 64, { array: true }), + str('am_markt_seit', 64), + str('stripe_customer_id', 64), + int('default_video_count', { min: 3, max: 6, default: 4 }), + enm('status', ['trial', 'aktiv', 'pausiert'], { default: 'trial' }), + ], + indexes: [{ key: 'idx_team', type: 'key', columns: ['team_id'] }], + }, + { + // 🔒 Dev-Modus: nur internes Team + id: 'rules', name: 'Rules', rowSecurity: false, permissions: internalOnly, + columns: [ + id('brand_id'), // null = global für alle Brands + str('titel', 255, { required: true }), + md('prompt_text'), + bool('aktiv', { default: true }), + int('sortierung', { default: 0 }), + ], + indexes: [{ key: 'idx_brand', type: 'key', columns: ['brand_id'] }], + }, + { + // 🔒 Dev-Modus: nur internes Team; Prompts sind versioniert + id: 'prompt_templates', name: 'Prompt Templates', rowSecurity: false, permissions: internalOnly, + columns: [ + enm('key', P_KEYS, { required: true }), + int('version', { min: 1, default: 1 }), + md('static_core_md'), + jsonCol('slots'), + enm('model_adapter', ['seedance', 'veo', 'sora', 'wan']), // null = kein Adapter + bool('aktiv', { default: true }), + ], + indexes: [{ key: 'uq_key_version', type: 'unique', columns: ['key', 'version'] }], + }, + { + id: 'categories', name: 'Categories', rowSecurity: true, + columns: [ + id('brand_id', { required: true }), + str('name', 64, { required: true }), // location, licht, farben, kamera, voice, geraeusche, texte-hooks, handlungen + int('avg_score', { min: 0, max: 10000, default: 5000 }), // Cache – per Function aktualisiert + int('attribute_count', { default: 0 }), + ], + indexes: [{ key: 'idx_brand_name', type: 'key', columns: ['brand_id', 'name'] }], + }, + { + id: 'attributes', name: 'Attributes', rowSecurity: true, + columns: [ + id('brand_id', { required: true }), + id('category_id', { required: true }), + id('parent_id'), // Unter-Attribute (self-reference) + str('name', 255, { required: true }), + str('slug', 255, { required: true }), + enm('status', ['aktiv', 'archiviert'], { default: 'aktiv' }), // nie löschen + int('score', { min: 0, max: 10000, default: 5000 }), + int('k_factor', { default: 32 }), // 32 neu → 8 etabliert + int('start_value', { min: 0, max: 10000 }), // Kategorie-Ø bei Anlage + int('used_count', { default: 0 }), int('wins', { default: 0 }), int('losses', { default: 0 }), + dt('last_used_at'), + str('tags', 255, { array: true }), + md('beschreibung_md'), md('essenz_md'), md('details_md'), + str('prompt_bausteine', 1024, { array: true }), + str('negativ_prompts', 1024, { array: true }), + ], + indexes: [ + { key: 'idx_topn', type: 'key', columns: ['brand_id', 'category_id', 'status', 'score'], orders: ['ASC', 'ASC', 'ASC', 'DESC'] }, + { key: 'idx_parent', type: 'key', columns: ['parent_id'] }, + { key: 'idx_slug', type: 'key', columns: ['brand_id', 'slug'] }, + { key: 'ft_tags', type: 'fulltext', columns: ['tags'], optional: true }, + ], + }, + { + // append-only Log – nie ändern/löschen + id: 'score_events', name: 'Score Events', rowSecurity: true, + columns: [ + id('attribute_id', { required: true }), + id('video_id'), id('opponent_attribute_id'), + enm('event_type', ['vote', 'vote_favorit_bestaetigt', 'performance', 'gezielte_frage', 'llm_vergleich'], { required: true }), + int('delta'), int('new_score', { min: 0, max: 10000 }), + flt('weight', { default: 1 }), // ×0,3 / ×1 / ×2 + str('kommentar', 1024), // z. B. P12-Erkenntnis + ], + indexes: [{ key: 'idx_attr_created', type: 'key', columns: ['attribute_id', '$createdAt'] }], + }, + { + id: 'assets', name: 'Assets', rowSecurity: true, + columns: [ + id('brand_id', { required: true }), + enm('typ', ['gesicht', 'produkt', 'logo', 'sonstiges'], { required: true }), + str('name', 255, { required: true }), + id('released_version_id'), // nur diese Version wird in Videos verwendet + ], + indexes: [{ key: 'idx_brand', type: 'key', columns: ['brand_id'] }], + }, + { + id: 'asset_versions', name: 'Asset Versions', rowSecurity: true, + columns: [ + id('asset_id', { required: true }), + int('version_no', { min: 1 }), + enm('status', ['entwurf', 'freigegeben', 'archiviert'], { default: 'entwurf' }), + md('beschreibung_md'), + str('merkmale', 255, { array: true }), + str('change_reason', 512), + id('won_against_version_id'), // Vorher/Nachher-Vote + id('reference_file_ids', { array: true }), // → Bucket asset-references + id('einwilligung_file_id'), // → Bucket consents + ], + indexes: [{ key: 'idx_asset', type: 'key', columns: ['asset_id', 'version_no'] }], + }, + { + id: 'scenes', name: 'Scenes', rowSecurity: true, + columns: [ + id('brand_id', { required: true }), + md('user_prompt'), // Original, unverändert aufheben + jsonCol('selected_context', 65535), // was P4 injiziert hat (inkl. Scores zum Zeitpunkt) + md('script_md'), md('script_final_md'), // Diff = P6-Signal + enm('status', ['entwurf', 'script', 'generiert', 'voting', 'fertig'], { default: 'entwurf' }), // Realtime-Kanal fürs UI + int('video_count', { min: 1, default: 4 }), + ], + indexes: [{ key: 'idx_brand_status', type: 'key', columns: ['brand_id', 'status'] }], + }, + { + id: 'videos', name: 'Videos', rowSecurity: true, + columns: [ + id('scene_id', { required: true }), + id('brand_id', { required: true }), + enm('ki_model', ['seedance', 'veo', 'sora', 'wan']), + md('prompt_sent'), // exakt was ans Modell ging (Dev-Modus) + id('storage_file_id'), // → Bucket generated-videos + jsonCol('tags'), // P9-Analyse + id('attribute_ids', { array: true }), // Attribution (many-to-many) + id('asset_version_ids', { array: true }), + bool('is_system_favorite', { default: false }), + enm('vote_result', ['gewonnen', 'verloren']), // null = noch offen ("–") + bool('published', { default: false }), + str('platforms', 64, { array: true }), + dt('published_at'), + ], + indexes: [ + { key: 'idx_scene', type: 'key', columns: ['scene_id'] }, + { key: 'idx_brand', type: 'key', columns: ['brand_id'] }, + ], + }, + { + // Zeitreihe (Cron-Function, Ads-APIs) – append-only + id: 'video_metrics', name: 'Video Metrics', rowSecurity: true, + columns: [ + id('video_id', { required: true }), + dt('fetched_at'), + flt('ctr'), flt('thumbstop'), flt('roas'), flt('follower_gained'), flt('spend'), + ], + indexes: [{ key: 'idx_video_fetched', type: 'key', columns: ['video_id', 'fetched_at'] }], + }, + { + id: 'votes', name: 'Votes', rowSecurity: true, + columns: [ + id('scene_id', { required: true }), + id('winner_video_id', { required: true }), + bool('favorit_bestaetigt', { default: false }), // → Gewichtung ×0,3 + ], + indexes: [{ key: 'idx_scene', type: 'key', columns: ['scene_id'] }], + }, + { + // gezielte Fragen (P13) + id: 'questions', name: 'Questions', rowSecurity: true, + columns: [ + id('brand_id', { required: true }), + id('scene_id'), + str('frage', 1024, { required: true }), + id('attribute_a_id'), id('attribute_b_id'), + enm('antwort', ['a', 'b', 'egal', 'offen'], { default: 'offen' }), + ], + indexes: [{ key: 'idx_brand_antwort', type: 'key', columns: ['brand_id', 'antwort'] }], + }, + { + // jede KI-Aktion als Zeile (Warteschlange + Kostenkontrolle) – append-only + id: 'jobs', name: 'Jobs', rowSecurity: true, + columns: [ + id('brand_id', { required: true }), + enm('typ', ['bild_gen', 'video_gen', 'tagging', 'vergleich', 'script'], { required: true }), + str('prompt_template_key', 16), // welcher Prompt … + int('prompt_template_version'), // … in welcher Version lief + enm('status', ['wartend', 'laeuft', 'fertig', 'fehler'], { default: 'wartend' }), + flt('cost_usd'), int('tokens'), + str('error', 4096), + jsonCol('refs', 8192), + ], + indexes: [ + { key: 'idx_brand_status', type: 'key', columns: ['brand_id', 'status'] }, + { key: 'idx_queue', type: 'key', columns: ['status', '$createdAt'] }, + ], + }, + { + // für Stripe (metered billing: Preis pro Video) + id: 'usage_records', name: 'Usage Records', rowSecurity: true, + columns: [ + id('brand_id', { required: true }), + str('periode', 16), // z. B. "2026-07" + int('videos_generiert', { default: 0 }), + flt('kosten_usd', { default: 0 }), + ], + indexes: [{ key: 'uq_brand_periode', type: 'unique', columns: ['brand_id', 'periode'] }], + }, +]; + +const BUCKETS = [ + { id: 'uploads', name: 'Uploads (Onboarding)' }, + { id: 'asset-references', name: 'Asset-Referenzbilder' }, + { id: 'generated-videos', name: 'Generierte Videos' }, + { id: 'consents', name: 'Einwilligungen', permissions: [`read("${INTERNAL}")`] }, +]; + +// ---- Ablauf --------------------------------------------------------------- +async function waitForColumn(tableId, key) { + for (let i = 0; i < 30; i++) { + const { status, json } = await api('GET', `/tablesdb/${DB_ID}/tables/${tableId}/columns/${key}`); + if (status === 200 && json.status === 'available') return true; + if (status === 200 && json.status === 'failed') { + console.warn(` ! Spalte ${tableId}.${key}: Status "failed"`); + warned++; + return false; + } + await new Promise(r => setTimeout(r, 1000)); + } + console.warn(` ! Spalte ${tableId}.${key}: nicht verfügbar nach 30s`); + warned++; + return false; +} + +async function main() { + console.log(`Endpoint: ${ENDPOINT}\nProjekt: ${PROJECT}\nDB: ${DB_ID}\n`); + + console.log('Team:'); + await ensure('internal (Internes Team / Dev-Modus)', 'POST', '/teams', { teamId: 'internal', name: 'Internes Team (Dev)' }); + + console.log('Datenbank:'); + await ensure(`${DB_ID}`, 'POST', '/tablesdb', { databaseId: DB_ID, name: 'BrandLoop' }); + + for (const t of TABLES) { + console.log(`Tabelle ${t.id}:`); + await ensure(t.id, 'POST', `/tablesdb/${DB_ID}/tables`, { + tableId: t.id, name: t.name, + permissions: t.permissions || [], + rowSecurity: t.rowSecurity, + }); + for (const c of t.columns) { + await ensure(`${t.id}.${c.body.key}`, 'POST', `/tablesdb/${DB_ID}/tables/${t.id}/columns/${c.type}`, c.body); + } + } + + // Indizes erst, wenn alle beteiligten Spalten "available" sind + for (const t of TABLES) { + if (!t.indexes?.length) continue; + console.log(`Indizes ${t.id}:`); + const needed = new Set(t.indexes.flatMap(i => i.columns).filter(c => !c.startsWith('$'))); + for (const key of needed) await waitForColumn(t.id, key); + for (const idx of t.indexes) { + const body = { key: idx.key, type: idx.type, columns: idx.columns, orders: idx.orders }; + if (idx.optional) await tryEnsure(`${t.id}.${idx.key}`, 'POST', `/tablesdb/${DB_ID}/tables/${t.id}/indexes`, body); + else await ensure(`${t.id}.${idx.key}`, 'POST', `/tablesdb/${DB_ID}/tables/${t.id}/indexes`, body); + } + } + + console.log('Storage-Buckets:'); + for (const b of BUCKETS) { + await ensure(b.id, 'POST', '/storage/buckets', { + bucketId: b.id, name: b.name, + fileSecurity: true, + permissions: b.permissions || [], + enabled: true, + }); + } + + console.log(`\nFertig: ${created} angelegt, ${skipped} existierten schon, ${warned} Warnung(en).`); +} + +main().catch(e => { console.error(`\nAbbruch: ${e.message}`); process.exit(1); });