From a716f9143b415ac180c9e4f0c07e49138e16b51e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 28 Jul 2026 16:56:38 +0000 Subject: [PATCH] feat(leads): intent qualification on by default, at a measured bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Writing to people who never asked for anything is the failure mode the whole feature exists to avoid, so it should be what somebody turns off deliberately rather than what they remember to turn on. New campaigns start with it on, and existing ones are moved to the same bar rather than being left selecting on resemblance forever. Choosing the number exposed a worse one behind it. The 36-hour half-life was invented rather than derived, and at that decay a day-old "can anyone recommend a X" scored 44 against 40 for a fresh unattributed grumble — four points between the best kind of lead and no lead at all. Those threads stay answerable for days; replying to a three-day-old request costs little and missing it costs the lead. The half-life is now 72 hours, matching the window the Reddit scorer already treats as the edge of repliable rather than a second number chosen alongside it. With that fixed the spread separates properly and the bar can come off measurement instead of intuition: 97 purchase, fresh 94 evaluating vendors 77 solicitation from a team lead 66 solicitation, fresh 56 solicitation, a day old 54 "our team is struggling with X" --- 50, the bar 42 a fresh complaint from nobody in particular 35 solicitation, three days old 28 complaint, two days old Real requests stay in for about three days and complaints from strangers never get in, which is the distinction the feature was asked for. Co-Authored-By: Claude Opus 5 (1M context) --- components/leads/campaign-panel.tsx | 10 ++++--- lib/outreach/intent.ts | 26 ++++++++++++++--- .../20260729070000_intent_on_by_default.sql | 18 ++++++++++++ tests/intent.test.ts | 28 ++++++++++++++++--- 4 files changed, 70 insertions(+), 12 deletions(-) create mode 100644 supabase/migrations/20260729070000_intent_on_by_default.sql diff --git a/components/leads/campaign-panel.tsx b/components/leads/campaign-panel.tsx index 774cace4..e7d089d3 100644 --- a/components/leads/campaign-panel.tsx +++ b/components/leads/campaign-panel.tsx @@ -9,6 +9,7 @@ import { toggleCampaignAction, } from "@/app/actions/leads"; import { LEAD_RUN_CREDITS } from "@/lib/credits"; +import { DEFAULT_MIN_INTENT } from "@/lib/outreach/intent"; export type CampaignRun = { ran_at: string; @@ -84,9 +85,10 @@ export function CampaignPanel({ // scan of the prospect to contribute. Selecting the audit pitch turns it // back on, because that pitch is built from findings. const [scanProspects, setScanProspects] = useState(false); - // Null means "don't qualify on intent" — the behaviour every campaign had - // before this existed, and the default for anything already running. - const [minIntent, setMinIntent] = useState(null); + // On by default for a new campaign. Writing to people who never asked is + // the failure mode the whole feature exists to avoid, so it should be what + // you turn off deliberately rather than what you remember to turn on. + const [minIntent, setMinIntent] = useState(DEFAULT_MIN_INTENT); const [goal, setGoal] = useState(""); const [generating, setGenerating] = useState(false); const [pitchNote, setPitchNote] = useState(null); @@ -503,7 +505,7 @@ export function CampaignPanel({ type="checkbox" className="mt-1" checked={minIntent !== null} - onChange={(e) => setMinIntent(e.target.checked ? 40 : null)} + onChange={(e) => setMinIntent(e.target.checked ? DEFAULT_MIN_INTENT : null)} /> Only work leads who publicly asked to buy diff --git a/lib/outreach/intent.ts b/lib/outreach/intent.ts index 4ba0bab7..11be7536 100644 --- a/lib/outreach/intent.ts +++ b/lib/outreach/intent.ts @@ -18,8 +18,17 @@ // The adapters live in intentSources.ts; this file knows nothing about where // the words came from. -/** Hours after which a signal has decayed to roughly half its strength. */ -const HALF_LIFE_HOURS = 36; +/** + * Hours after which a signal has decayed to roughly half its strength. + * + * Matches the 72-hour window the Reddit scorer already treats as the edge of + * repliable, rather than a number chosen independently. The first value here + * was 36, which priced a day-old "can anyone recommend a X" at barely more + * than a fresh unattributed grumble — and those threads stay answerable for + * days. Replying to a three-day-old request costs little; missing it costs a + * lead. + */ +const HALF_LIFE_HOURS = 72; /** Past this, treat a signal as stale regardless of how strong it was. */ const MAX_AGE_HOURS = 24 * 14; @@ -340,8 +349,17 @@ export function scoreIntent(input: { }; } -/** Default bar for acting on a signal. Roughly "a fresh solicitation". */ -export const DEFAULT_MIN_INTENT = 40; +/** + * Default bar for acting on a signal. + * + * Chosen from the measured spread rather than picked round. At 72-hour + * half-life a day-old "can anyone recommend a X" scores 56 and a fresh + * unattributed grumble scores 42, and 50 sits in the gap between them: real + * requests stay in for about three days, complaints from nobody in particular + * never get in. A team lead saying "our team is struggling with X" clears it + * at 54, because that is a lead and a stranger's grumble is not. + */ +export const DEFAULT_MIN_INTENT = 50; /** * Whether a lead clears a campaign's bar. diff --git a/supabase/migrations/20260729070000_intent_on_by_default.sql b/supabase/migrations/20260729070000_intent_on_by_default.sql new file mode 100644 index 00000000..c5745cb8 --- /dev/null +++ b/supabase/migrations/20260729070000_intent_on_by_default.sql @@ -0,0 +1,18 @@ +-- Intent qualification on by default. +-- +-- Writing to people who never asked for anything is the failure mode the +-- feature exists to avoid, so it should be what somebody turns off +-- deliberately rather than what they remember to turn on. +-- +-- 50 is taken from the measured spread rather than picked round: at a 72-hour +-- half-life a day-old "can anyone recommend a X" scores 56 and a fresh +-- unattributed grumble scores 42, so the bar sits in the gap between a request +-- and a complaint. +alter table public.outreach_campaigns + alter column min_intent set default 50; + +-- Existing campaigns were created before the column existed and would +-- otherwise keep selecting purely on resemblance forever. +update public.outreach_campaigns +set min_intent = 50 +where min_intent is null; diff --git a/tests/intent.test.ts b/tests/intent.test.ts index 01821a7d..90763e7a 100644 --- a/tests/intent.test.ts +++ b/tests/intent.test.ts @@ -52,8 +52,10 @@ describe("recency is half the signal", () => { }); it("halves at the half-life", () => { - expect(recencyFactor(36)).toBeCloseTo(0.5, 2); - expect(recencyFactor(72)).toBeCloseTo(0.25, 2); + // 72h, matching the window the Reddit scorer already treats as the edge + // of repliable rather than a second number invented alongside it. + expect(recencyFactor(72)).toBeCloseTo(0.5, 2); + expect(recencyFactor(144)).toBeCloseTo(0.25, 2); }); it("gives up entirely on something ancient", () => { @@ -124,8 +126,26 @@ describe("qualifying against a campaign's bar", () => { expect(qualifies(weak, undefined)).toBe(true); }); - it("holds a weak signal below the bar", () => { - expect(qualifies(score("Struggling with load testing", 24 * 6), DEFAULT_MIN_INTENT)).toBe(false); + it("holds a bare complaint below the bar, however fresh", () => { + // The bar sits in the measured gap between "somebody asked for something" + // and "somebody grumbled": a fresh unattributed complaint scores 42. + expect(qualifies(score("Struggling with load testing", 1), DEFAULT_MIN_INTENT)).toBe(false); + }); + + it("keeps a day-old request in", () => { + // These threads stay answerable for days, and losing them to an + // over-eager decay curve is what the 72h half-life fixed. + expect(qualifies(score("Can anyone recommend a load testing tool?", 24), DEFAULT_MIN_INTENT)).toBe(true); + }); + + it("lets a team lead's complaint through where a stranger's does not", () => { + // "Our team is struggling with X" is a lead. The same words from nobody + // in particular are not. + expect(qualifies(score("Our team is struggling with load testing.", 6), DEFAULT_MIN_INTENT)).toBe(true); + }); + + it("drops a request that has gone stale", () => { + expect(qualifies(score("Can anyone recommend a load testing tool?", 24 * 5), DEFAULT_MIN_INTENT)).toBe(false); }); it("passes a fresh explicit request", () => {