Add digest command to email processor

Read-only summary of recent decisions, grouped by action with
[auto]/[user] markers. Supports --recent N for multi-day lookback.
This commit is contained in:
Yanxin Lu
2026-03-13 11:17:43 -07:00
parent 3c54098b1d
commit 36143fcd93
5 changed files with 97 additions and 3 deletions

View File

@@ -60,6 +60,7 @@ The system separates **classification** (what the LLM does) from **confidence**
- **Repeat senders** with consistent tag signatures reach 85%+ confidence and get auto-acted during `scan`. They never touch the pending queue.
- **New or ambiguous senders** start at 50% and get queued.
- **You occasionally run `review list`** to handle stragglers — each decision further builds history.
- **`digest` gives a quick glance** at what was processed recently — subject lines grouped by action, with `[auto]`/`[user]` markers.
- **`stats` shows your automation rate** climbing over time.
### Confidence Computation
@@ -96,6 +97,10 @@ chmod +x email-processor.sh
./email-processor.sh review all delete # delete all pending
./email-processor.sh review accept # accept all suggestions
# --- Digest ---
./email-processor.sh digest # today's processed emails
./email-processor.sh digest --recent 3 # last 3 days
# --- Other ---
./email-processor.sh stats # show decision history
```
@@ -244,13 +249,16 @@ ollama list # should show kamekichi128/qwen3-4b-instruct-2507:latest
# 7. Check that the decision was recorded
./email-processor.sh stats
# 8. Quick glance at what was processed today
./email-processor.sh digest
```
## File Structure
```
email_processor/
main.py # Entry point — scan/review/stats subcommands
main.py # Entry point — scan/review/stats/digest subcommands
classifier.py # LLM prompt builder + response parser, tag taxonomy
decision_store.py # Decision history, confidence computation, few-shot retrieval
config.json # Ollama + automation settings

View File

@@ -14,7 +14,7 @@ auto decision.
import json
import re
from datetime import datetime
from datetime import datetime, timedelta
from pathlib import Path
from collections import Counter
@@ -200,6 +200,33 @@ def get_known_labels():
return labels
def get_recent_decisions(days=1):
"""Return recent decisions grouped by action.
Args:
days: number of days to look back (default 1 = today).
Returns:
dict of action -> list of entries, e.g. {"delete": [...], "archive": [...]}.
Returns empty dict if no decisions found in the period.
"""
history = _load_history()
if not history:
return {}
cutoff = datetime.now() - timedelta(days=days)
grouped = {}
for entry in history:
try:
ts = datetime.fromisoformat(entry["timestamp"])
except (KeyError, ValueError):
continue
if ts >= cutoff:
action = entry.get("action", "unknown")
grouped.setdefault(action, []).append(entry)
return grouped
def get_all_stats():
"""Compute aggregate statistics across the full decision history.

View File

@@ -11,6 +11,8 @@
# ./email-processor.sh review all delete # act on all pending
# ./email-processor.sh review accept # accept all suggestions
# ./email-processor.sh stats # show history stats
# ./email-processor.sh digest # today's processed emails
# ./email-processor.sh digest --recent 3 # last 3 days
#
# Requires: uv, himalaya, Ollama running with model.

View File

@@ -19,6 +19,8 @@ Subcommands:
python main.py review all <action> # act on all pending
python main.py review accept # accept all suggestions
python main.py stats # show decision history
python main.py digest # today's processed emails
python main.py digest --recent 3 # last 3 days
Action mapping (what each classification does to the email):
delete -> himalaya message delete <id> (moves to Trash)
@@ -633,6 +635,56 @@ def cmd_stats():
print(f"\nKnown labels: {', '.join(sorted(labels))}")
# ---------------------------------------------------------------------------
# Subcommand: digest
# ---------------------------------------------------------------------------
def cmd_digest(days=1):
"""Print a compact summary of recently processed emails, grouped by action.
Shows both auto and user decisions with a marker to distinguish them.
"""
grouped = decision_store.get_recent_decisions(days)
if not grouped:
period = "today" if days == 1 else f"last {days} days"
print(f"No processed emails in this period ({period}).")
return
period_label = "today" if days == 1 else f"last {days} days"
print(f"Email digest ({period_label})")
print("=" * 40)
# Map action names to display labels
action_labels = {
"delete": "Deleted",
"archive": "Archived",
"keep": "Kept",
"mark_read": "Marked read",
}
total = 0
auto_count = 0
user_count = 0
for action, entries in sorted(grouped.items()):
label = action_labels.get(action, action.replace("label:", "Labeled ").title())
print(f"\n{label} ({len(entries)}):")
for entry in entries:
source = entry.get("source", "?")
sender = entry.get("sender", "unknown")
subject = entry.get("subject", "(no subject)")
print(f" [{source}] {sender}")
print(f" {subject}")
total += 1
if source == "auto":
auto_count += 1
else:
user_count += 1
print(f"\nTotal: {total} emails processed ({auto_count} auto, {user_count} user)")
# ---------------------------------------------------------------------------
# Entry point & argument parsing
#
@@ -695,7 +747,10 @@ if __name__ == "__main__":
elif subcommand == "stats":
cmd_stats()
elif subcommand == "digest":
cmd_digest(days=recent if recent else 1)
else:
print(f"Unknown subcommand: {subcommand}")
print("Usage: python main.py [scan|review|stats] [--recent N] [--dry-run]")
print("Usage: python main.py [scan|review|stats|digest] [--recent N] [--dry-run]")
sys.exit(1)