Reject empty excerpts and fix content extraction bugs - #62
Conversation
An empty or whitespace-only excerpt used to pass the whole validation stack without complaint: the empty string is a substring of every document, so every "is this quote in the reference?" check succeeded and the item was recorded as verified evidence quoting nothing at all. Every layer now treats a present-but-blank excerpt as an error: - SupportingTextValidator.validate rejects blank text up front, before skip-prefix handling and before any reference is fetched. A blank excerpt is a defect in the data regardless of what it cites. - find_text_in_reference reports blank text as blank rather than blaming the reference for having no content. - The LinkML plugin distinguishes an absent excerpt slot from a blank one. Absent stays LinkML's concern (required-ness); blank is reported even when the item carries no reference to check against. - The repairer flags blank text for removal without fetching. There is nothing to correct, and fuzzy-matching nothing against the reference would invent a quote nobody wrote. - The repair CLI collects blank snippets instead of dropping them, so they reach the report. A non-blank value still wins when both supporting_text and snippet are present. Reported in monarch-initiative/dismech#8550. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ePtLQDGiymEgrfJKKPrLs
A blank excerpt is the extreme case of the vacuous pass; a two-character one is barely better, since a short string matches almost any paper by chance and so is weak evidence even when the match succeeds. min_excerpt_length (default 0 = off, so existing data keeps validating as before) sets a minimum measured in non-whitespace characters of quoted text: - Whitespace is ignored, so PDF-to-text extraction damage does not change the count: "t he pro tein" and "the protein" both measure 10. - Nothing is tokenized, so chemical nomenclature and other punctuation-dense strings count in full: "2,3-dihydroxybenzoate" measures 21 rather than being split into words. - Editorial [brackets] and "..." separators do not count, since they are never matched against the reference. The blank and minimum-length rules now share one entry point, SupportingTextValidator.check_excerpt_content, so the validator, the LinkML plugin and the repairer cannot drift apart on what counts as insufficient evidence. As with the blank check, a breach is an ERROR, is decided before any reference is fetched, and is flagged for removal rather than repaired: there is no way to guess which longer passage the curator meant. Configured via the validation config file, like literal_bracket_patterns. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ePtLQDGiymEgrfJKKPrLs
Reported in monarch-initiative/genesets#10, where the reference gate rejected four correctly curated snippets across two files. Over-aggressive stub detection. The XML extractor discarded any document whose raw markup contained "restricted" or "cannot be obtained" anywhere at all. Morris 2019 (PMC6358485) is a 155 KB open-access paper whose Methods restrict an analysis "to up to 13,977,204 high quality HRC imputed variants", so the whole article was thrown away; the fetcher then fell through to the HTML path and cached an 8.7 KB stub page, which cleared the 1000-character floor and was labelled full text. A cited title containing the word was enough to do the same. Detection now runs on the extracted body rather than the raw markup, uses specific placeholder phrases, and honours them only below 1000 characters. Length is what makes the phrases safe: a real article body cannot be mistaken for a placeholder, whatever its prose says. Short placeholders whose wording is not anticipated are still caught by the existing full-text length floors downstream. PMIDSource._fetch_pmc_xml carried its own copy of the same blanket check and its own body parsing. It now delegates to XMLExtractor, so the two cannot drift apart again. Markup stripping corrupted text. The HTML extractor called get_text(strip=True) with no separator, which trims every markup-delimited run before joining, welding words to their neighbours across inline tags: "(<i>GUSB</i>, <i>GRN</i>, and <i>NEU1</i>)" came out as "(GUSB,GRN, andNEU1)". That broke every snippet spanning an italicised gene symbol. Trimming the finished paragraph instead preserves the source's own spacing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ePtLQDGiymEgrfJKKPrLs
|
Claude finished @cmungall's task in 4m 18s —— View job PR Review: Reject empty excerpts and fix content extraction bugs
Overall this is a well-constructed PR. The core insight — that Below are the issues I found, roughly in order of importance.
1. Cached stub content is not invalidated by the extractor fix
At minimum this needs a release note telling users to clear their cache. Better would be a version stamp in the cache path or the cached file's header, so stale entries are re-fetched automatically. 2. The HTML fallback path still has the bug that was just fixed
The regression test for this path, Suggest applying the same treatment to the fallback (or 3.
|
The whole-document HTML fallback still had the bug just fixed in the paragraph branch. get_text(separator="\n", strip=True) puts a separator between every pair of markup-delimited runs, so "(<i>GUSB</i>, <i>GRN</i>)" came out as "(\nGUSB\n,\nGRN\n)". It survived validation only because normalize_text collapses whitespace and drops punctuation, and the regression test went through the matcher, so it read as covered while pinning nothing. Block boundaries are now marked explicitly (and <br> turned into a newline) so get_text() can run bare and keep the source's own spacing inside each block; the test asserts on the extracted string instead. Excerpts that quote nothing are now diagnosed like blank ones. "..." and "[editorial note]" have zero quoted characters and are exactly as vacuous as "", but were only caught later, after a pointless reference fetch and under a different message. check_excerpt_content now rejects a zero count outright, independent of min_excerpt_length. STUB_NOTICE_PHRASES traded false positives for false negatives: "access to the full text is restricted" could never match independently of "full text is restricted", while real wordings such as "access to this article is restricted" matched neither. Since the length gate is what makes matching safe, a broad "restricted" is both simpler and wider. Also: PMIDSource encodes to bytes rather than relying on the extractor's tolerance, so the declared bytes contract holds; a discarded article logs at info rather than debug, since silent dropping is what hid the original bug; the plugin reports the excerpt under its own slot name instead of a hardcoded "supporting_text"; and the precedence of the blank check over skip_references and trusted_low_similarity is now documented and pinned by tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ePtLQDGiymEgrfJKKPrLs
|
Claude finished @cmungall's task in 5m 17s —— View job PR Review: Reject empty excerpts and fix content extraction bugs
Re-reviewed at
Four things remain, in order of importance. 1. The new diagnosis never reaches the human-facing repair report
lines.append(f" Similarity: {action.similarity_score*100:.0f}%")
lines.append(f" Snippet: '{result.original_text[:60]}...'")For
2.
|
Empty excerpts are now their own repair action and report section. They were reported as REMOVAL, which meant the curator-facing report rendered them as "Similarity: 0%" over "Snippet: '...'" - implying a comparison that never happened (the fetch is deliberately skipped) over what reads as an ellipsis-only quote rather than an empty one, and indistinguishable from a fabricated quote. A removal recommendation is a verdict reached by comparison; nothing is compared here, so EMPTY_EXCERPT says what actually happened. The removals section now prints its action description too, and neither section appends an ellipsis to text short enough to be shown in full. Adding the section left empty excerpts uncounted in the summary, so RepairReport gained empty_excerpt_count. <br> is now turned into a newline before either extraction branch. It was handled only in the whole-document fallback, so "<p>Line one<br>Line two</p>" still extracted as "Line oneLine two" on the path users hit most - the same welding across markup this PR exists to fix. The empty-query branch in find_text_in_reference was unreachable once check_excerpt_content began rejecting a zero quoted-character count: count_quoted_characters sums over the same _split_query output, whose parts each hold a non-whitespace character, so count == 0 exactly when the part list is empty (verified across bracket, ellipsis and mixed cases). Rather than delete it outright, the guard moves down to _substring_match, which was returning found=True with similarity 1.0 for an empty part list - a vacuous pass of precisely the kind this PR exists to remove, and one that no longer had anything in front of it. XML is handed to BeautifulSoup as bytes instead of being decoded as UTF-8 first, so an article declaring another encoding no longer raises; this is now the shared path for every XML source, not just PMID. docs/troubleshooting.md described the removed "Query is empty after removing brackets" string, and gains a section on clearing cached stubs after upgrading, since the extractor fix cannot rewrite what is already cached. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ePtLQDGiymEgrfJKKPrLs
|
Claude finished @cmungall's task in 3m 2s —— View job PR Review: Reject empty excerpts and fix content extraction bugs
Re-reviewed at
Two things, one of them introduced by this commit. 1. The new action type silently drops empty excerpts out of the CLI exit code
if report.removal_count > 0 or report.unverifiable_count > 0:
typer.echo("\n⚠ Manual review required for some items")
raise typer.Exit(1)
elif report.suggested_count > 0:
...Before Concretely: a YAML file whose only defect is
2.
|
Two problems, both verified by running the code rather than reading it.
The exit code silently stopped failing on empty excerpts. Splitting them
out of REMOVAL in the previous commit also split them out of the
condition that decides the exit status: repair data checks removal_count
and unverifiable_count, and suggested_count requires MEDIUM confidence
while these actions are VERY_LOW, so all three branches missed them. A
file whose only defect was supporting_text: "" exited 0 with no warning,
where it exited 1 before - CI gating on the status would have gone green
on exactly the defect this PR exists to catch. The count now joins that
condition, pinned by CLI tests on both sides.
The bytes conversion on the PMC path produced mojibake. Entrez returns
str; encoding it to UTF-8 left any ISO-8859-1 declaration in place for
the parser to believe, so "François" arrived as "François". Measured:
encode utf-8 then parse : 'François measured résultats.'
pass str directly : 'François measured résultats.'
So the encode is dropped and Extractor.extract widened to Union[bytes,
str] - which was the reviewer's original suggestion, and better than the
encode I substituted for it, since BeautifulSoup fixes up the declaration
when handed str and cannot when handed bytes that contradict it. The
regression test now goes through _fetch_pmc_xml rather than building
bytes directly, so it covers the production path. PDFExtractor rejects
str explicitly, since a decoded PDF is not reparseable.
EMPTY_EXCERPT is renamed INSUFFICIENT_EXCERPT: with min_excerpt_length
set, a too-short excerpt landed under a name and a section heading that
said "empty" when it had in fact quoted something, just too little.
Also: the three identical action-counting loops collapse into one
_count_results_with helper, and the report assertion is scoped to its
section so it cannot fail later for an unrelated reason.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ePtLQDGiymEgrfJKKPrLs
|
Claude finished @cmungall's task in 5m 6s —— View job PR Review: Reject empty excerpts and fix content extraction bugs
Re-reviewed at
Five things, two of them regressions introduced by the last two commits. 1. The docs still tell curators these are removals
That was true at This page was added by this PR, so it's the PR's own documentation contradicting the PR's own behaviour. Both paragraphs need the section name and the action type updated. 2. The
|
Two doc defects, both introduced by this PR, both verified by running the
code rather than reading it.
repair-validation-errors.md still told curators that empty and too-short
excerpts are "flagged for removal". That was true until the commit that
split them out of removals precisely because a removal is a verdict
reached by comparison. The report prints INSUFFICIENT EXCERPTS, the action
type is INSUFFICIENT_EXCERPT, and removal_count deliberately excludes
them, so anyone following the doc and grepping for REMOVAL - or scripting
against removal_count - would find nothing. Both paragraphs now name the
real section and action type, and say why these are not removals.
The min_excerpt_length example was nested where it does nothing. Config
loading returns the "validation:" section when one exists and only falls
back to top-level keys otherwise, and every other config example in the
docs has that section, so a reader following the setup guide and then
pasting the snippet would have silently disabled the check:
top-level min_excerpt_length -> 0
nested min_excerpt_length -> 20
The worst failure mode for an opt-in safety check, since it looks enabled.
The example is now nested, with a note about why placement matters, and
the key is documented in the setup guide and CLI reference so the correct
nesting is discoverable.
Also: repair text no longer prints "VERY_LOW (0%)" for an excerpt that was
never compared, matching what format_report already does; the report test
now truncates at the removals section it claimed to exclude, so it cannot
fail later for an unrelated reason; and PDFExtractor's rejection of str
gets the test its new raise should have come with.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ePtLQDGiymEgrfJKKPrLs
|
Claude finished @cmungall's task in 6m 11s —— View job PR Review: Reject empty excerpts and fix content extraction bugs
Re-reviewed at
One new regression, plus smaller items. 1. The PMC encoding fix landed on one of the two call sites — and the busier one still has the bug
def _fetch_pmc_xml_bytes(self, pmcid, config) -> Optional[bytes]:
...
xml_content = handle.read()
if isinstance(xml_content, str):
xml_content = xml_content.encode("utf-8") # <-- the line 1c44a60 deleted from pmid.py
return xml_contentThis is the same It's a regression introduced by this PR, not pre-existing. On The fix is the one already applied next door: drop the encode, return the 2. Extraction and write-back disagree about which key the quote came from
text_key = "supporting_text" if "supporting_text" in d else "snippet"For the exact dict the new Two smaller consequences of the same preference, both worth a line in the PR description since they qualify its claims:
3. Smaller items
What's good
Item 1 is the one I'd fix before merge — it's a regression this PR introduces on the default full-text path, and it corrupts cached content silently. |
The encoding fix reached one of two call sites. PMCFullTextProvider
._fetch_pmc_xml_bytes still carried the encode("utf-8") that was removed
from PMIDSource for exactly this reason, and it is the busier path:
ReferenceFetcher resolves full text through the provider registry, so
that is where most PMC full text comes from.
It is a regression this PR introduced rather than a pre-existing one. On
main, XMLExtractor opened by decoding bytes back to str, so the encode
round-tripped harmlessly; once bytes went straight to the parser, which
honours the in-document declaration, the same line started corrupting
text. Measured against a document declaring ISO-8859-1:
main (encode + decode-back): 'François measured µg.'
now (encode + raw bytes) : 'François measured µg.'
fixed (no encode, str) : 'François measured µg.'
The XML is now returned exactly as Entrez gave it, with a test going
through the provider rather than building bytes directly.
The same file's HTML fallback kept its own paragraph walk - the private
copy this PR already deleted from pmid.py. It never had the welding bug,
but it also got none of the fixes: no <br> handling, no block-boundary
fallback. It now selects the region and hands that to HTMLExtractor.
Repair write-back could land in the wrong field. Extraction prefers the
first non-blank of supporting_text and snippet, while write-back took
supporting_text whenever present, so for an item with a blank
supporting_text and a real snippet, a repair of the snippet was written
into supporting_text - leaving the snippet wrong and inventing a quote in
a field that never had one. Both now call one _excerpt_key helper, so
they cannot disagree by construction.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ePtLQDGiymEgrfJKKPrLs
|
Claude finished @cmungall's task in 3m 15s —— View job PR Review: Reject empty excerpts and fix content extraction bugs
Re-reviewed at
One item, plus smaller notes. 1. The HTML consolidation landed on one of the two sites — and
|
PMIDSource._fetch_pmc_html held the third copy of the same find_all("p")
walk - after the one this PR removed from _fetch_pmc_xml in the same
module, and the one removed from the PMC provider. It is live as the HTML
fallback in PMIDSource's full-text path.
It never had the welding bug, so nothing was broken; it simply received
none of this PR's HTML work, so "<p>Line one<br>Line two</p>" still came
out as "Line oneLine two" there while both other paths handled it. The
region is still selected here; only the text extraction is delegated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ePtLQDGiymEgrfJKKPrLs
|
Claude finished @cmungall's task in 3m 58s —— View job PR Review: Reject empty excerpts and fix content extraction bugs
Re-reviewed at
Nothing blocking. Findings this round are all structural follow-ons from the consolidation. 1. Three call sites now round-trip through
|
| Site | Value |
|---|---|
xml.py:29 |
MAX_STUB_NOTICE_CHARS = 1000 |
pmc.py:32 |
_MIN_PMC_FULLTEXT_CHARS = 1000 |
pmid.py:358,362 |
bare literal 1000, twice |
Today that works out exactly: a notice is_stub_notice would catch is by definition ≤ 1000 chars, and every HTML caller rejects text ≤ 1000 chars. But the coverage is a numeric coincidence across three uncoupled declarations, and the two in pmid.py aren't even named. Raise MAX_STUB_NOTICE_CHARS to catch a longer notice wording and the HTML paths silently start accepting stubs in the 1000–N band. Given this PR's own theme — the same logic in two places drifts — one shared constant (and ideally running is_stub_notice on the HTML text too) is the cheap version of the fix.
4. Smaller items
_fetch_pmc_xml_bytesstill says bytes (pmc.py:60,101) — carried unaddressed from the last round. The whole point of8e679bfwas that this returns astron the production path; both the method name and thexml_byteslocal now contradict the docstring directly above them. That's precisely the kind of name that invites the next reader to re-add theencode("utf-8")to make it true again. One rename.- Silent widening of
_fetch_pmc_html— both providers previously returnedNonewhen the region held no<p>; they now return the region's block-separated text. Almost certainly an improvement, gated by the ≥1000-char checks, but it's a change in what counts as full text that no test or note records. - Only one excerpt key is ever validated by
repair data—_excerpt_keyreturns a single key, so an item with bothsupporting_textandsnippetnon-blank has the second silently unchecked. The plugin iterates_find_excerpt_fieldsand checks each (reference_validation_plugin.py:187), so the two layers still disagree. Pre-existing (the oldorchain did the same), but the named helper now makes it look deliberate. pmid.py:258-259—_fetch_pubmed_xmlstill hand-decodes UTF-8, now with two neighbouring comments explaining why not to. No mojibake (bs4 fixes up the declaration forstr), but non-UTF-8 bytes raiseUnicodeDecodeErrorand take the MeSH/publication-type metadata with them. Out of the stated scope; the last inconsistent one.- Broad
"restricted"+ 1000-char gate — still the right trade; residual risk that a short erratum using the word is dropped, made diagnosable by theinfolog atxml.py:115. - Still unmentioned in the PR description: the plugin emits an ERROR for any present-but-empty-string excerpt on any instance, so data using
supporting_text: ""as a placeholder will light up (Noneis correctly left alone by theisinstanceguard); andvalidate()rejects blank excerpts ahead ofskip_prefixes/trusted_low_similarity, so a blank excerpt on a skipped or trusted reference changes from INFO/valid to ERROR. Both are the point of the PR — they want a release-note line so first-run output isn't a surprise.
What's good
- The delegation keeps region selection at the call site and moves only text extraction. That's the correct seam: the region is a source-specific fact (
div.article-bodyvsdiv.tsecis a PMC detail), while<br>handling and inline-markup spacing are not. A blunter consolidation would have pushed the PMC selectors into the extractor. - Naming the change accurately — "It never had the welding bug, so nothing was broken; it simply received none of this PR's HTML work" — is what makes the commit reviewable. Over seven rounds the commit messages have consistently distinguished fixed a bug from closed a drift, and that distinction has held up every time I checked it against the code.
- Three rounds running, the fix has landed on the shared level rather than the reported site:
check_excerpt_content()as the single source of truth for excerpt substance,_excerpt_keyas the single resolver for read and write-back, and now one HTML text extractor for all three PMC paths. Each of the last three rounds surfaced a twin of the reported defect; the surface for that class of finding is now essentially closed. is_stub_notice's design — breadth in the phrase list, safety from the length gate — remains the right inversion of the original bug, and the doctest demonstrates both halves rather than asserting them._substring_match's empty-parts guard is still the load-bearing invariant: it keeps the primitive honest even as new callers (the plugin, the repairer,repair text) were added around it over the course of the PR.
I'd take the _fetch_pmc_xml_bytes rename (item 4) since it's a one-liner guarding against a regression this PR already had to fix twice; the rest is optional follow-up. Nothing here blocks merge.
· Branch: claude/empty-snippets-pgwe8z
The three PMC paths consolidated onto HTMLExtractor were reaching it via extract(str(article_body)), which serialised an already-parsed tag back to markup so the extractor could parse the article body a second time on every fetch. Worse, extract() opens by choosing <article>/<main>, so it re-ran region selection inside a region the caller had already settled: a nested <article> would have silently narrowed the scope and dropped everything around it. extract_scope() now does the <br>/paragraph/block work on a tag it is handed, and extract() is the parse-then-delegate wrapper. The seam stays where it was - the region is a source-specific fact (div.article-body vs div.tsec is a PMC detail), the text handling is not. The 1000-char threshold was three uncoupled declarations: MAX_STUB_NOTICE _CHARS, _MIN_PMC_FULLTEXT_CHARS, and two bare literals in pmid.py. Only the XML path runs is_stub_notice, so on both HTML paths that length gate is the only thing between a placeholder page and the cache - and it held purely because the numbers happened to match. Raising one would have opened a band where stubs are accepted. They are now one constant. _fetch_pmc_xml_bytes is renamed _fetch_pmc_xml_source: it deliberately returns whatever Entrez gave us, usually str, and a name promising bytes is an invitation to re-add the encode() this PR twice had to remove. Also corrects two test docstrings that credited the delegation with covering the no-paragraph fallback. Both fixtures use <p> elements, so they never reach it; the fallback now has its own test, which also records that these paths return a region's text where they used to return None. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ePtLQDGiymEgrfJKKPrLs
|
Claude finished @cmungall's task in 2m 25s —— View job PR Review: Reject empty excerpts and fix content extraction bugs
Re-reviewed at
One regression introduced by this commit, plus smaller notes. 1.
|
Moving the three PMC paths onto extract_scope() bypassed the script/style
decompose, which lived in extract() ahead of the region selection. In
practice nothing leaked - bs4's get_text() skips Script and Stylesheet
strings by default, verified against bs4 4.14.2:
interesting_string_types: {NavigableString, CData}
- so this held by a library default rather than by anything the extractor
did. That is thin cover for a guarantee three call sites now depend on: a
JSON-LD blob or stylesheet reaching cached article text would corrupt
every snippet checked against it.
The decompose moves into extract_scope, where the PMC paths actually
enter, and a test pins it there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ePtLQDGiymEgrfJKKPrLs
|
Claude finished @cmungall's task in 2m 3s —— View job PR Review: Reject empty excerpts and fix content extraction bugs
Re-reviewed at
Nothing blocking. Two carryovers and small notes. 1.
|
extract_scope edited the tag it was handed. Under extract() that was
invisible, since the soup was local and thrown away; as a public entry
point taking a caller-owned tag it is not. Measured:
first : 'One\nTwo'
second : 'One\n\nTwo'
third : 'One\n\n\nTwo'
Each call inserted another block separator into the caller's tree, so
extraction was neither repeatable nor free of side effects on a document
the caller still holds.
It now works on a copy. That is cheaper than the serialise-and-reparse it
replaced - ~14ms against ~24ms on a 76 KB body - and both are noise beside
the network fetch that produced the markup, so the earlier commit's
performance argument survives while the API becomes honest. Two tests pin
it: repeated calls agree, and the caller's document is unchanged.
The equivalence test between the two entry points held by construction:
its fixture had no script or style, which is exactly what distinguished
them. It has both now, so it would have caught the regression fixed in
the previous commit.
The constants comment argued only the safe direction. Raising
MAX_STUB_NOTICE_CHARS is safe and is what the coupling exists for;
lowering it - the tempting response to a short erratum being discarded
for saying "restricted" - drags the PMC full-text gate down with it and
admits stubs in the band it just vacated. Both halves are now written
down, and pmid.py says why it uses the constant rather than importing it
bare.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ePtLQDGiymEgrfJKKPrLs
|
Claude finished @cmungall's task in 3m 22s —— View job PR Review: Reject empty excerpts and fix content extraction bugs
Re-reviewed at
I also checked the copy's safety rather than assuming it: bs4's
Nothing blocking. Three notes, all follow-ons from this commit. 1.
|
The script/style removal used the call shorthand while the three selections around it use find_all. Same operation, same six lines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ePtLQDGiymEgrfJKKPrLs
|
Claude finished @cmungall's task in 1m 57s —— View job PR Review: Reject empty excerpts and fix content extraction bugs
Re-reviewed at Nothing new found. Two items carry over from the last round, both optional.
1.
|
extract() parses its own soup and discards it, so the defensive copy
extract_scope makes for caller-owned tags protected nobody there - it just
walked the whole tree again on every non-PMC HTML fetch, reinstating on the
generic path the cost the scope-taking entry point was added to remove.
The body moves to a private _extract_scope; the public extract_scope is
that plus the copy, and extract() enters directly.
The review flagged the pessimistic case, that BeautifulSoup.__copy__ might
re-encode and re-parse rather than walk. It does not, on bs4 4.14.2:
__copy__ -> __deepcopy__({})
parse : 16.7 ms
copy.copy(soup) : 13.4 ms
So the waste was a tree walk, not a reparse - still waste, and the split
removes it either way.
The constants comment said "lower the notice length only with a floor kept
here", but there was nowhere for pmid.py to keep one: the floor was private
to the provider module while pmid.py compared against MAX_STUB_NOTICE_CHARS
directly, so following the instruction would have left the PMID full-text
path silently tracking the notice length down. The floor is now
MIN_FULLTEXT_CHARS in extract/xml.py, beside the constant it is derived
from and imported by both gates, so the instruction has one place to be
true.
Both purity tests were self-referential: the idempotence test compared
calls only to each other, which three identical wrong answers satisfy, and
the caller-tree test discarded the result, which an early return satisfies.
Both now assert the expected text as well.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ePtLQDGiymEgrfJKKPrLs
|
Claude finished @cmungall's task in 2m 40s —— View job PR Review: Reject empty excerpts and fix content extraction bugs
Re-reviewed at
One finding, plus carryovers. 1. The floor three commits now reason about has no test, and the one fixture that crosses it clears it by 28 characters
Grepping the suite: Worse, that one fixture sits almost exactly on the boundary. Its 40 paragraphs are The gap matters more after this commit than before it. The comment's whole argument is that lowering 2. Smaller items
What's good
Nothing here blocks merge. Item 1 is a coverage gap rather than a defect — but it's a gap under the one constant this PR has now spent three commits explaining. |
MIN_FULLTEXT_CHARS had twelve lines of comment across three commits and
nothing asserting it. All four gates could have been deleted, or their >
changed to >=, with the suite still green - and the constant is what the
comment itself calls the only stub defence on the HTML paths.
The one test that crossed the gate cleared it by 28 characters, measured
rather than estimated:
floor : 1000
extracted length : 1028
so shortening a sentence, dropping a paragraph or raising the floor would
have failed it for a reason unrelated to what it checks. It is now sized
from the constant.
Five tests cover the gates: rejection below the floor on the PMC XML path,
the PMC HTML fallback and the PMID full-text path, plus both sides of the
boundary and an acceptance case so the rejections cannot pass by rejecting
everything. Checked by mutation rather than assumed:
> became >= -> 1 failure
HTML gate deleted -> 2 failures
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ePtLQDGiymEgrfJKKPrLs
|
Claude finished @cmungall's task in 2m 49s —— View job PR Review: Reject empty excerpts and fix content extraction bugs
Re-reviewed at
One finding, plus carryovers. 1. Deletion is pinned on all four gates; exclusivity is pinned on one
That's consistent with the commit's own report — " Cheapest close: parametrize the boundary test over the two 2. Smaller items
What's good
Nothing here blocks merge. Item 1 is a narrowing of a claim rather than a defect: the gates can no longer be deleted, only their exclusivity is unpinned on three of four. |
Both gates read "from ...etl.extract.xml import MIN_FULLTEXT_CHARS" on the line above a check applied to HTML output. The constant stays defined beside MAX_STUB_NOTICE_CHARS, which is where its reasoning belongs, but it is re-exported from etl.extract so the import reads like what it is: an extraction-layer floor, not an XML one. _extract_scope also now says outright who it is for - callers that own the tree - so reaching for it instead of the public method reads as opting out of the copy rather than as picking the shorter name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ePtLQDGiymEgrfJKKPrLs
|
Claude finished @cmungall's task in 2m 6s —— View job PR Review: Reject empty excerpts and fix content extraction bugs
Re-reviewed at
One finding, plus carryovers. 1.
|
Correcting an overclaim in 3527e9e. Its message reported "> became >= -> 1 failure", which reads as though the boundary had been verified for the constant. It had not: the mutation ran against the PMC gates and only the HTML fallback's fixture sat near the floor, so exclusivity was pinned on one of four uses. Measured on the other three: both pmid.py gates > to >= -> 776 passed pmc.py XML gate > to >= -> 776 passed The boundary check is now parametrized over all four gates - both locate() gates and both _fetch_pmc_fulltext() gates - each driven at exactly the floor and one character past it. Verified by mutating each in turn: pmc XML -> [pmc-xml] fails pmc HTML -> [pmc-html] fails both pmid -> [pmid-xml] and [pmid-html] fail TestPMCProvider's config fixture was an exact duplicate of the module-level one added alongside the floor tests, so an edit to either would have missed half the PMC tests. The class now uses the module fixture. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ePtLQDGiymEgrfJKKPrLs
|
Claude finished @cmungall's task in 2m 37s —— View job PR Review: Reject empty excerpts and fix content extraction bugs
Re-reviewed at
One finding, plus carryovers. 1. Every floor fixture is sized from the floor, so the value the comment defends is unpinned
That last sentence is an instruction to a human that nothing enforces, and this commit is the one that makes the gap visible — because it completes the relative half of the coverage. Every fixture in the suite is now expressed in terms of the constant: Concretely: someone takes the erratum case this PR has carried as a known residual since round three, sets The enforceable version of the paragraph is one assertion: def test_the_floor_does_not_follow_the_notice_length_below_1000():
"""xml.py's comment says to pin this to a literal before lowering
MAX_STUB_NOTICE_CHARS. Nothing else in the suite would notice if it moved:
every fixture is sized from the constant, so they all move with it."""
assert MIN_FULLTEXT_CHARS >= 1000Cheap, and it turns the one piece of reasoning this PR has spent four commits writing down into something that fails rather than something that has to be read. 2. Carryover:
|
Added needlessly in 9d5899b alongside MIN_FULLTEXT_CHARS, which had a reason to move: it gates four sites across two formats. This one is used by is_stub_notice and nothing else, exactly as xml.py says two lines above it - "Only the XML path runs is_stub_notice" - so promoting it to the package surface asserted the opposite of its own comment. Nothing imported it from the new address; the only consumer already takes it from extract.xml beside is_stub_notice, which was never exported, so the package spelling could not have been used anyway. A second address for one constant is the shape the rest of this PR spent its time removing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ePtLQDGiymEgrfJKKPrLs
|
Claude finished @cmungall's task in 1m 28s —— View job PR Review: Reject empty excerpts and fix content extraction bugs
Re-reviewed at
One finding, unchanged from last round, plus carryovers. 1. Carryover: the floor's absolute value is still unpinned, and this commit narrows the blast radius without closing it
Nothing enforces that. Every fixture in the suite is sized from the constant — Set This commit makes that slightly more likely to happen, not less: with One assertion turns the paragraph into something that fails: def test_the_floor_does_not_follow_the_notice_length_below_1000():
"""xml.py's comment says to pin this to a literal before lowering
MAX_STUB_NOTICE_CHARS. Nothing else in the suite would notice if it moved:
every fixture is sized from the constant, so they all move with it."""
assert MIN_FULLTEXT_CHARS >= 10002. Smaller items
What's good
Nothing here blocks merge. Item 1 is the last open gap: the floor's relative behaviour is fully pinned, its absolute value isn't, and that value is what the seven-line comment above it exists to defend. |
The outstanding item from the first review round. Fixing an extractor does not rewrite what it already cached, and cache paths key only on reference id, so anyone who validated an affected paper before this PR kept being served the 8.7 KB placeholder or the welded markup - correct snippets rejected, with nothing in the output to say why. The fix never reached them. Entries now carry extractor_version in their frontmatter, and the validation read path treats an entry stamped below the current version as absent, so it is re-fetched and rewritten. Refreshing is lazy and per-reference: nothing is deleted, and a cache is refreshed as it is used rather than in one sweep. A stamp *newer* than the current one is left alone, so an older tool reading a newer cache does not re-fetch on every run. Two deliberate exemptions. iter_cached_references, which backs cache export and the Zotero enrichment, reads unstamped entries unchanged: it walks the cache as a record of what was fetched, and dropping older entries there would lose them rather than refresh them. And the legacy pre-Markdown .txt format is exempt, because nothing has written it for a long time - such entries are as likely to be hand-maintained as tool-written, and they are not what the extractor bugs produced, since those wrote Markdown. Re-fetching them would discard someone's data to fix a problem they do not have. The test fixtures are stamped, since they stand in for entries the current extractors wrote. Also in this commit, the remaining review items: _fetch_pubmed_xml no longer hand-decodes UTF-8. It was the last site doing so, two functions above the one fixed for it; a PubMed record in another encoding raised UnicodeDecodeError and took the abstract, MeSH terms and publication types with it. repair data now checks every excerpt key on an item rather than the first usable one. The LinkML plugin iterates each excerpt field, so picking one key left a second quote silently unvalidated. Reported paths now name the field (evidence[0].snippet), which is also what keeps a repair from landing in the wrong one. The floor's absolute value is pinned. Every fixture is sized from MIN_FULLTEXT_CHARS, so lowering MAX_STUB_NOTICE_CHARS would move the tests with it and silently reopen the band the gates just closed - the failure xml.py asks a human to prevent by hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ePtLQDGiymEgrfJKKPrLs
|
Claude finished @cmungall's task in 4m 25s —— View job PR Review: Reject empty excerpts and fix content extraction bugs
Re-reviewed at
Four things, the first of which is a behaviour regression on a workflow this tool documents. 1. A stale entry whose source no longer answers is discarded with nothing to fall back on
That matters more here than it would in most caches, because this one is explicitly a pre-population store. The invalidation is right; the missing half is the fallback. Serving the stale entry when the refresh yields nothing (with a warning naming it as stale) keeps the whole benefit — every reachable reference still refreshes on first use — and costs only the case where the alternative is no reference at all. Neither behaviour is currently pinned by a test, so whichever you want is worth writing down. 2. The line that explains the re-fetch is invisible unless the user passes
|
The extractor_version stamp makes the validation read path treat an older
entry as absent so the current extractors rewrite it. That is only safe while
a replacement can actually be fetched. Offline, during a provider outage, or
for a withdrawn record, fetch() returned None and the usable copy on disk was
unreachable - measured against a pre-populated cache with the source raising
OSError("network unreachable"):
cache file exists : True
offline fetch : None
`cache reference` is documented as pre-population for offline validation, so
every entry pre-fetched before this branch is unstamped: the first offline run
after upgrading reported every reference as not found, and so did the second.
fetch() now falls back to the stale entry, warning that its text may still
contain the errors this version fixes. The entry is deliberately not rewritten,
so the next run that can reach the source still refreshes it, and --force opts
out - an explicit refresh that failed should say so.
Also from the same review:
- Frontmatter now ends at a line that is exactly `---`, not at `---` inside a
value. A reference id like url:https://example.com/a---b truncated the
searched block and hid the stamp, so the entry was re-fetched, rewritten with
the same id, and read as unstamped again on every run. The same split ran in
_load_markdown_format, where it silently dropped every field after such a
value: a title of "Before --- after" lost the journal.
- The legacy .txt exemption follows which file was read rather than sniffing
for a leading `---`, making the comment true by construction.
- extract/__init__.py, xml.py and html.py point at EXTRACTOR_CACHE_VERSION,
where the next person changing extraction will be standing.
- Troubleshooting says the re-fetch explanation needs -v (it logs at INFO, and
setup_logging configures logging only when verbose), states that the refresh
covers the whole cache, and describes the offline fallback.
- Sample repair output in the docs shows the path shape the excerpt-key fix
produces, evidence[0].supporting_text.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ePtLQDGiymEgrfJKKPrLs
|
Claude finished @cmungall's task in 4m 10s —— View job PR Review: Reject empty excerpts and fix content extraction bugs
Re-reviewed at
Two findings, plus carryovers. 1.
|
Summary
Three reported bugs, all in the same family: things that were substantively empty or corrupted passed or failed validation for the wrong reasons.
get_text(strip=True)trimmed each markup-delimited run before joining, so(<i>GUSB</i>, <i>GRN</i>, and <i>NEU1</i>)extracted as(GUSB,GRN, andNEU1), breaking every snippet spanning an italicised gene symbol.These are the point of the PR, but they will surface immediately:
supporting_text: ""orsnippet: ""as a placeholder will report errors where it previously passed. An absent excerpt field is untouched — that stays your schema's business.skip_prefixesandtrusted_low_similarity. A blank excerpt on a skipped or trusted reference changes from valid/INFO to ERROR. Those settings are statements about a reference; a blank excerpt is a defect in the data that no reference can excuse.rate_limit_delayper reference. Lazy and per-reference: nothing is deleted, andcache export/ Zotero enrichment read older entries unchanged. If a reference can't be re-fetched (offline, outage, withdrawn record) the older copy is served with a warning rather than reported as missing. The explanation for the ordinary re-fetch is logged at INFO, so it needs-v. See troubleshooting.repair dataexits non-zero for a file whose only defect is an empty excerpt.evidence[0]→evidence[0].supporting_text. That is what stops a repair landing in a different field than the quote came from, but anything downstream parsing those paths sees a different string.Key changes
Empty excerpts
check_excerpt_content()is the single source of truth for whether an excerpt has substance, used by the validator, the LinkML plugin and the repairer. It runs before any reference is fetched.[brackets]and...separators are removed.INSUFFICIENT_EXCERPTis its own repair action with its own report section and exit-code contribution — deliberately not aREMOVAL, since a removal is a verdict reached by comparison and nothing here was compared.min_excerpt_length(opt-in, default0) rejects excerpts below a minimum, measured in non-whitespace characters of quoted text so PDF-to-text damage and chemical nomenclature don't distort it. Set it undervalidation:— top-level keys are silently ignored when avalidation:section exists._substring_matchrefuses an empty part list rather than returningfound=True, keeping the primitive honest for new callers.Content extraction
<br>becomes a newline on every path.extract_scope(), which takes an already-parsed region without re-parsing or re-scoping it, and does not modify the caller's tree.François→François).MIN_FULLTEXT_CHARSfloor for all four full-text gates.EXTRACTOR_CACHE_VERSION, so the next extraction fix is made next to a note about the cache it invalidates.Caching
extractor_version; the validation read path treats older stamps as absent so they refresh. Newer stamps are left alone, so two versions sharing one cache don't re-fetch each other's work.--forceopts out.---. Splitting on the bare string let a value containing---(a URL reference id, a title) truncate the block: it hid the version stamp, making such entries re-fetch on every run, and dropped every field after it on load.Testing
799 tests, 200 doctests, mypy and ruff clean. Regression tests name the exact cases from the reports — the Morris 2019 sentence, the
(GUSB, GRN, and NEU1)gene list — and assert the previously-rejected snippets now validate end to end.The full-text floor, the empty-excerpt exit code and the offline fallback are verified by mutating the source and confirming the intended tests fail, rather than by writing tests and watching them pass.
Smaller behaviour notes
repair datanow checks every excerpt key on an item, matching the plugin. An item carrying the same quote under bothsupporting_textandsnippetis therefore reported once per key; the reference itself is fetched once.supporting_text(a list, say) is dropped by the repair CLI rather than throwing inside_split_query.https://claude.ai/code/session_018ePtLQDGiymEgrfJKKPrLs