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.
- reference_audit/__init__.py +8 -0
- reference_audit/alignmentcheck.py +136 -0
- reference_audit/bookcheck.py +98 -0
- reference_audit/cache/__init__.py +1 -0
- reference_audit/cache/db.py +108 -0
- reference_audit/cache/store.py +128 -0
- reference_audit/cli.py +190 -0
- reference_audit/config.py +179 -0
- reference_audit/fieldcheck.py +599 -0
- reference_audit/inputs.py +96 -0
- reference_audit/llm/__init__.py +6 -0
- reference_audit/llm/client.py +85 -0
- reference_audit/llm/prompts.py +206 -0
- reference_audit/llm/schemas.py +38 -0
- reference_audit/matching/__init__.py +6 -0
- reference_audit/matching/adjudicate.py +63 -0
- reference_audit/matching/features.py +185 -0
- reference_audit/matching/names.py +238 -0
- reference_audit/matching/pool.py +344 -0
- reference_audit/matching/sameobject.py +150 -0
- reference_audit/matching/scoring.py +75 -0
- reference_audit/matching/verdict.py +56 -0
- reference_audit/matching/webcheck.py +189 -0
- reference_audit/models.py +412 -0
- reference_audit/parsing/__init__.py +1 -0
- reference_audit/parsing/bib.py +392 -0
- reference_audit/parsing/context.py +55 -0
- reference_audit/parsing/entities.py +22 -0
- reference_audit/parsing/identifiers.py +165 -0
- reference_audit/parsing/latex.py +83 -0
- reference_audit/parsing/tex.py +159 -0
- reference_audit/pdf/__init__.py +16 -0
- reference_audit/pdf/embedded.py +117 -0
- reference_audit/pdf/grobid.py +234 -0
- reference_audit/pdf/tei.py +470 -0
- reference_audit/pipeline.py +1692 -0
- reference_audit/report.py +330 -0
- reference_audit/sources/__init__.py +6 -0
- reference_audit/sources/arxiv.py +81 -0
- reference_audit/sources/base.py +54 -0
- reference_audit/sources/clickhouse.py +575 -0
- reference_audit/sources/crossref.py +90 -0
- reference_audit/sources/dblp.py +140 -0
- reference_audit/sources/google_books.py +101 -0
- reference_audit/sources/http.py +260 -0
- reference_audit/sources/normalize.py +464 -0
- reference_audit/sources/openalex.py +93 -0
- reference_audit/sources/openlibrary.py +158 -0
- reference_audit/sources/publisher.py +186 -0
- reference_audit/sources/registry.py +117 -0
- reference_audit/sources/render.py +115 -0
- reference_audit/sources/semantic_scholar.py +68 -0
- reference_audit/sources/titlewords.py +31 -0
- reference_audit/sources/web.py +256 -0
- reference_audit/versioning.py +125 -0
- reference_audit-0.1.0.dist-info/METADATA +715 -0
- reference_audit-0.1.0.dist-info/RECORD +60 -0
- reference_audit-0.1.0.dist-info/WHEEL +4 -0
- reference_audit-0.1.0.dist-info/entry_points.txt +2 -0
- 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 & 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."""
|