Skip to content

Record why a keyword failed, instead of only logging it - #215

Merged
ralyodio merged 3 commits into
masterfrom
autoblog-keyword-quality
Aug 28, 2026
Merged

Record why a keyword failed, instead of only logging it#215
ralyodio merged 3 commits into
masterfrom
autoblog-keyword-quality

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Publishing stopped on 2026-08-19 and nobody could tell why. All 308 failed lx_keyword rows carry a null status_reason, because failKeyword() wrote the reason to console.warn and nothing else — so it lived in the Railway log buffer and expired there.

Every caller already computes a precise reason. It was simply not being kept. Now it lands on the row.

The quota path gets the same treatment even though it requeues rather than fails, because it is the one outcome that leaves no trace anywhere: the keyword looks untouched, no article row is created, nothing reaches the model, and the dashboard shows a healthy queue while the site quietly stops publishing.

Diagnosis so far

Article generation is dying before the writer model:

  • ai_usage shows zero lx_article calls since 2026-08-19
  • buyer_journey_keyword_research ran today on the same OpenAI key
  • Credits are not the blocker (3,583 on the account, 20/article)
  • text-embedding-3-small answers normally when called directly

The next 09:00 run will say which step it is.

1931 tests pass, clean typecheck.

🤖 Generated with Claude Code

https://claude.ai/code/session_013cbcXdpLFUgJZuVfCB4Gzv

ralyodio and others added 3 commits August 28, 2026 16:07
…n article

The anchored gate shipped and the hourly cron wrote its first 162 keywords
across 11 sites. The vendor and junk classes are gone. Reading the actual
output found two things the fixtures could not have.

**Partial matches leaked.** Eight keywords, all one shape: one generic word of
a multi-word subject matched, and a word from the generic commercial
vocabulary rescued it. "open standards" matched on "open", "software" did the
rest, and logicsrc was queued to write about OBS Studio. "supply chain
security" matched on "supply" and admitted "industrial automation supply".
"online shopping" matched on "online" and admitted "quickbooks online
pricing". So a partial match on a multi-word subject may now only be rescued
by an anchor the site itself supplied — never by the defaults, which are
generic by construction. A complete match is unaffected, including a
single-word subject, where "iptv alternatives" is a real query.

**And the cross-product floor was publishing constructions.** That one is mine
and it was new. Used as per-subject filler it produced "saving money pricing",
"deals platform", "coordination d0rz" and "ai content loop" — on-niche,
gate-passing, and not topics anybody searches. 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.

The crosses' real value was always upstream: as DataForSEO seeds they are what
turns "peptide" into "peptide merchant account", and they still seed every
expansion. They are simply no longer published on the strength of being
grammatically adjacent to the niche. They remain the floor, but site-level
rather than per-subject — only a run that would otherwise insert nothing falls
back to them, which keeps "a blog with every upstream down still publishes"
without letting constructions pad a healthy run. The result reports
usedCrossFloor so a site sitting on that for weeks is visible.

All eight leaked keywords are now regression cases, with the on-niche
keywords from the same run that must still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cbcXdpLFUgJZuVfCB4Gzv
Companion to purge-offniche-keywords.ts, for rows written before the gate was
tightened. Two verdicts, both computed with the real planner rather than
approximated in SQL.

The second one needed care. A construction cannot be found by checking for a
null search volume, which was the obvious approach: the buyer-journey model
also returns keywords with no volume, and those are the best output in the run
— "zeek vs suricata for network security monitoring" and "langchain
alternatives for agents" both sit in the same null bucket as "deals platform".
So a construction is identified by fingerprint against the cross set the
planner would build for that site, which is exact rather than heuristic.

Ran against the 162 rows from the first production cron: kept 53, removed 95
constructions and 14 partial-match leaks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cbcXdpLFUgJZuVfCB4Gzv
Publishing stopped on 2026-08-19 and nobody could tell why. All 308 failed
lx_keyword rows carry a null status_reason, because failKeyword() wrote the
reason to console.warn and nothing else — so it lived in the Railway log
buffer and expired there. From the database and the dashboard the outage was
invisible: a number going up, with no cause attached.

Every caller already computes a precise reason ("embedding failed: …",
"quality gate failed after N attempts (slop=…)", "insert failed: …"). It was
simply not being kept. Now it lands on the row, truncated, with a fallback
write so a keyword that cannot record its reason is still marked failed rather
than retried forever.

The quota path gets the same treatment even though it requeues rather than
fails, because it is the one outcome that leaves no trace anywhere: the
keyword looks untouched, no article row is created, nothing reaches the model,
and the dashboard shows a healthy queue while the site quietly stops
publishing. That is the exact shape of this outage.

Diagnosis so far, recorded here because the evidence is easy to lose: article
generation is dying BEFORE the writer model. ai_usage shows zero lx_article
calls since 2026-08-19 while buyer_journey_keyword_research ran today on the
same OpenAI key; credits are not the blocker (3,583 on the account, 20 per
article); and text-embedding-3-small answers normally when called directly.
The next 09:00 run will now say which step it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cbcXdpLFUgJZuVfCB4Gzv
@ralyodio
ralyodio merged commit bacabf1 into master Aug 28, 2026
7 checks passed
console.log("\n-- SQL:");
for (let i = 0; i < allIds.length; i += 200) {
console.log(
`delete from lx_keyword where status='queued' and id in (${allIds
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

40 finding(s)

HIGH/CRITICAL: 3 | MEDIUM: 28 | LOW: 9

Severity Rule Location
HIGH tls-verification-disabled lib/onion.ts:47
HIGH secret-generic-credential lib/sp/platforms/facebook.ts:32
HIGH sh-remote-script-execution prober/deploy/provision.sh:30
MEDIUM js-unescaped-html-sink app/(app)/dashboard/admin/email-broadcast/EmailBroadcastForm.tsx:125
MEDIUM js-unescaped-html-sink app/(app)/dashboard/projects/[id]/autoblog/articles/[articleId]/page.tsx:214
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:67
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:97
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:104
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:110
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:186
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:190
MEDIUM js-unescaped-html-sink app/c/[project]/[slug]/page.tsx:77
MEDIUM js-unescaped-html-sink app/c/[project]/page.tsx:57
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:228
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:285
MEDIUM js-unescaped-html-sink app/layout.tsx:129
MEDIUM js-open-redirect app/login/form.tsx:39
MEDIUM js-unescaped-html-sink app/r/[token]/page.tsx:176
MEDIUM js-open-redirect app/signup/form.tsx:43
MEDIUM js-open-redirect components/billing/buy-credits-modal.tsx:98
MEDIUM js-unescaped-html-sink components/json-ld.tsx:8
MEDIUM js-unescaped-html-sink components/report/markdown-view.tsx:15
MEDIUM redos-nested-quantifier lib/careers/jobs.ts:139
MEDIUM js-unescaped-html-sink lib/careers/page-templates.ts:198
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:130
MEDIUM redos-nested-quantifier lib/lx/articleGen.ts:98
MEDIUM redos-nested-quantifier lib/sp/parseHandle.ts:92
MEDIUM redos-nested-quantifier lib/tracker/agent-gate.ts:61
MEDIUM sql-template-interpolation scripts/detect-slot-themes.ts:31
MEDIUM sql-template-interpolation scripts/purge-constructed-keywords.ts:131
MEDIUM sql-template-interpolation scripts/purge-offniche-keywords.ts:124
LOW secret-generic-credential app/(marketing)/docs/autoblog-webhook/page.tsx:145
LOW secret-generic-credential lib/sp/platforms/linkedin.ts:25
LOW js-dynamic-code-execution tests/careers-page-templates.test.ts:21
LOW js-dynamic-code-execution tests/careers-widget-script.test.ts:19
LOW js-dynamic-code-execution tests/careers-widget-script.test.ts:69
LOW js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:51
LOW js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:52
LOW secret-generic-credential tests/contract/posthog-integration.test.ts:13
LOW secret-generic-credential tests/lead-campaign.test.ts:16

Snippets are redacted; ThreatCrush never prints matched credential material.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants