├── email_opt ├── __init__.py ├── extract.py ├── program.py ├── judge.py └── signatures.py ├── requirements.txt ├── template.txt ├── wins.csv ├── leads.csv ├── .gitignore ├── README.md └── cli.py /email_opt/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | dspy-ai>=2.5.0 2 | pandas>=2.2 3 | python-dotenv>=1.0 4 | -------------------------------------------------------------------------------- /template.txt: -------------------------------------------------------------------------------- 1 | Subject: {subject_hook} 2 | 3 | Hi {first_name}, 4 | 5 | Startups like {company} often struggle with {pain}. We built {product} to help with {product_value}. 6 | Example: {customer_example} → {impact_metric}. 7 | 8 | Worth a 12‑min chat to see if {product} helps {company}? 9 | 10 | – {sender_name}, {sender_role} 11 | -------------------------------------------------------------------------------- /wins.csv: -------------------------------------------------------------------------------- 1 | subject,body 2 | "Cut QA time 37% at AcmeCo","Hi Maya — we helped AcmeCo catch bad AI replies 37% faster with EvalOps. Would a 12‑min chat be useful?" 3 | "2x faster deploys","Hi Alex, PostHog went from 60min to 30min deploys after switching to our CI pipeline. Worth exploring for YourStartup?" 4 | "Save $12K/month GPU","Hey Jordan — helped TechCorp cut GPU costs by $12K monthly with smarter batching. Quick call to discuss?" 5 | -------------------------------------------------------------------------------- /leads.csv: -------------------------------------------------------------------------------- 1 | first_name,company,persona,pain,product,product_value,customer_example,impact_metric,sender_name,sender_role 2 | Maya,HeronAI,Head of RevOps,manual QA on AI replies,EvalOps,trustable evals & feedback loops,AcmeCo,"cut bad replies 37%",Jonathan,CEO 3 | Alex,DevFlow,CTO,slow deployment cycles,FastDeploy,automated CI/CD pipelines,PostHog,"reduced deploy time 50%",Sarah,Head of DevRel 4 | Jordan,ScaleUp,VP Engineering,high GPU costs,BatchOpt,intelligent resource batching,TechCorp,"saved $12K monthly",Mike,Founder 5 | -------------------------------------------------------------------------------- /email_opt/extract.py: -------------------------------------------------------------------------------- 1 | import json 2 | import dspy 3 | from .signatures import ExtractSlots 4 | 5 | _extractor = dspy.ChainOfThought(ExtractSlots) 6 | 7 | def extract_slots_from_winner(subject: str, body: str) -> dict: 8 | out = _extractor(subject=subject, body=body).slots_json.strip() 9 | try: 10 | slots = json.loads(out) 11 | except Exception: 12 | slots = {} 13 | # Coerce to known keys 14 | keys = ["persona","pain","product","product_value","customer_example","impact_metric"] 15 | return {k: (slots.get(k) or "") for k in keys} 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | build/ 8 | develop-eggs/ 9 | dist/ 10 | downloads/ 11 | eggs/ 12 | .eggs/ 13 | lib/ 14 | lib64/ 15 | parts/ 16 | sdist/ 17 | var/ 18 | wheels/ 19 | pip-wheel-metadata/ 20 | share/python-wheels/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # Virtual environments 26 | .env 27 | .venv 28 | env/ 29 | venv/ 30 | ENV/ 31 | env.bak/ 32 | venv.bak/ 33 | 34 | # IDE 35 | .vscode/ 36 | .idea/ 37 | *.swp 38 | *.swo 39 | 40 | # OS 41 | .DS_Store 42 | Thumbs.db 43 | 44 | # Project specific 45 | out.csv 46 | *.log 47 | -------------------------------------------------------------------------------- /email_opt/program.py: -------------------------------------------------------------------------------- 1 | import dspy 2 | from .signatures import DraftEmail, CritiqueEmail, RewriteEmail 3 | 4 | class EmailProgram(dspy.Module): 5 | def __init__(self): 6 | super().__init__() 7 | self.writer = dspy.Predict(DraftEmail) 8 | self.critic = dspy.Predict(CritiqueEmail) 9 | self.rewriter = dspy.Predict(RewriteEmail) 10 | 11 | def forward(self, **slots): 12 | draft = self.writer(**slots) 13 | crit = self.critic(subject=draft.subject, body=draft.body) 14 | final = self.rewriter(subject=draft.subject, body=draft.body, feedback=crit.feedback) 15 | return dspy.Prediction( 16 | subject=final.subject_new, 17 | body=final.body_new, 18 | feedback=crit.feedback 19 | ) 20 | -------------------------------------------------------------------------------- /email_opt/judge.py: -------------------------------------------------------------------------------- 1 | import json, re 2 | import dspy 3 | from .signatures import JudgeEmail 4 | 5 | SPAMMY = {"free","guarantee","winner","act now","limited time","cheap","exclusive"} 6 | FLUFFY = {"innovative","cutting-edge","disruptive","revolutionary","groundbreaking"} 7 | 8 | def hard_checks(subject: str, body: str) -> int: 9 | score = 10 10 | words = len(body.split()) 11 | if words > 120: score -= 3 12 | if len(subject.split()) > 6: score -= 1 13 | if any(w in body.lower() for w in SPAMMY): score -= 2 14 | if any(w in body.lower() for w in FLUFFY): score -= 1 15 | if not re.search(r"\d{1,3}%|\d+(\.\d+)?\s*(x|hrs?|days?)", body.lower()): # no numbers/metrics 16 | score -= 2 17 | if "?" not in body: score -= 1 # weak CTA proxy 18 | return max(0, min(10, score)) 19 | 20 | _llm_judge = dspy.Predict(JudgeEmail) 21 | 22 | def llm_judge(subject: str, body: str): 23 | j = _llm_judge(subject=subject, body=body).json.strip() 24 | try: 25 | data = json.loads(j) 26 | return int(data.get("score", 6)), data.get("reason", "ok") 27 | except Exception: 28 | return 6, "fallback" 29 | 30 | def scoring_metric(example, pred, trace=None) -> float: 31 | s_llm, _ = llm_judge(pred.subject, pred.body) 32 | s_hard = hard_checks(pred.subject, pred.body) 33 | return round(0.7 * s_llm + 0.3 * s_hard, 2) 34 | -------------------------------------------------------------------------------- /email_opt/signatures.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | import dspy 3 | 4 | # Writer: produces subject + body using structured slots + template 5 | class DraftEmail(dspy.Signature): 6 | """Write a startup-founder-friendly cold email: ≤120 words, 1 metric of proof, 1 clear CTA. 7 | No fluff. Be specific and polite. Keep subject ≤6 words, no clickbait.""" 8 | first_name: str = dspy.InputField() 9 | company: str = dspy.InputField() 10 | persona: Optional[str] = dspy.InputField() 11 | pain: Optional[str] = dspy.InputField() 12 | product: Optional[str] = dspy.InputField() 13 | product_value: Optional[str] = dspy.InputField() 14 | customer_example: Optional[str] = dspy.InputField() 15 | impact_metric: Optional[str] = dspy.InputField() 16 | template: str = dspy.InputField() 17 | subject: str = dspy.OutputField(desc="Catchy, specific, ≤6 words") 18 | body: str = dspy.OutputField(desc="Email body plain text") 19 | 20 | # Critic: suggests concrete adjustments 21 | class CritiqueEmail(dspy.Signature): 22 | """Critique for tone, specificity, proof, CTA, and length.""" 23 | subject: str = dspy.InputField() 24 | body: str = dspy.InputField() 25 | feedback: str = dspy.OutputField(desc="JSON: {tone, specificity, proof, CTA, length}") 26 | 27 | # Rewriter: applies the critique 28 | class RewriteEmail(dspy.Signature): 29 | """Rewrite using feedback; preserve facts and constraints.""" 30 | subject: str = dspy.InputField() 31 | body: str = dspy.InputField() 32 | feedback: str = dspy.InputField() 33 | subject_new: str = dspy.OutputField() 34 | body_new: str = dspy.OutputField() 35 | 36 | # Slot extractor: turns 'winner' emails into structured slots for training 37 | class ExtractSlots(dspy.Signature): 38 | """Extract slots from a winning sales email. Return JSON keys: 39 | {persona, pain, product, product_value, customer_example, impact_metric}""" 40 | subject: str = dspy.InputField() 41 | body: str = dspy.InputField() 42 | slots_json: str = dspy.OutputField() 43 | 44 | # Judge: returns JSON {score: int(0-10), reason: str} 45 | class JudgeEmail(dspy.Signature): 46 | """Score for startup founders: brevity, specificity, proof (numbers), peer signal, clear CTA.""" 47 | subject: str = dspy.InputField() 48 | body: str = dspy.InputField() 49 | json: str = dspy.OutputField() 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Founder Email Optimizer (DSPy) 2 | 3 | Give us your **3 best cold emails** and a **leads CSV**. Get back optimized, founder‑style outreach. 4 | 5 | ## Quickstart 6 | ```bash 7 | pip install -r requirements.txt 8 | echo 'OPENAI_API_KEY=sk-...' > .env 9 | python cli.py --wins wins.csv --leads leads.csv --template template.txt --out out.csv --optimizer bootstrap 10 | ``` 11 | 12 | ## Inputs 13 | 14 | - **wins.csv**: columns `subject`, `body` (more rows welcome) 15 | - **leads.csv**: columns: `first_name`, `company` (+ optional `persona`, `pain`, `product`, `product_value`, `customer_example`, `impact_metric`, `sender_name`, `sender_role`) 16 | 17 | ## Output 18 | 19 | - **out.csv**: adds `optimized_subject`, `optimized_body`, `score_0_10`, `feedback` 20 | 21 | ## Notes 22 | 23 | Uses DSPy Signatures + Predict modules and BootstrapFewShot or MIPROv2 for optimization. 24 | 25 | Default LM: `openai/gpt-4o-mini`. Override with env: 26 | 27 | ```bash 28 | MODEL_ID=openai/gpt-4o-mini 29 | API_BASE=http://localhost:7501/v1 30 | OPENAI_API_KEY=... 31 | ``` 32 | 33 | ## Risk checks & blind spots (called out) 34 | 35 | - **Correlation to real replies**: the LLM judge is a proxy. Expect it to roughly enforce style/shape, not perfectly predict replies. Do a small **A/B** (1–2 weeks) to calibrate + auto‑tune rubric weights. 36 | - **3 winners is sparse**: we compensate by bootstrapping demos + optimizing instructions. If they can provide 5–10, MIPROv2 tends to stabilize. 37 | - **Hallucinated facts**: we keep slots explicit and penalize fluff; still add a simple regex whitelist for product names if needed. 38 | - **Privacy/PII**: CSVs likely contain names; don't ship logs outside your network. 39 | 40 | ## Fast extensions 41 | 42 | - `--variants 3 --pick best` (generate N and keep highest judge score) 43 | - Clickbait guard: hard stop if subject contains spammy tokens 44 | - Subject‑line policy: enforce presence of a concrete noun + numeric 45 | - Persona presets: `--persona startup_founder` vs `--persona cio_enterprise` 46 | - Cache control: set unique `rollout_id` when you want fresh generations 47 | 48 | ## Usage Examples 49 | 50 | ```bash 51 | # Basic usage with bootstrap optimization 52 | python cli.py 53 | 54 | # Use MIPROv2 optimizer (stronger but slower) 55 | python cli.py --optimizer mipro 56 | 57 | # No optimization, just run the base pipeline 58 | python cli.py --optimizer none 59 | 60 | # Custom files 61 | python cli.py --wins my_winners.csv --leads my_leads.csv --out results.csv 62 | ``` 63 | -------------------------------------------------------------------------------- /cli.py: -------------------------------------------------------------------------------- 1 | import os, argparse, json, pandas as pd 2 | from dotenv import load_dotenv 3 | import dspy 4 | 5 | from email_opt.program import EmailProgram 6 | from email_opt.extract import extract_slots_from_winner 7 | from email_opt.judge import scoring_metric 8 | 9 | def configure_lm(): 10 | load_dotenv() 11 | model_id = os.getenv("MODEL_ID", "openai/gpt-4o-mini") 12 | api_key = os.getenv("OPENAI_API_KEY", os.getenv("API_KEY", "")) 13 | api_base = os.getenv("API_BASE", None) 14 | lm = dspy.LM(model_id, api_key=api_key, api_base=api_base, model_type="chat") 15 | dspy.configure(lm=lm) # modern DSPy configure 16 | return lm 17 | 18 | def row_to_example(slots: dict, template: str, subject: str, body: str): 19 | fields = ["first_name","company","persona","pain","product", 20 | "product_value","customer_example","impact_metric"] 21 | filled = {k: slots.get(k, "") for k in fields} 22 | filled["template"] = template 23 | ex = dspy.Example(**filled, subject=subject, body=body).with_inputs(*([*fields, "template"])) 24 | return ex 25 | 26 | def build_trainset_from_wins(wins_df: pd.DataFrame, template: str): 27 | train = [] 28 | for _, r in wins_df.iterrows(): 29 | subj = str(r.get("subject", "")).strip() 30 | body = str(r.get("body", "")).strip() 31 | slots = extract_slots_from_winner(subj, body) 32 | # use neutral placeholders to avoid overfitting to names 33 | slots.setdefault("first_name", "Alex") 34 | slots.setdefault("company", "BatchmateX") 35 | train.append(row_to_example(slots, template, subj, body)) 36 | return train 37 | 38 | def gepa_metric_wrapper(gold, pred, trace=None, pred_name=None, pred_trace=None): 39 | """Wrapper for GEPA which requires 5 parameters but sometimes only passes 2""" 40 | # Handle both calling conventions 41 | if trace is None: 42 | # Called with just (gold, pred) during evaluation 43 | return scoring_metric(gold, pred) 44 | else: 45 | # Called with all 5 parameters during optimization 46 | return scoring_metric(gold, pred, trace) 47 | 48 | def compile_program(trainset, optimizer: str): 49 | prog = EmailProgram() 50 | if optimizer == "none" or len(trainset) == 0: 51 | return prog 52 | if optimizer == "bootstrap": 53 | tele = dspy.BootstrapFewShot(metric=scoring_metric, max_bootstrapped_demos=min(4, len(trainset))) 54 | return tele.compile(prog, trainset=trainset) 55 | if optimizer == "gepa": 56 | # GEPA requires a reflection LM - use the same model with higher temperature 57 | reflection_lm = dspy.LM( 58 | model=os.getenv("MODEL_ID", "openai/gpt-4o-mini"), 59 | api_key=os.getenv("OPENAI_API_KEY", os.getenv("API_KEY", "")), 60 | api_base=os.getenv("API_BASE", None), 61 | temperature=1.0, 62 | max_tokens=2000 63 | ) 64 | tele = dspy.GEPA( 65 | metric=gepa_metric_wrapper, 66 | reflection_lm=reflection_lm, 67 | track_stats=True, 68 | auto="light" 69 | ) 70 | # use same tiny set for val to keep v0 simple 71 | return tele.compile(prog, trainset=trainset, valset=trainset) 72 | # mipro 73 | tele = dspy.MIPROv2(metric=scoring_metric, auto="light", num_candidates=4, init_temperature=1.0) 74 | # use same tiny set for val to keep v0 simple 75 | return tele.compile(prog, trainset=trainset, valset=trainset, num_trials=6) 76 | 77 | def generate_for_leads(prog, leads_df: pd.DataFrame, template: str): 78 | outs = [] 79 | for _, r in leads_df.iterrows(): 80 | slots = {k: str(r.get(k, "") or "") for k in [ 81 | "first_name","company","persona","pain","product","product_value","customer_example","impact_metric" 82 | ]} 83 | slots["template"] = template 84 | pred = prog(**slots) 85 | score = scoring_metric(None, pred) 86 | outs.append({ 87 | **r.to_dict(), 88 | "optimized_subject": pred.subject, 89 | "optimized_body": pred.body, 90 | "score_0_10": score, 91 | "feedback": pred.feedback 92 | }) 93 | return pd.DataFrame(outs) 94 | 95 | def main(): 96 | ap = argparse.ArgumentParser() 97 | ap.add_argument("--wins", default="wins.csv", help="CSV with columns: subject, body") 98 | ap.add_argument("--leads", default="leads.csv", help="CSV of leads") 99 | ap.add_argument("--template", default="template.txt") 100 | ap.add_argument("--out", default="out.csv") 101 | ap.add_argument("--optimizer", default="bootstrap", choices=["none","bootstrap","mipro","gepa"]) 102 | args = ap.parse_args() 103 | 104 | configure_lm() 105 | template = open(args.template).read() 106 | wins_df = pd.read_csv(args.wins) 107 | trainset = build_trainset_from_wins(wins_df, template) 108 | program = compile_program(trainset, args.optimizer) 109 | leads_df = pd.read_csv(args.leads) 110 | out_df = generate_for_leads(program, leads_df, template) 111 | out_df.to_csv(args.out, index=False) 112 | print(f"Wrote {args.out} | rows={len(out_df)}") 113 | 114 | if __name__ == "__main__": 115 | main() 116 | --------------------------------------------------------------------------------