Skip to content

fix(T-29): bound the trending digest, and stop declaring unreachable LLM fallbacks - #19

Merged
DamengRandom merged 2 commits into
masterfrom
fix/T-29-digest-bounds
Aug 1, 2026
Merged

fix(T-29): bound the trending digest, and stop declaring unreachable LLM fallbacks#19
DamengRandom merged 2 commits into
masterfrom
fix/T-29-digest-bounds

Conversation

@DamengRandom

Copy link
Copy Markdown
Owner

Closes #18. Both reported bugs reproduced against the real code paths first, then fixed, then re-measured.

1. One repo's tags could take down the whole trending digest

summary was capped at 140 chars in code, but tags — also written by the curator model — had no bound on count or per-tag length, in the schema or the builder. Telegram rejects a message past 4096 rather than truncating it, so a single verbose entry cost every repo in the digest, all saved with sent=false.

The exact payload from the issue, before and after:

7 normal repos + 1 with a verbose tags array
  before : 4107 chars → Telegram rejects, all 8 lost
  after  : 2647 chars → delivered, 8 of 8 repos kept

Three parts:

  • Bound the model-written fields. TRENDING_TAGS_MAX (5) and TRENDING_TAG_MAX (24); the existing magic 140 becomes TRENDING_SUMMARY_MAX. Scraped fields are left alone — GitHub bounds those itself.
  • A trim guard in buildTrendingMessage that drops repos from the end until the message fits. The per-field caps cannot guarantee this on their own, because escaping runs after them and expands text: 140 & characters become 700. There's a test for exactly that path. A short digest beats no digest.
  • Extracted buildTrendingMessage from the tool's func, mirroring buildAiNewsMessage, so it's testable without stubbing fetch.

Deliberately not done: adding .max() to TrendingRepoOutputSchema.tags. curator.graph.ts:8 validates the model's output against that schema inside a retry loop, so a cap there turns "returned six tags" into a validation failure and a wasted retry — possibly failing the job. Truncating in code degrades gracefully; rejecting in the schema doesn't.

2. The third LLM fallback was unreachable

LLM_FALLBACK_MODELS declared three models, but OpenRouter caps its models list at three entries including the primary, so createLlm's slice(0, 3) silently dropped the last one:

configured : [nemotron-3-super, gemma-4-31b, gpt-oss-20b]
actually sent : [nemotron-3-ultra (primary), nemotron-3-super, gemma-4-31b]
silently dropped : [openai/gpt-oss-20b:free]

Not a crash — configuration that reads as a safety net and isn't one. Cut to a single fallback, which is what the cap leaves room for.

3. Schema created before every job

CI never runs pnpm run setup, so a job shipping a new table hit relation ... does not exist in production. initDb is idempotent, so paying it per run is cheaper than the failure mode it prevents. Separate commit.

Verification

  • pnpm exec tsc --noEmit — clean
  • pnpm format:check — clean
  • pnpm test62/62 pass, 9 new. news-telegram.tool.ts had no test file at all; it now has 8 cases including the reported payload and the escape-expansion path. createLlm had no coverage; one test now asserts every configured fallback actually reaches OpenRouter, so this can't regress if the list is lengthened.
  • Live run: 26 repos scraped → top 8 curated → Telegram digest delivered → 8 rows saved with sent=true. initDb ran first, exercising the change in part 3.

Note on the issue's discarded candidates

The four killed candidates were correctly killed — the two NaN ones are structurally unreachable as described. One thing worth picking up separately: company-db.ts:29 says the table name "comes from env", but it's a hardcoded constant. Not a bug, but a comment that will mislead someone eventually.

Stacked note: #16 (T-28) is still open and also touches src/constants/index.ts — different hunks, so it should auto-merge, but whichever lands second may want a quick rebase.

🤖 Generated with Claude Code

https://claude.ai/code/session_01F5CmYPdQcMeLzzJc3iDQ2u

DamengRandom and others added 2 commits August 1, 2026 14:26
CI never runs `pnpm run setup`, so a job shipping a new table hit
"relation ... does not exist" in production. initDb is idempotent, so
paying it per run is cheaper than the failure mode it prevents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F5CmYPdQcMeLzzJc3iDQ2u
…LLM fallbacks

Closes #18.

One repo's tags could take down the whole digest. summary was capped at
140 chars in code, but tags — also model-written — had no bound on count
or length, in the schema or the builder. Eight repos with one verbose
tags array rendered at 4107 chars against Telegram's 4096 limit, and
Telegram rejects an over-long message rather than truncating it, so all
eight were lost and saved with sent=false. Same payload now renders at
2647 and delivers all eight.

Fixed in three parts:

- TRENDING_TAGS_MAX / TRENDING_TAG_MAX bound the tag list, and the
  existing magic 140 becomes TRENDING_SUMMARY_MAX. Only the model-written
  fields are capped; GitHub already bounds the scraped ones.
- buildTrendingMessage drops repos from the end until the message fits.
  The per-field caps alone cannot guarantee this, because escaping runs
  after them and expands text — 140 '&' characters become 700. A short
  digest beats no digest.
- The builder is extracted from the tool's func, mirroring
  buildAiNewsMessage, so it is testable without stubbing fetch. This file
  had no test coverage at all; it now has eight cases, including the
  reported payload and the escape-expansion path.

Deliberately not added: a .max() on TrendingRepoOutputSchema.tags.
curator.graph.ts validates the model's output against it inside a retry
loop, so a cap there turns "returned six tags" into a validation failure
and a wasted retry. Truncating in code degrades; rejecting in the schema
does not.

LLM_FALLBACK_MODELS declared three models but OpenRouter caps its `models`
list at three entries including the primary, so createLlm's slice(0, 3)
silently dropped the last one — configuration that read as a safety net
and was unreachable. Cut to a single fallback, which is what the cap
leaves room for. createLlm had no test; one now asserts every configured
fallback actually reaches OpenRouter.

Verified with a live run: 26 repos scraped, top 8 curated, Telegram
digest delivered, 8 rows saved with sent=true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F5CmYPdQcMeLzzJc3iDQ2u
@DamengRandom
DamengRandom merged commit 47086d2 into master Aug 1, 2026
4 checks passed
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.

Bug scan: confirmed issues found on 2026-07-31

1 participant