File size: 11,462 Bytes
997ff9c
 
d46c16c
997ff9c
 
 
 
 
d46c16c
997ff9c
 
4213fd7
997ff9c
 
 
 
 
 
 
4213fd7
 
91c1f57
 
 
 
 
 
 
 
 
 
997ff9c
f83c753
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d46c16c
997ff9c
 
 
 
 
02600fe
 
 
d46c16c
 
4213fd7
 
d46c16c
02600fe
 
 
997ff9c
 
d46c16c
997ff9c
f83c753
 
fa1a3d1
997ff9c
1930881
997ff9c
 
02600fe
 
997ff9c
 
d46c16c
 
 
 
 
 
f83c753
 
02600fe
997ff9c
 
 
 
 
 
 
 
 
 
 
02600fe
997ff9c
 
 
 
 
 
 
 
 
d46c16c
 
997ff9c
d46c16c
02600fe
91c1f57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f83c753
 
 
 
 
 
 
 
 
 
 
 
 
 
 
997ff9c
 
 
02600fe
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
---
license: apache-2.0
base_model: Qwen/Qwen3-4B-Instruct-2507
tags:
  - gguf
  - email
  - triage
  - ollama
  - lora
  - unsloth
  - cipher
  - voice-intent
language:
  - en
pipeline_tag: text-generation
---

# Cipher Pro

Cipher Pro is a LoRA fine-tune of [Qwen/Qwen3-4B-Instruct-2507](https://huggingface.co/Qwen/Qwen3-4B-Instruct-2507), trained on **every LLM-backed feature of a local-first email assistant**: email triage (importance/summary/category JSON), chat, daily-summary synthesis, draft reply, compose assist, and voice-command intent parsing β€” not just prompted for these tasks, actually trained on them.

**Update:** added a 6th task, voice-command intent parsing, across 4 retrain passes total β€”
each one targeting a specific failure mode found in real end-to-end testing (not just raw
model output) rather than broad re-training. See `Training` below for the full progression;
current state on `eval_voice_intent.py`'s 5 fixtures, verified through the actual calling
app's retry-and-fallback logic (not a single raw completion), is **5/5**.

Also fixed in this update: a garbled-leading-token failure mode specific to the compose-
assist task (a stray non-ASCII token before otherwise-correct output, unrelated to the
`<think>`/`<tool_call>` leak below) β€” caught by manual spot-checking since compose has no
automated schema to catch it against, not by any benchmark number.

**Second update β€” security fix:** live testing found this model would occasionally comply
with a prompt-injection attempt embedded in email content on the draft-reply/chat tasks
(echoing a fake "security verification" request for banking details back into a drafted
reply). Fixed with a 5th training pass adding substantially richer injection coverage
(credential-phishing, wire-transfer, and data-exfiltration attempts across both tasks, not
just one generic case). A fresh, single-pass full retrain from the base model was also
tried as an alternative to another incremental patch, on the theory that one clean pass on
a properly-rebalanced dataset should be more robust than a long chain of narrow
continue-trains β€” it wasn't: two different learning rates each fixed one known issue but
introduced a *different* new one (one broke ~21% of triage fixtures even after retries; the
other introduced a bizarre off-topic role-play hallucination on ~50% of a chat fixture,
confirmed via repeated testing). The incrementally-built version β€” the one actually shipped
here β€” didn't have either problem and was kept instead. See `Training` below for the full
pass-by-pass history and the specific comparison numbers.

It's the largest of the three **Cipher** tiers (`cipher-nano` / `cipher-air` / `cipher-pro`), and the strongest on structured-output accuracy β€” **100% category accuracy** on the triage benchmark below. Cipher is the local-model engine for an unreleased larger email-assistant project β€” that project isn't public yet, but these weights, the training code, the eval script, and all five dataset generators are fully open now, in this repo.

## Why this exists

Most email triage today means sending your inbox to a third-party API. Cipher runs entirely on your own hardware via [Ollama](https://ollama.com) β€” nothing about your email ever leaves your machine.

## What's in this repo

- `cipher-pro.Q4_K_M.gguf` β€” the model weights, ready for Ollama
- `Modelfile` β€” the exact Ollama Modelfile (system prompt, explicit ChatML `TEMPLATE`, inference params) used in training/eval β€” **use `ollama create`, not `ollama pull hf.co/...`**, see the integration note below
- `train_cipher_pro.py` / `export_gguf_cipher_pro.py` β€” the exact scripts used to produce this model (Unsloth LoRA on the base model above)
- `generate2.py`, `generate_chat.py`, `generate_daily_summary.py`, `generate_draft_reply.py`, `generate_compose.py`, `generate_voice_intent.py` β€” the six task-specific synthetic-data generators (produces the full multi-task training set)
- `eval_voice_intent.py` β€” regression harness for the voice-intent task
- `eval_triage.py` / `eval_fixtures.json` β€” a standalone benchmark harness (no external dependencies beyond `httpx`/`pydantic`) reproducing the triage numbers below

Everything needed to reproduce this model from scratch, or fine-tune your own variant, is in this repo β€” nothing here depends on an unreleased package.

## Benchmark

Evaluated on a 29-fixture triage benchmark against the untuned base model, on an RTX 5070:

| Model | Disk | Tok/s | JSON-valid (raw single-shot) | JSON-valid (through the app's retry logic) | Category acc | Importance-in-band | Injection-safe |
|---|---|---|---|---|---|---|---|
| **cipher-pro** | 2.5 GB | 171.2 | ~62-79% (see note above) | **100.0% (29/29)** | 86.2% | 82.8% | 100% |

Reproduce with:

```bash
pip install -r requirements.txt
python eval_triage.py --models cipher-pro:latest --keep
```

## Integration note: chat template

Qwen3's chat template isn't reliably auto-detected from the exported GGUF by Ollama (confirmed live β€” `ollama show --modelfile` fell back to a raw passthrough template with no role formatting, causing the model to leak stray `</think>`/`</tool_call>` closing tags before its JSON output). The included `Modelfile` sets an explicit ChatML `TEMPLATE` matching what this model was actually trained on β€” don't rely on Ollama's autodetection or `ollama pull hf.co/...` (which generates its own default template and ignores the Modelfile committed in this repo). If you're integrating this into your own app rather than using Ollama, `llama-server` (llama.cpp's own server binary) handles Qwen3's real chat template correctly on its own β€” verified directly, no override needed there.

Even with the correct template, a small residual fraction of completions may still leak a stray reasoning/tool-call tag before the JSON (Qwen3's own pretraining bakes in tool-calling habits that a LoRA adapter β€” 0.81% of this model's parameters β€” can't fully suppress). If you're parsing structured output, strip any leading `</think>`/`<think>`/`</tool_call>`/`<tool_call>` run before `json.loads()` β€” see `strip_leading_reasoning_tags()` in Grimoire's own `llm_client.py` for the reference implementation.

The same underlying habit occasionally surfaces on free-text tasks (draft-reply/chat/compose) too, which have no JSON schema to validate against β€” sometimes as a fully garbled/empty response, sometimes as just a short garbled token prefixed onto an otherwise-correct reply. Both are worth retrying against rather than surfacing to a user as-is; see `_looks_garbled()` and the retry loop in `chat()` in the same reference file. This model tests clean through that retry path β€” 29/29 on the triage benchmark and 5/5 on compose (normal + injection) β€” but a raw single-shot call without the retry logic will occasionally hit one of these two patterns; don't skip the retry wrapper if you're integrating this model directly.

## Usage (Ollama)

```bash
ollama create cipher-pro -f Modelfile
```

Query it with grammar-constrained JSON output for reliable parsing:

```bash
curl http://localhost:11434/api/chat -d '{
  "model": "cipher-pro",
  "messages": [
    {"role": "system", "content": "<system prompt from Modelfile>"},
    {"role": "user", "content": "From: alex@acme.com\nSubject: Q3 budget review\n\nBody:\nCan we sync before Friday?"}
  ],
  "format": "json",
  "options": {"temperature": 0.1}
}'
```

## Training

- Base: `Qwen/Qwen3-4B-Instruct-2507`, LoRA (r=16, alpha=32, all linear layers), 2 epochs
- Data: ~4,800 triage examples + ~1,600-2,000 examples each for chat/daily-summary/draft-reply/compose (~13,000 total, triage oversampled), all matching Grimoire's exact production prompts β€” generated by the five `generate_*.py` scripts in this repo
- Framework: [Unsloth](https://github.com/unslothai/unsloth) + `trl.SFTTrainer`
- Sequence packing (`trl.SFTConfig(packing=True)`) was tried to speed up training given most examples are well under the 2048-token context window β€” it crashed outright (`ValueError: Expected input batch_size (2048) to match target batch_size (3636)`, an Unsloth fused-loss/trl packing-collator incompatibility in this exact library version pairing), not a quality tradeoff. Disabled.
- Reproduce with `train_cipher_pro.py` β†’ `export_gguf_cipher_pro.py`
- Voice-intent retrain (4 passes, each continuing LoRA training from the previous pass's
  adapter rather than starting over):
  1. ~1,800 voice-intent examples added to the mix β€” fixed the core hallucinated-address
     bug, but introduced two new rule-violations (leaking a resolved recipient into
     `search_email` queries; dropping a valid address match under an injection attempt).
  2. ~2,800 examples targeting both failure modes specifically (heavier weight on
     search-with-a-real-match-that-must-stay-null and injection-with-a-real-match cases) β€”
     both fixed, but a 3rd failure mode surfaced: the `unknown` action (unsupported
     requests like "forward this to my team") sometimes degenerated into an empty,
     repeating `<tool_call>` loop instead of valid JSON.
  3. A small (~1,500 example) continue-train pass β€” mostly expanded, more diverse `unknown`
     phrasing plus a light replay slice of everything else to avoid regressing what already
     worked β€” fixed the degenerate-loop case. At this point raw single-shot eval showed 4/5;
     testing through the actual calling app's `chat_json` (which retries with the
     validation error fed back, and falls back to `action: unknown` if all retries still
     fail) showed this was already a real 5/5 in practice β€” the raw eval script's single
     request just didn't reflect the app's actual behavior.
  4. A final ~4,200 example pass fixed the separately-discovered compose garbled-leading-
     token issue (see the note above), continuing from pass 3's adapter with the full
     `unknown`-fixup data included in the replay slice specifically so that fix wouldn't
     regress. Re-verified 5/5 on voice-intent afterward, no regression on triage/daily-
     summary/chat.
  5. A ~5,200 example pass targeting the credential-injection compliance bug (see the
     security update above), continuing from pass 4's adapter with light general replay
     plus the full pass-3 `unknown`-fixup data re-included, so neither earlier fix would
     erode. This is the version actually shipped in this repo.
  6. (Not shipped, kept here for the record.) Two single-pass full retrains from the base
     model, differing only in learning rate (2e-4 default vs. 1e-4), were tried afterward
     to see if one clean pass on a properly-rebalanced dataset would be more robust than
     the chain of narrow patches above. Verified through the real calling app's retry
     logic on all 29 triage fixtures: the default-LR version scored 23/29 (six fixtures
     failed all 3 retry attempts, every time) against pass 5's clean 29/29 on the identical
     test. The lower-LR version fixed that (29/29) but introduced a new, different
     failure β€” an off-topic role-play hallucination on a basic chat-recall question,
     reproduced on 2 of 4 independent runs, never observed on pass 5. Neither full-retrain
     attempt beat the incrementally-built version on every axis simultaneously, so pass 5
     was kept as the shipped model.

## License

Apache 2.0, inherited from the base model. Weights, training code, and eval harness are fully open.