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
24 changes: 24 additions & 0 deletions app/actions/leads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { addSuppression } from "@/lib/outreach/suppress";
import { loadAddressSettings } from "@/lib/outreach/postalAddress";
import { discoverProspects } from "@/lib/outreach/discover";
import { upsertContact } from "@/lib/outreach/contacts";
import { generatePitch } from "@/lib/outreach/generatePitch";
import { runEmailCampaignTick, CAMPAIGN_COLUMNS, summarize, type CampaignRow } from "@/lib/outreach/runner";

type Ok<T = Record<string, never>> = { ok: true } & T;
Expand Down Expand Up @@ -160,6 +161,29 @@ export async function findLeadsAction(input: {
* only ever report sends, and a reply rate that is structurally zero is
* worse than no reply rate at all.
*/
/**
* Turn a rough goal into the three pitch fields.
*
* The form asks for precisely-scoped inputs and the grounding guard refuses
* drafts that stray outside them, which is a lot to ask of someone who just
* wants to describe what they are doing. This does the splitting, and
* deliberately does not author: a fact invented here is one the guard will
* pass straight through to a stranger, because drafts are checked against
* this output rather than against reality.
*/
export async function generatePitchAction(input: {
projectId: string;
goal: string;
senderName?: string;
}): Promise<Ok<{ intro: string; ask: string; facts: string[] }> | Err> {
const auth = await requireLeadAccess(input.projectId);
if (!auth.ok) return auth;

const res = await generatePitch({ goal: input.goal, senderName: input.senderName });
if (!res.ok) return { ok: false, error: res.error };
return { ok: true, ...res.pitch };
}

export async function markLeadOutcomeAction(input: {
projectId: string;
host: string;
Expand Down
56 changes: 56 additions & 0 deletions components/leads/campaign-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
generatePitchAction,
runCampaignAction,
saveCampaignAction,
toggleCampaignAction,
Expand Down Expand Up @@ -81,6 +82,9 @@ 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);
const [goal, setGoal] = useState("");
const [generating, setGenerating] = useState(false);
const [pitchNote, setPitchNote] = useState<string | null>(null);
const [senderName, setSenderName] = useState("");
const [replyTo, setReplyTo] = useState("");

Expand All @@ -95,6 +99,30 @@ export function CampaignPanel({
} else setError(res.error);
});

// Fills the three pitch fields from a plain description of the goal.
// Writing an intro, a single ask and a list of checkable claims is a
// precise task, and the grounding guard refuses drafts that stray outside
// them — which punishes anyone who describes their goal the way people
// actually describe goals.
const generate = () =>
start(async () => {
setPitchNote(null);
setError(null);
setGenerating(true);
const res = await generatePitchAction({ projectId, goal, senderName });
setGenerating(false);
if (!res.ok) {
setError(res.error);
return;
}
setPitchIntro(res.intro);
setPitchAsk(res.ask);
setPitchFacts(res.facts.join("\n"));
setPitchMode("custom");
setScanProspects(false);
setPitchNote("Filled in below — read it before saving. Nothing was invented, but check it says what you meant.");
});

// Runs one campaign-row action: marks that button busy, then reports the
// outcome inline next to the campaign it belongs to.
const rowAct = (
Expand Down Expand Up @@ -398,6 +426,34 @@ export function CampaignPanel({

{pitchMode === "custom" && (
<>
<div className="rounded border border-[var(--color-border)] p-3 sm:col-span-2">
<label className="text-sm">
Describe what you&apos;re trying to do
<textarea
className="input mt-1 w-full"
rows={2}
value={goal}
onChange={(e) => setGoal(e.target.value)}
placeholder="I own threatcrush.com and want people to download our free whitepaper at https://threatcrush.com/get-whitepaper"
/>
</label>
<div className="mt-2 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={generate}
disabled={pending || goal.trim().length < 15}
className="btn text-sm"
>
{generating ? "Generating…" : "Generate with AI"}
</button>
<span className="text-xs text-[var(--color-muted)]">
Fills the three fields below. It only rearranges what you wrote — it will not
invent claims, because anything it adds gets stated to strangers as true.
</span>
</div>
{pitchNote && <p className="mt-2 text-xs">{pitchNote}</p>}
</div>

<label className="text-sm sm:col-span-2">
Who is writing, and why
<textarea
Expand Down
4 changes: 2 additions & 2 deletions lib/outreach/cold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,10 +433,10 @@ export function unsupportedCustomClaims(body: string, declaredText: string[]): s
const value = Number(raw);
if (!Number.isFinite(value) || value <= 3) continue;
const appears = declared.includes(raw) || declared.includes(m[1].toLowerCase());
if (!appears) problems.push(`states "${m[0].trim()}", which is not in the campaign's declared facts`);
if (!appears) problems.push(`states "${m[0].trim()}", which the campaign never mentions`);
}

// Links have to come from the declared facts too: a made-up portfolio URL
// Links must be traceable too: a made-up portfolio URL
// is both a false claim and a broken promise.
for (const m of body.matchAll(/https?:\/\/[^\s)>\]]+/gi)) {
const url = m[0].replace(/[.,]$/, "");
Expand Down
131 changes: 131 additions & 0 deletions lib/outreach/generatePitch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Turn a rough description of what someone is trying to do into a pitch the
// drafting prompt and the grounding guard can both work with.
//
// The campaign form asks for three precisely-scoped fields — who is writing,
// the single ask, and the checkable claims — and then refuses drafts that
// stray outside them. Someone with a goal in their head does not naturally
// write in that shape: they type the whole thing into every box, which is
// how a live campaign ended up with an ask that was really an intro and a
// facts list that omitted the URL the ask depended on.
//
// So this does the splitting. It is restructuring, not authoring: nothing may
// be added that the person did not say or that their own site does not say
// about itself. A fact invented here is a fact the guard will happily wave
// through into a stranger's inbox, because the guard checks drafts against
// this output — it cannot check this output against reality.

import { z } from "zod/v4";
import { env } from "@/lib/env";
import { aiClients } from "./pipeline";
import { generateStructuredOutput } from "@/lib/lx/backendAi";
import { loadRecipientContext } from "./recipientContext";

const PitchSchema = z.object({
intro: z
.string()
.describe("Who is writing and why, in the first person. One or two sentences."),
ask: z.string().describe("The single, small thing the recipient is being asked to do."),
facts: z
.array(z.string())
.describe("Checkable claims the email is allowed to state. One claim per entry."),
});

export type GeneratedPitch = z.infer<typeof PitchSchema>;

const SYSTEM = `You turn a rough description of an outreach goal into three fields for a cold-email campaign.

You are restructuring what you are given, not writing new material. This is the whole job.

intro — who is writing and why they are writing, in the first person. One or two sentences.

ask — the single small thing the recipient is asked to do. If the description names a URL, the ask must contain that URL exactly as written, because the email is expected to include it.

facts — the checkable claims the email may state, one per entry. Copy them from the description. Include every URL, number, duration and product name the description mentions, each as its own entry, because a later check refuses any draft that states something absent from this list.

Hard rules:
1. Invent nothing. No numbers, dates, durations, links, company names, credentials or benefits that are not in the material you were given. An invented fact here will be repeated to strangers as true.
2. Do not embellish a claim into a stronger one. "30 years as a software engineer" does not become "three decades of industry leadership".
3. If the description is vague about what is offered, leave facts short. A short honest list is worth more than a long invented one.
4. Do not write the email. These are inputs to it.
5. Plain language. No marketing register.`;

/**
* Build a pitch from a free-text goal.
*
* The sender's own site is read when its domain appears in the goal, because
* a company's own description of itself is the one extra fact available
* without guessing — and it is exactly the material someone leaves out when
* describing their goal in a hurry.
*/
export async function generatePitch(input: {
goal: string;
senderName?: string | null;
}): Promise<{ ok: true; pitch: GeneratedPitch } | { ok: false; error: string }> {
const goal = input.goal.trim();
if (goal.length < 15) {
return { ok: false, error: "Say a little more about what you're trying to do." };
}

const { anthropic, openai } = aiClients();
if (!anthropic && !openai) {
return { ok: false, error: "No AI provider is configured." };
}

// A domain in the goal is nearly always the sender's own.
const domain = goal.match(/\b([a-z0-9-]+\.[a-z]{2,})\b/i)?.[1] ?? null;
const senderContext = domain ? await loadRecipientContext(domain) : null;

const user = [
"GOAL, in their own words:",
goal,
"",
senderContext
? `Their own site (${domain}) describes itself as: "${senderContext.selfDescription}". You may use this as a fact. Nothing else about them is known.`
: "Nothing further is known about them beyond the goal above.",
input.senderName ? `They sign off as: ${input.senderName}` : "",
]
.filter(Boolean)
.join("\n");

try {
const res = await generateStructuredOutput({
name: "campaign_pitch",
schema: PitchSchema,
system: SYSTEM,
user,
anthropic,
openai,
preference: env.backendAiProvider,
anthropicModel: "claude-haiku-4-5-20251001",
openaiModel: env.backendAiOpenaiModel,
maxTokens: 900,
anthropicEffort: false,
});

const pitch = res.output;
const facts = pitch.facts.map((f) => f.trim()).filter(Boolean);

// Any URL the person wrote has to survive into the facts, or the guard
// will reject every draft that uses it — which is the exact failure this
// generator exists to prevent, and the model drops them often enough
// that it is not worth trusting.
for (const url of goal.match(/https?:\/\/[^\s<>"')]+/g) ?? []) {
if (!facts.some((f) => f.includes(url))) facts.push(url);
}

if (!facts.length) {
return {
ok: false,
error:
"Couldn't find anything checkable to say. Add a concrete detail — what you offer, a link, or your background.",
};
}

return { ok: true, pitch: { intro: pitch.intro.trim(), ask: pitch.ask.trim(), facts } };
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message.slice(0, 200) : "Generation failed.",
};
}
}
Loading