outreach: per-user voice from own emails + transparency; active-thread context (v0.1.0:70)
Voice upgrade. draft_outreach now learns the SENDER's voice: the codified rules PLUS a
few-shot of that user's own recent sent emails (_voice_examples; from_email = the
sender, de-identified in the same scrub batch as the recipient context, reference-only).
The response returns which of the sender's emails were used (subject + date + recipient),
shown in the UI as "Voice based on: …" — transparency to avoid the black-box problem.
Falls back to rules-only with a clear note when the user has no captured sent email.
Context restructured: _context groups the investor's email by thread and labels the most
recent thread as the "Active conversation (what you are replying to)" with earlier emails
as background, so replies stay on-topic instead of dredging old threads.
Sender email resolved in handle_outreach_draft (users table by user_id). Test extended
(active/background split, voice examples + meta, no-sender fallback). Fixed a UI bug the
preview caught: the manual Draft button was onClick={draft}, which passed the click event
as the investor arg after draft() gained params -> circular-JSON error; now onClick={()=>draft()}.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ the thesis is Ten31's own non-sensitive messaging and goes to Claude as-is; the
|
||||
context (CRM notes + email history) is scrubbed first, so the LP list never reaches the
|
||||
API in the clear, and the draft is re-hydrated locally for the human.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
@@ -89,43 +90,91 @@ def follow_up_radar(conn, our_addresses, now_iso, warm_days=45, limit=60):
|
||||
|
||||
|
||||
def _context(conn, investor_id):
|
||||
"""Assemble the recipient's context: CRM notes + recent matched email with them.
|
||||
Returns (investor_name, context_text) or (None, None)."""
|
||||
"""Assemble the recipient's context. Structured so the model replies to the ACTIVE
|
||||
conversation (the most recent email thread) while still having earlier emails as
|
||||
background. Returns (investor_name, context_text) or (None, None)."""
|
||||
row = conn.execute("SELECT investor_name, notes FROM fundraising_investors WHERE id=?",
|
||||
(investor_id,)).fetchone()
|
||||
if not row:
|
||||
return None, None
|
||||
name = row["investor_name"]
|
||||
parts = [f"Investor: {name}"]
|
||||
header = [f"Investor: {name}"]
|
||||
notes = (row["notes"] or "").strip()
|
||||
if notes:
|
||||
parts.append("CRM notes:\n" + notes)
|
||||
header.append("CRM notes:\n" + notes)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT e.subject, e.body_text, e.snippet, e.sent_at FROM emails e "
|
||||
"SELECT e.subject, e.body_text, e.snippet, e.sent_at, e.thread_id FROM emails e "
|
||||
"JOIN email_investor_links l ON l.email_id = e.id "
|
||||
"WHERE l.fundraising_investor_id = ? AND e.is_matched = 1 "
|
||||
"ORDER BY e.sent_at DESC LIMIT 6", (investor_id,)).fetchall()
|
||||
"ORDER BY e.sent_at DESC LIMIT 20", (investor_id,)).fetchall()
|
||||
except Exception:
|
||||
rows = [] # email tables may be absent / not yet captured
|
||||
active, background = [], []
|
||||
if rows:
|
||||
active_thread = rows[0]["thread_id"]
|
||||
for em in rows:
|
||||
body = (em["body_text"] or em["snippet"] or "")[:1500].strip()
|
||||
if body or em["subject"]:
|
||||
parts.append(f"Email ({(em['sent_at'] or '')[:10]}) — {em['subject'] or '(no subject)'}\n{body}")
|
||||
block = f"({(em['sent_at'] or '')[:10]}) {em['subject'] or '(no subject)'}\n{body}"
|
||||
in_active = active_thread is not None and em["thread_id"] == active_thread
|
||||
(active if in_active else background).append(block)
|
||||
sections = ["\n".join(header)]
|
||||
if active:
|
||||
sections.append("=== Active conversation (the most recent thread — this is what you are replying to) ===\n"
|
||||
+ "\n\n".join(reversed(active[:6])))
|
||||
if background:
|
||||
sections.append("=== Earlier emails (background only, not the active thread) ===\n"
|
||||
+ "\n\n".join(background[:4]))
|
||||
return name, "\n\n".join(sections)
|
||||
|
||||
|
||||
def _voice_examples(conn, sender_email, limit=4):
|
||||
"""The sender's OWN recent sent LP emails — used as voice few-shot AND surfaced for
|
||||
transparency (no black box). Returns (blocks_for_model, meta_for_ui). meta is the
|
||||
sender's own emails, safe to show them."""
|
||||
if not sender_email:
|
||||
return [], []
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT subject, body_text, snippet, sent_at, to_emails_json FROM emails "
|
||||
"WHERE LOWER(from_email) = LOWER(?) AND is_matched = 1 "
|
||||
"AND body_text IS NOT NULL AND TRIM(body_text) <> '' "
|
||||
"ORDER BY sent_at DESC LIMIT ?", (sender_email, limit)).fetchall()
|
||||
except Exception:
|
||||
pass # email tables may be absent / not yet captured
|
||||
return name, "\n\n---\n\n".join(parts)
|
||||
return [], []
|
||||
blocks, meta = [], []
|
||||
for r in rows:
|
||||
body = (r["body_text"] or r["snippet"] or "")[:1200].strip()
|
||||
if not body:
|
||||
continue
|
||||
blocks.append(f"Example — {r['subject'] or '(no subject)'}\n{body}")
|
||||
to = ""
|
||||
try:
|
||||
arr = json.loads(r["to_emails_json"] or "[]")
|
||||
if arr:
|
||||
to = arr[0].get("email") if isinstance(arr[0], dict) else arr[0]
|
||||
except Exception:
|
||||
to = ""
|
||||
meta.append({"subject": r["subject"] or "(no subject)", "date": (r["sent_at"] or "")[:10], "to": to})
|
||||
return blocks, meta
|
||||
|
||||
|
||||
def _draft_with_claude(aa, thesis, type_desc, deident_context, guidance):
|
||||
def _draft_with_claude(aa, thesis, type_desc, deident_context, deident_voice, guidance):
|
||||
voice_block = ""
|
||||
if deident_voice:
|
||||
voice_block = ("\n\nHere are examples of how THIS sender actually writes (de-identified). Match their "
|
||||
"voice, tone, sentence rhythm, openers, and sign-off — not just the rules above:\n\n"
|
||||
+ "\n\n---\n\n".join(deident_voice))
|
||||
system = (
|
||||
"You are Ten31's outreach copilot. Draft ONE ready-to-send LP outreach email in Ten31's voice. "
|
||||
f"VOICE RULES (follow exactly): {aa.VOICE}\n\n"
|
||||
"You are Ten31's outreach copilot. Draft ONE ready-to-send LP outreach email in the SENDER's voice. "
|
||||
f"VOICE RULES (follow exactly): {aa.VOICE}" + voice_block + "\n\n"
|
||||
"Ten31 invests in critical infrastructure across bitcoin, AI, energy, and freedom technologies, "
|
||||
"with scarcity as the connecting idea. Current working thesis:\n" + aa._render_thesis(thesis) + "\n\n"
|
||||
"The recipient's context below is DE-IDENTIFIED: people, firms, and amounts appear as placeholders "
|
||||
"like [PERSON_1], [ORG_1], [AMOUNT_1]. Keep every placeholder EXACTLY as written and NEVER invent new "
|
||||
"ones — they are swapped back to real values after you reply. Output a subject line, then the email body. "
|
||||
"Ground it in the actual context; do NOT fabricate facts, numbers, returns, or commitments that are not "
|
||||
"present in the context or the thesis.")
|
||||
"ones — they are swapped back to real values after you reply. Reply to the ACTIVE conversation; use the "
|
||||
"earlier emails only as background. Output a subject line, then the email body. Do NOT fabricate facts, "
|
||||
"numbers, returns, or commitments that are not present in the context or the thesis.")
|
||||
user = (f"Outreach type: {type_desc}\n\n"
|
||||
f"Recipient context (de-identified):\n{deident_context}\n\n"
|
||||
+ (f"Additional guidance from the sender: {guidance}\n\n" if (guidance or "").strip() else "")
|
||||
@@ -137,31 +186,35 @@ def _draft_with_claude(aa, thesis, type_desc, deident_context, guidance):
|
||||
return "".join(b.text for b in resp.content if getattr(b, "type", None) == "text")
|
||||
|
||||
|
||||
def draft_outreach(conn, investor_id, outreach_type, guidance, db_path):
|
||||
"""Draft tailored outreach for one investor. FAILS CLOSED: if the scrub can't be
|
||||
prepared or Claude hallucinates a placeholder, no de-anonymized draft is returned."""
|
||||
def draft_outreach(conn, investor_id, outreach_type, guidance, db_path, sender_email=None):
|
||||
"""Draft tailored outreach for one investor, in the SENDER's voice (few-shot from
|
||||
their own prior emails). FAILS CLOSED: if the scrub can't be prepared or Claude
|
||||
hallucinates a placeholder, no de-anonymized draft is returned."""
|
||||
name, context = _context(conn, investor_id)
|
||||
if not name:
|
||||
return {"status": "not_found"}
|
||||
type_desc = OUTREACH_TYPES.get(outreach_type, OUTREACH_TYPES["follow_up"])
|
||||
voice_blocks, voice_meta = _voice_examples(conn, sender_email)
|
||||
|
||||
# 1) Scrub the LP context — the LP list / identifiers never reach Claude in the clear.
|
||||
# 1) Scrub the sender's voice examples + the recipient context TOGETHER (shared token
|
||||
# space). Nothing reaches Claude in the clear; the voice examples are reference only.
|
||||
try:
|
||||
sys.path.insert(0, os.path.dirname(_HERE)) # backend/ for the redaction package
|
||||
from redaction.client import Boundary
|
||||
boundary = Boundary(db_path=db_path, actor="closer")
|
||||
scrubbed = boundary.scrub([context], bucket=False, conn=conn)
|
||||
scrubbed = boundary.scrub(list(voice_blocks) + [context], bucket=False, conn=conn)
|
||||
except Exception as exc:
|
||||
return {"status": "scrub_unavailable", "reason": str(exc)}
|
||||
deident = scrubbed["items"][0]
|
||||
items = scrubbed["items"]
|
||||
deident_voice, deident_target = items[:-1], items[-1]
|
||||
handle = scrubbed["handle"]
|
||||
|
||||
# 2) Claude drafts over the de-identified context + (non-sensitive) thesis.
|
||||
# 2) Claude drafts over the de-identified context + voice + (non-sensitive) thesis.
|
||||
try:
|
||||
sys.path.insert(0, _HERE)
|
||||
import architect_agent as aa
|
||||
thesis = aa.at.get_thesis("core", db=db_path)
|
||||
raw = _draft_with_claude(aa, thesis, type_desc, deident, guidance)
|
||||
raw = _draft_with_claude(aa, thesis, type_desc, deident_target, deident_voice, guidance)
|
||||
except Exception as exc:
|
||||
boundary.forget(handle)
|
||||
return {"status": "claude_not_configured", "reason": str(exc)}
|
||||
@@ -172,4 +225,4 @@ def draft_outreach(conn, investor_id, outreach_type, guidance, db_path):
|
||||
if rehy.get("error"):
|
||||
return {"status": "rehydrate_failed"}
|
||||
return {"status": "ok", "draft": rehy["text"], "investor_name": name,
|
||||
"scrub_stats": scrubbed.get("stats", {})}
|
||||
"scrub_stats": scrubbed.get("stats", {}), "voice_examples": voice_meta}
|
||||
|
||||
@@ -27,14 +27,15 @@ def main():
|
||||
c.row_factory = sqlite3.Row
|
||||
c.executescript("""
|
||||
CREATE TABLE fundraising_investors (id TEXT PRIMARY KEY, investor_name TEXT, notes TEXT);
|
||||
CREATE TABLE emails (id TEXT PRIMARY KEY, subject TEXT, body_text TEXT, snippet TEXT, sent_at TEXT, is_matched INT);
|
||||
CREATE TABLE emails (id TEXT PRIMARY KEY, subject TEXT, body_text TEXT, snippet TEXT, sent_at TEXT,
|
||||
from_email TEXT, to_emails_json TEXT, thread_id TEXT, is_matched INT);
|
||||
CREATE TABLE email_investor_links (id TEXT, email_id TEXT, fundraising_investor_id TEXT);
|
||||
""")
|
||||
c.execute("INSERT INTO fundraising_investors VALUES ('inv1','Harbor & Vine','Met at the conference; interested in Fund III.')")
|
||||
c.executemany("INSERT INTO emails (id,subject,body_text,sent_at,is_matched) VALUES (?,?,?,?,1)", [
|
||||
("e1", "Re: Fund III", "Thanks for the call. We are still weighing the lock-up terms.", "2026-06-02T10:00:00"),
|
||||
("e2", "Intro", "Good to meet you at the dinner.", "2026-05-01T10:00:00"),
|
||||
("e3", "Spam", "ignore me", "2026-04-01T10:00:00"), # not linked -> excluded
|
||||
c.executemany("INSERT INTO emails (id,subject,body_text,sent_at,thread_id,is_matched) VALUES (?,?,?,?,?,1)", [
|
||||
("e1", "Re: Fund III", "Thanks for the call. We are still weighing the lock-up terms.", "2026-06-02T10:00:00", "t1"),
|
||||
("e2", "Intro", "Good to meet you at the dinner.", "2026-05-01T10:00:00", "t0"),
|
||||
("e3", "Spam", "ignore me", "2026-04-01T10:00:00", "t9"), # not linked -> excluded
|
||||
])
|
||||
c.executemany("INSERT INTO email_investor_links (id,email_id,fundraising_investor_id) VALUES (?,?, 'inv1')",
|
||||
[("l1", "e1"), ("l2", "e2")])
|
||||
@@ -43,10 +44,23 @@ def main():
|
||||
name, ctx = oa._context(c, "inv1")
|
||||
check(name == "Harbor & Vine", f"resolves investor name (got {name!r})")
|
||||
check("Met at the conference" in ctx, "includes CRM notes")
|
||||
check("lock-up terms" in ctx, "includes matched email body")
|
||||
check("Good to meet you" in ctx, "includes a second matched email")
|
||||
check("lock-up terms" in ctx, "active-thread email present")
|
||||
check("Good to meet you" in ctx, "earlier email present as background")
|
||||
check("ignore me" not in ctx, "excludes email not linked to this investor")
|
||||
check(ctx.index("lock-up terms") < ctx.index("Good to meet you"), "newest email first")
|
||||
check("Active conversation" in ctx and "Earlier emails" in ctx
|
||||
and ctx.index("lock-up terms") < ctx.index("Good to meet you"),
|
||||
"active thread is separated from background, active first")
|
||||
|
||||
# voice examples: the sender's own sent emails (few-shot + transparency)
|
||||
c.execute("INSERT INTO emails (id,subject,body_text,sent_at,from_email,to_emails_json,thread_id,is_matched) "
|
||||
"VALUES ('v1','My note','Hi there, quick update on the fund. Best, Grant',"
|
||||
"'2026-06-01T10:00:00','grant@ten31.xyz','[{\"email\":\"lp@x.example\"}]','tv',1)")
|
||||
c.commit()
|
||||
blocks, meta = oa._voice_examples(c, "grant@ten31.xyz")
|
||||
check(len(blocks) == 1 and "quick update on the fund" in blocks[0], "voice example pulls the sender's own email")
|
||||
check(len(meta) == 1 and meta[0]["subject"] == "My note" and meta[0]["to"] == "lp@x.example",
|
||||
"voice meta carries subject + recipient for transparency")
|
||||
check(oa._voice_examples(c, None) == ([], []), "no sender -> no voice examples")
|
||||
|
||||
n2, c2 = oa._context(c, "missing")
|
||||
check(n2 is None and c2 is None, "unknown investor -> (None, None)")
|
||||
|
||||
+7
-1
@@ -3948,8 +3948,14 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
return self.send_error_json("investor_id required", 400)
|
||||
conn = get_db()
|
||||
try:
|
||||
sender_email = None
|
||||
try:
|
||||
r = conn.execute("SELECT email FROM users WHERE id=?", (user.get('user_id'),)).fetchone()
|
||||
sender_email = r[0] if r else None
|
||||
except Exception:
|
||||
pass
|
||||
res = _outreach_agent.draft_outreach(conn, inv, body.get('outreach_type', 'follow_up'),
|
||||
body.get('guidance', '') or '', DB_PATH)
|
||||
body.get('guidance', '') or '', DB_PATH, sender_email=sender_email)
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO interaction_log (id, ts, actor_type, actor_id, action, target_type, target_id, payload, source, created_at) "
|
||||
|
||||
+10
-2
@@ -10071,7 +10071,7 @@
|
||||
value={guidance} onChange={(e) => setGuidance(e.target.value)} />
|
||||
</div>
|
||||
<div className="index-action-buttons">
|
||||
<button onClick={draft} disabled={drafting || !investorId}>
|
||||
<button onClick={() => draft()} disabled={drafting || !investorId}>
|
||||
{drafting ? 'Drafting… (this can take a moment)' : 'Draft outreach'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -10093,7 +10093,15 @@
|
||||
<div className="index-action-buttons" style={{ marginTop: '10px' }}>
|
||||
<button onClick={copy}>Copy draft</button>
|
||||
</div>
|
||||
<div className="index-action-hint" style={{ marginTop: '10px' }}>
|
||||
<div className="index-action-hint" style={{ marginTop: '12px' }}>
|
||||
<strong>Voice based on:</strong>{' '}
|
||||
{result.voice_examples && result.voice_examples.length > 0
|
||||
? <>your codified rules + {result.voice_examples.length} of your prior emails — {result.voice_examples.map((v, i) => (
|
||||
<span key={i}>{i > 0 ? '; ' : ''}"{v.subject}"{v.date ? ` (${v.date})` : ''}</span>
|
||||
))}</>
|
||||
: 'your codified voice rules only (no prior emails of yours were found to learn from yet)'}
|
||||
</div>
|
||||
<div className="index-action-hint" style={{ marginTop: '6px' }}>
|
||||
Review and edit before sending. Nothing is sent automatically.
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -34,8 +34,9 @@ export const PACKAGE_TITLE = 'Ten31 Database'
|
||||
// * 0.1.0:66 (LP Objections page: UI trigger for the Architect grounding pass)
|
||||
// * 0.1.0:67 (remove LP Objections page — generic/unverifiable; pivot to proactive outreach)
|
||||
// * 0.1.0:68 (Outreach Draft Assistant — tailored LP drafts via thesis + redaction boundary)
|
||||
// * Current: 0.1.0:69 (follow-up radar — deterministic "needs attention" list + one-click draft)
|
||||
export const PACKAGE_VERSION = '0.1.0:69'
|
||||
// * 0.1.0:69 (follow-up radar — deterministic "needs attention" list + one-click draft)
|
||||
// * Current: 0.1.0:70 (outreach voice upgrade — per-user voice from own emails + transparency; active-thread context)
|
||||
export const PACKAGE_VERSION = '0.1.0:70'
|
||||
|
||||
export const DATA_MOUNT_PATH = '/data'
|
||||
export const WEB_PORT = 8080
|
||||
|
||||
@@ -30,8 +30,9 @@ import { v_0_1_0_66 } from './v0.1.0.66'
|
||||
import { v_0_1_0_67 } from './v0.1.0.67'
|
||||
import { v_0_1_0_68 } from './v0.1.0.68'
|
||||
import { v_0_1_0_69 } from './v0.1.0.69'
|
||||
import { v_0_1_0_70 } from './v0.1.0.70'
|
||||
|
||||
export const versionGraph = VersionGraph.of({
|
||||
current: v_0_1_0_69,
|
||||
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68],
|
||||
current: v_0_1_0_70,
|
||||
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
// Outreach voice upgrade. Drafts now learn each sender's own voice: the codified rules
|
||||
// plus a few-shot of that user's recent sent emails (de-identified), and the result
|
||||
// lists which of their emails were used (transparency — no black box). The recipient
|
||||
// context is restructured around the active conversation (the most recent email thread
|
||||
// is what you reply to; earlier emails are background). No schema change.
|
||||
export const v_0_1_0_70 = VersionInfo.of({
|
||||
version: '0.1.0:70',
|
||||
releaseNotes: {
|
||||
en_US: [
|
||||
'Outreach drafts now sound like you: each user’s voice is learned from their own prior',
|
||||
'emails (plus the rules), and the draft shows exactly which of your emails it used. It',
|
||||
'also focuses on the most recent thread as the active conversation, with earlier emails',
|
||||
'as background.',
|
||||
].join(' '),
|
||||
},
|
||||
migrations: { up: async () => {}, down: async () => {} },
|
||||
})
|
||||
Reference in New Issue
Block a user