# Gegenrede: Architektur-Brief KI-Recherche

Adressiert an Tech-Redaktionen, die die Datenschutz- und EU-Hosting-Aussagen aus der Pressemappe technisch nachvollziehen möchten. Zusammengefasst aus der internen Architektur-Dokumentation; Originalquellen einzelner Aussagen auf Anfrage.

---

## 1. Überblick

Die KI-Recherche prüft eine vom Nutzer eingegebene Behauptung gegen eine Allowlist offizieller deutscher und europäischer Quellen und liefert eine strukturierte Antwort in drei Tonarten zurück. Die gesamte Verarbeitung läuft in der EU; auf dem Server werden weder die Eingabe noch die IP-Adresse gespeichert.

Der Verarbeitungsweg in Schritten:

1. App: Pre-flight-Guardrails (Mindestlänge, PII-Heuristik) und lokaler Tages-Quota-Check.
2. App: HTTPS-Streaming-POST an die Cloud Function `aiLookupStream`, Region `europe-west3` (Frankfurt).
3. Function: App-Check-Token-Validierung (App Attest auf iOS, Play Integrity auf Android).
4. Function: Server-Guardrails (Längen-Cap, Injection-Regex, Topic-Gate).
5. Function: Tages-Quota-Decrement im Firestore-Counter (per device + global).
6. Function: Aufruf der Mistral Agents API mit `stream: true` und dem eingebauten `web_search`-Tool.
7. Function: Filter aller vom Modell zitierten URLs gegen die Allowlist (`filterToAllowlist`), anschließend HEAD-Verifikation mit Host-Root-Fallback (`verifyAndNormalizeSources`).
8. Function: SSE-Events zurück an die App, sobald die jeweiligen Sektionen geschlossen sind.
9. App: Progressive Rendering Block für Block; beim `done`-Event Cross-Fade in den finalen Antwort-Screen.

---

## 2. Pipeline-Diagramm

```mermaid
flowchart LR
    A["iOS / Android App"] -->|"HTTPS POST + App Check Token + SHA-256(device UUID) + claim text"| B["aiLookupStream<br/>(Cloud Function Gen 2, europe-west3 / Frankfurt)"]
    B --> C{"Guardrails<br/>len ≤ 500, injection regex, topic gate"}
    C -->|reject| Z1["SSE: error event"]
    C -->|pass| D{"Rate Limit<br/>per-device + global (Firestore counters)"}
    D -->|exhausted| Z2["SSE: rateLimited / quotaExhausted"]
    D -->|allow| E["Mistral Agents API<br/>mistral-large-latest, stream: true<br/>(France / Frankfurt)"]
    E --> F["web_search Tool<br/>(Brave-backed, EU-only)"]
    F --> E
    E -->|streamed tokens| G["Marker Parser<br/>«META», «RESPONSE:tone», «FACT», «SOURCE» ..."]
    G --> H["Allowlist Filter<br/>trusted_sources.json (75 domains)"]
    H --> I["HEAD Verifier<br/>host-root fallback"]
    I -->|"no source survives"| Z3["SSE: noEvidence + reformulation chips"]
    I -->|"≥1 source ok"| J["SSE: typed events back to client"]
    J --> A
    Z1 --> A
    Z2 --> A
    Z3 --> A
```

---

## 3. Streaming-Protokoll (Server-Sent Events)

Die Function liefert die Antwort nicht in einem Block, sondern als typisierte SSE-Frames. Damit erscheint das Verdikt-Chip nach ~5–10 Sekunden, während Quellen und Perspektiven im Hintergrund weiter eintrudeln.

**Frame-Reihenfolge:**

```
meta          → optional rephrased   → 3 × response   → fact   → N × source   → M × perspective   → conversation starters   → keywords   → done | error
```

- `meta` enthält `evidenceStrength` (confirmed / contested / refuted / unclear) und den Topic-Identifier, damit kann die App das Verdikt-Chip sofort rendern.
- `rephrased` ist die grammatikalisch geglättete Version der Nutzer-Eingabe (gleiche Bedeutung, kein Modal-Hedging). Wird in allen UI-Surfaces der ursprünglichen Eingabe vorgezogen.
- `source`-Frames kommen einzeln, sobald die jeweilige URL den Allowlist-Filter und die HEAD-Verifikation passiert hat. Fällt ein Frame durch, wird er stillschweigend verworfen.
- `done` schließt den Stream und übergibt der App den final reduzierten `Claim`-Payload.

Beide Plattformen rollen ihren eigenen chunk-basierten SSE-Parser (`AILookupSSEParser` auf iOS, `AILookupSseParser` auf Android), weil die System-Line-Iteratoren in beiden Stacks Leerzeilen verschlucken und genau diese Leerzeilen sind in SSE der Frame-Separator.

---

## 4. Allowlist und Citation Gating

**Die harte Regel:** keine Antwort ohne mindestens eine durch die Allowlist + HEAD-Pipeline gelaufene Quelle.

- **Quelle der Allowlist:** Eine `trusted_sources.json`-Datei liegt in drei Kopien (im Backend, im iOS-Bundle und im Android-Bundle). Ein CI-Workflow diff't alle drei bei jedem Pull Request und blockiert Merges, sobald sie auseinanderlaufen. Das stellt sicher, dass die in der App angezeigte Quellenliste exakt die ist, gegen die der Server tatsächlich filtert.
- **Zählweise:** 75 namentlich gelistete Institutionen auf 75 eindeutigen Domains. Die Liste umfasst Bundesbehörden (Destatis, BAMF, BKA, RKI, PEI, BfR, BMI, BMJ, BMWK, Bundesverfassungsgericht, Bundesbank, …), europäische Institutionen (Eurostat, EZB, EU-Kommission, EUR-Lex), Faktencheck-Redaktionen (Tagesschau Faktenfinder, dpa-Faktencheck, Correctiv, Volksverpetzer, AFP Faktencheck), wissenschaftliche Einrichtungen (Leopoldina, Max-Planck-Gesellschaft, ifo, IfW Kiel, DIW, Sachverständigenrat Wirtschaft, PIK Potsdam) und Demokratie-/Gesellschaftsforschung (Amadeu Antonio Stiftung, CeMAS, Mediendienst Integration, SVR Migration, DeZIM, Otto Brenner Stiftung). Eine `topics`-Sektion ordnet jede Quelle dem inhaltlichen Schwerpunkt zu (z. B. „Migration", „Klima", „Wirtschaft").
- **Soft hint vs. hard filter:** Der Allowlist-Hinweis an Mistrals `web_search`-Tool (`tool_configuration.include`) ist empirisch ein weicher Hint. Brave liefert weiterhin gemischte Hosts zurück. Der eigentliche Filter läuft deshalb **serverseitig nach dem Modell-Output**, nicht im Tool-Aufruf selbst. Das ist eine wichtige Architektur-Eigenschaft: Selbst wenn das Modell off-allowlist-Quellen zitiert, erreichen sie den Client nicht.
- **HEAD-Verifikation mit Host-Root-Fallback:** Jede Allowlist-konforme URL wird per HEAD-Request angefragt. Antwortet die Tiefenseite mit 4xx/5xx, fällt die Verifikation auf die Host-Root zurück und akzeptiert die Quelle, wenn diese erreichbar ist. Damit gehen valide Quellen nicht verloren, nur weil eine konkrete Deep-Link-URL nicht (mehr) erreichbar ist.
- **Trust-Gap (transparent dokumentiert):** Die Modell-Prose kann von Inhalten beeinflusst sein, die wir später als off-allowlist verwerfen. Wir filtern nur die Zitate, nicht den Gedankengang. Das ist der Trade-off, den wir für die EU-Datenresidenz akzeptieren, und er steht in der Datenschutzrichtlinie § 3.6.

---

## 5. Rate Limits und Cost Control

Drei Ebenen, die unterschiedliche Angriffsflächen abdecken.

| Ebene | Limit | Wo | Zweck |
|---|---|---|---|
| Client (UX-Framing) | 3 / Gerät / Tag | iOS Keychain, Android EncryptedSharedPreferences | Faire Quotenwand für den Nutzer, transparent kommuniziert |
| Server (per Device) | 50 / Gerät / Tag | Firestore `ai_rate_limits/{deviceHash}`, 24h-Rolling-Window | Autoritatives Spend-Cap, greift auch dann, wenn die Client-Wand umgangen wird |
| Server (global) | 1.000 / Tag | Firestore `ai_rate_limits_global/today` | Cost-Runaway-Verteidigung (~50 € / Tag Cap bei ~0,05 €/Lookup) |

Der Abstand zwischen Client-Wand (3) und Server-Cap (50) ist beabsichtigt. Die 3 sind die Wand, die der Nutzer sieht, mit Mitternachts-Reset und klarer Ansage. Die 50 sind die Reißleine für den Fall, dass jemand den Client-Counter aushebelt, etwa durch wiederholte Neuinstallation unter Android, wo die Geräte-Kennung eine Deinstallation nicht überlebt. Ein echter Nutzer kommt dieser Grenze nie nahe; sie ist so gesetzt, dass sie beim normalen Gebrauch und beim Testen nicht im Weg steht, aber die Kosten pro Gerät nach oben hin begrenzt.

Für `submitFeedback` (Feedback, Inhaltsmeldungen, Themenvorschläge, ab App-Version 2.3.0) laufen zwei weitere, voneinander getrennte Zähler: 5 / Gerät / Tag für Feedback aus den Einstellungen und 15 / Gerät / Tag für Meldungen und Vorschläge an einer Behauptung. Getrennt deshalb, weil das Melden von drei toten Quellen-Links in einer Sitzung normale Nutzung ist und niemandem das Kontingent für einen echten Fehlerbericht wegnehmen soll.

---

## 6. App Check und Device Identity

Zwei orthogonale Mechanismen sorgen dafür, dass nur die offizielle App den Endpunkt aufrufen kann und Quoten pro Gerät zugeordnet werden, ohne dass eine persönliche Identität entsteht.

- **App Check:** iOS App Attest, Android Play Integrity. In DEBUG-Builds laufen Debug-Provider mit in der Firebase-Konsole registrierten Tokens. Der `aiLookupStream`-Endpoint ist mit `enforceAppCheck: true` konfiguriert, Requests ohne gültiges Token werden mit HTTP 401 verworfen, bevor die Function-Logik überhaupt anläuft.
- **Anonyme Geräte-Identität:** Bei der ersten KI-Recherche generiert die App lokal eine UUID, persistiert sie sicher (iOS Keychain mit `kSecAttrAccessibleAfterFirstUnlock`, Android Keystore-backed EncryptedSharedPreferences), und sendet ausschließlich den SHA-256-Hash. Die rohe UUID verlässt das Gerät nie. Auf iOS überlebt der Keychain-Eintrag eine App-Deinstallation; auf Android nicht (Plattform-Verhalten der Keystore-Datei), beide Fälle sind durch das serverseitige Cap abgesichert.

---

## 7. Failure Modes

Jeder Fehlerpfad hat einen eigenen UI-State und einen eigenen Telemetrie-Marker, damit sich im Nachhinein nachvollziehen lässt, wie häufig welcher Fall greift.

| Auslöser | UI-State | Backend-Code | Quoten-Verbrauch |
|---|---|---|---|
| Quota lokal erschöpft | `quotaExhausted` | (Frontend, kein Backend-Hit) | nein |
| Server-Quota pro Gerät erschöpft | `rateLimited` | `resource-exhausted` | nein |
| App-Version unter Minimum | `unsupportedAppVersion` | `failed-precondition` | nein |
| Mistral Timeout / Netzwerk | `error.transport` | (Client-Side AbortError) | nein |
| Mistral antwortet, aber keine Allowlist-Quelle | `noEvidence` + Reformulierungs-Chips | `no_evidence`, reason `no_sources_verified` | ja |
| Modell-Output unparsbar | `error.rejected` | `internal` mit Tag | nein |

Das absichtliche Verhalten: nur erfolgreiche Mistral-Arbeit kostet einen Quota-Slot. Transport-Fehler oder Server-Rejects, bei denen der User „Server gerade nicht erreichbar" sieht, ziehen keinen Slot, andernfalls wäre die UX einer schlechten Verbindung eine doppelte Bestrafung.

---

## 8. Code-Einblicke

Das Code-Repository ist nicht öffentlich. Für Tech-Redaktionen, die einzelne Aussagen tiefer nachvollziehen möchten, teilen wir gezielt einzelne Code-Stellen oder Auszüge aus der internen Architektur-Dokumentation auf Anfrage. Stack-Übersicht:

- **Backend:** Firebase Cloud Functions Gen 2 in TypeScript (Node 24), Region `europe-west3`. SSE-Handler, Rate Limit, Citation Verifier und Guardrails sind in eigenen Modulen mit einer Jest-Test-Suite (rund 95 Tests).
- **iOS:** Swift 6 mit Strict Concurrency und Default-MainActor-Isolation, `URLSession.bytes(for:)`-basierter Streaming-Client, eigene Services für Geräte-Identität (Keychain) und Tages-Quota.
- **Android:** Kotlin 2.2 mit Jetpack Compose, OkHttp-basierter Streaming-Client, EncryptedSharedPreferences über dem Android Keystore für Geräte-Identität und Quota.

Kontakt für tiefergehende Fragen: <kontakt@gegenrede.app>.

---

## 9. Was hier bewusst fehlt

- **Der genaue Prompt-Text.** Das Prompt-Template wird auf Anfrage geteilt, wir publizieren es nicht in der Mappe, damit Prompt-Injection-Angriffe einen extra Schritt kosten.
- **Eval-Ergebnisse.** Phase-1-Evaluation lief intern; aggregierte Zahlen (Hit-Rate gegen Allowlist, False-Positive-Rate auf der „nichts gefunden"-Wand) auf direkte Nachfrage.
- **Mistral-API-Key.** Liegt ausschließlich in Google Secret Manager und nie im App-Binary.

---

## Stand

Zuletzt aktualisiert am 25. Juli 2026. Veröffentlicht war zu diesem Zeitpunkt App-Version 2.3.0 für iOS und Android.
Bei technischen Rückfragen: <kontakt@gegenrede.app>.
