3 Mail Kategorisierung
admin-mrrm edited this page 2026-05-16 11:30:19 +02:00
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.

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-native für Inferenz, @huggingface/transformers nur 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-Instruct Q4_K_M (~2 GB GGUF) via llama.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 35 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: confirmTagconfirmCount++, removeMessageTagremoveCount++ (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 (shared normalizeSenderAddr).
  • 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):

  1. Soft-Decay: Einträge mit last_seen_at < now() - SENDER_MEMORY_DECAY_AFTER_DAYS werden in den Counts halbiert (Integer-Division durch SENDER_MEMORY_DECAY_FACTOR, default 2).
  2. Hard-Prune: Anschließend alle Einträge mit confirmCount = 0 AND removeCount = 0 löschen.

Soft- statt Hard-Delete: ein stale Eintrag verliert nach 12 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 confidence an mail_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