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,96 @@
|
|
|
1
|
+
"""What the positional CLI arguments mean. Pure and side-effect-free, so it is directly testable.
|
|
2
|
+
|
|
3
|
+
Two input shapes are supported, distinguished by file extension:
|
|
4
|
+
|
|
5
|
+
reference-audit audit main.tex references.bib # authored sources
|
|
6
|
+
reference-audit audit paper.pdf # GROBID extracts both from the PDF
|
|
7
|
+
|
|
8
|
+
Every rejected combination gets a message naming the fix, because "usage: ..." tells a user what the
|
|
9
|
+
grammar is but not which of their two paths was the wrong one.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
CACHE_DIRNAME = ".reference_audit"
|
|
18
|
+
_TEX_SUFFIXES = (".tex", ".ltx")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InvalidInputError(ValueError):
|
|
22
|
+
"""The given paths are not a recognized input combination."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class TexBibInput:
|
|
27
|
+
"""A `.bib` to audit, optionally with the manuscript that cites it."""
|
|
28
|
+
|
|
29
|
+
bib_path: Path
|
|
30
|
+
tex_path: Path | None = None
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def anchor(self) -> Path:
|
|
34
|
+
return self.bib_path
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class PdfInput:
|
|
39
|
+
"""A PDF that is the whole input: its references and citations are both extracted from it."""
|
|
40
|
+
|
|
41
|
+
pdf_path: Path
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def anchor(self) -> Path:
|
|
45
|
+
return self.pdf_path
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
AuditInput = TexBibInput | PdfInput
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def resolve_input(first: Path, second: Path | None) -> AuditInput:
|
|
52
|
+
"""Classify the positional arguments, or explain why they cannot be."""
|
|
53
|
+
a = first.suffix.lower()
|
|
54
|
+
b = second.suffix.lower() if second is not None else None
|
|
55
|
+
|
|
56
|
+
if a == ".pdf":
|
|
57
|
+
if second is not None:
|
|
58
|
+
raise InvalidInputError(
|
|
59
|
+
f"a PDF is the whole input: GROBID extracts both the reference list and the in-text "
|
|
60
|
+
f"citations from {first.name}, so there is nothing for {second.name} to add. "
|
|
61
|
+
f"Run 'reference-audit audit {first.name}' on its own, or pass a .tex + .bib pair "
|
|
62
|
+
f"instead."
|
|
63
|
+
)
|
|
64
|
+
return PdfInput(first)
|
|
65
|
+
|
|
66
|
+
if b == ".pdf":
|
|
67
|
+
raise InvalidInputError(
|
|
68
|
+
f"a .pdf cannot be combined with another input ({first.name} + {second.name}). "
|
|
69
|
+
f"Run 'reference-audit audit {second.name}' on its own."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if second is None:
|
|
73
|
+
raise InvalidInputError(
|
|
74
|
+
f"expected 'audit <paper.pdf>' or 'audit <main.tex> <references.bib>', but got a single "
|
|
75
|
+
f"{a or 'extension-less'} file ({first.name}). A .bib needs its manuscript; a PDF stands "
|
|
76
|
+
f"alone."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if a == ".bib" and b in _TEX_SUFFIXES:
|
|
80
|
+
raise InvalidInputError(
|
|
81
|
+
f"the arguments look swapped — the manuscript comes first: "
|
|
82
|
+
f"'reference-audit audit {second.name} {first.name}'."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if b != ".bib":
|
|
86
|
+
raise InvalidInputError(
|
|
87
|
+
f"the second argument must be the .bib bibliography to audit, but {second.name} has "
|
|
88
|
+
f"suffix '{b or '(none)'}'."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
return TexBibInput(bib_path=second, tex_path=first)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def default_cache_path(source: AuditInput) -> Path:
|
|
95
|
+
"""`<input_dir>/.reference_audit/cache.db`, beside whichever file the run is anchored on."""
|
|
96
|
+
return source.anchor.parent / CACHE_DIRNAME / "cache.db"
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""LLM adjudication: structured-output filtering of the ambiguous candidate zone.
|
|
2
|
+
|
|
3
|
+
The LLM is authoritative only in the danger/ambiguous zones (the `adjudicate` bucket and, in M5,
|
|
4
|
+
the SAME_WORK tie-break). Both prompts require the model to *affirmatively assert* sameness, so a
|
|
5
|
+
near-miss neighbour is never force-accepted (the README's hallucination-screening guarantee).
|
|
6
|
+
"""
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Async OpenAI wrapper for structured-output adjudication.
|
|
2
|
+
|
|
3
|
+
Thin layer over `AsyncOpenAI` returning validated pydantic objects. Retries transient failures;
|
|
4
|
+
caps in-flight requests with a semaphore for batch calls. The model defaults to `gpt-6-luna`
|
|
5
|
+
(configurable). `temperature` is sent only when set; otherwise the model default applies. The cache (layer 2) is consulted by the pipeline *before* calling here, so this
|
|
6
|
+
layer is pure I/O.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from typing import TypeVar
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, ValidationError
|
|
15
|
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
|
16
|
+
|
|
17
|
+
from reference_audit.llm.schemas import response_format
|
|
18
|
+
|
|
19
|
+
T = TypeVar("T", bound=BaseModel)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LLMError(Exception):
|
|
23
|
+
"""A structured-output call failed after retries (caller leaves the entry unresolved)."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LLMClient:
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
model: str,
|
|
31
|
+
api_key: str | None,
|
|
32
|
+
base_url: str | None = None,
|
|
33
|
+
concurrency: int = 8,
|
|
34
|
+
temperature: float | None = None,
|
|
35
|
+
client=None,
|
|
36
|
+
):
|
|
37
|
+
self.model = model
|
|
38
|
+
self.temperature = temperature
|
|
39
|
+
self._semaphore = asyncio.Semaphore(concurrency)
|
|
40
|
+
if client is not None:
|
|
41
|
+
self._client = client
|
|
42
|
+
else:
|
|
43
|
+
from openai import AsyncOpenAI
|
|
44
|
+
|
|
45
|
+
self._client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def available(self) -> bool:
|
|
49
|
+
return self._client is not None
|
|
50
|
+
|
|
51
|
+
async def structured(
|
|
52
|
+
self, system: str, user: str, schema_model: type[T], schema_name: str
|
|
53
|
+
) -> T:
|
|
54
|
+
"""One structured call → a validated `schema_model` instance. Raises LLMError on failure."""
|
|
55
|
+
|
|
56
|
+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=0.5, min=0.5, max=8),
|
|
57
|
+
reraise=True)
|
|
58
|
+
async def _call() -> str:
|
|
59
|
+
extra = {} if self.temperature is None else {"temperature": self.temperature}
|
|
60
|
+
resp = await self._client.chat.completions.create(
|
|
61
|
+
model=self.model,
|
|
62
|
+
messages=[
|
|
63
|
+
{"role": "system", "content": system},
|
|
64
|
+
{"role": "user", "content": user},
|
|
65
|
+
],
|
|
66
|
+
response_format=response_format(schema_model, schema_name),
|
|
67
|
+
**extra,
|
|
68
|
+
)
|
|
69
|
+
return resp.choices[0].message.content or ""
|
|
70
|
+
|
|
71
|
+
async with self._semaphore:
|
|
72
|
+
try:
|
|
73
|
+
content = await _call()
|
|
74
|
+
return schema_model.model_validate_json(content)
|
|
75
|
+
except (ValidationError, Exception) as exc: # noqa: BLE001 — normalize to LLMError
|
|
76
|
+
if isinstance(exc, LLMError):
|
|
77
|
+
raise
|
|
78
|
+
raise LLMError(f"{schema_name} call failed: {exc}") from exc
|
|
79
|
+
|
|
80
|
+
async def aclose(self) -> None:
|
|
81
|
+
close = getattr(self._client, "aclose", None) or getattr(self._client, "close", None)
|
|
82
|
+
if close is not None:
|
|
83
|
+
result = close()
|
|
84
|
+
if asyncio.iscoroutine(result):
|
|
85
|
+
await result
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Prompt construction for the LLM adjudication calls.
|
|
2
|
+
|
|
3
|
+
Both prompts use AFFIRMATIVE polarity: the model must positively conclude sameness; absence of
|
|
4
|
+
evidence ⇒ "no". This is what makes a garbled entry's nearest fuzzy neighbour fail to match (T4).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from reference_audit.models import BibEntry, FeatureVector, SourceRecord
|
|
10
|
+
|
|
11
|
+
CAN_CORRESPOND_SYSTEM = (
|
|
12
|
+
"You verify whether a returned database record is the SAME PUBLISHED ARTIFACT that a "
|
|
13
|
+
"bibliography entry refers to. Titles and author names may be spelled differently, "
|
|
14
|
+
"transliterated, abbreviated, or have a missing field (e.g. a dropped co-author or wrong "
|
|
15
|
+
"month) — do NOT reject for those. Set can_correspond=true ONLY if you can affirmatively "
|
|
16
|
+
"conclude they are the same document. Set can_correspond=false when there is positive evidence "
|
|
17
|
+
"they are DIFFERENT works: a different scope or topic in the title tail, disjoint page ranges "
|
|
18
|
+
"in the same venue, an author set that differs by more than spelling, or a clearly different "
|
|
19
|
+
"subject. A merely missing field is not evidence of difference. Respond in strict JSON."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
SAME_WORK_SYSTEM = (
|
|
23
|
+
"Decide whether two database records describe the SAME WORK. Choose exactly one relation: "
|
|
24
|
+
"'same_artifact' (the identical item), 'versions_of_same_work' (a preprint and its published "
|
|
25
|
+
"version, or different editions/printings of one work), 'distinct_works' (different papers, "
|
|
26
|
+
"even if by the same authors in the same venue/year), or 'uncertain'. Two papers with "
|
|
27
|
+
"different titles and non-overlapping page ranges are distinct_works. Conclude 'same' or "
|
|
28
|
+
"'versions' ONLY with positive evidence. Respond in strict JSON."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
WEB_MATCH_SYSTEM = (
|
|
32
|
+
"A bibliography entry cites a WEB PAGE by URL — a blog post, a software/project page, "
|
|
33
|
+
"documentation, a dataset, or another non-journal resource. You are given the .bib entry and the "
|
|
34
|
+
"title, metadata, and text of the page actually fetched from that URL. Decide whether the fetched "
|
|
35
|
+
"page IS the resource the entry cites. Set corresponds=true ONLY if you can affirmatively conclude "
|
|
36
|
+
"the page is that resource — its title/topic/authors match the citation, allowing for spelling, "
|
|
37
|
+
"abbreviation, transliteration, or an appended site name. Set corresponds=false ONLY with positive "
|
|
38
|
+
"evidence the page is something else: a different article, a site homepage or index/listing, a "
|
|
39
|
+
"login/paywall wall, a 'page not found'/error notice, or an unrelated topic. When the page has too "
|
|
40
|
+
"little content to tell, or it is ambiguous, use low confidence rather than guessing. Respond in "
|
|
41
|
+
"strict JSON."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
FIELD_CHECK_SYSTEM = (
|
|
45
|
+
"A bibliography entry has already been matched, by identifier, to a specific real publication, "
|
|
46
|
+
"so the two refer to the SAME work. You judge ONE field of that entry against the authoritative "
|
|
47
|
+
"database value and decide whether the entry's value is merely formatted differently or is a "
|
|
48
|
+
"genuine mistake.\n"
|
|
49
|
+
"Classify 'formatting_variant' (NOT a mistake) for: capitalization; punctuation; accents and "
|
|
50
|
+
"transliteration (Müller/Mueller); LaTeX braces; standard abbreviations vs full forms of a "
|
|
51
|
+
"journal or conference (e.g. 'J. Theor. Biol.' = 'Journal of Theoretical Biology', 'NeurIPS' = "
|
|
52
|
+
"'Advances in Neural Information Processing Systems'); an added or dropped 'Proceedings of'/year/"
|
|
53
|
+
"edition qualifier on a conference series; en-dash vs hyphen; 'and' vs '&'; word-order in a "
|
|
54
|
+
"series name.\n"
|
|
55
|
+
"Classify 'error' for a substantively different value: a different number; a wrong, misspelled, "
|
|
56
|
+
"or split word (e.g. 'Un iversity'); a missing word that changes the name (e.g. a journal "
|
|
57
|
+
"written 'Annual Review Condensed Matter Physics', dropping 'of'); a truncated or different "
|
|
58
|
+
"title or venue; a value that belongs to a different field.\n"
|
|
59
|
+
"Databases are themselves sometimes wrong or incomplete, and the bibliography value is usually "
|
|
60
|
+
"correct: \n"
|
|
61
|
+
"- If the database VENUE is a preprint server, institutional repository, or aggregator "
|
|
62
|
+
"('arXiv', 'bioRxiv', 'medRxiv', 'SSRN', 'ResearchGate', 'Radboud Repository', 'HAL', a "
|
|
63
|
+
"university or 'Technical Reports Server') while the entry names a journal or conference, the "
|
|
64
|
+
"database simply indexed a different copy — classify 'formatting_variant', not 'error'.\n"
|
|
65
|
+
"- If the database value looks like a TRUNCATION or substring of the entry's value (e.g. "
|
|
66
|
+
"database 'Complex' vs entry 'Complexity'), the entry is the fuller, correct form — classify "
|
|
67
|
+
"'formatting_variant' or 'uncertain', never 'error'.\n"
|
|
68
|
+
"- A TITLE that differs from the database title by a substituted, inserted or deleted content "
|
|
69
|
+
"word ('Robust' vs 'Resilient', 'towards' vs 'for', 'Self-supervised' vs 'Unsupervised') is an "
|
|
70
|
+
"'error': the identifier already fixed the work, so its title is known.\n"
|
|
71
|
+
"- A VENUE that names a different journal or conference series than the database venue ('ACL' vs "
|
|
72
|
+
"'ICLR', 'UAI' vs 'ICML') is an 'error': the database records where the identified work "
|
|
73
|
+
"appeared (this does not apply to a preprint server or repository, above).\n"
|
|
74
|
+
"If the entry's value is plausibly correct and the database merely differs, prefer 'uncertain' "
|
|
75
|
+
"over 'error'. Use 'uncertain' whenever you cannot affirmatively decide. Respond in strict JSON."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
CITATION_ALIGNMENT_SYSTEM = (
|
|
80
|
+
"A bibliography entry has been matched to a specific real publication (the CITED WORK). You are "
|
|
81
|
+
"given (1) the sentence(s) from the citing paper that invoke the cited work — the CLAIM the "
|
|
82
|
+
"author attaches to the citation — and (2) the ABSTRACT of the cited work. Decide whether the "
|
|
83
|
+
"cited work's abstract is consistent with the way it is cited.\n"
|
|
84
|
+
"Classify 'supported' when the abstract affirmatively corroborates the citing claim.\n"
|
|
85
|
+
"Classify 'contradicted' ONLY with positive evidence that the abstract asserts the OPPOSITE of "
|
|
86
|
+
"the citing claim (the claim attributes to the work a finding the abstract explicitly refutes, "
|
|
87
|
+
"reverses, or excludes).\n"
|
|
88
|
+
"Classify 'not_in_abstract' when the abstract neither supports nor contradicts the claim — it is "
|
|
89
|
+
"silent on that point. An abstract is only a summary, so its silence is NOT evidence of misuse: "
|
|
90
|
+
"prefer 'not_in_abstract' over 'contradicted' whenever the abstract simply does not address the "
|
|
91
|
+
"claim. Do NOT use outside knowledge; judge only against the abstract text provided.\n"
|
|
92
|
+
"For a 'supported' or 'contradicted' verdict, put the short span of the abstract that justifies "
|
|
93
|
+
"it in evidence_quote (leave it empty for 'not_in_abstract'). Respond in strict JSON."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Cap the abstract text fed to the model: enough to judge the claim, bounded for cost.
|
|
97
|
+
ABSTRACT_TEXT_LIMIT = 4000
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def citation_alignment_user(entry: BibEntry, context_text: str, abstract: str) -> str:
|
|
101
|
+
return (
|
|
102
|
+
"CITED WORK:\n"
|
|
103
|
+
f" title: {entry.title or '(none)'}\n"
|
|
104
|
+
f" authors: {'; '.join(entry.authors) or '(none)'}\n"
|
|
105
|
+
f" year: {entry.year or '(none)'}\n\n"
|
|
106
|
+
"HOW IT IS CITED (the claim the citing paper attaches to this reference):\n"
|
|
107
|
+
f" {context_text}\n\n"
|
|
108
|
+
"ABSTRACT OF THE CITED WORK:\n"
|
|
109
|
+
f"{abstract[:ABSTRACT_TEXT_LIMIT] or '(none)'}\n\n"
|
|
110
|
+
"Is the citing claim supported by, contradicted by, or simply not addressed in the abstract?"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def field_check_user(
|
|
115
|
+
field: str, bib_value: str, canonical_value: str, sources: list[str], work: SourceRecord
|
|
116
|
+
) -> str:
|
|
117
|
+
"""`work` is the matched work as the database records it, never the entry under review."""
|
|
118
|
+
src = ", ".join(sources) if sources else "database"
|
|
119
|
+
authors = "; ".join(work.authors[:12]) + ("; …" if len(work.authors) > 12 else "")
|
|
120
|
+
return (
|
|
121
|
+
f"FIELD UNDER REVIEW: {field}\n"
|
|
122
|
+
f" citation (.bib) value: {bib_value or '(empty)'}\n"
|
|
123
|
+
f" authoritative value: {canonical_value or '(empty)'} [source: {src}]\n\n"
|
|
124
|
+
"CONTEXT — the matched work as the database records it (for grounding only):\n"
|
|
125
|
+
f" title: {work.title or '(none)'}\n"
|
|
126
|
+
f" authors: {authors or '(none)'}\n"
|
|
127
|
+
f" year: {work.year or '(none)'}\n"
|
|
128
|
+
f" venue: {work.venue or '(none)'}\n\n"
|
|
129
|
+
f"Is the .bib {field} value the same as the authoritative value apart from formatting, "
|
|
130
|
+
"or is it a genuine mistake?"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _fmt_entry(entry: BibEntry) -> str:
|
|
135
|
+
ids = entry.ids
|
|
136
|
+
return (
|
|
137
|
+
f" title: {entry.title}\n"
|
|
138
|
+
f" authors: {'; '.join(entry.authors) or '(none)'}\n"
|
|
139
|
+
f" year: {entry.year or '(none)'}\n"
|
|
140
|
+
f" venue: {entry.venue or '(none)'}\n"
|
|
141
|
+
f" pages: {entry.pages or '(none)'}\n"
|
|
142
|
+
f" ids: doi={ids.doi} arxiv={ids.arxiv_id} isbn={ids.isbn13}"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _fmt_record(rec: SourceRecord) -> str:
|
|
147
|
+
ids = rec.ids
|
|
148
|
+
return (
|
|
149
|
+
f" source: {rec.source}\n"
|
|
150
|
+
f" title: {rec.title}\n"
|
|
151
|
+
f" authors: {'; '.join(rec.authors) or '(none)'}\n"
|
|
152
|
+
f" year: {rec.year or '(none)'}\n"
|
|
153
|
+
f" venue: {rec.venue or '(none)'}\n"
|
|
154
|
+
f" pages: {rec.pages or '(none)'}\n"
|
|
155
|
+
f" ids: doi={ids.doi} arxiv={ids.arxiv_id} isbn={ids.isbn13}"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _fmt_flags(f: FeatureVector) -> str:
|
|
160
|
+
return (
|
|
161
|
+
f" title_prefix_trap={f.title_prefix_trap} pages_conflict={f.pages_conflict} "
|
|
162
|
+
f"author_set_jaccard={f.author_set_jaccard:.2f} id_agreement={f.id_agreement} "
|
|
163
|
+
f"title_ratio={f.title_ratio:.2f}"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def can_correspond_user(entry: BibEntry, rec: SourceRecord, features: FeatureVector) -> str:
|
|
168
|
+
return (
|
|
169
|
+
"BIB ENTRY:\n" + _fmt_entry(entry) + "\n\n"
|
|
170
|
+
"DATABASE RECORD:\n" + _fmt_record(rec) + "\n\n"
|
|
171
|
+
"ATTENTION FLAGS (formal signals; weigh them):\n" + _fmt_flags(features) + "\n\n"
|
|
172
|
+
"Does the database record correspond to the same published artifact as the bib entry?"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def same_work_user(a: SourceRecord, b: SourceRecord) -> str:
|
|
177
|
+
return (
|
|
178
|
+
"RECORD A:\n" + _fmt_record(a) + "\n\n"
|
|
179
|
+
"RECORD B:\n" + _fmt_record(b) + "\n\n"
|
|
180
|
+
"What is the relation between record A and record B?"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# Cap the fetched page text fed to the model: enough to judge identity, bounded for cost.
|
|
185
|
+
WEB_PAGE_TEXT_LIMIT = 4000
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def web_match_user(entry: BibEntry, page: SourceRecord) -> str:
|
|
189
|
+
raw = page.raw or {}
|
|
190
|
+
text = (raw.get("text") or "").strip()[:WEB_PAGE_TEXT_LIMIT]
|
|
191
|
+
return (
|
|
192
|
+
"CITED REFERENCE (.bib):\n"
|
|
193
|
+
f" title: {entry.title or '(none)'}\n"
|
|
194
|
+
f" authors: {'; '.join(entry.authors) or '(none)'}\n"
|
|
195
|
+
f" year: {entry.year or '(none)'}\n"
|
|
196
|
+
f" cited url: {entry.ids.url or '(none)'}\n\n"
|
|
197
|
+
"FETCHED PAGE (from the cited URL):\n"
|
|
198
|
+
f" final url: {page.ids.url or '(none)'}\n"
|
|
199
|
+
f" meta title: {page.title or '(none)'}\n"
|
|
200
|
+
f" meta authors: {'; '.join(page.authors) or '(none)'}\n"
|
|
201
|
+
f" site name: {raw.get('site_name') or '(none)'}\n"
|
|
202
|
+
f" description: {raw.get('description') or '(none)'}\n\n"
|
|
203
|
+
"PAGE TEXT (truncated):\n"
|
|
204
|
+
f"{text or '(no extractable text)'}\n\n"
|
|
205
|
+
"Is the fetched page the same resource the .bib entry cites?"
|
|
206
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""pydantic → strict JSON schema for OpenAI structured outputs.
|
|
2
|
+
|
|
3
|
+
OpenAI `response_format=json_schema` with `strict: true` requires every object to set
|
|
4
|
+
`additionalProperties: false` and list ALL properties in `required`. pydantic's default schema
|
|
5
|
+
omits fields that have defaults from `required`, so we tighten it here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _strictify(node: Any) -> Any:
|
|
16
|
+
if isinstance(node, dict):
|
|
17
|
+
node.pop("default", None)
|
|
18
|
+
if node.get("type") == "object" and "properties" in node:
|
|
19
|
+
node["additionalProperties"] = False
|
|
20
|
+
node["required"] = list(node["properties"].keys())
|
|
21
|
+
for value in node.values():
|
|
22
|
+
_strictify(value)
|
|
23
|
+
elif isinstance(node, list):
|
|
24
|
+
for item in node:
|
|
25
|
+
_strictify(item)
|
|
26
|
+
return node
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def strict_schema(model: type[BaseModel]) -> dict:
|
|
30
|
+
"""Return an OpenAI strict-mode JSON schema for a pydantic model."""
|
|
31
|
+
return _strictify(model.model_json_schema())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def response_format(model: type[BaseModel], name: str) -> dict:
|
|
35
|
+
return {
|
|
36
|
+
"type": "json_schema",
|
|
37
|
+
"json_schema": {"name": name, "schema": strict_schema(model), "strict": True},
|
|
38
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Matching / disambiguation — the heart.
|
|
2
|
+
|
|
3
|
+
Formal code produces interpretable *features/evidence* (`features.py`); buckets and the
|
|
4
|
+
SAME-OBJECT rule turn evidence into verdicts. The LLM (M4+) adjudicates only the danger/ambiguous
|
|
5
|
+
zones. `_best_match`'s single scalar is demoted to one advisory `composite` feature.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""LLM adjudication of the `adjudicate` bucket (the danger zone).
|
|
2
|
+
|
|
3
|
+
Runs one independent `CAN_CORRESPOND` call per surviving candidate (capped, top-k by composite),
|
|
4
|
+
caching each decision. A candidate the model affirmatively confirms is promoted to `auto_accept`;
|
|
5
|
+
everything else stays as-is. Returns the LLM errors that occurred (empty when none did), so the
|
|
6
|
+
caller can keep an entry UNRESOLVED rather than emit a false `none` when the deciding call failed,
|
|
7
|
+
and say which call failed.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
|
|
14
|
+
from reference_audit.cache.store import AuditCache, prompt_hash
|
|
15
|
+
from reference_audit.config import AuditConfig
|
|
16
|
+
from reference_audit.llm.client import LLMClient, LLMError
|
|
17
|
+
from reference_audit.llm.prompts import CAN_CORRESPOND_SYSTEM, can_correspond_user
|
|
18
|
+
from reference_audit.models import CanCorrespondResult, EntryAudit
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def adjudicate_entry(
|
|
22
|
+
audit: EntryAudit, llm: LLMClient, config: AuditConfig, cache: AuditCache | None
|
|
23
|
+
) -> list[str]:
|
|
24
|
+
"""Adjudicate an entry's `adjudicate` candidates with the LLM. Returns the LLM errors."""
|
|
25
|
+
candidates = [c for c in audit.candidates if c.bucket == "adjudicate"]
|
|
26
|
+
candidates.sort(key=lambda c: c.features.composite, reverse=True)
|
|
27
|
+
candidates = candidates[: config.llm_max_candidates]
|
|
28
|
+
if not candidates:
|
|
29
|
+
return []
|
|
30
|
+
|
|
31
|
+
async def decide(candidate) -> tuple[object, CanCorrespondResult | None, str | None]:
|
|
32
|
+
system = CAN_CORRESPOND_SYSTEM
|
|
33
|
+
user = can_correspond_user(audit.entry, candidate.record, candidate.features)
|
|
34
|
+
p_hash = prompt_hash(system + "\n" + user)
|
|
35
|
+
if cache is not None:
|
|
36
|
+
cached = cache.get_llm_decision(p_hash, "can_correspond")
|
|
37
|
+
if cached is not None:
|
|
38
|
+
return candidate, CanCorrespondResult.model_validate_json(cached), None
|
|
39
|
+
try:
|
|
40
|
+
result = await llm.structured(
|
|
41
|
+
system, user, CanCorrespondResult, "can_correspond"
|
|
42
|
+
)
|
|
43
|
+
except LLMError as exc:
|
|
44
|
+
where = f"{candidate.record.source} '{candidate.record.title[:80]}'"
|
|
45
|
+
return candidate, None, f"candidate {where}: {exc}"
|
|
46
|
+
if cache is not None:
|
|
47
|
+
cache.put_llm_decision(p_hash, "can_correspond", result.model_dump_json())
|
|
48
|
+
return candidate, result, None
|
|
49
|
+
|
|
50
|
+
outcomes = await asyncio.gather(*(decide(c) for c in candidates))
|
|
51
|
+
errors: list[str] = []
|
|
52
|
+
for candidate, result, err in outcomes:
|
|
53
|
+
if err:
|
|
54
|
+
errors.append(err)
|
|
55
|
+
if result is None:
|
|
56
|
+
continue
|
|
57
|
+
candidate.llm = result
|
|
58
|
+
if result.confidence in ("high", "medium"):
|
|
59
|
+
# Confident LLM ruling is decisive: accept → match; reject → confirmed non-match, so the
|
|
60
|
+
# entry can conclude `none` when every candidate is rejected. Low confidence stays
|
|
61
|
+
# `adjudicate` (the entry remains unresolved rather than guessed).
|
|
62
|
+
candidate.bucket = "auto_accept" if result.can_correspond else "auto_reject"
|
|
63
|
+
return errors
|