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", () => {