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
37 changes: 34 additions & 3 deletions lib/lx/articleGen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1057,9 +1057,19 @@ export async function generateArticle(
};
if (chargeSource === "none") {
// Return the claim — the keyword can run later once credits exist.
//
// The reason is written even though the row goes back to `queued`, because
// this is the one failure that leaves no trace anywhere else: the keyword
// looks untouched, no article row is created, and nothing reaches the
// model, so a site silently stops publishing while its dashboard shows a
// healthy queue. That is exactly the shape of the outage that ran from
// 2026-08-19 undetected.
await supabase
.from("lx_keyword")
.update({ status: "queued" })
.update({
status: "queued",
status_reason: "out of article quota and credits",
})
.eq("id", keyword.id);
return { ok: false, error: "out of article quota and credits" };
}
Expand Down Expand Up @@ -1500,16 +1510,37 @@ export async function generateArticle(
return { ok: true, articleId: inserted.id, slug: finalSlug };
}

/**
* Mark a keyword failed, and record WHY on the row.
*
* The reason used to go only to `console.warn`, which meant it lived in the
* Railway log buffer and nowhere else. That cost nine days: publishing stopped
* on 2026-08-19 and every one of the 308 failed rows carried a null
* `status_reason`, so the outage was invisible from the database and from the
* dashboard — the only symptom was a number going up. Every caller here
* already computes a precise reason ("embedding failed: …", "quality gate
* failed after N attempts (slop=…)"); it simply was not being kept.
*
* Best-effort and never throws: a keyword that cannot record its reason must
* still be marked failed, or the generator retries it forever.
*/
async function failKeyword(
supabase: SupabaseClient<any>,
keywordId: string,
reason: string,
): Promise<void> {
console.warn(`[lx] keyword ${keywordId} failed:`, reason);
await supabase
const { error } = await supabase
.from("lx_keyword")
.update({ status: "failed" })
.update({ status: "failed", status_reason: reason.slice(0, 2000) })
.eq("id", keywordId);
if (error) {
console.warn(`[lx] keyword ${keywordId}: could not record reason:`, error.message);
await supabase
.from("lx_keyword")
.update({ status: "failed" })
.eq("id", keywordId);
}
}

// Bump the user's credit balance back by SCAN_CREDITS — called when generation
Expand Down
91 changes: 75 additions & 16 deletions lib/lx/keywordsResearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
crossQueries,
dropDuplicates,
isOnNiche,
ownAnchorTokens,
resolveMasters,
resolveModifiers,
signature,
Expand Down Expand Up @@ -77,8 +78,14 @@ const MAX_BUYER_JOURNEY_VOLUME_LOOKUP = 160;
*/
const CROSS_PER_MASTER = 3;

/** A candidate with the subject it belongs to. */
type Candidate = { row: DfsKeywordRow; master: string };
/**
* A candidate with the subject it belongs to.
*
* `fromCross` marks the locally-built subject x modifier constructions. They
* are excellent *seeds* — that is their real job — and mediocre *articles*, so
* they are tracked separately and only published as a last resort.
*/
type Candidate = { row: DfsKeywordRow; master: string; fromCross?: boolean };

function parseStoredKeyword(row: string): DfsKeywordRow | null {
const idx = row.indexOf(",");
Expand Down Expand Up @@ -271,6 +278,13 @@ export type KeywordResearchResult = {
apiCost: number;
/** Rows allocated per subject — surfaced so a skewed queue is visible. */
perMaster?: Record<string, number>;
/**
* True when nothing researched survived and the run fell back to
* constructed subject x modifier topics. A degraded result, not a failure —
* reported rather than swallowed, because a site sitting on this for weeks
* means its upstreams or its modifiers need attention.
*/
usedCrossFloor?: boolean;
error?: string;
};

Expand Down Expand Up @@ -303,6 +317,9 @@ export async function researchKeywords(
// halves of the gate with one token. See anchorTokens.
const modifiers = resolveModifiers(site, masters);
const anchors = anchorTokens(site, masters);
// Site-supplied anchors only. A partial match on a multi-word subject may
// not be rescued by the generic vocabulary — see isOnNiche.
const ownAnchors = ownAnchorTokens(site, masters);

if (masters.length === 0) {
return {
Expand Down Expand Up @@ -376,6 +393,7 @@ export async function researchKeywords(
const crosses = crossQueries(masters, modifiers, CROSS_PER_MASTER);
for (const { master, query } of crosses) {
candidates.push({
fromCross: true,
row: {
keyword: query,
search_volume: null,
Expand Down Expand Up @@ -487,7 +505,9 @@ export async function researchKeywords(
// ------------------------------------------------------------------
// Gate, rank, allocate.
// ------------------------------------------------------------------
const onNiche = candidates.filter((c) => isOnNiche(c.row.keyword, c.master, anchors));
const onNiche = candidates.filter((c) =>
isOnNiche(c.row.keyword, c.master, anchors, ownAnchors),
);

// Volume filtering applies only to what came back from an API with a volume
// attached. The locally-built crosses have no volume by construction and
Expand Down Expand Up @@ -524,20 +544,53 @@ export async function researchKeywords(
publishedSignatures,
);

// Take each subject's allocated share, then interleave so the published
// sequence alternates subjects rather than running one to exhaustion.
const byMaster = new Map<string, Candidate[]>();
for (const master of masters) byMaster.set(master, []);
for (const candidate of deduped) {
const bucket = byMaster.get(candidate.master);
if (!bucket) continue;
if (bucket.length >= (allocation.get(candidate.master) ?? 0)) continue;
bucket.push({ row: candidate.row, master: candidate.master });
/**
* Take each subject's allocated share, then interleave so the published
* sequence alternates subjects rather than running one to exhaustion.
*/
function select(pool: Candidate[]): Candidate[] {
const byMaster = new Map<string, Candidate[]>();
for (const master of masters) byMaster.set(master, []);
for (const candidate of pool) {
const bucket = byMaster.get(candidate.master);
if (!bucket) continue;
if (bucket.length >= (allocation.get(candidate.master) ?? 0)) continue;
bucket.push(candidate);
}
// Subjects that could not fill their share hand it back, so a subject
// with no available candidates costs the run coverage rather than volume.
return interleave(byMaster).slice(0, TARGET_KEYWORDS);
}

// Subjects that could not fill their share hand it back, so a subject with
// no available candidates costs the run coverage rather than volume.
const chosen = interleave(byMaster).slice(0, TARGET_KEYWORDS);
// The crosses are held back from the normal pass.
//
// They were being used as per-subject filler, and the first production run
// showed what that publishes: "saving money pricing", "deals platform",
// "coordination d0rz", "ai content loop". On-niche, gate-passing, and not
// article topics anybody would search for — bl0ggers' niche is
// "human-in-the-loop AI publishing", so its derived modifiers are literally
// "human" and "loop", and crossing a subject with those yields nonsense.
//
// Their real value is upstream: as DataForSEO seeds they are what turns
// "peptide" into "peptide merchant account". So they still seed every
// expansion — they just no longer get published on the strength of being
// grammatically adjacent to the niche.
let chosen = select(deduped.filter((c) => !c.fromCross));

// Last resort, and site-level rather than per-subject: only when the entire
// run would otherwise insert nothing does the constructed floor get used.
// That preserves "a blog with every upstream down still publishes" without
// letting constructions pad an otherwise healthy run.
let usedCrossFloor = false;
if (chosen.length === 0) {
chosen = select(deduped);
usedCrossFloor = chosen.length > 0;
if (usedCrossFloor) {
console.warn(
`[lx] ${site.domain}: no researched keywords survived; falling back to ${chosen.length} constructed subject x modifier topics`,
);
}
}

if (chosen.length === 0) {
const details = sourceErrors.length > 0
Expand Down Expand Up @@ -593,5 +646,11 @@ export async function researchKeywords(
perMaster[row.master_keyword] = (perMaster[row.master_keyword] ?? 0) + 1;
}

return { ok: true, inserted: insertRows.length, apiCost: totalCost, perMaster };
return {
ok: true,
inserted: insertRows.length,
apiCost: totalCost,
perMaster,
usedCrossFloor,
};
}
45 changes: 45 additions & 0 deletions lib/lx/topicPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,29 @@ export function resolveModifiers(
* the very same word, satisfying a two-part test with one token. The anchor
* has to be evidence the subject match did not already provide.
*/
export function ownAnchorTokens(
site: SiteTopicFields,
masters: string[] = [],
): Set<string> {
const masterTokens = new Set(masters.flatMap((m) => tokens(m).map(stem)));
const defaults = new Set(DEFAULT_MODIFIERS.flatMap((m) => tokens(m).map(stem)));
const out = new Set<string>();

const add = (phrase: string) => {
for (const token of tokens(phrase)) {
const stemmed = stem(token);
if (!masterTokens.has(stemmed) && !defaults.has(stemmed)) out.add(stemmed);
}
};

const explicit = (site.modifiers ?? [])
.map((m) => (m ?? "").trim())
.filter((m) => m.length > 0);
for (const modifier of explicit) add(modifier);
add(site.niche ?? "");
return out;
}

export function anchorTokens(
site: SiteTopicFields,
masters: string[] = [],
Expand Down Expand Up @@ -274,6 +297,7 @@ export function isOnNiche(
keyword: string,
master: string,
anchors: Set<string>,
ownAnchors?: Set<string>,
): boolean {
const candidate = new Set(tokens(keyword).map(stem));
if (candidate.size === 0) return false;
Expand All @@ -284,6 +308,27 @@ export function isOnNiche(
const hits = masterTokens.filter((t) => candidate.has(t)).length;
if (hits === 0) return false;

// A PARTIAL match on a multi-word subject may only be rescued by an anchor
// the site itself supplied — never by the generic commercial vocabulary.
//
// The first production run leaked exactly this shape: "open standards"
// matched on "open" alone and the default anchor "software" then admitted
// "open broadcaster software"; "supply chain security" matched on "supply"
// and "automation" admitted "industrial automation supply"; "online
// shopping" matched on "online" and "pricing" admitted "quickbooks online
// pricing". One generic subject word plus one generic tail word is not
// evidence of anything, and the defaults are generic by construction.
//
// A complete subject match is unaffected — including a single-word subject,
// where "iptv" + "alternatives" is a real query.
if (ownAnchors && masterTokens.length > 1 && hits < masterTokens.length) {
if (ownAnchors.size === 0) return false;
for (const token of candidate) {
if (ownAnchors.has(token)) return true;
}
return false;
}

// A COMPLETE match on a multi-word subject is its own evidence, and needs no
// anchor.
//
Expand Down
Loading
Loading