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
42 changes: 42 additions & 0 deletions app/actions/leads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,11 @@ export async function saveCampaignAction(input: {
angle?: string;
senderName?: string;
replyTo?: string;
pitchMode?: "audit" | "custom";
pitchIntro?: string;
pitchAsk?: string;
pitchFacts?: string;
scanProspects?: boolean;
}): Promise<Ok<{ note: string }> | Err> {
const auth = await requireLeadAccess(input.projectId);
if (!auth.ok) return auth;
Expand All @@ -412,6 +417,38 @@ export async function saveCampaignAction(input: {
if (!queries.length && !seedUrls.length) {
return { ok: false, error: "A campaign needs at least one search query or directory URL." };
}
const pitchMode = input.pitchMode ?? "audit";
const pitchFacts = (input.pitchFacts ?? "")
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
if (pitchMode === "custom") {
// The same bar the audit pitch holds itself to: without a description
// and some declared facts there is nothing truthful for a draft to say,
// and the grounding guard would reject every draft anyway.
if (!input.pitchIntro?.trim()) {
return { ok: false, error: "A custom pitch needs a description of who is writing and why." };
}
if (!pitchFacts.length) {
return {
ok: false,
error:
"A custom pitch needs at least one fact. Drafts may only state what you list here, so an empty list means every draft gets rejected.",
};
}
}

// Scanning is the audit pitch's evidence step; default it off for a custom
// pitch rather than scanning people we're emailing about something else.
const scanProspects = input.scanProspects ?? pitchMode === "audit";
if (pitchMode === "audit" && !scanProspects) {
return {
ok: false,
error:
"The audit pitch is built from scan findings, so it can't run with scanning turned off. Switch to a custom pitch instead.",
};
}

if (input.autoSend) {
const postal = await loadAddressSettings({ projectId: input.projectId, ownerId: auth.userId });
if (!postal.address) {
Expand Down Expand Up @@ -440,6 +477,11 @@ export async function saveCampaignAction(input: {
angle: input.angle?.trim() || null,
sender_name: input.senderName?.trim() || null,
reply_to: input.replyTo?.trim() || null,
pitch_mode: pitchMode,
pitch_intro: input.pitchIntro?.trim() || null,
pitch_ask: input.pitchAsk?.trim() || null,
pitch_facts: pitchFacts,
scan_prospects: scanProspects,
},
{ onConflict: "project_id,name" },
);
Expand Down
84 changes: 84 additions & 0 deletions components/leads/campaign-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ export function CampaignPanel({
const [maxScore, setMaxScore] = useState(70);
const [dailyLimit, setDailyLimit] = useState(10);
const [angle, setAngle] = useState("");
const [pitchMode, setPitchMode] = useState<"audit" | "custom">("audit");
const [pitchIntro, setPitchIntro] = useState("");
const [pitchAsk, setPitchAsk] = useState("");
const [pitchFacts, setPitchFacts] = useState("");
const [scanProspects, setScanProspects] = useState(true);
const [senderName, setSenderName] = useState("");
const [replyTo, setReplyTo] = useState("");

Expand Down Expand Up @@ -75,6 +80,11 @@ export function CampaignPanel({
angle,
senderName,
replyTo,
pitchMode,
pitchIntro,
pitchAsk,
pitchFacts,
scanProspects,
}),
);

Expand Down Expand Up @@ -215,6 +225,80 @@ export function CampaignPanel({
onChange={(e) => setDailyLimit(Number(e.target.value))}
/>
</label>
<label className="text-sm sm:col-span-2">
What is this campaign pitching?
<select
className="input mt-1 w-full"
value={pitchMode}
onChange={(e) => {
const next = e.target.value as "audit" | "custom";
setPitchMode(next);
// Scanning is the audit pitch's evidence step. A custom pitch
// has no reason to scan the people it writes to.
setScanProspects(next === "audit");
}}
>
<option value="audit">A CrawlProof scan of their site</option>
<option value="custom">Something else — I&apos;ll describe it</option>
</select>
</label>

{pitchMode === "custom" && (
<>
<label className="text-sm sm:col-span-2">
Who is writing, and why
<textarea
className="input mt-1 w-full"
rows={2}
value={pitchIntro}
onChange={(e) => setPitchIntro(e.target.value)}
placeholder="Anthony, a freelance 3D modeller looking for contract work with game studios"
/>
</label>
<label className="text-sm sm:col-span-2">
Facts drafts may state — one per line
<textarea
className="input mt-1 w-full font-mono text-xs"
rows={4}
value={pitchFacts}
onChange={(e) => setPitchFacts(e.target.value)}
placeholder={"9 years of experience in hard-surface modelling\nPortfolio at https://example.com/work\nAvailable from March"}
/>
<span className="mt-1 block text-xs text-[var(--color-muted)]">
Drafts are checked against this list and rejected if they state anything else.
Numbers and links that don&apos;t appear here are treated as invented.
</span>
</label>
<label className="text-sm sm:col-span-2">
The one ask
<input
className="input mt-1 w-full"
value={pitchAsk}
onChange={(e) => setPitchAsk(e.target.value)}
placeholder="take a look at my portfolio"
/>
</label>
</>
)}

<label className="flex items-start gap-2 text-sm sm:col-span-2">
<input
type="checkbox"
className="mt-1"
checked={scanProspects}
onChange={(e) => setScanProspects(e.target.checked)}
disabled={pitchMode === "audit"}
/>
<span>
Scan each discovered lead
<span className="mt-0.5 block text-xs text-[var(--color-muted)]">
{pitchMode === "audit"
? "Required for the audit pitch — the email is built from what the scan finds."
: "Off by default: this campaign isn't pitching an audit, so there's nothing to scan for."}
</span>
</span>
</label>

<label className="text-sm sm:col-span-2">
Angle (optional)
<input
Expand Down
55 changes: 55 additions & 0 deletions lib/outreach/cold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,61 @@ export function unsupportedClaims(body: string, facts: ProspectFacts): string[]
return problems;
}

/**
* Grounding guard for a custom pitch.
*
* The audit pitch earns its claims from a scan. A custom pitch has no scan,
* so the campaign declares what it is allowed to say and this checks the
* draft against that list. Dropping the guard entirely for custom campaigns
* would turn the same machinery into something that states invented facts
* about strangers, at volume, over email.
*
* The check is deliberately narrow. Proving a sentence is entailed by a fact
* list is not something a regex can do, and pretending otherwise would give
* false confidence. What it catches is the specific, checkable species of
* fabrication: numbers and durations that appear nowhere in the declared
* facts, links to places the campaign never mentioned, and claimed prior
* contact. Tone and phrasing are the prompt's job.
*/
export function unsupportedCustomClaims(body: string, facts: string[]): string[] {
const problems: string[] = [];
const lower = body.toLowerCase();
const declared = facts.join(" \n ").toLowerCase();

// A number in the body that appears in no declared fact is invented —
// years of experience, team sizes, prices. Small integers are excluded
// because they are usually prose ("one thing", "a couple of weeks").
for (const m of body.matchAll(/\b(\d[\d,]*(?:\.\d+)?)\s*(%|\+)?/g)) {
const raw = m[1].replace(/,/g, "");
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`);
}

// Links have to come from the declared facts 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(/[.,]$/, "");
if (!declared.includes(url.toLowerCase())) {
problems.push(`links to ${url}, which is not in the campaign's declared facts`);
}
}

for (const phrase of [
"as we discussed",
"following up on our call",
"great speaking with you",
"as promised",
"per our conversation",
"thanks for your time yesterday",
]) {
if (lower.includes(phrase)) problems.push(`implies a prior relationship: "${phrase}"`);
}

return [...new Set(problems)];
}

// ------------------------------------------------- obfuscation & link crawl

/**
Expand Down
Loading
Loading