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:
@@ -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)")
|
||||
|
||||
Reference in New Issue
Block a user