Files
videogen/planung/datenbank-aufbau.md
2026-08-06 12:44:41 +02:00

309 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (36) | 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 P1P22 (Dev-Modus) 🔒 *nur internes Team*
| Spalte | Typ | Hinweis |
|---|---|---|
| key | enum P1…**P22** | siehe Prompt-Inventar (P19P22 = 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 (010.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.