File size: 13,462 Bytes
81805fc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
"""ColdScore β€” cold-email deliverability scoring on an open-weights model.

Hugging Face Space. Loads a fine-tuned ModernBERT from the Hub and runs it in
this container. No proprietary model API is called.

Set MODEL_ID to your Hub repo before deploying.
"""

import csv
import io
import os
import re

import gradio as gr
import torch
from transformers import AutoModelForSequenceClassification, AutoTokenizer

MODEL_ID = os.environ.get("MODEL_ID", "Ashsinha1/coldscore-modernbert")
MAX_LEN = 512

# Lazy-load: the Space is deployed before the model is pushed, and loading at
# import time would crash-loop the container until the repo exists. This way the
# UI boots immediately and picks the model up on the first request after it lands.
_M = {"tok": None, "model": None, "error": None}


def get_model():
    if _M["model"] is None:
        try:
            _M["tok"] = AutoTokenizer.from_pretrained(MODEL_ID)
            m = AutoModelForSequenceClassification.from_pretrained(MODEL_ID)
            m.eval()
            _M["model"], _M["error"] = m, None
        except Exception as e:  # repo missing, still uploading, or gated
            _M["error"] = (
                f"Model `{MODEL_ID}` isn't loadable yet.\n\n"
                f"Train and push it first (see the Colab one-liner), then hit **Score it** "
                f"again β€” no redeploy needed.\n\n<sub>{type(e).__name__}: {e}</sub>"
            )
    return _M

GRADES = [
    (0.15, "A", "Should land in the primary inbox."),
    (0.35, "B", "Likely to deliver. A couple of things worth tightening."),
    (0.60, "C", "Coin flip. The promotions tab is a real possibility."),
    (0.85, "D", "High spam risk. Rewrite before sending at volume."),
    (1.01, "F", "Very high spam risk. Sending this will hurt your domain reputation."),
]


def grade_for(risk):
    for cutoff, letter, verdict in GRADES:
        if risk < cutoff:
            return letter, verdict
    return "F", GRADES[-1][2]


@torch.inference_mode()
def score_many(pairs):
    """Score a list of (subject, body). Batched β€” one forward pass per chunk."""
    M = get_model()
    if M["model"] is None:
        raise RuntimeError(M["error"])
    tokenizer, model = M["tok"], M["model"]
    out = []
    for i in range(0, len(pairs), 16):
        chunk = pairs[i:i + 16]
        enc = tokenizer([s for s, _ in chunk], [b for _, b in chunk],
                        truncation=True, padding=True, max_length=MAX_LEN,
                        return_tensors="pt")
        probs = model(**enc).logits.float().softmax(-1)[:, 1]
        out.extend(float(p) for p in probs)
    return out


# --- fix suggestions -------------------------------------------------------
# These are deterministic checks against published deliverability guidance, NOT
# the model's reasoning. ModernBERT can't tell you why it scored something β€”
# claiming otherwise would be inventing an explanation. Kept separate on purpose.

URL_RE = re.compile(r"https?://\S+|www\.\S+")
MERGE_RE = re.compile(r"\{\{?\s*[a-z_]+\s*\}?\}", re.I)


def suggestions(subject, body):
    tips = []
    full = f"{subject}\n{body}"
    letters = [c for c in full if c.isalpha()]
    caps = sum(1 for c in letters if c.isupper()) / len(letters) if letters else 0

    if MERGE_RE.search(full):
        tips.append("**Unrendered merge tag** β€” something like `{{first_name}}` will send literally. "
                    "This is the single most damaging thing in the list.")
    if caps > 0.3:
        tips.append(f"**{caps:.0%} of letters are capitals.** Shouting is a classic filter trigger.")
    if re.search(r"\b[A-Z]{4,}\b", full):
        tips.append("**ALL-CAPS words.** Drop them to sentence case.")
    n_ex = full.count("!")
    if n_ex > 2:
        tips.append(f"**{n_ex} exclamation marks.** One is plenty; zero is usually better.")
    links = len(URL_RE.findall(body))
    if links > 2:
        tips.append(f"**{links} links.** Multiple links in a first-touch email reads as bulk mail. Cut to one.")
    if re.match(r"^\s*(re|fwd?)\s*:", subject, re.I):
        tips.append("**Fake `Re:`/`Fwd:` prefix.** It implies a conversation that never happened β€” "
                    "recipients mark it as spam, and that costs you domain reputation.")
    if len(body.split()) < 25:
        tips.append("**Very short body.** Too little content to look like a real 1:1 email.")
    if len(body.split()) > 220:
        tips.append(f"**{len(body.split())} words.** Long first-touch emails lose replies; aim for 90–130.")
    if len(subject) > 60:
        tips.append(f"**{len(subject)}-character subject.** It'll be truncated on mobile. Aim under 50.")
    if "unsubscribe" not in body.lower():
        tips.append("_No opt-out._ Not required for genuine 1:1 mail, but at volume its absence "
                    "raises complaint rates β€” which is what actually burns a domain.")

    if not tips:
        return "No mechanical issues found. Anything left is about the writing itself."
    return "\n\n".join("- " + t for t in tips)


def score_single(subject, body):
    subject = (subject or "").strip()
    body = (body or "").strip()
    if not subject and not body:
        return {}, "Enter a subject and a body.", ""

    try:
        risk = score_many([(subject, body)])[0]
    except RuntimeError as e:
        return {}, str(e), ""
    letter, verdict = grade_for(risk)
    headline = f"### Grade {letter} β€” {risk:.1%} spam risk\n{verdict}"
    return ({"spam-prone": risk, "deliverable": 1 - risk}, headline, suggestions(subject, body))


# --- batch -----------------------------------------------------------------

def score_batch(file, pasted):
    """CSV with subject,body columns β€” or one email per line as 'subject | body'."""
    pairs, errors = [], []

    if file is not None:
        try:
            with open(file.name, encoding="utf-8-sig", newline="") as fh:
                reader = csv.DictReader(fh)
                cols = {c.lower().strip(): c for c in (reader.fieldnames or [])}
                if "subject" not in cols or "body" not in cols:
                    return [], f"CSV needs `subject` and `body` columns. Found: {reader.fieldnames}"
                for row in reader:
                    pairs.append(((row[cols["subject"]] or "").strip(),
                                  (row[cols["body"]] or "").strip()))
        except Exception as e:
            return [], f"Could not read that CSV: {e}"

    if pasted and pasted.strip():
        for line in pasted.strip().splitlines():
            if not line.strip():
                continue
            subject, _, body = line.partition("|")
            pairs.append((subject.strip(), body.strip()))

    if not pairs:
        return [], "Upload a CSV or paste some emails first."

    pairs = pairs[:200]
    try:
        risks = score_many(pairs)
    except RuntimeError as e:
        return [], str(e)
    rows = []
    for (s, b), r in zip(pairs, risks):
        letter, _ = grade_for(r)
        rows.append([letter, f"{r:.1%}", s[:70], f"{len(b.split())}w"])
    rows.sort(key=lambda x: -float(x[1].rstrip("%")))

    risky = sum(1 for r in risks if r >= 0.6)
    note = (f"Scored **{len(pairs)}** emails. **{risky}** at grade C or worse "
            f"({risky / len(pairs):.0%}). Sorted worst first.")
    if len(pairs) == 200:
        note += "\n\n_Capped at 200 rows for this demo._"
    return rows, note


GOOD = ("quick question about Northwind's onboarding",
        "Hi Sarah,\n\nI noticed Northwind shipped a self-serve trial last month. Curious how "
        "you're handling the handoff from trial to sales conversation right now.\n\nWe built "
        "something for that and I'd rather hear how you do it before I assume anything.\n\n"
        "Worth 15 minutes next week?\n\nAshish")
BAD = ("ACT NOW!!! Northwind qualifies for FREE revenue growth!!!",
       "Dear Sir/Madam,\n\nCONGRATULATIONS!!! Northwind has been SELECTED for our EXCLUSIVE "
       "offer!\n\nGUARANTEED 10X ROI or your money back!!! This is a LIMITED TIME offer that "
       "EXPIRES at midnight!\n\nCLICK HERE NOW: http://bit.ly/xy12\n\nDON'T MISS OUT!!! ACT FAST!!!")
MERGE = ("{{first_name}} - I have an amazing offer for {{company}}",
         "Hi {{first_name}},\n\nWe are the best in the world at what we do and our results are "
         "guaranteed.\n\nSign up free today! Click here to buy now: http://example.com/signup\n\n"
         "Best,\nSales Team")

with gr.Blocks(title="ColdScore", theme=gr.themes.Soft()) as demo:
    gr.Markdown(
        f"""
        # 🧲 ColdScore
        ### Will this cold email reach the inbox β€” or the spam folder?

        A fine-tuned **[ModernBERT](https://huggingface.co/answerdotai/ModernBERT-base)**
        (Apache-2.0, 149M params) running on a free CPU. Open weights, MIT licence.
        **No proprietary model API is called** β€” the weights are public, and you can run
        this identical model inside your own network.
        """
    )

    with gr.Tab("Score an email"):
        with gr.Row():
            with gr.Column(scale=3):
                subject = gr.Textbox(label="Subject", placeholder="quick question about onboarding")
                body = gr.Textbox(label="Body", lines=12, placeholder="Hi Sarah,\n\nI noticed...")
                btn = gr.Button("Score it", variant="primary", size="lg")
            with gr.Column(scale=2):
                headline = gr.Markdown()
                label = gr.Label(label="Model output", num_top_classes=2)

        gr.Markdown("#### What to fix")
        tips = gr.Markdown()
        gr.Markdown(
            "_The fix list is deterministic checks against published deliverability guidance β€” "
            "not the model's reasoning. A 149M-parameter transformer can't tell you why it "
            "scored something, and inventing an explanation would be worse than not having one._"
        )

        gr.Examples(examples=[list(GOOD), list(BAD), list(MERGE)],
                    inputs=[subject, body], label="Try one")

        btn.click(score_single, [subject, body], [label, headline, tips])

    with gr.Tab("Score a campaign"):
        gr.Markdown(
            "Score a whole list at once β€” the actual job if you're sending at volume.\n\n"
            "Upload a **CSV with `subject` and `body` columns**, or paste one email per line "
            "as `subject | body`."
        )
        with gr.Row():
            up = gr.File(label="CSV", file_types=[".csv"])
            paste = gr.Textbox(label="Or paste", lines=6,
                               placeholder="quick question about onboarding | Hi Sarah, I noticed...")
        bbtn = gr.Button("Score all", variant="primary")
        bnote = gr.Markdown()
        table = gr.Dataframe(headers=["Grade", "Risk", "Subject", "Length"],
                             datatype=["str", "str", "str", "str"],
                             wrap=True, label="Worst first")
        bbtn.click(score_batch, [up, paste], [table, bnote])

    with gr.Tab("Use it yourself"):
        gr.Markdown(
            f"""
            The model is public. Run it wherever your data is allowed to live:

            ```python
            from transformers import pipeline

            clf = pipeline("text-classification", model="{MODEL_ID}")
            clf({{"text": "quick question about onboarding", "text_pair": "Hi Sarah, I noticed..."}})
            # [{{'label': 'deliverable', 'score': 0.98}}]
            ```

            ### Why an open model instead of a frontier API

            Scoring a cold email means handing over a prospect's name, their email address, and
            your pitch. That's customer data. Sending it to a third-party model provider is a
            decision that shows up in your customers' security review.

            This model doesn't force that decision: the weights are public, inference is a CPU
            process, and it runs inside your own network with the four lines above. A frontier
            model would score these emails better β€” but for the half of the pipeline that touches
            every contact in the database, "good and local" beats "excellent and elsewhere".

            ### Limitations β€” read before trusting a number

            1. **Content only.** Inbox placement is dominated by things the model cannot see:
               domain and IP reputation, SPF/DKIM/DMARC alignment, sending volume, list hygiene,
               complaint rates. A perfect email from a burned domain still lands in spam.
            2. **The benchmark is easy.** Trained on 100 hand-written emails whose spam examples
               are unsubtle. A keyword blocklist already scores ~0.94 F1 on that corpus, so
               held-out numbers are an optimistic upper bound, not real-world accuracy.
            3. **Labels are judgements, not outcomes.** Nobody sent these emails and measured
               where they landed.
            4. **English only.** Not a phishing detector β€” well-written phishing scores clean.

            Treat it as a content lint, not a deliverability guarantee.

            ### Privacy

            Text you enter is processed in this container and is not stored or logged. If that
            still isn't good enough for your data β€” which is a reasonable position, and rather
            the point of the project β€” clone the Space or run the model locally.
            """
        )

if __name__ == "__main__":
    demo.launch()