# 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: > 1. **`attributes` ist aufgetrennt.** Scores und Zähler liegen jetzt in `attribute_scores` – ein Attribut trägt **mehrere Scores, einen je Ordner**. Ohne diesen Schnitt gäbe es keine ordnerbezogene Wissens-Segmentierung. > 2. **`assets` heißt fachlich „Modelle"** und kennt den neuen Typ `kulisse`. 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`**, `jobs` werden 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`**, nie `prompt_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.