Explainable, offline-first notification routing for multimodal message streams
Decide what interrupts a person — and be able to prove why.
- Why this exists
- What it does
- Quick start
- Features
- Design philosophy
- Architecture
- Decision engine
- Security model
- Media pipeline
- LLM providers
- Offline mode
- Benchmarks
- Cost comparison
- Installation
- CLI usage
- Configuration
- Docker
- Development
- Project structure
- FAQ
- Troubleshooting
- Roadmap
- Contributing
- License
- Acknowledgements
A person receives a hundred messages a day. Their phone treats all of them the same, which produces two failures at once: important messages get buried, and unwanted or dangerous ones interrupt anyway.
Most triage systems answer "is this message important?" That is the wrong question. The right one is "is this message important to this person, right now — and is it safe?"
Those are two different problems with opposite requirements:
| Preference | Safety | |
|---|---|---|
| Varies by person? | Yes — deeply | No — never |
| Learned from behaviour? | Yes | No |
| Overridable? | Yes | Never |
AegisRoute separates them architecturally. An unconditional safety layer sits in front of a personalized preference layer, so a scam cannot be delivered because someone happens to trust the sender.
For every incoming message, AegisRoute emits one of three actions plus a written justification, a calibrated confidence, and citations to the user's own history:
| Action | Meaning |
|---|---|
notify |
Interrupt now — concrete, time-bound, personally relevant |
digest |
Safe and possibly useful — show it later |
mute |
Low-value, repetitive, unwanted, or unsafe |
$ aegisroute --dataset examples/sample_dataset explain demo_003
Message : demo_003 (user u_100, personal)
Text : Please share your OTP here quickly to avoid account closure.
Decision: MUTE / scam @ 0.86
Reason : The message asks for urgent OTP or account verification through a suspicious flow.
Evidence: none
Path : deterministic
Reasoning trace
asks the user to transmit a secret ['share your otp']
= safety gate short-circuit -> mute (risk_locked)Every decision is reproducible like this. No black boxes.
git clone https://github.com/aegisroute/aegisroute.git
cd aegisroute
pip install -e .
# Route the bundled synthetic demo corpus
aegisroute --dataset examples/sample_dataset run --output demo_output.csvTwo dependencies (pandas, PyYAML). No API key, no GPU, no network.
Expected output
demo_001 notify urgent 0.91 Work context with a direct deadline or meeting dependency.
demo_002 mute greeting 0.84 Sender has a pattern of repeated forwards the user ignores.
demo_003 mute scam 0.86 Asks for urgent OTP or account verification.
demo_004 notify business_update 0.90 Verified business update matching recent order history.
demo_005 mute promotion 0.88 User opted out of or dismissed similar marketing.
demo_006 digest event 0.80 Useful group information, not urgent.
demo_007 mute scam 0.86 Tries to instruct the router; judged on real content.
demo_008 digest promotion 0.81 Matches known interests but is low priority.
Note demo_007: a prompt injection instructing the router to mark it notify.
It is muted, and the attempt itself is treated as a risk signal.
- 🛡️ Unconditional safety gate — credential solicitation is muted regardless of sender trust or engagement history
- 🎯 True personalization — identical text routes differently per user based on their own reaction history
- 🔍 Full explainability — line-by-line reasoning trace for every decision
- 🎙️ Multimodal — real OCR and speech-to-text, not just metadata
- 🔌 Nine LLM providers — behind one interface, hot-swappable by config
- 📴 Offline-first — complete functionality with zero credentials
- 🎚️ Calibrated confidence — Brier 0.0215; admits uncertainty instead of asserting it
- 🧪 209 tests including an 87-case adversarial red-team suite
- ⚡ 1,090 msg/s at 0.72 ms p50, single-threaded
- 🔁 Deterministic — byte-identical output across runs, environments and cache states
1. Safety is not a preference. No legitimate sender asks a user to transmit an OTP, PIN, CVV or password. Because that is categorically true, any rule that lets another signal soften it becomes an exploit path — and the obvious attack is to build familiarity first.
2. Interruption cost is asymmetric.
A history of suppression is strong evidence to mute (weight 0.55). A history
of engagement is only weak evidence to interrupt (weight 0.22). Opening a
message is passive consumption, not a request to be alerted.
3. The LLM advises; it never decides. Language models are non-deterministic and jailbreakable. Putting one in a safety-critical path means an attacker can argue their way past it.
4. Measure decision impact, not component metrics. We benchmarked three OCR engines. The most accurate scored +22 points higher — and changed zero routing decisions. We kept the one that runs in 335 MB.
5. State limitations plainly. Every known weakness is documented. A defence that hides a limitation fails the moment someone finds it.
flowchart TD
A[Message stream<br/>CSV / API] --> B[Loader<br/>validate · index · integrity]
M[Media files<br/>images · audio] --> C
B --> C[MediaUnderstanding port]
C --> C1[LiveAdapter<br/>RapidOCR + faster-whisper]
C --> C2[CachedAdapter<br/>JSON replay]
C1 --> D
C2 --> D
D[SignalExtractor<br/>~40 signals · 6 families] --> E{Decision Engine}
E --> E1[1 · Safety Gate<br/>unconditional]
E1 -->|risk found| LOCK[[risk_locked → mute]]
E1 -->|clear| E2[2 · Classification<br/>ordered precedence]
E2 --> E3[3 · Priority Scoring<br/>personalized]
E3 --> F{LLM configured?}
F -->|no| G
F -->|yes| F1[LLM Advisor<br/>bounded arbitration]
F1 --> G
LOCK --> G
G[Evidence · Reason · Confidence] --> H[Schema validation]
H --> I[(output.csv<br/>+ decision trace)]
style E1 fill:#fff5f5,stroke:#c92a2a,stroke-width:2px
style LOCK fill:#ffe3e3,stroke:#c92a2a,stroke-width:2px
style I fill:#e6fcf5,stroke:#2b8a3e,stroke-width:2px
Component diagram
graph LR
subgraph io["io_ · boundaries"]
L[loader.py]
W[writer.py]
end
subgraph feat["features"]
S[signals.py]
end
subgraph eng["engine"]
R[rules.py]
EV[evidence.py]
RE[reasons.py]
RS[reasoner.py]
end
subgraph llm["llm · provider port"]
BA[base.py]
PR[providers.py]
RG[registry.py]
end
subgraph media["media"]
MU[understanding.py]
end
subgraph util["util"]
SEC[security.py]
LOG[logging_setup.py]
end
P[pipeline.py] --> L
P --> MU
P --> S
P --> R
P --> EV
P --> RE
P --> RS
P --> W
RS --> RG
RG --> PR
PR --> BA
MU --> SEC
W --> SEC
Data flow
sequenceDiagram
participant U as CLI
participant P as Pipeline
participant M as MediaUnderstanding
participant S as SignalExtractor
participant E as DecisionEngine
participant L as LLM Advisor
participant W as Writer
U->>P: run --dataset ./data
P->>P: load + index 13 tables
loop each message
P->>M: get(media_id)
M-->>P: text (live | cached | unavailable)
P->>S: extract(message, media)
S-->>P: SignalSet (~40 signals)
P->>E: decide(message, signals)
alt safety gate triggers
E-->>P: mute + risk_locked
Note over L: LLM never consulted
else clear
E-->>P: action + score + trace
P->>L: refine (optional)
L-->>P: bounded verdict
end
P->>P: evidence + reason + confidence
end
P->>W: write + validate schema
W-->>U: output.csv + trace.jsonl
Package dependency graph
graph TD
CLI[cli.py] --> PIPE[pipeline.py]
PIPE --> CFG[config.py]
PIPE --> SCH[schemas.py]
PIPE --> IO[io_]
PIPE --> MED[media]
PIPE --> FEA[features]
PIPE --> ENG[engine]
ENG --> LLM[llm]
ENG --> FEA
MED --> UTL[util]
IO --> UTL
LLM --> SCH
style SCH fill:#eef2ff,stroke:#4c6ef5
style UTL fill:#eef2ff,stroke:#4c6ef5
Dependencies point inward toward schemas and util. No cycles.
Three stages in strict order. Each appends to a human-readable trace.
flowchart LR
IN[SignalSet] --> G{1 · Safety Gate}
G -->|credential solicitation| M1[mute / scam<br/>LOCKED]
G -->|prompt injection| M2[mute / scam or spam<br/>LOCKED]
G -->|domain spoof + ask| M3[mute / scam<br/>LOCKED]
G -->|clear| T[2 · Classify type]
T --> SC[3 · Score]
SC --> TH{Threshold}
TH -->|score ≥ 0.65| N[notify]
TH -->|score ≤ −0.71| MU[mute]
TH -->|between| D[digest]
style G fill:#fff5f5,stroke:#c92a2a,stroke-width:2px
style M1 fill:#ffe3e3,stroke:#c92a2a
style M2 fill:#ffe3e3,stroke:#c92a2a
style M3 fill:#ffe3e3,stroke:#c92a2a
| Family | Examples |
|---|---|
| Content | urgency, action requests, events, payments, promos, greetings, deferral markers |
| Sender | admin role, group type, business verification, domain match, account age |
| Relationship | prior orders, opt-in state, opt-out timestamp, recent activity |
| Behaviour | opened / replied / dismissed / muted / reported history |
| Risk | credential solicitation, pressure language, unsafe flows, injection tactics |
| Load | quiet hours, notification fatigue ratio |
| Signal | Δ |
|---|---|
Type base (urgent … spam) |
+0.85 … −0.85 |
| Direct mention | +0.35 |
| Sender is admin | +0.28 |
| Matches recent business activity | +0.30 |
| Sender said it can wait | −0.45 |
| Opted out of promotions | −0.55 |
| User muted this group | −0.45 |
| Quiet hours | −0.28 |
| Suppression history | × 0.55 |
| Engagement history | × 0.22 |
The asymmetry in the last two rows is the single most important parameter in the system. See DESIGN_DECISIONS.md.
flowchart TD
UNTRUSTED[/"UNTRUSTED<br/>message text · OCR output · transcripts · file paths"/]
UNTRUSTED --> L1[Layer 1 · Input validation<br/>path containment · size caps · type allowlists]
L1 --> L2[Layer 2 · Injection detection<br/>22 patterns · 8 tactic families]
L2 --> L3[Layer 3 · Neutralisation<br/>control chars · delimiters · length]
L3 --> L4[Layer 4 · Structural fencing<br/>labelled data blocks in prompts]
L4 --> L5[Layer 5 · Architectural bypass<br/>safety-locked decisions never reach the LLM]
L5 --> SAFE[/"TRUSTED<br/>rules · thresholds · config"/]
style UNTRUSTED fill:#fff5f5,stroke:#c92a2a,stroke-width:2px
style L5 fill:#e6fcf5,stroke:#2b8a3e,stroke-width:2px
style SAFE fill:#e6fcf5,stroke:#2b8a3e
| Threat | Control |
|---|---|
| Path traversal | Canonical resolution + containment check; extension and size allowlists |
| Prompt injection | Detection → treated as a risk signal → architectural bypass |
| Safety-gate jailbreak | Credential solicitation is unconditional; LLM never sees these |
| CSV formula injection | Output values escaped |
| PII leakage | Redaction at the logging handler — covers every sink |
| Resource exhaustion | Text caps, image downscaling, timeouts |
| Provider outage | Retry → failover → offline engine |
Layer 5 is the one that matters. Pattern matching is evadable — our own red team found a bypass using a payload that mimics the router's output schema. The architectural control does not depend on detection succeeding.
Full threat model: docs/SECURITY.md · Reporting: SECURITY.md
flowchart LR
F[media file] --> V{path safe?}
V -->|no| X[reject + log]
V -->|yes| C{in cache?}
C -->|yes| R[return cached text]
C -->|no| A{live deps available?}
A -->|no| U[unavailable → metadata-only routing]
A -->|yes| K{kind}
K -->|image| O[RapidOCR<br/>downscale to 1000px]
K -->|voice| S[faster-whisper base<br/>int8 CPU]
O --> W[persist to cache]
S --> W
W --> R
style X fill:#ffe3e3,stroke:#c92a2a
style U fill:#fff9db,stroke:#e8590c
Every degradation step is logged, never silent.
Voice notes carry no text at all — the transcript is the message. A metadata-only system is blind to them.
Engine selection is documented with measurements in docs/OCR.md and docs/SPEECH.md.
Nine adapters behind one LLMProvider interface. Switching is a config change.
flowchart TD
APP[Pipeline] --> PORT[[LLMProvider interface]]
PORT --> REG[Registry<br/>discovery · priority · failover]
REG --> H1[OpenAI]
REG --> H2[Anthropic]
REG --> H3[Gemini]
REG --> H4[Groq]
REG --> H5[OpenRouter]
REG --> L1[Ollama]
REG --> L2[LM Studio]
REG --> L3[vLLM]
REG --> L4[Any OpenAI-compatible]
REG --> GW[LiteLLM gateway<br/>optional]
REG -.->|all unavailable| OFF[[Deterministic engine]]
style PORT fill:#eef2ff,stroke:#4c6ef5,stroke-width:2px
style OFF fill:#e6fcf5,stroke:#2b8a3e,stroke-width:2px
Resolution order: explicitly selected → first configured hosted → responding local → deterministic offline engine.
Built in: capability detection, retry with exponential backoff, typed error taxonomy (auth errors never retried, rate limits always are), structured-JSON coercion with malformed-output repair, token and cost accounting, cross-provider failover.
The deterministic engine runs first. The LLM sees its verdict and may refine it only within these bounds:
| Rule | Behaviour |
|---|---|
| Decision is safety-locked | LLM is not called at all |
| Override the action | Requires confidence ≥ 0.72 |
notify ↔ mute flip |
Blocked under safety lock |
| Escalate into a risk type | Always permitted |
| Malformed / illegal response | Silently rejected; rule verdict stands |
Details: docs/PROVIDERS.md
This is the default, not a fallback.
aegisroute --dataset ./data run --no-llm| Property | Offline | With LLM |
|---|---|---|
| Output produced | ✅ complete | ✅ complete |
| Cost | $0.00 | see cost table |
| Latency | 0.72 ms/msg | + network |
| Determinism | guaranteed | best-effort |
| Safety decisions | rule engine | rule engine (LLM bypassed) |
| Data leaves machine | never | prompts sent to provider |
Verified with a deliberately invalid API key: the system hit a real endpoint,
received AuthenticationError, failed over, degraded to the deterministic
engine, and produced byte-identical output.
Measured on 2 vCPU / 1,985 MB RAM, CPU-only. Reproduce with
python scripts/benchmark.py.
| Metric | Value |
|---|---|
| Throughput (warm) | 1,090 msg/s |
| Latency p50 / p90 / p99 | 0.72 ms / 1.23 ms / 5.88 ms |
| Warm run | 0.82 s |
| Cold run (live OCR + STT) | 56.0 s |
| Cache speedup | 68× |
| Peak RSS warm / cold | < 200 MB / 1,121 MB |
| Determinism across 3 runs | byte-identical |
| Engine | Keyword recall | Wall time | Peak RSS | Install | Routing changes |
|---|---|---|---|---|---|
| RapidOCR ✅ | 0.713 | 32 s | 335 MB | 72 MB | baseline |
| EasyOCR | 0.842 | 143 s | 1,468 MB | 4,880 MB | 0 |
| PaddleOCR | 0.931 | 347 s | 1,514 MB | ~420 MB | 0 |
A 22-point accuracy gain changed zero decisions. EasyOCR pulls the entire CUDA stack (2.7 GB) onto a GPU-less machine; PaddleOCR was OOM-killed.
| Model | Wall time | Realtime | Peak RSS | Routing changes |
|---|---|---|---|---|
| tiny | 27.5 s | 8.8× | 804 MB | 1 ❌ |
| base ✅ | 45.9 s | 5.3× | 896 MB | baseline |
| small | 183.3 s | 1.3× | 1,508 MB | 0 |
base is the smallest model that transcribes every safety-critical token
correctly. small renders OTP as OTT on a bank-scam clip.
Full analysis: docs/BENCHMARKS.md
Per 1,000 messages, assuming ~55% reach the LLM (safety-locked decisions bypass it entirely — a security control that also cuts spend by 45%).
| Provider | Model | Cost / 1k msgs | Latency |
|---|---|---|---|
| None (default) | deterministic | $0.00 | 0.0009 s |
| Gemini | 2.0 Flash | $0.09 | ~0.8 s |
| OpenAI | gpt-4o-mini | $0.13 | ~1.0 s |
| Groq | llama-3.3-70b | $0.43 | ~0.3 s |
| Anthropic | Claude 3.5 Haiku | $0.76 | ~0.9 s |
| Anthropic | Claude 3.5 Sonnet | $2.88 | ~2.0 s |
| Self-hosted | vLLM | ~$310/mo | 1–2 s (GPU) |
Break-even for self-hosting vs Gemini Flash: ~3.5M messages/month. Full analysis: docs/PROVIDERS.md
- Python 3.10+ (uses
dataclass(slots=True)) - No GPU, no root, no network required
git clone https://github.com/aegisroute/aegisroute.git
cd aegisroute
pip install -e . # core only
pip install -e ".[media]" # + OCR and speech-to-text
pip install -e ".[llm]" # + LiteLLM gateway
pip install -e ".[all]" # everything including dev tools| Extra | Adds | Without it |
|---|---|---|
| core | pandas, PyYAML |
— required |
media |
RapidOCR, faster-whisper, Pillow, numpy | media routed on metadata only |
llm |
LiteLLM | native adapters still work |
dev |
pytest, ruff, mypy, radon | tests cannot run |
aegisroute --dataset ./data run # route and write output.csv
aegisroute --dataset ./data run --no-llm # force deterministic
aegisroute --dataset ./data run --provider ollama
aegisroute --dataset ./data validate # check output schema
aegisroute --dataset ./data evaluate # score against labelled samples
aegisroute --dataset ./data explain <id> # full reasoning trace
aegisroute providers # list detected LLM providers| Flag | Purpose |
|---|---|
--dataset PATH |
Input directory |
--config PATH |
Config file (default config.yaml) |
--output PATH |
Output CSV path |
--no-llm |
Deterministic engine only |
--provider NAME |
Pin a provider |
--media-adapter |
auto · live · cached · null |
-v, --verbose |
Debug logging |
from pathlib import Path
from aegisroute.config import load_config
from aegisroute.pipeline import RouterPipeline
cfg = load_config(Path("config.yaml"), Path("."))
cfg["llm"]["enabled"] = False
pipeline = RouterPipeline(cfg, Path("."))
for message in pipeline.ds.messages:
decision = pipeline.route_one(message)
print(decision.action, decision.message_type, decision.confidence)
for line in decision.signals["trace"]:
print(" ", line)All behaviour lives in config.yaml. Nothing is hardcoded.
engine:
notify_threshold: 0.65 # plateau centre from a 2-D sweep
mute_threshold: -0.71
quiet_hours_penalty: 0.28
notification_fatigue_penalty: 0.18
confidence: # mirrors observed ground-truth calibration
notify: {base: 0.87, min: 0.85, max: 0.91}
digest: {base: 0.81, min: 0.78, max: 0.85}
mute: {base: 0.83, min: 0.81, max: 0.88}
llm:
provider: auto # or openai | anthropic | gemini | groq | ...
arbitration:
llm_override_min_confidence: 0.72
safety_lock: true # scam/spam mutes are never overriddenOverride anything from the environment:
export AEGISROUTE_ENGINE__NOTIFY_THRESHOLD=0.70
export AEGISROUTE_PROVIDER=groq| Variable | Purpose |
|---|---|
AEGISROUTE_PROVIDER |
Pin a provider |
AEGISROUTE_MODEL |
Override the model |
AEGISROUTE_<SECTION>__<KEY> |
Override any config value |
OPENAI_API_KEY / ANTHROPIC_API_KEY / GEMINI_API_KEY |
Hosted providers |
GROQ_API_KEY / OPENROUTER_API_KEY |
Hosted providers |
OLLAMA_BASE_URL / LMSTUDIO_BASE_URL / VLLM_BASE_URL |
Local servers |
Secrets are read only from the environment. Copy .env.example to .env.
docker build -t aegisroute .
docker run --rm -v "$PWD/data:/data" aegisroute --dataset /data runWith a provider:
docker run --rm -v "$PWD/data:/data" -e GEMINI_API_KEY="$GEMINI_API_KEY" \
aegisroute --dataset /data runpip install -e ".[all]"
pytest # 209 tests, ~4s
pytest tests/test_adversarial.py -v # 87 red-team cases
ruff check src tests # lint
mypy src # type check
radon cc src -s -n C # complexity
python scripts/benchmark.py # performance| Suite | Tests | Focus |
|---|---|---|
test_security.py |
32 | Traversal, injection, redaction, CSV escaping |
test_engine.py |
30 | Safety gate, classification, personalization |
test_llm.py |
33 | Registry, retry, failover, arbitration |
test_pipeline.py |
27 | Loading, media, contract, determinism |
test_adversarial.py |
87 | Red team |
See CONTRIBUTING.md and docs/CODE_STYLE.md.
aegisroute/
├── src/aegisroute/
│ ├── cli.py Command-line interface
│ ├── config.py YAML + .env + env-var precedence
│ ├── pipeline.py Orchestration
│ ├── schemas.py Typed domain models
│ ├── evaluate.py Scoring harness
│ ├── engine/ Safety gate, classification, scoring, evidence
│ ├── features/ Signal extraction (~40 signals, 6 families)
│ ├── llm/ Provider port, 9 adapters, registry
│ ├── media/ OCR + speech-to-text adapters
│ ├── io_/ Loading, validation, writing
│ └── util/ Security primitives, structured logging
├── tests/ 209 tests
├── docs/ Architecture, security, benchmarks, decisions
├── prompts/ LLM prompt templates
├── examples/ Synthetic demo corpus
├── assets/ Logo, banner, social preview
└── scripts/ Benchmark and report tooling
Why io_ has a trailing underscore: to avoid shadowing the stdlib io module.
Why rules instead of a fine-tuned model?
Three reasons. Rules are auditable — you can read exactly why a message was muted. They are deterministic, so the same input always yields the same output. And they cannot be jailbroken by text in the message itself.
The LLM layer exists precisely to add the semantic generalisation rules lack — but as an advisor, never as the authority.
Can I use this without any AI provider?
Yes — that is the default. The deterministic engine produces complete output with zero credentials, zero cost and guaranteed reproducibility.
How do I adapt it to my own message schema?
Implement a loader that produces Message objects (see schemas.py) and point
the pipeline at it. The engine only consumes SignalSet, so the input format is
entirely swappable.
Is the lexicon approach multilingual?
Partially. Romanised Hindi scam patterns are included because they appeared in our evaluation corpus. Coverage is not systematic across languages — this is a known limitation, tracked in the roadmap. Lexicons are data, not code, so adding a language does not require touching the engine.
Why is confidence capped at 0.91?
Because calibration matters more than confidence theatre. Emitting 0.99
everywhere looks assured and scores badly on any proper scoring rule. Our Brier
score is 0.0215.
How is this different from a spam filter?
A spam filter answers a binary, global question: is this junk? AegisRoute
answers a three-way, per-person, time-aware one: should this interrupt this
user right now? A legitimate sale poster is not spam — but it is mute for
someone who has dismissed six similar posts and digest for someone who buys
from that group.
FileNotFoundError: required dataset file missing
Point --dataset at a directory containing messages.csv:
aegisroute --dataset examples/sample_dataset runMedia not being processed
Install the extras and check adapter selection:
pip install -e ".[media]"
aegisroute --dataset ./data run -v 2>&1 | grep "media adapter"mode=cached means live dependencies were not importable.
Out of memory during OCR
Lower the downscale ceiling in config.yaml:
media:
ocr:
max_side_px: 640Provider not detected
aegisroute providersLocal providers must actually be answering — the registry probes them and skips anything unreachable.
TypeError: dataclass() got an unexpected keyword argument 'slots'
You are on Python 3.9. AegisRoute requires 3.10+.
| Version | Theme | Highlights |
|---|---|---|
| v1.1 | Hardening | Lexicons to YAML · parallel media · embedding-based evidence retrieval |
| v2.0 | Learning & scale | Continual learning · memory engine · RAG · distributed workers |
| v2.5 | Real-time & edge | Streaming ingestion · on-device inference · federated learning |
| v3.0 | Platform | Policy engine · dashboard · analytics · observability |
Two things will never change: the safety gate stays rule-based (a learned one can be poisoned), and offline capability stays a first-class mode.
Full plan: docs/ROADMAP.md
Contributions are welcome. Start with good first issues.
- Read CONTRIBUTING.md and docs/CODE_STYLE.md
- Fork, branch, and add tests — safety changes require adversarial tests
- Run
pytest && ruff check src tests && mypy src - Open a PR describing the trade-off you chose and what you rejected
By participating you agree to the Code of Conduct.
Apache License 2.0 — free for commercial and private use, with an explicit patent grant.
Design informed by published work and production systems:
- Gmail Priority Inbox (Aberdeen et al., 2010) — per-user thresholding
- Android Notification Channels — dismissal as the strongest negative signal
- Apple Focus / Time Sensitive — raising the interruption bar rather than blocking
- Greshake et al., 2023 — indirect prompt injection via retrieved content
- Guo et al., 2017 — confidence calibration and proper scoring rules
- Pielot et al., 2014 — notification fatigue
Built on RapidOCR, faster-whisper, and LiteLLM.