citegraph 0.2.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.
citegraph/reports.py ADDED
@@ -0,0 +1,214 @@
1
+ """Report and sidecar artifact helpers for pipeline runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from dataclasses import asdict, is_dataclass
8
+ from datetime import UTC, datetime
9
+ from importlib.metadata import PackageNotFoundError, version
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from citegraph.dedup import DedupConfig
14
+ from citegraph.io import OutLayout, read_json, write_json
15
+ from citegraph.pdf_to_markdown import _has_unmappable_glyphs, _is_image_only
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ ARTIFACT_SCHEMA_VERSION = 1
20
+
21
+
22
+ def _package_version() -> str:
23
+ try:
24
+ return version("citegraph")
25
+ except PackageNotFoundError:
26
+ return "0.2.0"
27
+
28
+
29
+ def write_artifact_manifest(
30
+ layout: OutLayout,
31
+ *,
32
+ stage: str,
33
+ artifacts: dict[str, Path],
34
+ config: dict[str, Any] | None = None,
35
+ ) -> None:
36
+ """Write a minimal manifest describing generated artifacts.
37
+
38
+ The manifest is intentionally small: it gives future versions enough
39
+ information to explain stale cache behavior without introducing a
40
+ migration system before the package needs one.
41
+ """
42
+ manifest = {
43
+ "schema_version": ARTIFACT_SCHEMA_VERSION,
44
+ "package_version": _package_version(),
45
+ "generated_at": datetime.now(UTC).isoformat(),
46
+ "stage": stage,
47
+ "artifacts": {
48
+ name: str(path.relative_to(layout.out_dir))
49
+ if path.is_relative_to(layout.out_dir)
50
+ else str(path)
51
+ for name, path in artifacts.items()
52
+ },
53
+ "config": config or {},
54
+ }
55
+ write_json(layout.artifact_manifest_json, manifest)
56
+
57
+
58
+ def write_failures(path: Path, failures: list[Any]) -> None:
59
+ """Persist failures as JSONL, or remove the file when none occurred."""
60
+ if not failures:
61
+ if path.exists():
62
+ path.unlink()
63
+ return
64
+ path.parent.mkdir(parents=True, exist_ok=True)
65
+ with path.open("w", encoding="utf-8") as f:
66
+ for fail in failures:
67
+ payload = asdict(fail) if is_dataclass(fail) else dict(fail)
68
+ f.write(json.dumps(payload, ensure_ascii=False) + "\n")
69
+
70
+
71
+ def count_failures(path: Path) -> int:
72
+ """Count entries in a failures.jsonl file; 0 if the file doesn't exist."""
73
+ if not path.exists():
74
+ return 0
75
+ with path.open("r", encoding="utf-8") as f:
76
+ return sum(1 for line in f if line.strip())
77
+
78
+
79
+ def detect_source_duplicates(records: list[dict], cfg: DedupConfig) -> list[dict]:
80
+ """Find records that are duplicate conversions of the same underlying paper."""
81
+ from citegraph.dedup import compare_papers
82
+
83
+ canonical_indices: list[int] = []
84
+ duplicate_of: dict[int, int] = {}
85
+
86
+ for i, rec in enumerate(records):
87
+ matched = False
88
+ for canon_idx in canonical_indices:
89
+ if compare_papers(rec, records[canon_idx], cfg):
90
+ duplicate_of[i] = canon_idx
91
+ matched = True
92
+ break
93
+ if not matched:
94
+ canonical_indices.append(i)
95
+
96
+ canon_to_dupes: dict[int, list[int]] = {}
97
+ for dup_idx, canon_idx in duplicate_of.items():
98
+ canon_to_dupes.setdefault(canon_idx, []).append(dup_idx)
99
+
100
+ groups = []
101
+ for canon_idx, dup_indices in canon_to_dupes.items():
102
+ canon = records[canon_idx]
103
+ groups.append(
104
+ {
105
+ "canonical_source_file": canon["source_file"],
106
+ "canonical_paper_id": canon["id"],
107
+ "title": canon.get("Title", ""),
108
+ "duplicate_source_files": [records[i]["source_file"] for i in dup_indices],
109
+ }
110
+ )
111
+ return groups
112
+
113
+
114
+ def write_no_references(path: Path, entries: list[dict]) -> None:
115
+ """Persist papers that returned zero references; remove the file when none exist."""
116
+ if not entries:
117
+ if path.exists():
118
+ path.unlink()
119
+ return
120
+ write_json(path, entries)
121
+
122
+
123
+ def write_source_duplicates(path: Path, groups: list[dict]) -> None:
124
+ """Persist duplicate groups as JSON, or remove the file when none exist."""
125
+ if not groups:
126
+ if path.exists():
127
+ path.unlink()
128
+ return
129
+ write_json(path, groups)
130
+
131
+
132
+ def count_source_duplicates(path: Path) -> int:
133
+ """Total number of duplicate markdown files; 0 if the file doesn't exist."""
134
+ if not path.exists():
135
+ return 0
136
+ data = read_json(path)
137
+ return sum(len(g.get("duplicate_source_files", [])) for g in data)
138
+
139
+
140
+ def count_no_references(path: Path) -> int:
141
+ if not path.exists():
142
+ return 0
143
+ return len(read_json(path))
144
+
145
+
146
+ def count_journal_alias_warnings(path: Path) -> int:
147
+ """Curated journal aliases that folded nothing; 0 if the file doesn't exist."""
148
+ if not path.exists():
149
+ return 0
150
+ return len(read_json(path).get("unused_aliases", []))
151
+
152
+
153
+ def check_conversion_quality(
154
+ paths: list[Path], layout: OutLayout, *, ocr_attempted: bool = False
155
+ ) -> None:
156
+ """After conversion, flag markdown that is image-only or glyph-corrupted."""
157
+ reason = (
158
+ "image-only markdown even after OCR (manual review needed)"
159
+ if ocr_attempted
160
+ else "image-only markdown (possibly a scanned PDF)"
161
+ )
162
+ glyph_reason = (
163
+ "unmappable glyphs even after OCR (manual review needed)"
164
+ if ocr_attempted
165
+ else "unmappable glyphs (font has no ToUnicode map); re-run this PDF with OCR"
166
+ )
167
+ warnings = []
168
+ for p in paths:
169
+ if _is_image_only(p):
170
+ warnings.append({"source_file": p.name, "reason": reason})
171
+ elif _has_unmappable_glyphs(p):
172
+ warnings.append({"source_file": p.name, "reason": glyph_reason})
173
+ write_conversion_warnings(layout.conversion_warnings_json, warnings)
174
+ if warnings:
175
+ hint = (
176
+ "OCR did not help; inspect the source PDFs and consider manual transcription."
177
+ if ocr_attempted
178
+ else "re-run with ocr=True / --ocr (or ocr='auto' / --ocr-auto) for better results."
179
+ )
180
+ logger.warning(
181
+ "%d markdown file(s) converted poorly; %s See %s",
182
+ len(warnings),
183
+ hint,
184
+ layout.conversion_warnings_json,
185
+ )
186
+
187
+
188
+ def write_conversion_warnings(path: Path, warnings: list[dict]) -> None:
189
+ if not warnings:
190
+ if path.exists():
191
+ path.unlink()
192
+ return
193
+ write_json(path, warnings)
194
+
195
+
196
+ def count_conversion_warnings(path: Path) -> int:
197
+ if not path.exists():
198
+ return 0
199
+ return len(read_json(path))
200
+
201
+
202
+ def write_author_review(path: Path, review: list[dict]) -> None:
203
+ """Persist flagged author clusters, or remove the file when there are none."""
204
+ if not review:
205
+ if path.exists():
206
+ path.unlink()
207
+ return
208
+ write_json(path, review)
209
+
210
+
211
+ def count_author_review(path: Path) -> int:
212
+ if not path.exists():
213
+ return 0
214
+ return len(read_json(path))
citegraph/schemas.py ADDED
@@ -0,0 +1,89 @@
1
+ """Pydantic schemas used both as I/O DTOs and Gemini response schemas.
2
+
3
+ ``Authors`` is a *derived* property (``", ".join(Authors_List)``), not part
4
+ of the LLM contract — only ``Authors_List`` is. The LLM used to be asked
5
+ for both, which (a) wasted output tokens and (b) created a real failure
6
+ mode where the two could disagree. Both schemas override ``model_dump`` to
7
+ include the derived ``Authors`` so CSV/cache writers see the column.
8
+
9
+ Old cache files that contain an ``Authors`` field still load cleanly: the
10
+ ``extra="ignore"`` config drops the unknown key during validation, and the
11
+ property recomputes from ``Authors_List`` on access.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ import pandas as pd
20
+ from pydantic import BaseModel, ConfigDict, Field
21
+
22
+
23
+ class PaperMetadata(BaseModel):
24
+ """Bibliographic metadata extracted from a single source paper.
25
+
26
+ Used both as the output of :mod:`citegraph.extract_metadata` and as the
27
+ ``response_schema`` for Gemini's structured output.
28
+ """
29
+
30
+ model_config = ConfigDict(extra="ignore")
31
+
32
+ Title: str = Field(description="The full title of the paper.")
33
+ Authors_List: list[str] = Field(description="List of every author name.")
34
+ Journal: str = Field(description="Journal or venue where the paper was published.")
35
+ Year: int = Field(description="Publication year as an integer.")
36
+
37
+ @property
38
+ def Authors(self) -> str:
39
+ return ", ".join(self.Authors_List)
40
+
41
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
42
+ data = super().model_dump(**kwargs)
43
+ data["Authors"] = self.Authors
44
+ return data
45
+
46
+
47
+ class Reference(BaseModel):
48
+ """A single bibliographic reference extracted from a paper's bibliography."""
49
+
50
+ model_config = ConfigDict(extra="ignore")
51
+
52
+ Title: str
53
+ Authors_List: list[str]
54
+ Journal: str
55
+ Year: int
56
+
57
+ @property
58
+ def Authors(self) -> str:
59
+ return ", ".join(self.Authors_List)
60
+
61
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
62
+ data = super().model_dump(**kwargs)
63
+ data["Authors"] = self.Authors
64
+ return data
65
+
66
+
67
+ class CitationLink(BaseModel):
68
+ """An edge from a citing paper to a (deduplicated) cited paper."""
69
+
70
+ citing_id: str
71
+ cited_id: str
72
+
73
+
74
+ @dataclass
75
+ class PipelineResult:
76
+ """Container returned by :meth:`citegraph.Pipeline.run`.
77
+
78
+ Pandas DataFrames mirroring the on-disk CSVs:
79
+
80
+ - ``works`` : one row per canonical work, indexed by ``id``; the
81
+ ``ring`` column marks provenance (0 = the user's core PDFs) and
82
+ ``source_file`` is non-empty for processed PDFs.
83
+ - ``graph`` : edges ``(citing_id, cited_id)`` between works.
84
+ """
85
+
86
+ works: pd.DataFrame
87
+ graph: pd.DataFrame
88
+ authors: pd.DataFrame | None = None
89
+ author_citations: pd.DataFrame | None = None