Skip to content

Commit 6d8314b

Browse files
ralyodioclaude
andcommitted
feat(autoblog): relax exchange matcher below network threshold
The strict score-must-be-positive matcher only fires when corpus has genuine topical density. With the network at ~5 sites in completely disjoint niches (security, crypto, AEO, shipping, freelancing), every generation logged 0/3 slots — the feature shipped but never produced links. Add RELAXED_NICHE_THRESHOLD = 2500 eligible articles. Below it: - rankExchangeCandidates accepts minScore=0 (caller-controlled); findExchangeCandidates flips to relaxed automatically based on a COUNT against the eligible network. - Topical matches still float to the top via the existing score sort, so even in relaxed mode a coinpay→threatcrush link only beats recency when there's real overlap. - buildUserPrompt switches the exchange-slot instruction to a network-first framing ("pre-vetted partners, place as 'related reading from our network'-style aside") so the model actually uses what we offer instead of skipping every "off-topic" candidate. Above 2500: identical strict behavior. Auto-tightens itself. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0a1deb0 commit 6d8314b

3 files changed

Lines changed: 162 additions & 14 deletions

File tree

‎lib/lx/articleGen.ts‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,17 @@ function buildUserPrompt(input: {
187187
linkSlots: number;
188188
exchangeCandidates: ExchangeCandidate[];
189189
exchangeSlots: number;
190+
exchangeRelaxed?: boolean;
190191
}): string {
191-
const { site, keyword, candidates, linkSlots, exchangeCandidates, exchangeSlots } = input;
192+
const {
193+
site,
194+
keyword,
195+
candidates,
196+
linkSlots,
197+
exchangeCandidates,
198+
exchangeSlots,
199+
exchangeRelaxed,
200+
} = input;
192201
const brand = site.domain;
193202
const niche = site.niche?.trim() || "B2B / technical SaaS";
194203
const audiences = site.target_audiences.length
@@ -218,8 +227,11 @@ function buildUserPrompt(input: {
218227
`${i + 1}. [PARTNER BLOG: ${c.domain}] ${c.url}\n Title: ${c.title}\n About: ${c.meta_description ?? "(no description)"}`,
219228
)
220229
.join("\n");
230+
const exchangeMax = Math.min(exchangeSlots, exchangeCandidates.length);
221231
const exchangeSlotLine = exchangeSlots > 0 && exchangeCandidates.length > 0
222-
? `Insert UP TO ${Math.min(exchangeSlots, exchangeCandidates.length)} of the following external partner-blog links inline as standard markdown links, where each fits naturally in a sentence whose topic genuinely overlaps. Pick the most relevant — skip any that don't fit; do not force a link. Do not invent URLs. Return the URLs you used in used_exchange_link_urls.`
232+
? exchangeRelaxed
233+
? `Insert UP TO ${exchangeMax} of the following external partner-blog links. These are pre-vetted blogs in our shared content network — the network is still small, so topical overlap may be loose. Place each as a natural "Related reading from our network: [anchor](url)"-style inline reference where the surrounding paragraph allows even a tangential connection (e.g., a "for adjacent reading" aside, a comparison, or a "teams in {their niche} face similar tradeoffs" sentence). Prefer using all ${exchangeMax}; only skip a candidate if including it would make a paragraph read as obviously incoherent. Do not invent URLs. Return the URLs you used in used_exchange_link_urls.`
234+
: `Insert UP TO ${exchangeMax} of the following external partner-blog links inline as standard markdown links, where each fits naturally in a sentence whose topic genuinely overlaps. Pick the most relevant — skip any that don't fit; do not force a link. Do not invent URLs. Return the URLs you used in used_exchange_link_urls.`
223235
: "External-link slots: 0. Return used_exchange_link_urls: [] and do not link out.";
224236

225237
return [
@@ -626,15 +638,17 @@ export async function generateArticle(
626638
const exchangeSlots = typedSite.backlinks_enabled
627639
? Math.max(0, typedSite.external_links_per_article)
628640
: 0;
629-
const exchangeCandidates = await findExchangeCandidates(supabase, {
641+
const exchangeMatch = await findExchangeCandidates(supabase, {
630642
selfSiteId: typedSite.id,
631643
selfNiche: typedSite.niche,
632644
keyword: keyword.keyword,
633645
slots: exchangeSlots,
634646
});
647+
const exchangeCandidates = exchangeMatch.candidates;
648+
const exchangeRelaxed = exchangeMatch.relaxed;
635649
if (exchangeSlots > 0) {
636650
console.log(
637-
`[lx] exchange candidates for ${typedSite.id} keyword="${keyword.keyword}": ${exchangeCandidates.length}/${exchangeSlots} slots`,
651+
`[lx] exchange candidates for ${typedSite.id} keyword="${keyword.keyword}": ${exchangeCandidates.length}/${exchangeSlots} slots (mode=${exchangeRelaxed ? "relaxed" : "strict"}, network=${exchangeMatch.networkSize})`,
638652
);
639653
}
640654

@@ -672,6 +686,7 @@ export async function generateArticle(
672686
linkSlots,
673687
exchangeCandidates,
674688
exchangeSlots,
689+
exchangeRelaxed,
675690
}),
676691
},
677692
],

‎lib/lx/exchangeMatcher.ts‎

Lines changed: 81 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ export type ExchangeCandidate = {
3939
meta_description: string | null;
4040
};
4141

42+
// Below this many eligible articles in the network, the matcher relaxes
43+
// its "score > 0" requirement and will surface the freshest candidates
44+
// even with zero topic overlap. Once the corpus is large enough, niche
45+
// overlap reliably finds something for any given keyword and strict
46+
// mode kicks back in. Tuned to ~250 sites × ~10 articles each.
47+
export const RELAXED_NICHE_THRESHOLD = 2500;
48+
4249
type RankableRow = {
4350
id: string;
4451
title: string;
@@ -92,13 +99,27 @@ export function buildArticleUrl(blogRootUrl: string, slug: string): string {
9299

93100
export function rankExchangeCandidates(
94101
rows: RankableRow[],
95-
input: { selfSiteId: string; selfNiche: string | null; keyword: string; slots: number },
102+
input: {
103+
selfSiteId: string;
104+
selfNiche: string | null;
105+
keyword: string;
106+
slots: number;
107+
// Minimum overlap score to accept. Defaults to 1 (strict — require
108+
// some topical overlap). Caller passes 0 in early-network "relaxed"
109+
// mode so the matcher can still find SOMETHING when the corpus is
110+
// too thin for niche overlap to fire reliably.
111+
minScore?: number;
112+
},
96113
): ExchangeCandidate[] {
97114
const { selfSiteId, selfNiche, keyword, slots } = input;
115+
const minScore = input.minScore ?? 1;
98116
if (slots <= 0) return [];
99117

100118
const selfTokens = topicTokens(keyword, selfNiche);
101-
if (selfTokens.size === 0) return [];
119+
// In relaxed mode (minScore = 0) we still surface candidates even if
120+
// the self-token set is empty — the network's just too small to be
121+
// picky. In strict mode, no self-tokens means nothing to match against.
122+
if (selfTokens.size === 0 && minScore > 0) return [];
102123

103124
const scored = rows
104125
.filter((r) => r.site.id !== selfSiteId)
@@ -109,7 +130,7 @@ export function rankExchangeCandidates(
109130
topicTokens(r.title, r.meta_description, r.site.niche),
110131
),
111132
}))
112-
.filter((s) => s.score > 0)
133+
.filter((s) => s.score >= minScore)
113134
.sort((a, b) => b.score - a.score);
114135

115136
const out: ExchangeCandidate[] = [];
@@ -131,18 +152,32 @@ export function rankExchangeCandidates(
131152
return out;
132153
}
133154

155+
export type ExchangeMatchResult = {
156+
candidates: ExchangeCandidate[];
157+
// True when the network is below RELAXED_NICHE_THRESHOLD — we surfaced
158+
// candidates without requiring topical overlap. Callers can use this
159+
// to soften prompt language so the model actually uses what we found.
160+
relaxed: boolean;
161+
networkSize: number;
162+
};
163+
134164
// Fetch + rank in one call. The PostgREST embed pulls the site columns
135165
// we need for filtering and URL construction in a single round-trip.
136166
export async function findExchangeCandidates(
137167
supabase: SupabaseClient<any>,
138168
input: { selfSiteId: string; selfNiche: string | null; keyword: string; slots: number },
139-
): Promise<ExchangeCandidate[]> {
140-
if (input.slots <= 0) return [];
169+
): Promise<ExchangeMatchResult> {
170+
if (input.slots <= 0) {
171+
return { candidates: [], relaxed: false, networkSize: 0 };
172+
}
141173

142174
// Cap the candidate pool so a large network can't blow up the prompt
143-
// or the ranking loop. slots*8 gives the ranker enough headroom to
144-
// filter on niche overlap and still meet the slot count.
145-
const limit = Math.max(20, input.slots * 8);
175+
// or the ranking loop. In relaxed mode we widen the pull window so
176+
// recency-ordering has enough variety to choose from.
177+
const networkSize = await countEligibleNetworkArticles(supabase);
178+
const relaxed = networkSize < RELAXED_NICHE_THRESHOLD;
179+
const minScore = relaxed ? 0 : 1;
180+
const limit = Math.max(20, input.slots * (relaxed ? 12 : 8));
146181

147182
const { data, error } = await supabase
148183
.from("lx_article")
@@ -160,7 +195,13 @@ export async function findExchangeCandidates(
160195

161196
if (error) {
162197
console.warn("[lx] exchange matcher query failed:", error.message);
163-
return [];
198+
return { candidates: [], relaxed, networkSize };
199+
}
200+
201+
if (relaxed) {
202+
console.log(
203+
`[lx] exchange matcher running in RELAXED mode (network=${networkSize} < ${RELAXED_NICHE_THRESHOLD} eligible articles)`,
204+
);
164205
}
165206
// PostgREST's `!inner` embed types come back as `site: T[]` even though
166207
// the runtime shape is a single object once the inner join is applied.
@@ -173,5 +214,35 @@ export async function findExchangeCandidates(
173214
meta_description: r.meta_description,
174215
site: Array.isArray(r.site) ? r.site[0] : r.site,
175216
})).filter((r) => r.site);
176-
return rankExchangeCandidates(rows, input);
217+
return {
218+
candidates: rankExchangeCandidates(rows, { ...input, minScore }),
219+
relaxed,
220+
networkSize,
221+
};
222+
}
223+
224+
// Count of articles eligible to be exchanged across the whole network —
225+
// the denominator that decides whether the matcher is in strict or
226+
// relaxed mode. Counts opted-in, non-flagged, active-site rows only;
227+
// a backlogged or paused site shouldn't push us into strict mode just
228+
// by existing.
229+
async function countEligibleNetworkArticles(
230+
supabase: SupabaseClient<any>,
231+
): Promise<number> {
232+
const { count, error } = await supabase
233+
.from("lx_article")
234+
.select("id, site:lx_site!inner(backlinks_enabled, status, inappropriate_content)", {
235+
count: "exact",
236+
head: true,
237+
})
238+
.in("status", ["ready", "published"])
239+
.eq("site.backlinks_enabled", true)
240+
.eq("site.status", "active")
241+
.eq("site.inappropriate_content", false);
242+
if (error) {
243+
console.warn("[lx] exchange network-size query failed:", error.message);
244+
// Fail safe: treat as small network, relax matching.
245+
return 0;
246+
}
247+
return count ?? 0;
177248
}

‎tests/contract/lx-exchange.test.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,4 +219,66 @@ describe("rankExchangeCandidates", () => {
219219
});
220220
expect(out[0].url).toMatch(/^https:\/\/threatcrush\.com\/blog\/[a-z-]+$/);
221221
});
222+
223+
it("relaxed mode (minScore=0) surfaces candidates with zero topic overlap", () => {
224+
// freelancing niche has no token overlap with any fixture row.
225+
// Strict mode returns [] (asserted above); relaxed should return
226+
// something so the early-network case isn't permanently dead.
227+
const strict = rankExchangeCandidates(FIX.rows, {
228+
selfSiteId: FIX.selfId,
229+
selfNiche: "AI-assisted freelancing and gig work",
230+
keyword: "freelancer rate cards",
231+
slots: 3,
232+
});
233+
expect(strict).toEqual([]);
234+
235+
const relaxed = rankExchangeCandidates(FIX.rows, {
236+
selfSiteId: FIX.selfId,
237+
selfNiche: "AI-assisted freelancing and gig work",
238+
keyword: "freelancer rate cards",
239+
slots: 3,
240+
minScore: 0,
241+
});
242+
expect(relaxed.length).toBeGreaterThan(0);
243+
expect(relaxed.length).toBeLessThanOrEqual(3);
244+
});
245+
246+
it("relaxed mode still dedups by giver site and respects slot cap", () => {
247+
const out = rankExchangeCandidates(FIX.rows, {
248+
selfSiteId: FIX.selfId,
249+
selfNiche: "completely unrelated topic",
250+
keyword: "nothing in common",
251+
slots: 2,
252+
minScore: 0,
253+
});
254+
expect(out.length).toBeLessThanOrEqual(2);
255+
const sites = new Set(out.map((c) => c.site_id));
256+
expect(sites.size).toBe(out.length); // every result a distinct giver
257+
});
258+
259+
it("relaxed mode prefers higher-scoring candidates when both have overlap and zero-overlap exist", () => {
260+
// payments keyword should still surface the crypto-payments article
261+
// first even in relaxed mode, before any zero-overlap stragglers.
262+
const out = rankExchangeCandidates(FIX.rows, {
263+
selfSiteId: FIX.selfId,
264+
selfNiche: "payments and merchant tooling",
265+
keyword: "crypto checkout flows",
266+
slots: 3,
267+
minScore: 0,
268+
});
269+
expect(out[0].site_id).toBe("site-coinpay");
270+
});
271+
272+
it("relaxed mode tolerates an empty self-token set", () => {
273+
// selfNiche null + keyword that tokenizes to nothing → strict returns
274+
// []; relaxed should still fall back to recency-ordered candidates.
275+
const out = rankExchangeCandidates(FIX.rows, {
276+
selfSiteId: FIX.selfId,
277+
selfNiche: null,
278+
keyword: "",
279+
slots: 2,
280+
minScore: 0,
281+
});
282+
expect(out.length).toBeGreaterThan(0);
283+
});
222284
});

0 commit comments

Comments
 (0)