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.
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:
aiLookupStream, Region europe-west3 (Frankfurt).stream: true und dem eingebauten web_search-Tool.filterToAllowlist), anschließend HEAD-Verifikation mit Host-Root-Fallback (verifyAndNormalizeSources).done-Event Cross-Fade in den finalen Antwort-Screen.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
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.
Die harte Regel: keine Antwort ohne mindestens eine durch die Allowlist + HEAD-Pipeline gelaufene Quelle.
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.topics-Sektion ordnet jede Quelle dem inhaltlichen Schwerpunkt zu (z. B. „Migration", „Klima", „Wirtschaft").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.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.
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.
aiLookupStream-Endpoint ist mit enforceAppCheck: true konfiguriert, Requests ohne gültiges Token werden mit HTTP 401 verworfen, bevor die Function-Logik überhaupt anläuft.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.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.
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:
europe-west3. SSE-Handler, Rate Limit, Citation Verifier und Guardrails sind in eigenen Modulen mit einer Jest-Test-Suite (rund 95 Tests).URLSession.bytes(for:)-basierter Streaming-Client, eigene Services für Geräte-Identität (Keychain) und Tages-Quota.Kontakt für tiefergehende Fragen: kontakt@gegenrede.app.
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.