fix(T-29): bound the trending digest, and stop declaring unreachable LLM fallbacks - #19
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #18. Both reported bugs reproduced against the real code paths first, then fixed, then re-measured.
1. One repo's
tagscould take down the whole trending digestsummarywas capped at 140 chars in code, buttags— 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 withsent=false.The exact payload from the issue, before and after:
Three parts:
TRENDING_TAGS_MAX(5) andTRENDING_TAG_MAX(24); the existing magic140becomesTRENDING_SUMMARY_MAX. Scraped fields are left alone — GitHub bounds those itself.buildTrendingMessagethat 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.buildTrendingMessagefrom the tool'sfunc, mirroringbuildAiNewsMessage, so it's testable without stubbingfetch.Deliberately not done: adding
.max()toTrendingRepoOutputSchema.tags.curator.graph.ts:8validates 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_MODELSdeclared three models, but OpenRouter caps itsmodelslist at three entries including the primary, socreateLlm'sslice(0, 3)silently dropped the last one: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 hitrelation ... does not existin production.initDbis idempotent, so paying it per run is cheaper than the failure mode it prevents. Separate commit.Verification
pnpm exec tsc --noEmit— cleanpnpm format:check— cleanpnpm test— 62/62 pass, 9 new.news-telegram.tool.tshad no test file at all; it now has 8 cases including the reported payload and the escape-expansion path.createLlmhad no coverage; one test now asserts every configured fallback actually reaches OpenRouter, so this can't regress if the list is lengthened.sent=true.initDbran first, exercising the change in part 3.Note on the issue's discarded candidates
The four killed candidates were correctly killed — the two
NaNones are structurally unreachable as described. One thing worth picking up separately:company-db.ts:29says 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