Table of Contents
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.
Mail-Kategorisierung
On-Device-Kategorisierung von E-Mails. Vorschläge laufen lokal auf dem Gerät — kein Mailinhalt verlässt das Telefon oder den Browser für die Klassifikation.
Überblick
Mail-Snippet ──► cleanSnippet ──► Kandidatenliste (Defaults + bestätigte Tags)
│
▼
rejection-store Filter
│
┌─────────────────┴─────────────────┐
▼ ▼
Web: MiniLM (Embedding) Mobile: NLI → Llama
Die Default-Kategorien (Newsletter, Werbung, Bestellung, Rechnung, Spam, Termin, Reise, Finanzen, Paket, Arbeit, Privat) leben in packages/shared-types/src/mail-categories.ts — einzige Quelle für beide Plattformen. Jeder vom User bestätigte Tag wird automatisch zum zusätzlichen Kandidaten für die nächste Klassifikation („das System lernt").
Web: Embedding-Similarity
apps/web/src/services/model-manager.ts
- Modell:
Xenova/paraphrase-multilingual-MiniLM-L12-v2(~45 MB) - Verfahren: Cosine-Similarity zwischen Mail-Embedding und je einer „Description" pro Kategorie (Schlüsselwörter, die das Embedding besser greifen lässt als nur der Label-Name).
- Threshold: 0.25 Similarity → wird vorgeschlagen, sortiert absteigend, top 3.
- Runtime: transformers.js, lazy-load beim ersten
suggest().
Mobile: NLI mit Llama-Fallback (Phase 1 von #175)
apps/mobile/src/services/model-manager.ts ist seit PR #241 ein Orchestrator, der zwei Modelle koordiniert.
Fast-Path: NLI Zero-Shot
apps/mobile/src/services/nli-classifier.ts
- Modell:
Xenova/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7(~280 MB Q8 ONNX, multilingual DE/EN) - Verfahren: Für jeden Kandidaten ein Forward-Pass mit Premise = Mail-Snippet, Hypothese =
"Diese E-Mail handelt von <Kategorie>.". Score = Softmax-Wahrscheinlichkeit für „entailment". - Runtime:
onnxruntime-react-nativefür Inferenz,@huggingface/transformersnur für Tokenisierung (Pipeline-API läuft auf RN nicht). - Modell-Cache:
documentDirectory/models/nli-mdeberta/model_quantized.onnx, einmaliger Download beim ersten Aufruf.
Fallback: Llama
- Modell:
Llama-3.2-3B-InstructQ4_K_M (~2 GB GGUF) viallama.rn - Verfahren: Generativ — Llama bekommt Snippet + Hinweis auf bestehende Tags, liefert kommagetrennte Kategorien.
- Modell-Cache:
documentDirectory/models/llama-3.2-3b-instruct-q4_k_m.gguf
Routing-Logik
1. blocked = rejection-store.getBlockedCategories()
2. candidates = [DEFAULT_CATEGORIES ∪ existingTags] \ blocked
3. try {
scored = nli.classify(snippet, candidates)
if (scored[0].score >= 0.7) → return top-N (score >= 0.5, max 3 Labels)
}
4. → Llama-Pfad (auch bei NLI-Error: Resilience)
5. parsed = parseTags(llamaOutput); filtered = parsed \ blocked
Konstanten in model-manager.ts:
| Konstante | Wert | Bedeutung |
|---|---|---|
NLI_CONFIDENCE_THRESHOLD |
0.7 | Top-Score darüber → NLI vertrauen, Llama nicht laden |
NLI_KEEP_THRESHOLD |
0.5 | Weitere Labels ab diesem Score zusätzlich aufnehmen |
Speicher- & Laufzeitverhalten
- Klare Mail (Newsletter, Rechnung, …): NLI antwortet in < 100 ms. Llama bleibt ungeladen — nur ~280 MB Modellspeicher im RAM.
- Mehrdeutige Mail: NLI liefert max. ~0.5 → Llama wird einmalig nachgeladen, antwortet in 3–5 s. Danach bleiben beide Modelle im RAM (~280 MB + ~2 GB).
- NLI-Crash (z. B. ONNX-Binding kaputt): Llama-Pfad greift, App funktioniert weiter — kein User-Impact.
EAS-Dev-Client-Build
onnxruntime-react-native bringt native Bindings mit. Nach pnpm install ist zwingend ein neuer EAS-Dev-Client-Build nötig, bevor die App mit dem neuen Modul startet — JS-Bundle-Reload reicht nicht. Drone CI baut keinen Native Client, nur typecheck/test.
Rejection-Store
apps/mobile/src/services/rejection-store.ts
Drei rejected Vorschläge derselben Kategorie ⇒ Kategorie ist gesperrt und kommt weder bei NLI noch bei Llama wieder vor. Bestätigung resettet den Zähler. Persistiert lokal pro Gerät.
Sender→Label Memory (Online-Feedback-Loop)
Server-seitiger Lern-Speicher: Bestätigt der User auf einer Mail von noreply@dhl.de den Tag „Sendung", merkt sich der Server {ownerSub, senderAddr, tagId, confirmCount, removeCount, lastSeenAt} in mail_sender_label_memory. Beim nächsten Batch aus dieser Adresse → Tag direkt vergeben, NLI/Llama gespart.
- Hooks:
confirmTag→confirmCount++,removeMessageTag→removeCount++(Service:apps/api/src/modules/mail/sender-memory.service.ts). - Match-Threshold im Categorizer:
confirmCount ≥ 2 AND confirm > remove→ Tag direkt setzen, NLI überspringen. - Adress-Normalisierung:
"DHL <Noreply@DHL.de>"→noreply@dhl.de(sharednormalizeSenderAddr). - Endpoints:
POST /mail/sender-memory/lookup(Batch),GET /mail/sender-memory(paginiert),DELETE /mail/sender-memory/:id.
Domain-Fallback (#299)
Bei einem Lookup ohne exakten Treffer auf senderAddr aggregiert der Server live nach Domain (split_part(sender_addr, '@', 2)) und liefert einen Treffer zurück, wenn:
- mindestens 2 verschiedene Sender-Adressen der Domain dasselbe Tag bestätigt haben,
sum(confirmCount) ≥ 3über die Domain,sum(confirmCount) > sum(removeCount).
Damit triggern info@dhl.de und service@dhl.de denselben Tag, sobald genug Bestätigungen aus der DHL-Domain im Memory sind. Schwelle bewusst strenger als beim Exact-Match, weil Domain-Aggregate weniger spezifisch sind.
Provider-Blacklist (gmail, googlemail, outlook, hotmail, live, msn, yahoo, gmx, web.de, t-online, freenet, icloud, me, mac, aol, proton, protonmail, tutanota, mail.ru, yandex, zoho, …) schließt Multi-Tenant-Domains vom Fallback aus — sonst würden alle @gmail.com-User dieselbe Kategorie kriegen.
API-Contract: SenderMemoryEntry.matchType: 'exact' | 'domain' exponiert die Herkunft. Alte Clients bleiben kompatibel — das Feld ist im zod-Schema mit .default('exact') versehen.
Kein Schema-Change, reine Live-Aggregation auf mail_sender_label_memory.
Auto-Decay (#298)
Wöchentlicher Cron (SenderMemoryDecayCron, Default Sonntag 03:00):
- Soft-Decay: Einträge mit
last_seen_at < now() - SENDER_MEMORY_DECAY_AFTER_DAYSwerden in den Counts halbiert (Integer-Division durchSENDER_MEMORY_DECAY_FACTOR, default 2). - Hard-Prune: Anschließend alle Einträge mit
confirmCount = 0 AND removeCount = 0löschen.
Soft- statt Hard-Delete: ein stale Eintrag verliert nach 1–2 Decay-Zyklen seinen Match-Threshold-Einfluss (confirmCount ≥ 2), bleibt aber sichtbar in der UI bis er natürlich auf 0 fällt.
ENV-Vars:
| Variable | Default | Bedeutung |
|---|---|---|
SENDER_MEMORY_DECAY_CRON |
0 3 * * 0 |
Cron-Expression |
SENDER_MEMORY_DECAY_DISABLED |
false |
Cron komplett aus |
SENDER_MEMORY_DECAY_AFTER_DAYS |
60 |
Alter ab dem ein Eintrag stale ist |
SENDER_MEMORY_DECAY_FACTOR |
2 |
Divisor für Counts (≥ 2) |
Verwandte Issues
- #175 Phase 1 (PR #240, #241): NLI auf Mobile + shared
DEFAULT_CATEGORIES. Aktueller Stand. - Offene Folge-Tickets:
- Confidence persistieren (Spalte
confidenceanmail_message_tags) — Basis für regelbasierte Aktion-Pipelines (#177) - Threshold konfigurierbar (Settings-Slider)
- Web auf NLI migrieren — nur falls MiniLM-Qualität sich live als unzureichend zeigt
- NLI-vs-Llama-Quote loggen, um Threshold datengetrieben zu kalibrieren
- Confidence persistieren (Spalte