Skip to content

Commit 8e75bf8

Browse files
ralyodioclaude
andauthored
fix(leads): a link ends where its URL characters end (#156)
The live draft read "...at https://threatcrush.com/get-whitepaper—download takes a minute", and the guard extracted the URL as "https://threatcrush.com/get-whitepaper—download" — matched it against the campaign, found nothing, and rejected the draft for linking somewhere invented. The campaign had declared that URL. The em-dash was prose. Matching "anything up to whitespace" is what did it. Non-ASCII cannot appear in a URL without being percent-encoded, so the dash was never part of the link; the pattern now matches only what RFC 3986 permits, and trailing sentence punctuation is stripped rather than only a full stop or a comma. The draft is also repaired rather than merely re-checked. A URL welded to an em-dash is one some mail clients will autolink including the dash, which is a 404 on the single thing the email asked the recipient to click — so a space is inserted, and the tidied body is what gets sent. Checking a repaired draft and then sending the broken original would have been worse than not repairing it. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 53cceba commit 8e75bf8

3 files changed

Lines changed: 97 additions & 5 deletions

File tree

‎lib/outreach/cold.ts‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,44 @@ export function unsupportedClaims(body: string, facts: ProspectFacts): string[]
414414
* facts, links to places the campaign never mentioned, and claimed prior
415415
* contact. Tone and phrasing are the prompt's job.
416416
*/
417+
/**
418+
* A URL as it appears inside prose.
419+
*
420+
* Restricted to the characters RFC 3986 actually permits. Matching "anything
421+
* up to whitespace" swallows whatever punctuation the writer put immediately
422+
* after the link: a draft reading "...at https://x.test/paper—it takes two
423+
* minutes" yielded the URL "https://x.test/paper—it", which matched nothing
424+
* the campaign declared and got the draft rejected for linking somewhere
425+
* invented. Non-ASCII cannot appear in a URL unencoded, so the dash is the
426+
* end of the link by definition.
427+
*/
428+
export const URL_IN_TEXT = /https?:\/\/[A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=%]+/g;
429+
430+
/** Punctuation that ends a sentence rather than belonging to the link. */
431+
const TRAILING_PUNCT = /[.,;:!?'")\]]+$/;
432+
433+
/** The URLs a piece of text actually links to. */
434+
export function urlsIn(text: string): string[] {
435+
return [...text.matchAll(URL_IN_TEXT)].map((m) => m[0].replace(TRAILING_PUNCT, ""));
436+
}
437+
438+
/**
439+
* Put a space between a link and whatever is jammed against it.
440+
*
441+
* Belt and braces for the same draft. Even with the check corrected, a URL
442+
* butted directly against an em-dash is a link some mail clients will
443+
* autolink *including* the dash — so the recipient gets a 404 on the one
444+
* thing the email asked them to click.
445+
*/
446+
export function separateUrlPunctuation(body: string): string {
447+
return body.replace(URL_IN_TEXT, (url, offset: number, whole: string) => {
448+
const next = whole[offset + url.length];
449+
// Only when something non-ASCII is touching the end of the link. A space,
450+
// a full stop or the end of the string are all already fine.
451+
return next && next.charCodeAt(0) > 127 ? `${url} ` : url;
452+
});
453+
}
454+
417455
export function unsupportedCustomClaims(body: string, declaredText: string[]): string[] {
418456
const problems: string[] = [];
419457
const lower = body.toLowerCase();
@@ -438,8 +476,7 @@ export function unsupportedCustomClaims(body: string, declaredText: string[]): s
438476

439477
// Links must be traceable too: a made-up portfolio URL
440478
// is both a false claim and a broken promise.
441-
for (const m of body.matchAll(/https?:\/\/[^\s)>\]]+/gi)) {
442-
const url = m[0].replace(/[.,]$/, "");
479+
for (const url of urlsIn(body)) {
443480
if (!declared.includes(url.toLowerCase())) {
444481
problems.push(`links to ${url}, which the campaign never mentions`);
445482
}

‎lib/outreach/pipeline.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
unsupportedClaims,
4040
roleAddressGuesses,
4141
unsupportedCustomClaims,
42+
separateUrlPunctuation,
4243
type ContactCandidate,
4344
type OutreachStep,
4445
type ProspectFacts,
@@ -841,7 +842,11 @@ async function draftCustomEmail(input: {
841842
// draft to them for inventing it — the campaign, quite correctly, had never
842843
// mentioned a four. Naming who you are writing to is not a claim about
843844
// them; it is the minimum a personalised email does.
844-
const problems = unsupportedCustomClaims(output.body, [
845+
// Applied before the check, and kept, so the recipient gets the tidied
846+
// version rather than a link with punctuation welded to its end.
847+
const body = separateUrlPunctuation(output.body);
848+
849+
const problems = unsupportedCustomClaims(body, [
845850
...facts,
846851
input.pitch.intro,
847852
input.pitch.ask ?? "",
@@ -857,7 +862,9 @@ async function draftCustomEmail(input: {
857862
return {
858863
ok: true,
859864
subject,
860-
body: output.body.trim(),
865+
// The tidied body, not the raw one — checking a repaired draft and then
866+
// sending the broken original would defeat the repair entirely.
867+
body: body.trim(),
861868
evidenceUsed: output.evidence_used ?? [],
862869
};
863870
}

‎tests/prod-regressions.test.ts‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it, expect } from "vitest";
22
import { readFileSync } from "node:fs";
3-
import { unsupportedCustomClaims } from "@/lib/outreach/cold";
3+
import { separateUrlPunctuation, unsupportedCustomClaims, urlsIn } from "@/lib/outreach/cold";
44

55
// Three failures that only appeared once campaigns ran against real
66
// businesses. Each is pinned by the case that produced it.
@@ -68,3 +68,51 @@ describe("people are output worth paying for", () => {
6868
expect(runner).toMatch(/if \(billing\.charged\(\) && !producedSomething\)/);
6969
});
7070
});
71+
72+
describe("a link ends where the URL characters end", () => {
73+
const guard = unsupportedCustomClaims;
74+
const declared = ["download it at https://threatcrush.com/get-whitepaper"];
75+
76+
it("does not swallow the punctuation jammed against it", () => {
77+
// The live draft read "...get-whitepaper—download..." and the guard read
78+
// the em-dash and the next word as part of the URL, then rejected the
79+
// draft for linking somewhere the campaign never mentioned.
80+
expect(urlsIn("grab it at https://threatcrush.com/get-whitepaper—download now")).toEqual([
81+
"https://threatcrush.com/get-whitepaper",
82+
]);
83+
});
84+
85+
it("accepts that draft instead of rejecting it", () => {
86+
const body = "Grab it at https://threatcrush.com/get-whitepaper—download takes a minute.";
87+
expect(guard(body, declared)).toEqual([]);
88+
});
89+
90+
it("still strips ordinary sentence punctuation", () => {
91+
expect(urlsIn("see https://x.test/a.")).toEqual(["https://x.test/a"]);
92+
expect(urlsIn("see (https://x.test/a), then")).toEqual(["https://x.test/a"]);
93+
});
94+
95+
it("keeps punctuation that is genuinely part of the path", () => {
96+
expect(urlsIn("see https://x.test/a_b-c~d/e?f=1&g=2#h then")).toEqual([
97+
"https://x.test/a_b-c~d/e?f=1&g=2#h",
98+
]);
99+
});
100+
101+
it("separates the dash so no mail client can autolink it", () => {
102+
// Even with the check corrected, a URL welded to an em-dash is a link
103+
// some clients will autolink including the dash — a 404 on the one thing
104+
// the email asked the recipient to click.
105+
expect(separateUrlPunctuation("at https://x.test/p—download now")).toBe(
106+
"at https://x.test/p —download now",
107+
);
108+
});
109+
110+
it("leaves a well-formed sentence untouched", () => {
111+
const clean = "Grab it at https://x.test/p. It takes a minute.";
112+
expect(separateUrlPunctuation(clean)).toBe(clean);
113+
});
114+
115+
it("still catches a genuinely invented link", () => {
116+
expect(guard("see https://not-declared.test/x", declared).join(" ")).toMatch(/links to/);
117+
});
118+
});

0 commit comments

Comments
 (0)