Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions components/leads/campaign-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<number | null>(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<number | null>(DEFAULT_MIN_INTENT);
const [goal, setGoal] = useState("");
const [generating, setGenerating] = useState(false);
const [pitchNote, setPitchNote] = useState<string | null>(null);
Expand Down Expand Up @@ -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)}
/>
<span>
Only work leads who publicly asked to buy
Expand Down
26 changes: 22 additions & 4 deletions lib/outreach/intent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions supabase/migrations/20260729070000_intent_on_by_default.sql
Original file line number Diff line number Diff line change
@@ -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;
28 changes: 24 additions & 4 deletions tests/intent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading