19 KiB
Datenbank-Aufbau (Appwrite, self-hosted)
Stand: Juli 2026 · basiert auf Appwrite TablesDB (Tabellen/Zeilen/Relationen), Functions, Storage mit S3-Adapter, Realtime, Auth + Teams, Messaging.
Grundsatz: Die brand-knowledge/-Ordnerstruktur aus dem Konzept wird 1:1 in Tabellen abgebildet. Die .md-Textebenen (Beschreibung, Essenz, Details) bleiben als Markdown-Felder erhalten – strukturierte Daten (Scores, Zähler, Status) werden eigene Spalten, damit man danach filtern und sortieren kann.
Update Juli 2026 – Bild-Modus, Feed & Ordner (Konzept:
konzept-bilder-feed.md) Zwei strukturelle Änderungen, keine reinen Ergänzungen:
attributesist aufgetrennt. Scores und Zähler liegen jetzt inattribute_scores– ein Attribut trägt mehrere Scores, einen je Ordner. Ohne diesen Schnitt gäbe es keine ordnerbezogene Wissens-Segmentierung.assetsheißt fachlich „Modelle" und kennt den neuen Typkulisse. Der Begriff „Modell" meint in diesem Projekt durchgehend das Asset (Person, Produkt, Kulisse) – nie das KI-Modell (Seedance, Veo, Sora). Diese Sprachregelung gilt für Doku und Code.Neu dazu:
folders,attribute_scores,posts,post_images,post_folders,post_metrics.
1. Appwrite-Features → unsere Nutzung
| Appwrite-Feature | Wofür wir es nutzen |
|---|---|
| Auth + Teams | Jede Brand = ein Team. Zeilen-Rechte pro Team → sauberes Multi-Tenant ohne eigene Logik. Internes Admin-Team für den Dev-Modus. |
| TablesDB + Relationen | Alle Tabellen unten; 4 Relationstypen decken Kategorie→Attribut, Attribut→Unter-Attribut (self-relation), Szene→Videos ab. |
| Storage (S3-Adapter!) | Buckets laufen direkt auf Hetzner Object Storage als Backend – Appwrite verwaltet Rechte/Uploads, Hetzner speichert. Kein doppeltes System. |
| Functions | Alle deterministischen Abläufe (Elo, Cron-Jobs) + Auslöser für LLM-Jobs. Event-getriggert oder geplant. |
| Realtime | Live-Status in der App: „Script fertig → generiere Video 2/4 → Analyse läuft" ohne Polling. |
| Messaging | Push/E-Mail: „Deine 4 Videos sind fertig – wähle das Beste." |
2. Tabellen
Mandant & Konfiguration
brands – eine Zeile pro Kunde
| Spalte | Typ | Hinweis |
|---|---|---|
| team_id | string | Appwrite-Team (Rechte) |
| label_name, produkte, zielgruppe, brand_worte[3], am_markt_seit | string | aus dem Onboarding (6 Pflichtfragen) |
| nische | enum: kosmetik/auto/gastro/fitness/mode/… | neu – feste Branchenliste, Pflichtfrage im Onboarding. Filtert den Feed und steuert die Kopier-Kompatibilität |
| stripe_customer_id | string | Payment |
| default_video_count | int (3–6) | einstellbare Videoanzahl |
| plan | enum: bild/video | neu – Bild-Modus ist die günstige Einstiegsstufe (Preismodell noch offen) |
| status | enum: trial/aktiv/pausiert |
rules – feste Regeln (Dev-Modus) 🔒 nur internes Team
| Spalte | Typ |
|---|---|
| brand_id | rel → brands (null = global für alle Brands) |
| titel, prompt_text | string |
| aktiv, sortierung | bool, int |
prompt_templates – die statischen Prompts P1–P22 (Dev-Modus) 🔒 nur internes Team
| Spalte | Typ | Hinweis |
|---|---|---|
| key | enum P1…P22 | siehe Prompt-Inventar (P19–P22 = Bild-Modus & Feed) |
| version | int | Prompts sind versioniert – man muss zurückrollen können |
| static_core_md | string | der statische Kern |
| slots | json | welche Slots injiziert werden (REGELN, ASSETS, …) |
| model_adapter | enum: null/seedance/veo/sora/wan | P8-Familie: ein Kern, mehrere Dialekte |
| aktiv | bool |
Knowledge Base
folders – Wissens-Scope (neu)
| Spalte | Typ | Hinweis |
|---|---|---|
| brand_id | rel → brands | privat pro Brand – Ordner sind kein soziales Objekt |
| name, theme_md | string, string (Markdown) | „Dark Studio"; theme_md schreibt P20 beim Anlegen |
| zweck | enum: sammlung / wissens_scope | sammlung = nur kategorisieren, keine Scores, kein Einfluss auf Generierung. wissens_scope = aus dem Ordner heraus wird erstellt |
| startwert_modus | enum: erben / aus_posts | nur bei wissens_scope. erben = Kopie der brand-weiten Scores beim Anlegen · aus_posts = nur was in den gesammelten Posts steckt, Startwert = Kategorie-Ø innerhalb des Ordners |
| ist_default | bool | Anzeige-Ordner „Allgemein" im UI. Die brand-weite Basisebene ist keine Ordner-Zeile, sondern folder_id = null in attribute_scores – der Default-Ordner zeigt sie nur an, er speichert sie nicht |
| post_count, signal_count | int | Reifegrad – steuert den K-Faktor und die UI-Anzeige „Ordner lernt noch" |
| created_at | datetime |
Kaltstart: keine Mindestanzahl an Posts, keine Sperre. Ein Ordner ist ab dem Anlegen nutzbar; die statistische Unsicherheit steckt im K-Faktor (32 → 8), nicht in einer Sperre. Das ist dieselbe Regel wie in
projekt-uebersicht.md§5, nur auf Ordner-Ebene.
categories
| Spalte | Typ | Hinweis |
|---|---|---|
| brand_id | rel | |
| name | string (location, licht, farben, kamera, voice, geraeusche, texte-hooks, handlungen) | |
| folder_id | rel → folders (null = brand-weit) | neu – avg_score ist scope-abhängig, also gibt es die Zeile je Ordner |
| avg_score | int | Cache für Startwerte neuer Einträge – per Function aktualisiert, nicht live berechnet |
| attribute_count | int |
attributes – nur noch die Definition (Scores siehe unten)
| Spalte | Typ | Hinweis |
|---|---|---|
| brand_id, category_id | rel | |
| parent_id | rel → attributes (self) | Unter-Attribute (kaiserslautern--stadion) |
| name, slug | string | |
| status | enum: aktiv/archiviert | nie löschen |
| tags | string[] | Volltext-Index |
| beschreibung_md, essenz_md, details_md | string (Markdown) | die drei Textebenen |
| prompt_bausteine, negativ_prompts | string[] | gehen direkt in P7/P8-Slots |
Warum aufgetrennt: Ein Attribut („badezimmer") ist einmal definiert, trägt aber mehrere Scores – einen brand-weit und je einen pro Ordner. „Warmes Abendlicht" kann im Ordner Sommerkampagne auf 6800 stehen und im Ordner Dark Studio gar nicht existieren. Die Textebenen bleiben bewusst am Attribut, nicht am Score: die Beschreibung ändert sich nicht mit dem Scope.
attribute_scores – Score je Attribut je Ordner (neu, Herzstück)
| Spalte | Typ | Hinweis |
|---|---|---|
| attribute_id | rel → attributes | |
| folder_id | rel → folders (null = brand-weite Basisebene) | Unique-Constraint auf (attribute_id, folder_id) |
| brand_id, category_id | rel | denormalisiert. Index: (brand, folder, category, score ↓) – das ist die Top-N-Abfrage, die P4 bei jeder Generierung fährt |
| score | int (0–10.000) | |
| k_factor | int | 32 neu → 8 etabliert; bleibt hoch, solange folders.signal_count niedrig ist |
| start_value, start_quelle | int, enum: kategorie_avg / geerbt / feed_import | dokumentiert, woher der Startwert kam – ohne das ist später nicht nachvollziehbar, warum ein Score dort steht |
| used_count, wins, losses | int | |
| last_used_at | datetime |
score_events – das Log (append-only, nie ändern)
| Spalte | Typ | Hinweis |
|---|---|---|
| attribute_id | rel | Index: (attribute, created_at) |
| folder_id | rel → folders (null = brand-weit) | neu und zwingend – ohne den Scope ist nicht nachvollziehbar, in welcher Wissensinsel ein Punkt vergeben wurde |
| video_id, post_id, opponent_attribute_id | rel | |
| event_type | enum: vote / vote_favorit_bestaetigt / performance / gezielte_frage / llm_vergleich / feed_import / post_reichweite | die zwei neuen Typen kommen aus dem Bild-Modus |
| delta, new_score, weight | int, int, float | weight: ×0,3 / ×1 / ×2 · feed_import und post_reichweite laufen mit ×0,3 |
| kommentar | string | z. B. P12-Erkenntnis („Stadion war der Unterschied") |
Assets / „Modelle" (Release-Prinzip)
assets – im UI „Modelle"
| Spalte | Typ | Hinweis |
|---|---|---|
| brand_id | rel | |
| typ | enum: gesicht/produkt/kulisse/logo/sonstiges | kulisse ist neu – die Umgebung ist ein eigenständiges, einzeln austauschbares Modell |
| name | string | „kleine Kerze" |
| nische | enum wie brands.nische |
Feed-Filter |
| typ_tags | string[] (Enum-Zwang) | fahrzeug, getraenk, tube, person_weiblich, innenraum, … – gegen diese Liste prüft die Slot-Kompatibilität beim Kopieren. Feste Liste + Code-Validator, keine freie KI-Vergabe |
| feed_score, feed_score_updated_at | float, datetime | Modelle werden getrennt von Posts gerankt (siehe post_metrics) |
| ist_teilbar | bool (default false) | Modelle werden grundsätzlich nicht geteilt. Das Feld existiert nur, um die Regel explizit und prüfbar zu machen |
| released_version_id | rel → asset_versions (nur diese wird in Videos und Bildern verwendet) |
Regel: Ein Kopierer bringt immer eigene Modelle mit. Fremde Modelle erscheinen nur als Beschriftung im Kopier-Screen („Modell: kleine Kerze"), damit klar ist, was das Rezept braucht – nie als nutzbares Asset. Begründung in
konzept-bilder-feed.md§3 (Marken-/Designrecht bei Produkten, Konsistenzentwertung bei Personen).
asset_versions – Versionskette
| Spalte | Typ | Hinweis |
|---|---|---|
| asset_id, version_no | rel, int | |
| status | enum: entwurf/freigegeben/archiviert | |
| beschreibung_md, merkmale | string, string[] | |
| change_reason | string | „Haare dunkler, natürlicher" |
| won_against_version_id | rel | Vorher/Nachher-Vote |
| reference_file_ids | string[] | → Storage-Bucket |
| einwilligung_file_id | string | dokumentierte Einwilligung bei echten Gesichtern |
Produktion
scenes
| Spalte | Typ | Hinweis |
|---|---|---|
| brand_id | rel | |
| folder_id | rel → folders (null = brand-weit) | neu – der Scope wird vor der Generierung gewählt und bestimmt, welches Wissen P4 zieht |
| user_prompt | string | Original, unverändert aufheben |
| selected_context | json | was P4 injiziert hat (Attribute + Scores zum Zeitpunkt!) |
| script_md, script_final_md | string | Original vs. vom Nutzer bearbeitet → Diff = P6-Signal |
| status | enum: entwurf/script/generiert/voting/fertig | Realtime-Kanal fürs UI |
| video_count | int |
videos
| Spalte | Typ | Hinweis |
|---|---|---|
| scene_id, brand_id | rel | |
| ki_model | enum: seedance/veo/sora/wan | |
| prompt_sent | string 🔒 | exakt was ans KI-Modell ging (Dev-Modus „Prompt-Einblick") |
| storage_file_id | string | → Bucket generated-videos |
| tags | json | P9-Analyse |
| attribute_ids | rel many→many | für Attribution |
| asset_version_ids | string[] | welche Versionen genau (v3? v4?) |
| is_system_favorite, vote_result | bool, enum: gewonnen/verloren/– | |
| published, platforms, published_at | bool, string[], datetime |
video_metrics – Zeitreihe (Cron-Function, Ads-APIs)
| Spalte | Typ |
|---|---|
| video_id, fetched_at | rel, datetime |
| ctr, thumbstop, roas, follower_gained, spend | float |
votes
| Spalte | Typ |
|---|---|
| scene_id, winner_video_id | rel |
| favorit_bestaetigt | bool (→ Gewichtung ×0,3) |
| created_at | datetime |
questions – gezielte Fragen (P13)
| Spalte | Typ |
|---|---|
| brand_id, scene_id | rel |
| frage, attribute_a_id, attribute_b_id | string, rel, rel |
| antwort | enum: a/b/egal/offen |
Bild-Modus: Posts, Feed & Ordner
posts – das Slot-Rezept (neu)
| Spalte | Typ | Hinweis |
|---|---|---|
| brand_id | rel | Ersteller |
| format | enum: 1_1 / 4_5 / 9_16 | gilt für die ganze Kette. Beim Kopieren deterministisch übernommen – kein LLM |
| nische | enum wie brands.nische |
Feed-Filter |
| kulisse_asset_id, person_asset_id, produkt_asset_id | rel → assets | die Modell-Slots |
| asset_version_ids | string[] | welche Versionen genau – wie bei videos |
| attribute_ids | rel many→many → attributes | Licht / Kamera / Farbe als Slots |
| werbetext | string | Inhalt des Text-Overlay-Bildes |
| prompt_sent | string 🔒 | exakt was ans Bildmodell ging – nur internes Team, nie im Kopier-Screen |
| slot_summary | json | die vereinfachte Chip-Darstellung für den Kopier-Screen (Output von P19). Das ist das Einzige, was ein fremder Nutzer sieht – eine Abstraktion, keine Offenlegung |
| copied_from_post_id | rel → posts (self) | Herkunftskette – erlaubt später „dieses Rezept wurde 400× kopiert" |
| sichtbarkeit | enum: privat / oeffentlich (default privat) | Veröffentlichen ist ein aktiver Schritt |
| published_at | datetime | |
| social_permalink, social_plattform | string, enum: instagram/tiktok/… | Grundlage für den Reichweiten-Abgleich |
| feed_score, feed_score_updated_at | float, datetime | Cache, per Cron berechnet – nie live |
| ki_kennzeichnung | bool | EU AI Act – im Feed sichtbar, nicht nur beim Export |
post_images – die Bilderkette
| Spalte | Typ | Hinweis |
|---|---|---|
| post_id, position | rel, int | Reihenfolge im Karussell |
| typ | enum: motiv / text_overlay | Das Text-Overlay ist ein eigenes Bild, keine Ebene auf dem Motiv – so lässt sich der Text tauschen, ohne die Motive neu zu generieren |
| storage_file_id | string | → Bucket generated-images |
| winkel, position_beschreibung | string | über die Kette variiert nur Position/Winkel; Modelle, Kulisse, Licht, Farbe und Text bleiben konstant |
| tags | json | P22-Analyse (Bild-Tagger) |
post_folders – Zuordnung (m:n, neu)
| Spalte | Typ | Hinweis |
|---|---|---|
| post_id, folder_id | rel, rel | Unique auf (post_id, folder_id) |
| ist_fremd | bool | Post stammt nicht vom Ordner-Besitzer. Ordner nehmen eigene und fremde Posts auf – Wissen entsteht durch Sammeln, nicht erst durch Ausgeben |
| quelle | enum: eigen / feed_gespeichert / kopiert | |
| zugeordnet_von | enum: nutzer / p20_vorschlag | P20 schlägt vor, der Nutzer bestätigt |
| created_at | datetime |
post_metrics – Feed-Signale (Zeitreihe, append-only)
| Spalte | Typ | Hinweis |
|---|---|---|
| post_id, fetched_at | rel, datetime | |
| views, likes, kopien | int | In-App-Signale |
| social_reach, social_engagement | int | von verbundenen Accounts |
| ist_verifiziert | bool | nur verifizierte Konten zählen voll – Manipulationsschutz |
Gewichtung Feed-Score: Views ×0,1 · Likes ×0,5 · Kopien ×1 · Social-Reichweite ×2. Eine Kopie wiegt mehr als ein Like – ein Like ist eine Meinung, eine Kopie eine Handlung mit Aufwand.
Der Feed-Score ist kein Elo. Elo braucht Duelle; hier werden Zähler aufsummiert. Deshalb eigene Tabelle, eigener Name, eigene Function. Details und Begründung:
konzept-bilder-feed.md§8.
Betrieb
jobs – jede KI-Aktion als Zeile (Warteschlange + Kostenkontrolle)
| Spalte | Typ | Hinweis |
|---|---|---|
| brand_id, typ | rel, enum: bild_gen/video_gen/tagging/vergleich/script/slot_analyse/ordner_vorschlag/werbetext | die drei neuen Typen kommen aus dem Bild-Modus (P19, P20, P21) |
| prompt_template_key, prompt_template_version | string, int | welcher Prompt in welcher Version lief – ohne das ist kein Prompt-Debugging möglich |
| status | enum: wartend/läuft/fertig/fehler | |
| cost_usd, tokens | float, int | speist die Beispielrechnung mit echten Zahlen |
| error, refs | string, json |
usage_records – für Stripe (metered billing: Preis pro Video)
| Spalte | Typ |
|---|---|
| brand_id, periode, videos_generiert, kosten_usd | rel, string, int, float |
3. Storage-Buckets (Backend: Hetzner Object Storage via S3-Adapter)
| Bucket | Inhalt | Rechte |
|---|---|---|
uploads |
Onboarding: Logo, Produktbilder, Top-Ads; Referenzen beim Verbessern | Team der Brand |
asset-references |
generierte Referenzbilder der Asset-Versionen | Team der Brand |
generated-videos |
alle generierten Videos | Team der Brand |
generated-images |
alle generierten Bilder (Motive + Text-Overlays) | Team der Brand; öffentlich lesbar, sobald posts.sichtbarkeit = oeffentlich |
consents |
Einwilligungs-Dokumente (Gesichter) | Team + intern |
4. Functions (deterministisch – die ⚙️-Punkte aus dem Prompt-Inventar)
| Function | Trigger | Macht |
|---|---|---|
elo-update |
Event: votes.create / video_metrics.create | Elo-Mathe, K-Faktor, Gewichtung, schreibt score_events – jetzt scope-bewusst: schreibt in attribute_scores der passenden folder_id |
favorit-berechnen |
Event: alle Videos einer Szene getaggt | Score-Summe → is_system_favorite |
ads-metriken-holen |
Cron täglich | Meta/TikTok-API → video_metrics |
kategorie-durchschnitt |
Cron täglich | avg_score-Cache für Startwerte – je Ordner |
archivierung |
Cron wöchentlich | Score unter Schwelle + inaktiv → Status archiviert |
job-dispatcher |
Event: jobs.create | ruft OpenRouter, schreibt Ergebnis + Kosten zurück |
ordner-initialisieren |
Event: folders.create | Bei startwert_modus = erben die brand-weiten Scores nach attribute_scores kopieren; bei aus_posts nur die Attribute der zugeordneten Posts anlegen, Startwert = Kategorie-Ø im Ordner. Setzt start_quelle |
feed-score-berechnen |
Cron stündlich | Gewichtete Summe aus post_metrics + exponentieller Zeit-Decay (Halbwertszeit-Parameter, Startwert 30 Tage) → posts.feed_score und aggregiert → assets.feed_score. Rein deterministisch, kein LLM |
feed-import |
Event: post kopiert | Legt die Slots des Originals als Attribute in der KB des Kopierers an (Scope = gewählter Ordner), start_quelle = feed_import, schreibt score_events |
social-reichweite-holen |
Cron täglich | Instagram/TikTok-API → post_metrics.social_reach; löst Rückfluss mit ×0,3 in die KB aus |
5. Rechte-Modell
- Brand-Team: sieht nur eigene Zeilen (Appwrite Row-Level-Permissions pro Team) – Multi-Tenant ohne Extra-Code.
- Internes Team (Dev-Modus): exklusiver Zugriff auf
rules,prompt_templates,jobs.prompt_sent,posts.prompt_sent– das App-Geheimnis liegt in den Rechten, nicht nur im UI. - append-only:
score_events,video_metrics,post_metrics,jobswerden nie geändert oder gelöscht – das ist die Log-Anforderung aus dem Konzept.
Neu mit dem Feed – die einzige öffentlich lesbare Ebene:
| Öffentlich lesbar | Bleibt privat |
|---|---|
posts bei sichtbarkeit = oeffentlich: Bilder, slot_summary, werbetext, nische, feed_score |
posts.prompt_sent 🔒 · attribute_scores · folders (immer privat) · rules · alle Modelle als Assets |
assets: nur Name, Typ und Typ-Tags als Beschriftung im Kopier-Screen |
die Asset-Version selbst, Referenzbilder, einwilligung_file_id |
Das ist die kritische Grenze des ganzen Features: Der Kopier-Screen zeigt
slot_summary, nieprompt_sent. Eine Abstraktion, keine Offenlegung – sonst wandert die Knowledge Base fremder Brands nach außen.
Zusätzlich nötig, weil der Feed öffentlich ist: Rate-Limits auf post_metrics-Signale pro Konto und eine Melde-/Moderationsfunktion (DSA). Beim geschlossenen B2B-Produkt gab es beides nicht.