Skip to content

Detect (and optionally repair) extraction schemas that no longer fit the page - #2106

Open
AliaksandrNazaruk wants to merge 1 commit into
unclecode:mainfrom
AliaksandrNazaruk:feat/schema-health-check
Open

Detect (and optionally repair) extraction schemas that no longer fit the page#2106
AliaksandrNazaruk wants to merge 1 commit into
unclecode:mainfrom
AliaksandrNazaruk:feat/schema-health-check

Conversation

@AliaksandrNazaruk

Copy link
Copy Markdown

The problem

generate_schema validates a candidate against the HTML it was generated from, refines it
if needed, and returns it. From then on the schema is applied to thousands of pages over
months while the site keeps changing — and nothing checks it again.

When a selector stops matching, extraction doesn't fail. It returns:

  • fewer records — a renamed container empties the result;
  • or the same number of records with a silently empty column — a renamed field. The
    record count is unchanged, so nothing downstream can see it. This is the dangerous one.

Reproduced on a real page (books.toscrape.com, 20 products), with two ordinary CSS class
renames:

page records signal today
original 20
card class renamed 0 none: no exception, no error, extract() reports success
price class renamed 20 (all prices empty) none

What this adds

Serve-time health scoring, off by default:

JsonCssExtractionStrategy(
    schema=schema,
    health_threshold=0.9,          # score every extraction, report below this
    on_degraded=my_callback,       # optional; defaults to a logger warning
    heal_llm_config=llm_config,    # optional: regenerate from the page that degraded
    max_regenerations=1,           # bound on that
)

Same two broken pages, with health_threshold=0.9:

original          20 records -> healthy
card renamed       0 records -> ALARM coverage=0%   empty=['title','price','availability']
price renamed     20 records -> ALARM coverage=67%  empty=['price']

Cost: none. The score is computed from the records extract() already produced — no
second pass over the DOM, no model call. I deliberately did not reuse _validate_schema
here, since it re-runs extraction internally and would double the work on every page.

Compatibility: without health_threshold nothing changes — same return values, same
silence, zero overhead. There's an explicit test for that.

Regeneration

Optional and conservative:

  • fires only on a degraded extraction, at most max_regenerations times per strategy
    (default 1) — a permanently unreadable site must not bill a model call per page;
  • the current schema's field names are carried into the request, because everything
    downstream is keyed on them: a repair that renames a column is not a repair;
  • the candidate is scored by the same measure that raised the alarm and adopted only if
    it beats it
    . "The selectors match something" is not "this extractor is healthy again",
    and adopting a replacement on the generator's own opinion is how a stale extractor
    becomes a differently-stale one;
  • a failed regeneration (provider down, bad candidate) keeps the old schema and never
    breaks the crawl.

End-to-end on the real page, generator stubbed: coverage 67% -> 100%, 20 records with
prices restored, and the next page is served by the healed schema at zero cost.

Tests

tests/test_schema_health.py, 12 tests, no network and no API key:

  • both rot shapes reported; healthy pages silent; threshold respected at 0.5 vs 0.6;
  • the off-by-default guarantee;
  • regeneration policy with the generator stubbed: adopts a real fix, refuses a
    replacement that is no better
    , carries field names, stays bounded across 5 pages,
    survives a provider failure.

12 passed. Existing tests touching this class (test_generate_schema_usage.py,
docker/test_serialization.py) still pass — 31 total. One unrelated file
(docker/test_config_object.py) fails to collect on main as well (FastFilterChain
import), verified with git stash.

Notes

Happy to split this: the detection half stands on its own and is the part with no policy
decisions in it. If you'd rather own the regeneration behaviour yourselves, I can drop it
from this PR and leave the health report plus the callback.

🤖 Generated with Claude Code

A generated schema is validated once, against the snapshot it was generated from, and
then applied for months while the site keeps changing. When a selector stops matching,
extraction does not fail: it returns fewer records, or records with a silently empty
column, and the caller is told nothing. The second shape is the dangerous one - the
record count is unchanged, so nothing downstream can see it.

The diagnostic for this already exists in the library (_validate_schema); it just never
runs after generation. This adds serve-time health scoring:

  health_threshold   score every extraction, report anything below it
  on_degraded        callback for the report; defaults to a logger warning
  heal_llm_config    optional: regenerate the schema from the page that degraded it
  max_regenerations  bound on that (default 1)

The score is computed from the records already extracted - no second pass over the DOM
and no model call, so the check costs nothing. Off by default: without health_threshold
the behaviour is byte-identical to before, which is pinned by a test.

Regeneration is deliberately conservative. The replacement is scored by the same measure
that raised the alarm and adopted only if it beats it, because 'the selectors match
something' is not the same as 'this extractor is healthy again' - and the current
schema's field names are carried into the request, since everything downstream is keyed
on them. A failed repair keeps the old schema and never breaks the crawl.

12 tests: both rot shapes, threshold behaviour, the off-by-default guarantee, and the
regeneration policy with the generator stubbed (adopt / refuse / bound / provider
failure).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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