sifter: a local text sorter you teach by example

sifter: a local text sorter you teach by example

Max

sifter — teach a small local tool to sort your own text. The model all-MiniLM-L6-v2 (open weights, quantized ONNX) runs on your machine. No API, no cloud, no per-call cost.

Install: pip install numpy onnxruntime tokenizers. Then save the files below, put model_quantized.onnx and tokenizer.json in models/ (from the Hugging Face repo Xenova/all-MiniLM-L6-v2), and run:

python sifter.py teach-file demo/expenses_train.csv
cat my_expenses.txt | python sifter.py sort
python serve.py # local page at 127.0.0.1:8765

embed.py

"""Local text embeddings with an open-weight ONNX model. No network, no API."""
import os, json
import numpy as np
import onnxruntime as ort
from tokenizers import Tokenizer

HERE = os.path.dirname(os.path.abspath(__file__))
MODEL_DIR = os.path.join(HERE, "models")

class Embedder:
def __init__(self, model_dir=MODEL_DIR, threads=2):
self.tok = Tokenizer.from_file(os.path.join(model_dir, "tokenizer.json"))
self.tok.enable_truncation(max_length=256)
self.tok.enable_padding(length=None)
so = ort.SessionOptions()
so.intra_op_num_threads = threads
self.sess = ort.InferenceSession(
os.path.join(model_dir, "model_quantized.onnx"),
sess_options=so, providers=["CPUExecutionProvider"])
self.inputs = {i.name for i in self.sess.get_inputs()}

def encode(self, texts):
if isinstance(texts, str):
texts = [texts]
encs = self.tok.encode_batch(list(texts))
ids = np.array([e.ids for e in encs], dtype=np.int64)
mask = np.array([e.attention_mask for e in encs], dtype=np.int64)
feed = {"input_ids": ids, "attention_mask": mask}
if "token_type_ids" in self.inputs:
feed["token_type_ids"] = np.zeros_like(ids)
out = self.sess.run(None, feed)[0] # last_hidden_state
m = mask[..., None].astype(np.float32)
pooled = (out * m).sum(1) / np.clip(m.sum(1), 1e-9, None)
norm = np.linalg.norm(pooled, axis=1, keepdims=True)
return (pooled / np.clip(norm, 1e-9, None)).astype(np.float32)

if __name__ == "__main__":
e = Embedder()
v = e.encode(["invoice from the train station", "coffee with a client"])
print(v.shape, float(np.dot(v[0], v[1])))

sifter.py

"""sifter - teach a small local tool to sort your own text. No API, no cloud.

The model (all-MiniLM-L6-v2, open weights) runs on your laptop via ONNX.
Your data never leaves the machine.
"""
import argparse, csv, json, os, sys
import numpy as np

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from embed import Embedder # noqa: E402

STORE = os.path.join(HERE, "sifter_store.json")


def load():
if os.path.exists(STORE):
with open(STORE) as f:
return json.load(f)
return {"examples": []} # [{"label":..,"text":..}]


def save(d):
with open(STORE, "w") as f:
json.dump(d, f, indent=2)


def centroids(emb, examples):
labels = sorted({e["label"] for e in examples})
C = []
for lab in labels:
texts = [e["text"] for e in examples if e["label"] == lab]
v = emb.encode(texts)
c = v.mean(0)
c = c / max(np.linalg.norm(c), 1e-9)
C.append(c)
return labels, np.vstack(C)


def classify(emb, store, texts, min_score):
labels, C = centroids(emb, store["examples"])
V = emb.encode(texts)
sims = V @ C.T # cosine, both sides normalized
# softmax over similarity*temperature for a readable confidence
T = 12.0
ex = np.exp((sims - sims.max(1, keepdims=True)) * T)
probs = ex / ex.sum(1, keepdims=True)
out = []
for i, t in enumerate(texts):
j = int(probs[i].argmax())
lab, sc = labels[j], float(probs[i, j])
if sc < min_score:
lab = "?unsure"
out.append((lab, sc, {labels[k]: round(float(probs[i, k]), 3) for k in range(len(labels))}))
return out


def main():
ap = argparse.ArgumentParser(prog="sifter", description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)

p = sub.add_parser("teach", help="add one example: sifter teach <label> <text>")
p.add_argument("label"); p.add_argument("text")

p = sub.add_parser("teach-file", help="add many from a CSV of label,text")
p.add_argument("path")
p.add_argument("--no-header", action="store_true")

p = sub.add_parser("sort", help="sort new lines (one per line) from a file or stdin")
p.add_argument("path", nargs="?")
p.add_argument("--min", type=float, default=0.34, help="below this -> ?unsure")

sub.add_parser("labels", help="show taught labels and counts")
sub.add_parser("reset", help="forget everything")

a = ap.parse_args()
emb = Embedder()
store = load()

if a.cmd == "teach":
store["examples"].append({"label": a.label, "text": a.text})
save(store)
print(f"taught {a.label!r}: {a.text!r} ({len(store['examples'])} total)")

elif a.cmd == "teach-file":
n = 0
with open(a.path, newline="") as f:
r = csv.reader(f)
for row in r:
if not row or len(row) < 2:
continue
lab, txt = row[0].strip(), row[1].strip()
if not a.no_header and lab.lower() in ("label", "class", "category"):
continue
store["examples"].append({"label": lab, "text": txt}); n += 1
save(store)
print(f"taught {n} examples ({len(store['examples'])} total)")

elif a.cmd == "sort":
lines = [l.rstrip("\n") for l in (open(a.path) if a.path else sys.stdin) if l.strip()]
if not store["examples"]:
sys.exit("nothing taught yet - use `teach` first")
res = classify(emb, store, lines, a.min)
for t, (lab, sc, _) in zip(lines, res):
print(f"{lab:<12} {sc:0.3f} {t}")

elif a.cmd == "labels":
from collections import Counter
c = Counter(e["label"] for e in store["examples"])
for k, v in c.items():
print(f"{k:<12} {v}")
if not c:
print("(nothing taught yet)")

elif a.cmd == "reset":
save({"examples": []})
print("forgotten.")


if __name__ == "__main__":
main()

serve.py

"""sifter web UI - a local page for teaching and sorting. Stdlib only.

python serve.py # then open http://127.0.0.1:8765
"""
import json, os, sys, threading, webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from embed import Embedder # noqa: E402
from sifter import classify, load, save # noqa: E402

EMB = Embedder()
LOCK = threading.Lock()

PAGE = """<!doctype html><meta charset="utf-8">
<title>sifter</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
:root{--bg:#111417;--fg:#e8e6e1;--dim:#8b949e;--acc:#7ee0a9;--card:#1a1f24}
*{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--fg);
font:15px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;padding:28px;max-width:900px;margin:auto}
h1{font-weight:600;margin:0 0 4px} .sub{color:var(--dim);margin:0 0 24px;font-size:13px}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}
@media(max-width:720px){.grid{grid-template-columns:1fr}}
.card{background:var(--card);border:1px solid #232a30;border-radius:10px;padding:14px}
textarea{width:100%;height:200px;background:#0c0f12;color:var(--fg);border:1px solid #232a30;
border-radius:8px;padding:10px;font:inherit;resize:vertical}
button{background:var(--acc);color:#08130c;border:0;border-radius:8px;padding:9px 16px;
font:inherit;font-weight:600;cursor:pointer;margin-top:10px}
.out{margin-top:14px;font-size:13px} .row{display:flex;gap:10px;padding:5px 0;border-bottom:1px solid #20262b}
.lab{color:var(--acc);min-width:86px;font-weight:600} .sc{color:var(--dim);min-width:56px}
.unsure{color:#e0b96a} .hint{color:var(--dim);font-size:12px;margin-top:8px}
</style>
<h1>sifter</h1>
<p class="sub">teach it with a few examples, then sort new lines. everything stays on this machine.</p>
<div class="grid">
<div class="card">
<b>1. examples</b> <span class="hint">label&nbsp;&nbsp;text, one per line</span>
<textarea id="ex" placeholder="Software monthly Figma subscription
Travel train ticket Berlin to Munich"></textarea>
<button onclick="teach()">teach</button>
<div class="hint" id="tmsg"></div>
</div>
<div class="card">
<b>2. sort</b> <span class="hint">one item per line</span>
<textarea id="items" placeholder="new monitor for the desk
client lunch downtown"></textarea>
<button onclick="sort()">sort</button>
</div>
</div>
<div class="out" id="out"></div>
<script>
async function teach(){
const rows=document.getElementById('ex').value.split('\\n').map(l=>l.trim()).filter(Boolean);
const r=await fetch('/api/teach',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({rows})});
const j=await r.json();
document.getElementById('tmsg').textContent=j.msg;
}
async function sort(){
const items=document.getElementById('items').value.split('\\n').map(l=>l.trim()).filter(Boolean);
const r=await fetch('/api/sort',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({items})});
const j=await r.json();
const o=document.getElementById('out');
if(j.error){o.textContent=j.error;return;}
o.innerHTML=j.results.map(x=>`<div class="row"><span class="lab ${x.label.startsWith('?')?'unsure':''}">${x.label}</span><span class="sc">${x.score}</span><span>${x.text}</span></div>`).join('');
}
</script>
"""


class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass

def _send(self, code, body, ctype="text/html; charset=utf-8"):
b = body.encode() if isinstance(body, str) else body
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(b)))
self.end_headers()
self.wfile.write(b)

def do_GET(self):
if self.path in ("/", "/index.html"):
self._send(200, PAGE)
else:
self._send(404, "not found")

def do_POST(self):
n = int(self.headers.get("Content-Length", 0))
data = json.loads(self.rfile.read(n) or b"{}")
if self.path == "/api/teach":
rows = data.get("rows", [])
with LOCK:
s = load()
added = 0
for row in rows:
parts = row.split(None, 1) if "\t" not in row else row.split("\t", 1)
if len(parts) == 2 and parts[0] and parts[1].strip():
s["examples"].append({"label": parts[0].strip(), "text": parts[1].strip()})
added += 1
save(s)
total = len(s["examples"])
self._send(200, json.dumps({"msg": f"taught {added} ({total} total)"}),
"application/json")
elif self.path == "/api/sort":
items = data.get("items", [])
with LOCK:
s = load()
if not s["examples"]:
self._send(200, json.dumps({"error": "teach a few examples first"}),
"application/json")
return
res = classify(EMB, s, items, 0.34)
out = [{"text": t, "label": lab, "score": round(sc, 3)} for t, (lab, sc, _) in zip(items, res)]
self._send(200, json.dumps({"results": out}), "application/json")
else:
self._send(404, "not found")


if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8765
print(f"sifter running at http://127.0.0.1:{port} (ctrl-c to stop)")
try:
webbrowser.open(f"http://127.0.0.1:{port}")
except Exception:
pass
ThreadingHTTPServer(("127.0.0.1", port), H).serve_forever()

eval.py

"""Honest evaluation: teach from a train CSV, score against a held-out test CSV."""
import csv, sys, os
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from embed import Embedder
from sifter import classify

def read(path):
rows = []
with open(path, newline="") as f:
for r in csv.reader(f):
if len(r) >= 2 and r[0].strip().lower() not in ("label", "class", "category"):
rows.append({"label": r[0].strip(), "text": r[1].strip()})
return rows

def main(train, test):
emb = Embedder()
ex = read(train)
store = {"examples": ex}
items = read(test)
res = classify(emb, store, [i["text"] for i in items], 0.34)
ok = 0
for it, (lab, sc, _) in zip(items, res):
good = (lab == it["label"])
ok += good
print(f"{'ok ' if good else 'X '}{it['label']:<9} -> {lab:<9} {sc:0.2f} {it['text']}")
n = len(items)
print(f"\naccuracy {ok}/{n} = {ok/n:.0%} (labels: {sorted({e['label'] for e in ex})})")

if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])

demo/expenses_train.csv

label,text
Software,Monthly subscription to the design app Figma
Software,1Password family plan renewal
Software,Notion workspace seat
Software,GitHub Copilot monthly
Software,Adobe Creative Cloud annual
Software,Cloud hosting invoice from Hetzner
Travel,Train ticket Berlin to Munich
Travel,Taxi from the airport to the hotel
Travel,Flight to the client meeting in Lisbon
Travel,Rental car for two days
Travel,Hotel night near the conference
Meals,Coffee with a client downtown
Meals,Lunch with the design team
Meals,Team dinner after the launch
Meals,Takeaway noodles while working late
Meals,Breakfast at the station
Office,Printer paper and toner
Office,Standing desk mat
Office,Desk lamp for the home office
Office,Notebooks and pens
Hardware,New laptop charger
Hardware,External monitor 27 inch
Hardware,Mechanical keyboard
Hardware,USB-C hub

README.md

# sifter

Teach a small local tool to sort your own text. No API, no cloud, no per-call cost.
The model runs on your machine.

You give it a handful of labeled examples. It embeds each one with an open-weight
sentence model ([all-MiniLM-L6-v2](https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2),
quantized ONNX, ~23 MB) and sorts new lines by nearest meaning. When it isn't sure,
it says `?unsure` instead of guessing.

Typical use: a freelancer drops in their expense lines and gets them filed
(Software / Travel / Meals / Office / Hardware). A solo shop sorts incoming
customer messages. Anyone with a repetitive text-sorting chore they'd rather not
send to a cloud API.

## Why local matters here

- **Your data stays yours.** Expense lines, client messages, patient notes — nothing
leaves the laptop. No account, no upload, no third party logging the strings.
- **No per-call bill.** The cloud classifiers charge per thousand items, forever.
This is one 23 MB download and then it's free to run.
- **You keep control.** Swap the model, change the categories, add examples. No
vendor decides what your labels mean or when the model changes under you.

## Run it

```bash
pip install numpy onnxruntime tokenizers

# teach (a few dozen examples is plenty)
python sifter.py teach Software "monthly Figma subscription"
python sifter.py teach Travel "train ticket Berlin to Munich"
python sifter.py teach-file demo/expenses_train.csv

# sort
cat my_expenses.txt | python sifter.py sort
python sifter.py sort demo/expenses_test.csv

# or use the local page
python serve.py # open http://127.0.0.1:8765
```

The first run downloads nothing; `models/` holds the weights. Everything after is offline.

## How well does it work

`python eval.py demo/expenses_train.csv demo/expenses_test.csv` teaches from 24
examples and scores 20 held-out lines it has never seen:

```
accuracy 18/20 = 90%
```

The two misses are useful, not hidden: `2 uhd screens for the desk` lands in
*Office* (hardware and office supplies genuinely overlap), and `a second ssd for
backups` comes back `?unsure` at 0.29. That `?unsure` is the point — a sorter you
can trust to flag its own doubt beats one that is confidently wrong.

## Files

- `embed.py` — tokenize + run the ONNX model, mean-pool, normalize
- `sifter.py` — teach / sort / labels / reset CLI
- `serve.py` — local web page (stdlib only)
- `eval.py` — honest held-out accuracy
- `demo/` — a worked expense-sorting example

`sifter_store.json` holds your examples. `reset` wipes it.

## License

MIT.

Report Page