reference-audit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. reference_audit/__init__.py +8 -0
  2. reference_audit/alignmentcheck.py +136 -0
  3. reference_audit/bookcheck.py +98 -0
  4. reference_audit/cache/__init__.py +1 -0
  5. reference_audit/cache/db.py +108 -0
  6. reference_audit/cache/store.py +128 -0
  7. reference_audit/cli.py +190 -0
  8. reference_audit/config.py +179 -0
  9. reference_audit/fieldcheck.py +599 -0
  10. reference_audit/inputs.py +96 -0
  11. reference_audit/llm/__init__.py +6 -0
  12. reference_audit/llm/client.py +85 -0
  13. reference_audit/llm/prompts.py +206 -0
  14. reference_audit/llm/schemas.py +38 -0
  15. reference_audit/matching/__init__.py +6 -0
  16. reference_audit/matching/adjudicate.py +63 -0
  17. reference_audit/matching/features.py +185 -0
  18. reference_audit/matching/names.py +238 -0
  19. reference_audit/matching/pool.py +344 -0
  20. reference_audit/matching/sameobject.py +150 -0
  21. reference_audit/matching/scoring.py +75 -0
  22. reference_audit/matching/verdict.py +56 -0
  23. reference_audit/matching/webcheck.py +189 -0
  24. reference_audit/models.py +412 -0
  25. reference_audit/parsing/__init__.py +1 -0
  26. reference_audit/parsing/bib.py +392 -0
  27. reference_audit/parsing/context.py +55 -0
  28. reference_audit/parsing/entities.py +22 -0
  29. reference_audit/parsing/identifiers.py +165 -0
  30. reference_audit/parsing/latex.py +83 -0
  31. reference_audit/parsing/tex.py +159 -0
  32. reference_audit/pdf/__init__.py +16 -0
  33. reference_audit/pdf/embedded.py +117 -0
  34. reference_audit/pdf/grobid.py +234 -0
  35. reference_audit/pdf/tei.py +470 -0
  36. reference_audit/pipeline.py +1692 -0
  37. reference_audit/report.py +330 -0
  38. reference_audit/sources/__init__.py +6 -0
  39. reference_audit/sources/arxiv.py +81 -0
  40. reference_audit/sources/base.py +54 -0
  41. reference_audit/sources/clickhouse.py +575 -0
  42. reference_audit/sources/crossref.py +90 -0
  43. reference_audit/sources/dblp.py +140 -0
  44. reference_audit/sources/google_books.py +101 -0
  45. reference_audit/sources/http.py +260 -0
  46. reference_audit/sources/normalize.py +464 -0
  47. reference_audit/sources/openalex.py +93 -0
  48. reference_audit/sources/openlibrary.py +158 -0
  49. reference_audit/sources/publisher.py +186 -0
  50. reference_audit/sources/registry.py +117 -0
  51. reference_audit/sources/render.py +115 -0
  52. reference_audit/sources/semantic_scholar.py +68 -0
  53. reference_audit/sources/titlewords.py +31 -0
  54. reference_audit/sources/web.py +256 -0
  55. reference_audit/versioning.py +125 -0
  56. reference_audit-0.1.0.dist-info/METADATA +715 -0
  57. reference_audit-0.1.0.dist-info/RECORD +60 -0
  58. reference_audit-0.1.0.dist-info/WHEEL +4 -0
  59. reference_audit-0.1.0.dist-info/entry_points.txt +2 -0
  60. reference_audit-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,189 @@
1
+ """Web-artifact verification funnel for URL-only ``@misc`` references.
2
+
3
+ Given the page fetched from the cited URL (`sources.web.WebAdapter`), decide the verdict with a
4
+ cheap-first funnel that mirrors the rest of the pipeline:
5
+
6
+ 1. **Fetch outcome.** A transport error / bot-wall leaves the entry UNRESOLVED (reported, retried —
7
+ never a false 'no match'). A dead link (404/410) is a real finding, also reported, and likewise
8
+ leaves the entry unresolved (a rotted or wrong URL is not, by itself, proof the work never
9
+ existed — reliability: report the gap, never guess a hallucination). A client-side-rendered
10
+ (single-page-app) shell we could not render (no browser, or empty even after rendering) is the
11
+ same: reported and left unresolved, NEVER read as a different/`none` resource.
12
+ 2. **HTML metadata (deterministic).** If the page's self-declared title matches the cited title
13
+ (and authors corroborate, when both sides list them), the page IS the cited resource →
14
+ ``exactly_one``, with no LLM call (the 100%-match short-circuit).
15
+ 3. **LLM fallback.** When the metadata is absent or inconclusive, ask the model whether the page is
16
+ the cited resource (affirmative polarity, cached). A confident yes → ``exactly_one``; a
17
+ high-confidence no → ``none`` (a live page that is positively a different/`not found`/login
18
+ resource — a likely wrong or fabricated URL); anything else stays UNRESOLVED.
19
+
20
+ Returns ``(verdict, issues)`` — the pipeline owns the audit object. A ``None`` verdict means
21
+ unresolved (never cached, retried next run).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from reference_audit.cache.store import AuditCache, prompt_hash
27
+ from reference_audit.config import AuditConfig
28
+ from reference_audit.llm.client import LLMClient, LLMError
29
+ from reference_audit.llm.prompts import WEB_MATCH_SYSTEM, web_match_user
30
+ from reference_audit.matching.features import title_ratio
31
+ from reference_audit.matching.names import author_overlap, author_subset
32
+ from reference_audit.models import (
33
+ BibEntry,
34
+ Identifiers,
35
+ MatchedArtifact,
36
+ SourceQueryResult,
37
+ SourceRecord,
38
+ Verdict,
39
+ WebMatchResult,
40
+ )
41
+
42
+
43
+ def _confirmed(page: SourceRecord, confidence: str, rationale: str) -> Verdict:
44
+ """An ``exactly_one`` verdict whose single artifact is the fetched web page itself."""
45
+ artifact = MatchedArtifact(
46
+ records=[page],
47
+ versions=[page],
48
+ best_record=page,
49
+ merged_ids=page.ids or Identifiers(),
50
+ )
51
+ return Verdict(
52
+ kind="exactly_one",
53
+ artifacts=[artifact],
54
+ confidence=confidence,
55
+ rationale=f"confirmed via web page — {rationale}",
56
+ )
57
+
58
+
59
+ def _metadata_confirms(entry: BibEntry, page: SourceRecord, config: AuditConfig) -> bool:
60
+ """Deterministic step 2: page's self-declared title (and authors, if any) match the citation."""
61
+ if not page.title or title_ratio(entry.title, page.title) < config.web_title_accept:
62
+ return False
63
+ # When both sides list authors they must corroborate; a missing list on either side never blocks
64
+ # (many legitimate pages omit author metadata — that case is what the LLM fallback is for, but a
65
+ # strong title match alone is enough to confirm here).
66
+ if entry.authors and page.authors:
67
+ return (
68
+ author_overlap(entry.authors, page.authors) >= config.author_accept
69
+ or author_subset(entry.authors, page.authors)
70
+ )
71
+ return True
72
+
73
+
74
+ async def _ask_llm(
75
+ entry: BibEntry,
76
+ page: SourceRecord,
77
+ llm: LLMClient,
78
+ cache: AuditCache | None,
79
+ ) -> WebMatchResult:
80
+ system = WEB_MATCH_SYSTEM
81
+ user = web_match_user(entry, page)
82
+ p_hash = prompt_hash(system + "\n" + user)
83
+ if cache is not None:
84
+ cached = cache.get_llm_decision(p_hash, "web_match")
85
+ if cached is not None:
86
+ return WebMatchResult.model_validate_json(cached)
87
+ result = await llm.structured(system, user, WebMatchResult, "web_match")
88
+ if cache is not None:
89
+ cache.put_llm_decision(p_hash, "web_match", result.model_dump_json())
90
+ return result
91
+
92
+
93
+ async def check_web_reference(
94
+ entry: BibEntry,
95
+ fetched: SourceQueryResult,
96
+ llm: LLMClient | None,
97
+ config: AuditConfig,
98
+ cache: AuditCache | None,
99
+ ) -> tuple[Verdict | None, list[str]]:
100
+ """Run the web funnel over the fetched page. Returns (verdict | None-for-unresolved, issues)."""
101
+ issues: list[str] = []
102
+
103
+ if fetched.error:
104
+ issues.append(
105
+ f"could not check the cited URL (left unresolved, will retry next run): {fetched.error}"
106
+ )
107
+ return None, issues
108
+
109
+ page = fetched.records[0] if fetched.records else None
110
+ if page is None:
111
+ issues.append("no URL to check for this web reference")
112
+ return None, issues
113
+
114
+ raw = page.raw or {}
115
+ if raw.get("dead"):
116
+ issues.append(
117
+ f"cited URL is a dead link (HTTP {raw.get('status')} — page no longer exists); "
118
+ "the reference could not be verified"
119
+ )
120
+ return None, issues
121
+
122
+ # A client-side-rendered (single-page-app) shell whose real content we could not read: NEVER a
123
+ # `none`. An empty app shell is not positive evidence of a different resource — feeding it to the
124
+ # LLM (which sees only a generic landing/spinner page) yields a false "wrong/fabricated URL"
125
+ # verdict. Report the gap and leave it unresolved, per the reliability rule. See `sources.web`.
126
+ render = raw.get("render")
127
+ if render == "unavailable":
128
+ issues.append(
129
+ "cited URL returns a JavaScript-rendered page whose content loads only in a browser, "
130
+ "and no headless browser is available to render it; left unresolved (install/configure "
131
+ "a headless browser to verify these — see web_render_browser_path)"
132
+ )
133
+ return None, issues
134
+ if render == "rendered_empty":
135
+ issues.append(
136
+ "cited URL returns a JavaScript-rendered page that stayed empty even after headless "
137
+ "rendering; the reference could not be verified (left unresolved, will retry next run)"
138
+ )
139
+ return None, issues
140
+
141
+ # Step 2 — deterministic HTML-metadata check (no LLM cost on a clean match). A clean confirm is
142
+ # NOT a problem: it is reported on the verdict line (rationale) only, never appended to `issues`
143
+ # — `issues` drives the report's needs-attention grouping + ⚠, so a success there is misleading.
144
+ if _metadata_confirms(entry, page, config):
145
+ author_note = " + author" if (entry.authors and page.authors) else ""
146
+ return _confirmed(page, "high", f"page HTML metadata matches the citation (title{author_note})"), issues
147
+
148
+ # Step 3 — metadata absent or inconclusive → LLM.
149
+ if llm is None:
150
+ issues.append(
151
+ "cited URL is live but its HTML metadata did not confirm the reference; "
152
+ "LLM check is disabled (--no-llm), so it is left unresolved"
153
+ )
154
+ return None, issues
155
+
156
+ try:
157
+ decision = await _ask_llm(entry, page, llm, cache)
158
+ except LLMError:
159
+ issues.append(
160
+ "cited URL is live but could not be confirmed: the LLM check failed "
161
+ "(left unresolved, will retry next run)"
162
+ )
163
+ return None, issues
164
+
165
+ if decision.corresponds and decision.confidence in ("high", "medium"):
166
+ # As with the metadata confirm: a success is carried by the verdict rationale, not `issues`.
167
+ return _confirmed(page, decision.confidence, f"LLM-verified ({decision.reason})"), issues
168
+
169
+ if not decision.corresponds and decision.confidence == "high":
170
+ # Positive evidence the live page is a *different* resource (a wrong/fabricated URL): the
171
+ # README's `none`. A weaker rejection is not enough to cry hallucination — it stays unresolved.
172
+ issues.append(
173
+ "the cited URL is live but its content does NOT correspond to the reference "
174
+ f"(LLM-judged: {decision.reason}) — possible wrong or fabricated URL"
175
+ )
176
+ return (
177
+ Verdict(
178
+ kind="none",
179
+ confidence="high",
180
+ rationale=f"the live page at the cited URL is not the cited resource ({decision.reason})",
181
+ ),
182
+ issues,
183
+ )
184
+
185
+ issues.append(
186
+ "cited URL is live but could not be confirmed as the reference "
187
+ f"(LLM uncertain: {decision.reason}) — left unresolved"
188
+ )
189
+ return None, issues
@@ -0,0 +1,412 @@
1
+ """All pydantic data models — the single source of truth.
2
+
3
+ Seeded from `paper-search-mcp` `Paper` and `sciwrite-lint` `Citation`, promoted to pydantic
4
+ with an explicit `Identifiers` value object and a version graph (`version_links`,
5
+ `openalex_work_id`) that lets the matcher recover preprint↔published relations (the T1 fix).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import re
12
+ from enum import StrEnum
13
+ from typing import Literal
14
+
15
+ from pydantic import BaseModel, Field, field_validator, model_validator
16
+
17
+ from reference_audit.parsing.entities import decode_html_entities
18
+
19
+
20
+ class EntryType(StrEnum):
21
+ ARTICLE = "article"
22
+ INPROCEEDINGS = "inproceedings"
23
+ BOOK = "book"
24
+ INCOLLECTION = "incollection"
25
+ MISC = "misc"
26
+ UNKNOWN = "unknown"
27
+
28
+
29
+ # bib type string -> EntryType
30
+ _BIB_TYPE_MAP = {
31
+ "article": EntryType.ARTICLE,
32
+ "inproceedings": EntryType.INPROCEEDINGS,
33
+ "conference": EntryType.INPROCEEDINGS,
34
+ "book": EntryType.BOOK,
35
+ "inbook": EntryType.INCOLLECTION,
36
+ "incollection": EntryType.INCOLLECTION,
37
+ "misc": EntryType.MISC,
38
+ "online": EntryType.MISC,
39
+ "electronic": EntryType.MISC,
40
+ "unpublished": EntryType.MISC,
41
+ "techreport": EntryType.MISC,
42
+ }
43
+
44
+
45
+ def entry_type_from_bib(bib_type: str) -> EntryType:
46
+ return _BIB_TYPE_MAP.get((bib_type or "").strip().lower(), EntryType.UNKNOWN)
47
+
48
+
49
+ def _norm_token_string(s: str) -> str:
50
+ """Lowercase, collapse non-alphanumerics to single spaces — for stable hashing."""
51
+ return re.sub(r"[^a-z0-9]+", " ", (s or "").lower()).strip()
52
+
53
+
54
+ class Identifiers(BaseModel):
55
+ """Normalized identifiers. DOIs are stored bare + lowercased (opaque-token equality)."""
56
+
57
+ doi: str | None = None # bare, e.g. "10.1073/pnas.2004976117" (no https://doi.org/)
58
+ arxiv_id: str | None = None # e.g. "2412.17799"
59
+ isbn13: str | None = None # primary 13-digit, separators stripped
60
+ # Every ISBN-13 this record carries. One physical book registers several (print + electronic, and
61
+ # per-edition), so a single `isbn13` scalar makes the *same* book look like an ISBN *conflict*
62
+ # when the cite and the source happen to pick different printings. `isbn13` stays the canonical
63
+ # one for display/lookup/backfill; matching and pooling use the whole set via `all_isbn13()`.
64
+ isbn13s: tuple[str, ...] = ()
65
+ pmid: str | None = None
66
+ bibcode: str | None = None # NASA ADS
67
+ openalex: str | None = None # OpenAlex Work id, bare + upper, e.g. "W3034344071"
68
+ google_books: str | None = None # Google Books volume id, e.g. "yIV_NMDDIvYC"
69
+ url: str | None = None
70
+
71
+ def primary_kind(self) -> Literal["doi", "isbn", "arxiv", "openalex", "google_books", "url"] | None:
72
+ if self.doi:
73
+ return "doi"
74
+ if self.isbn13:
75
+ return "isbn"
76
+ if self.arxiv_id:
77
+ return "arxiv"
78
+ if self.openalex:
79
+ return "openalex"
80
+ if self.google_books:
81
+ return "google_books"
82
+ if self.url:
83
+ return "url"
84
+ return None
85
+
86
+ def all_isbn13(self) -> frozenset[str]:
87
+ """Every ISBN-13 this record carries (primary + the rest), deduplicated.
88
+
89
+ Robust to whether the primary is also listed in `isbn13s`, so callers can populate either
90
+ field without double-bookkeeping."""
91
+ return frozenset(i for i in (self.isbn13, *self.isbn13s) if i)
92
+
93
+ def any_present(self) -> bool:
94
+ return any(
95
+ (
96
+ self.doi, self.arxiv_id, self.isbn13, self.pmid, self.bibcode,
97
+ self.openalex, self.google_books, self.url,
98
+ )
99
+ )
100
+
101
+ def has_strong_id(self) -> bool:
102
+ """True if a *scholarly anchor* is present — a DOI / ISBN / arXiv / PMID / bibcode /
103
+ OpenAlex Work id / Google Books volume id. A bare `url` does NOT count: no matching feature
104
+ compares it, so for scoring an entry identified only by a URL is as anchorless as one with no
105
+ identifier at all, and must be eligible for the strict title+author backfill path."""
106
+ return any(
107
+ (
108
+ self.doi, self.arxiv_id, self.isbn13, self.pmid, self.bibcode,
109
+ self.openalex, self.google_books,
110
+ )
111
+ )
112
+
113
+
114
+ def compute_content_hash(
115
+ title: str, authors: list[str], year: int | None, venue: str, ids: Identifiers
116
+ ) -> str:
117
+ """sha256 over normalized identifying fields. Cosmetic edits don't bust the cache;
118
+ a real metadata change does."""
119
+ parts = [
120
+ _norm_token_string(title),
121
+ "|".join(sorted(_norm_token_string(a) for a in authors)),
122
+ str(year or ""),
123
+ _norm_token_string(venue),
124
+ ids.doi or "",
125
+ ids.arxiv_id or "",
126
+ ids.isbn13 or "",
127
+ ids.openalex or "",
128
+ ]
129
+ return hashlib.sha256("␟".join(parts).encode("utf-8")).hexdigest()
130
+
131
+
132
+ class BibEntry(BaseModel):
133
+ """One parsed .bib entry, with derived/normalized identifiers."""
134
+
135
+ key: str
136
+ entry_type: EntryType = EntryType.UNKNOWN
137
+ title: str = ""
138
+ authors: list[str] = Field(default_factory=list) # surface strings, as written
139
+ year: int | None = None
140
+ venue: str = "" # journal OR booktitle
141
+ publisher: str = "" # NOT a match feature (gavrilets typo)
142
+ pages: str = ""
143
+ ids: Identifiers = Field(default_factory=Identifiers)
144
+ raw_fields: dict[str, str] = Field(default_factory=dict)
145
+ cited: bool = False # set by parsing/tex
146
+ is_commented: bool = False # the bagrov2024visual twin; informational
147
+ content_hash: str = ""
148
+
149
+ @model_validator(mode="after")
150
+ def _fill_hash(self) -> BibEntry:
151
+ if not self.content_hash:
152
+ self.content_hash = compute_content_hash(
153
+ self.title, self.authors, self.year, self.venue, self.ids
154
+ )
155
+ return self
156
+
157
+
158
+ class UnparsedEntry(BaseModel):
159
+ """A live `.bib` entry the BibTeX parser could not read. It is reported, never audited: its fields
160
+ are unknown, so any check would be a guess."""
161
+
162
+ key: str
163
+ entry_type: str = ""
164
+ line: int = 0 # 1-based line of the `@type{key` header
165
+ reason: str
166
+
167
+
168
+ class SourceRecord(BaseModel):
169
+ """One candidate, normalized across sources."""
170
+
171
+ source: str
172
+ source_native_id: str = "" # OpenAlex Wxxx / S2 id / crossref DOI — dedupe within source
173
+ title: str = ""
174
+ authors: list[str] = Field(default_factory=list)
175
+ year: int | None = None
176
+ venue: str = ""
177
+ volume: str = ""
178
+ issue: str = "" # the canonical issue/number
179
+ pages: str = ""
180
+ publisher: str = ""
181
+ abstract: str = "" # the work's abstract, when the source supplies one (OpenAlex/S2)
182
+ ids: Identifiers = Field(default_factory=Identifiers)
183
+ is_preprint: bool = False
184
+ edition: int | None = None
185
+ citation_count: int = 0
186
+ version_links: list[str] = Field(default_factory=list) # locations + relation targets (version graph)
187
+ openalex_work_id: str | None = None # merge anchor (M4)
188
+ raw: dict = Field(default_factory=dict)
189
+ # A pooled record's own source records (preprint and published versions, one per source), each
190
+ # without `raw`, `abstract` or members of its own. The pooled fields above are a compilation; the
191
+ # field check reads these to compare an entry against the version it cites. Empty for a leaf.
192
+ members: list[SourceRecord] = Field(default_factory=list)
193
+
194
+ @field_validator("title", "venue", "publisher", mode="after")
195
+ @classmethod
196
+ def _decode_text(cls, v: str) -> str:
197
+ """Sources return HTML-escaped text (Crossref's `Allocation &amp; Regularization`). Decoded
198
+ here, once for every adapter and for records reloaded from the cache, so that the field check
199
+ compares — and reports — the real value; see `parsing.entities`."""
200
+ return decode_html_entities(v)
201
+
202
+ @field_validator("authors", mode="after")
203
+ @classmethod
204
+ def _decode_authors(cls, v: list[str]) -> list[str]:
205
+ return [decode_html_entities(a) for a in v]
206
+
207
+
208
+ class SourceQueryResult(BaseModel):
209
+ """Result of one adapter call. `error` is distinct from an empty record list:
210
+ an outage must never be cached/treated as 'not found'."""
211
+
212
+ source: str
213
+ query_kind: Literal["id", "metadata", "editions", "web"]
214
+ records: list[SourceRecord] = Field(default_factory=list)
215
+ error: str | None = None
216
+
217
+
218
+ class FeatureVector(BaseModel):
219
+ """Interpretable features for one (entry, candidate) pair."""
220
+
221
+ title_ratio: float = 0.0
222
+ title_prefix_trap: bool = False
223
+ author_overlap: float = 0.0
224
+ author_set_jaccard: float = 0.0
225
+ author_subset: bool = False # one author set ⊆ other (abbreviation, not distinct work)
226
+ year_factor: float = 1.0
227
+ venue_compatible: float = 1.0
228
+ id_agreement: Literal["match", "conflict", "absent"] = "absent"
229
+ pages_conflict: bool = False
230
+ composite: float = 0.0
231
+
232
+
233
+ class CanCorrespondResult(BaseModel):
234
+ """LLM per-candidate filter verdict (strict json_schema)."""
235
+
236
+ can_correspond: bool
237
+ confidence: Literal["high", "medium", "low"]
238
+ reason: str
239
+ distinguishing_evidence: str = ""
240
+
241
+
242
+ class SameWorkResult(BaseModel):
243
+ """LLM equivalence verdict for two records (strict json_schema)."""
244
+
245
+ relation: Literal[
246
+ "same_artifact", "versions_of_same_work", "distinct_works", "uncertain"
247
+ ]
248
+ confidence: Literal["high", "medium", "low"]
249
+ reason: str
250
+
251
+
252
+ class WebMatchResult(BaseModel):
253
+ """LLM verdict on whether a fetched web page IS the resource a URL-only @misc entry cites
254
+ (strict json_schema). Affirmative polarity: ``corresponds`` is true only on positive evidence
255
+ the page is that resource, false only on positive evidence it is something else (a different
256
+ article, a homepage/index, a login/paywall/404 notice); otherwise low confidence.
257
+ """
258
+
259
+ corresponds: bool
260
+ confidence: Literal["high", "medium", "low"]
261
+ reason: str
262
+
263
+
264
+ class FieldJudgment(BaseModel):
265
+ """LLM classification of one ambiguous field discrepancy (strict json_schema).
266
+
267
+ `formatting_variant` = the .bib value denotes the same thing as the database value, differing
268
+ only in style (abbreviation, capitalization, accents, punctuation, braces). `error` = the .bib
269
+ value is genuinely wrong (a different/garbled/truncated value). `uncertain` when neither can be
270
+ affirmatively concluded — the entry is then surfaced for manual review, never silently passed.
271
+ """
272
+
273
+ classification: Literal["formatting_variant", "error", "uncertain"]
274
+ confidence: Literal["high", "medium", "low"]
275
+ reason: str
276
+
277
+
278
+ class CitationAlignmentResult(BaseModel):
279
+ """LLM classification of one citing context against the cited work's abstract (strict json_schema).
280
+
281
+ Affirmative polarity for `contradicted`: choose it ONLY on positive evidence the abstract asserts
282
+ the opposite of the citing claim. `supported` when the abstract corroborates the claim.
283
+ `not_in_abstract` when the abstract neither supports nor contradicts it — the abstract is not the
284
+ full paper, so its silence is NOT misuse. The system, not the model, owns `unverifiable` (no
285
+ abstract / error). `evidence_quote` is the span of the abstract that justifies a
286
+ supported/contradicted call (empty for `not_in_abstract`).
287
+ """
288
+
289
+ classification: Literal["supported", "contradicted", "not_in_abstract"]
290
+ confidence: Literal["high", "medium", "low"]
291
+ reason: str
292
+ evidence_quote: str = ""
293
+
294
+
295
+ class FieldFinding(BaseModel):
296
+ """Step-3 correctness check of one .bib field against the identified canonical record.
297
+
298
+ Runs only on an `exactly_one` match (the artifact is confirmed to be the cited work), so a
299
+ difference is a property of the *field*, not evidence about identity. `status`:
300
+ - ``ok`` value agrees with the canonical record (after normalization).
301
+ - ``formatting`` differs only in formatting/style — not a mistake, a cosmetic fix at most.
302
+ - ``error`` a genuine mistake (wrong, garbled, or empty/placeholder value).
303
+ - ``uncertain`` could not be classified confidently — surfaced for manual review.
304
+ - ``unverifiable`` the .bib has a value but no source returned this field to check it against.
305
+ """
306
+
307
+ field: str
308
+ bib_value: str = ""
309
+ canonical_value: str = ""
310
+ sources: list[str] = Field(default_factory=list) # source(s) carrying the canonical value
311
+ status: Literal["ok", "formatting", "error", "uncertain", "unverifiable"]
312
+ detail: str = ""
313
+ via_llm: bool = False
314
+
315
+
316
+ class CitationContext(BaseModel):
317
+ """One in-text citing occurrence of a `.bib` key: the surrounding sentence(s) that state *why*
318
+ the work is cited, plus where it occurred. Extracted offline from the `.tex` (and its includes).
319
+
320
+ A key cited in several places yields several contexts, each judged independently. `ordinal` is
321
+ the 0-based occurrence order for that key (stable, so findings are reproducible)."""
322
+
323
+ key: str
324
+ text: str # the citing sentence(s) — the claim attached to the citation
325
+ ordinal: int = 0
326
+ # The cite-family command used (cite / citep / citet / nocite). `bibr` marks a context recovered
327
+ # from a PDF, where the TEI marker `<ref type="bibr">` is all the provenance there is.
328
+ command: str = "cite"
329
+
330
+
331
+ class AlignmentFinding(BaseModel):
332
+ """Citation-alignment check of one citing context against the cited work's abstract.
333
+
334
+ Runs only on an `exactly_one` match with a retrievable abstract. `status`:
335
+ - ``supported`` the abstract corroborates the citing claim.
336
+ - ``contradicted`` the abstract asserts the opposite — the one "loud" finding.
337
+ - ``not_in_abstract`` the abstract is silent on the claim (NOT misuse; full text may support it).
338
+ - ``unverifiable`` no abstract available, the entry did not resolve, or the check failed.
339
+ A silent/absent abstract or any failure is NEVER reported as ``contradicted``.
340
+ """
341
+
342
+ key: str
343
+ context_text: str = "" # the citing sentence(s) judged
344
+ ordinal: int = 0
345
+ status: Literal["supported", "contradicted", "not_in_abstract", "unverifiable"]
346
+ evidence_quote: str = "" # the abstract span justifying a supported/contradicted call
347
+ detail: str = ""
348
+ confidence: Literal["high", "medium", "low"] = "low"
349
+ via_llm: bool = False
350
+
351
+
352
+ class CandidateAssessment(BaseModel):
353
+ record: SourceRecord
354
+ features: FeatureVector
355
+ bucket: Literal["auto_accept", "auto_reject", "adjudicate"]
356
+ llm: CanCorrespondResult | None = None
357
+
358
+
359
+ class MatchedArtifact(BaseModel):
360
+ """One real-world object an entry matched (possibly several merged source records)."""
361
+
362
+ records: list[SourceRecord] = Field(default_factory=list)
363
+ merged_ids: Identifiers = Field(default_factory=Identifiers)
364
+ versions: list[SourceRecord] = Field(default_factory=list) # preprint + published / editions
365
+ best_record: SourceRecord | None = None
366
+
367
+
368
+ class Verdict(BaseModel):
369
+ """README 5.1 / 5.2 / 5.3."""
370
+
371
+ kind: Literal["none", "exactly_one", "multiple"]
372
+ artifacts: list[MatchedArtifact] = Field(default_factory=list)
373
+ confidence: Literal["high", "medium", "low"] = "low"
374
+ rationale: str = ""
375
+
376
+
377
+ class EntryAudit(BaseModel):
378
+ entry: BibEntry
379
+ candidates: list[CandidateAssessment] = Field(default_factory=list)
380
+ verdict: Verdict | None = None # None until step-1 matching has run
381
+ field_findings: list[FieldFinding] = Field(default_factory=list) # step 3 field correctness
382
+ alignment_findings: list[AlignmentFinding] = Field(default_factory=list) # citation alignment
383
+ canonical_bibtex: str = "" # step 3 (follow-on)
384
+ issues: list[str] = Field(default_factory=list)
385
+ # Why the verdict is None, one line per cause (a named source error, an LLM failure, an
386
+ # adjudication that stayed inconclusive). Empty once a verdict is reached. Each is also an issue.
387
+ unresolved_reasons: list[str] = Field(default_factory=list)
388
+ from_cache: bool = False
389
+
390
+
391
+ class AuditReport(BaseModel):
392
+ entries: list[EntryAudit] = Field(default_factory=list)
393
+ cited_but_missing: list[str] = Field(default_factory=list) # in .tex, absent from .bib
394
+ uncited: list[str] = Field(default_factory=list) # in .bib, never cited
395
+ commented_twins: list[str] = Field(default_factory=list) # informational (T1 context)
396
+ missing_includes: list[str] = Field(default_factory=list) # \input targets not found on disk
397
+ unparsed: list[UnparsedEntry] = Field(default_factory=list) # .bib entries that could not be read
398
+ summary: dict = Field(default_factory=dict)
399
+
400
+ # --- provenance of the input itself ---
401
+ # `pdf`: the entries and contexts were EXTRACTED by GROBID rather than read from authored source
402
+ # files, so several fields above change meaning. `commented_twins` and `missing_includes` are
403
+ # structurally inapplicable (a PDF has neither), and `uncited` depends on GROBID having linked
404
+ # in-text markers to bibliography items — hence `citation_linking`, which is `unavailable` when
405
+ # it linked none (bookkeeping is then unknown, not "everything is cited"). `notes` carries the
406
+ # document-level caveats the renderer must show so no reader mistakes an extraction artifact for
407
+ # an error in the manuscript. Defaults reproduce the .bib/.tex behavior exactly.
408
+ # `pdf_embedded`: the entries are the BibTeX records the PDF embeds for its cited works
409
+ # (`pdf.embedded`) — authored source like a .bib, every one of them cited, no citing contexts.
410
+ input_kind: Literal["tex_bib", "pdf", "pdf_embedded"] = "tex_bib"
411
+ citation_linking: Literal["available", "unavailable", "not_applicable"] = "available"
412
+ notes: list[str] = Field(default_factory=list)
@@ -0,0 +1 @@
1
+ """Parsing layer: .bib entries, .tex citations, and identifier normalization."""