Skip to content

Per-level card text: fix the collapse, restore per-level titles, un-break deepseek - #717

Merged
mircealungu merged 5 commits into
masterfrom
wt/level-text
Aug 28, 2026
Merged

Per-level card text: fix the collapse, restore per-level titles, un-break deepseek#717
mircealungu merged 5 commits into
masterfrom
wt/level-text

Conversation

@mircealungu

Copy link
Copy Markdown
Member

Started from "titles and summaries don't change when I change my Danish level". Three separate causes, one per commit.

1. Learners at or above the article's level got the wrong summary

ArticleLevelSummary rows exist only for levels below the article's own, so "highest stored row at or below the learner's level" silently collapsed every reader from the article's level upwards onto the same row.

In Danish that is nearly the whole feed: 366 of 368 recently assessed B1 articles carry only A1 and A2 rows, so A2, B1, B2, C1 and C2 readers were all served the identical A2 summary. An A2 reader on an A2 article got the A1 row rather than the article's own summary.

pick_best now takes the article's own level and returns None at or above it — which is what best_for_user_level's docstring already promised. The caller falls back to Article.summary, itself the LLM summary written at the article's own level.

2. No per-level titles

Before on-demand simplification the card did show a level-appropriate headline — the overlay took title and summary off the level-matched simplified child. When the crawl stopped generating children (0d047e3), article_level_summary replaced the summary half and nothing replaced the title half. The comment "we don't produce per-level titles" described the hole rather than justifying it.

It matters more than it sounds: the default feed view is Headlines, which renders the title and no summary at all — so with no per-level title, the CEFR selector changes nothing a default-view reader can see.

The assess call already emits one section per level, so this is a few dozen extra tokens on a call we already make, not a new call.

  • article_level_summary gains title + tokenized_title, both nullable (existing rows have none; a title dropped by the language check leaves its summary intact — both fall back to the article's own title).
  • ArticleLevelTitleContext is a separate join from ArticleLevelSummaryContext even though both point at the same row: title and summary are different token streams, and one table for both would return the title's bookmarks when highlighting the summary.
  • Web needs no changes — it already renders article.title and interactiveTitle.

3. deepseek was never broken — the prompt was

The crawler has been pinned to --provider anthropic since 2026-08-02 because deepseek-chat "returned unfinished for 90–100% of articles". That is a prompt shape deepseek cannot follow, not a model regression.

The assess prompt's only way to report junk was the bare token unfinished, which also let the model end the whole response in one word. Measured on 6 complete Danish articles: 6/6 came back as 10 characters in ~1s, rejected as paywalled.

Moving the signal into INCOMPLETE_ARTICLE / ADVERTORIAL_CONTENT fields, same articles, same model:

variant full articles truncated copies
current prompt 0/6 assessed 6/6 rejected
fields 6/6 assessed 6/6 rejected

The signal survives; the escape hatch doesn't. Verified end-to-end through the real assess_and_summarize.

This matters beyond deepseek: with one working provider, the Anthropic monthly cap starves assessment in every language — which is what left the 664 most recent Danish articles with no cefr_level, no summary and no level summaries since 21 Aug. The cron pins anthropic, and _select_provider_and_key only fails over on a missing key, not on a quota error.

Caveats, stated rather than buried

  • The field variant is validated on deepseek only. The Anthropic account is capped until Sept 1, so it could not be re-tested against Haiku — which is exactly why _raise_if_paywall_or_advertorial still honours a bare unfinished, and why an absent field reads as NO.
  • get_adaptive_simplification_prompt has the same bare-word exit and would fail the same way on deepseek. Left alone deliberately: it runs on anthropic today and is not what starved the crawl.
  • Per-level titles are not language-guarded in practice — MIN_CHARS_TO_JUDGE is 60 and headlines are shorter. There is a test that asserts this limit out loud rather than implying titles are checked. Small residual risk: one call produces the whole response, so an English title arrives beside English summaries, which are long enough to be judged and do trigger the retry.

Deploying

Order matters — the prompt fix must be live before the crawler is pointed at deepseek.

  1. Deploy the API and run tools/migrations/26-08-28--add_article_level_title.sql
  2. Flip the crawl cron to --provider deepseek (the comment justifying anthropic is now obsolete)
  3. Backfill: tools/backfill_reassess_summaries.py --language da --since 2026-08-21 --apply --provider deepseek — one pass gives the stranded articles levels, summaries, per-level summaries and titles

After Sept 1, validate Haiku against the new prompt before switching back rather than assuming it.

462 tests pass.

ArticleLevelSummary rows exist only for levels BELOW the article's own, so
"highest stored row at or below the learner's level" quietly collapses every
reader from the article's level upwards onto the same row.

In Danish that is almost the whole feed: 366 of 368 recently assessed B1
articles carry only A1 and A2 rows, so A2, B1, B2, C1 and C2 readers were all
served the identical A2 summary and changing CEFR level changed nothing on the
card. An A2 reader on an A2 article got the A1 row rather than the article's
own summary, for the same reason.

pick_best now takes the article's own level and returns None at or above it,
which is what best_for_user_level's docstring already promised — the caller
falls back to Article.summary, itself the LLM summary written at the article's
own level. Callers with no article level to pass keep the old behaviour.

The feed overlay joins Article.cefr_level into its columns-only query rather
than reading metrics.cefr_level off the result dicts: that one is the
*effective* level and can come back compound ("B1/B2").
Before on-demand simplification the card DID show a level-appropriate headline:
the overlay took title and summary off the level-matched simplified child
article. When the crawl stopped generating children (0d047e3),
article_level_summary replaced the summary half and nothing replaced the title
half — "we don't produce per-level titles" described the hole rather than
justifying it.

It matters more than it sounds: the default feed view is Headlines, which
renders the title and no summary at all, so with no per-level title the CEFR
selector changes nothing a default-view reader can see.

The assess+summarize call already emits one section per level, so a title per
level is a few dozen extra tokens on a call we already make — no new LLM call.

- prompt asks for [LEVEL]_TITLE beside [LEVEL]_SUMMARY, headline-shaped and
  bound to the same subject as the original (it is what the reader taps).
- article_level_summary carries title + tokenized_title, both nullable: every
  existing row has none, and a title dropped by the language check leaves its
  summary intact. Both fall back to the article's own title.
- ArticleLevelTitleContext is a separate join from ArticleLevelSummaryContext
  even though both point at the same row — title and summary are different
  token streams, and one table for both would return the title's bookmarks
  when highlighting the summary.
- the feed overlay and the single-article endpoint serve the level title the
  same way they already serve the level summary.

Language check caveat, covered by a test that states it rather than hides it:
MIN_CHARS_TO_JUDGE is 60 and headlines are usually shorter, so a wrong-language
title alone is not caught. Small residual risk — one call produces the whole
response, so an English title arrives beside English summaries, which are long
enough to be judged and do trigger the retry.
The assess prompt's only way to report junk was to answer with the bare token
"unfinished" — which also let a model end the entire response with one word.
deepseek-chat took that exit on EVERY article: measured on 6 complete Danish
articles, 6/6 came back as 10 characters in ~1s and were rejected as paywalled.

That is why the crawler has been pinned to --provider anthropic since
2026-08-02. It was read as "deepseek-chat regressed", but it is a prompt shape
deepseek cannot follow: with the same articles and the same model, moving the
signal into INCOMPLETE_ARTICLE / ADVERTORIAL_CONTENT fields gives 6/6 correctly
assessed — and truncated copies of those same six (what a paywalled article
actually looks like) are still 6/6 rejected. The signal survives; the escape
hatch doesn't.

This matters beyond deepseek: with one working provider the Anthropic monthly
cap starves all assessment in every language, which is what emptied Danish of
summaries from 21 Aug (664 consecutive articles with no cefr_level, no summary
and no level summaries). The cron pins anthropic, and _select_provider_and_key
only fails over on a MISSING KEY, not on a quota error — so nothing degrades
gracefully.

Backward compatible on purpose: _raise_if_paywall_or_advertorial still rejects
a bare "unfinished"/"advertorial", and an absent field reads as NO. The
Anthropic account is capped until Sept 1, so this could NOT be re-validated
against Haiku — old-shape answers keep working precisely because that is
unverified.

Only the assess prompt changes. get_adaptive_simplification_prompt has the same
bare-word exit and would fail the same way on deepseek, but it runs on anthropic
today and is not what starved the crawl; left alone deliberately.
@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown

ArchLens detected architectural changes in the following views:
diff

The comment claimed the article title "names the source the learner saw the
word in" — which is exactly backwards for a level title: an A1 learner met the
word in the A1 headline, not in the publisher's. The behaviour is right, the
justification wasn't: get_source_title answers "which document", and the
article title is the stable answer where a level title would drift with the
reader's CEFR level. States the trade-off it accepts instead of hiding it.
"article level" reads as the article's CEFR level, or as article-level
granularity. The thing stored is an article's text ADAPTED TO a level, and now
that the same row carries a title as well, "..._summary" is wrong outright.

  ContextType.ARTICLE_LEVEL_{SUMMARY,TITLE}
      -> LEVEL_ADAPTED_ARTICLE_{SUMMARY,TITLE}
  article_level_summary          -> level_adapted_article_text
  article_level_summary_context  -> level_adapted_article_summary_context
  article_level_title_context    -> level_adapted_article_title_context
  article_level_summary_id       -> level_adapted_article_text_id

Cheap to do now and expensive later: bookmark_context points at context_type by
id, and UPDATE preserves the id, so the 31 existing rows need no migration at
all. The title table and columns are brand new and still empty, so they are
created under the right names rather than renamed afterwards — the migration
supersedes 26-08-28--add_article_level_title.sql and carries the undo for anyone
who applied that draft.

The one thing that is NOT internal: context_identifier is opaque to the client,
which posts it straight back when a word is translated. An app holding a payload
built before this rename would post the old spellings, so from_dictionary accepts
both the old id key and the two old context-type strings. Deletable once no
client can still hold a pre-rename payload; tests pin the behaviour meanwhile.

Those tests earned their keep immediately: the bulk rename had rewritten the
ContextType string VALUES to "LevelAdaptedArticleText" and "ArticleLevelTitle",
which would have written unmatchable context types into the database.
@mircealungu
mircealungu merged commit 01b8388 into master Aug 28, 2026
3 checks passed
mircealungu added a commit that referenced this pull request Aug 28, 2026
80% was derived from healthy days measured under Anthropic + the bare-word
prompt, which under-enforced the prompt's own "fewer than 3 paragraphs = likely
incomplete" rule: only 2-3% of articles were rejected as paywalled, so coverage
sat at 97-98%.

DeepSeek with the field-based prompt follows that rule literally. The 29 Aug
Danish backfill rejected 118 of 629 (19%) — correctly: the rejected set averages
190 words and 1,043 characters against 598 words and 3,246 characters for the
assessed set. That is what a paywall stub looks like, not a false positive.

So healthy coverage is now ~81%, and an 80% threshold would have paged on a
perfectly good day. 70% keeps a real margin under the new baseline while staying
far above every capped day (15-38%), which is what the check exists to catch.

An alarm that fires on normal operation is worse than no alarm.
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.

1 participant