starfriend commited on
Commit
db682e5
·
verified ·
1 Parent(s): fffec10

Upload paper_linking.py

Browse files
Files changed (1) hide show
  1. backend/paper_linking.py +306 -143
backend/paper_linking.py CHANGED
@@ -1,199 +1,362 @@
1
  from __future__ import annotations
2
 
3
- import hashlib
 
4
  import re
 
 
5
 
6
- from local_rag.summaries import load_manifest, summary_path
 
7
 
8
- _RP_SEGMENT_RE = re.compile(r";\s*")
9
- _FILENAME_AUTHORS_RE = re.compile(r"^(.+?)\s*-\s*\d{4}\s*-\s*")
10
- _ET_AL_RE = re.compile(r"\bet\s+al\b\.?", re.I)
11
 
12
- _NON_AUTHOR_PREFIXES = (
13
- "not specified",
14
- "not explicitly",
15
- "not listed",
16
- "not available",
17
- "not provided",
18
- "unknown",
19
- "co-author",
20
  )
21
 
 
 
 
 
22
 
23
- def _is_non_author_text(value: str) -> bool:
24
- text = (value or "").strip().lower()
 
 
 
 
25
 
26
- if not text:
27
- return True
 
 
 
 
 
 
28
 
29
- if text in {"n/a", "na", "none"}:
30
- return True
31
 
32
- return text.startswith(_NON_AUTHOR_PREFIXES)
 
 
33
 
 
 
 
34
 
35
- def _surname_key(name: str) -> str:
36
- """Normalize an author label for surname-level deduplication."""
37
- text = (name or "").strip()
38
- if not text:
39
- return ""
40
 
41
- # For "... Surname et al.", always use the word immediately before "et al."
42
- et_al_match = re.search(r"\bet\s+al\b\.?", text, flags=re.I)
43
- if et_al_match:
44
- before_et_al = text[:et_al_match.start()].strip()
45
- parts = before_et_al.split()
46
- if parts:
47
- return parts[-1].strip(".,;:").lower()
48
 
49
- # "Surname, Initials" format
50
- if "," in text:
51
- return text.split(",", 1)[0].strip().lower()
 
52
 
53
- # Fallback
54
- parts = text.split()
55
- return parts[0].strip(".,;:").lower() if parts else ""
56
 
 
 
 
57
 
58
- def parse_authors_from_filename(file_name: str) -> list[str]:
59
- """Extract author names from 'Authors - YYYY - Title.ext' filenames."""
60
- name = (file_name or "").strip()
61
- if not name:
62
- return []
63
 
64
- stem = name.rsplit(".", 1)[0]
65
- match = _FILENAME_AUTHORS_RE.match(stem)
66
- if not match:
67
- return []
68
 
69
- author_part = match.group(1).strip()
70
- if _ET_AL_RE.search(author_part):
71
- return [author_part]
72
- return [part.strip() for part in re.split(r"\s+and\s+", author_part, flags=re.I) if part.strip()]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
 
 
 
74
 
75
- def parse_authors_from_rp(rp: str) -> str:
76
- """Extract usable author names from RP and keep the more specific duplicate."""
77
- text = (rp or "").strip()
78
- if not text or _is_non_author_text(text):
79
- return ""
80
 
81
- names: list[str] = []
82
- by_surname: dict[str, int] = {}
 
83
 
84
- def _prefer(current: str, candidate: str) -> str:
85
- # Prefer the more specific/longer representation
86
- if len(candidate) > len(current):
87
- return candidate
88
- return current
 
89
 
90
- for segment in _RP_SEGMENT_RE.split(text):
91
- segment = segment.strip()
 
 
 
92
 
93
- if not segment or _is_non_author_text(segment):
94
- continue
 
 
95
 
96
- name = segment.split("(", 1)[0].strip().rstrip(",")
 
97
 
98
- if not name or _is_non_author_text(name):
99
- continue
100
 
101
- key = _surname_key(name)
102
- if not key:
103
  continue
104
 
105
- if key in by_surname:
106
- idx = by_surname[key]
107
- names[idx] = _prefer(names[idx], name)
108
- else:
109
- by_surname[key] = len(names)
110
- names.append(name)
111
 
112
- return "; ".join(names)
113
 
114
 
115
- def resolve_authors(row: dict) -> str:
116
- """
117
- Resolve authors using the best available metadata source.
 
118
 
119
- Priority:
120
- 1. AU - full author list from publication metadata
121
- 2. RP - author information parsed from full text
122
- 3. PDF/TXT filename - fallback only
123
- """
 
 
 
 
 
 
124
 
125
- # 1. Prefer the full author list from metadata
126
- au = str(row.get("AU") or "").strip()
127
- if au:
128
- return au
129
 
130
- # 2. Fall back to RP
131
- rp_authors = parse_authors_from_rp(row.get("RP") or "").strip()
132
- if rp_authors:
133
- return rp_authors
134
 
135
- # 3. Final fallback: filename
136
- filename_authors = parse_authors_from_filename(
137
- row.get("PDF_file") or row.get("TXT_file") or ""
 
 
 
 
 
138
  )
139
 
140
- return "; ".join(filename_authors)
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
- def format_authors(authors: str, *, max_names: int | None = None) -> str:
144
- """Display string for UI cards. Default shows every parsed author."""
145
- if not authors:
146
- return ""
147
- parts = [part.strip() for part in authors.split(";") if part.strip()]
148
- if max_names is None or len(parts) <= max_names:
149
- return "; ".join(parts)
150
- return "; ".join(parts[:max_names]) + "; et al."
151
 
 
152
 
153
- def document_id_for_txt_file(txt_file: str) -> str | None:
154
- name = (txt_file or "").strip()
155
- if not name:
 
 
 
 
 
 
 
 
 
156
  return None
157
 
158
- manifest = load_manifest()
159
- files = manifest.get("files", [])
160
 
161
- for entry in files:
162
- if entry.get("file_name") == name:
163
- return entry.get("document_id")
164
 
165
- lowered = name.lower()
166
- for entry in files:
167
- file_name = entry.get("file_name", "")
168
- if file_name.lower() == lowered:
169
- return entry.get("document_id")
170
 
171
- if not name.endswith(".txt"):
172
- with_txt = f"{name}.txt"
173
- for entry in files:
174
- if entry.get("file_name") == with_txt:
175
- return entry.get("document_id")
176
 
177
- if summary_path(name) is not None:
178
- stem = name[:-4] if name.lower().endswith(".txt") else name
179
- return hashlib.sha1(stem.encode("utf-8")).hexdigest()[:16]
 
 
 
 
 
 
 
 
 
 
 
 
180
 
181
  return None
182
 
183
 
184
- def enrich_metadata_row(row: dict, row_index: int) -> dict:
185
- txt_file = (row.get("TXT_file") or row.get("txt_file") or "").strip()
186
- document_id = document_id_for_txt_file(txt_file)
187
- authors = resolve_authors(row)
188
- return {
189
- "metadata_row_index": row_index,
190
- "title": (row.get("TI") or "").strip(),
191
- "abstract": (row.get("AB") or "").strip(),
192
- "keywords": (row.get("DE") or "").strip(),
193
- "authors": authors,
194
- "doi": (row.get("DI") or "").strip(),
195
- "year": (row.get("PY") or "").strip(),
196
- "txt_file": txt_file,
197
- "document_id": document_id,
198
- "has_chat": document_id is not None,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ import json
4
+ import os
5
  import re
6
+ import unicodedata
7
+ from pathlib import Path
8
 
9
+ from huggingface_hub import hf_hub_download
10
+ from local_rag.config import LocalRagConfig
11
 
 
 
 
12
 
13
+ TITLE_PATTERN = re.compile(r"\*\*Title:\*\*\s*(.+)", re.MULTILINE)
14
+ _FILENAME_TITLE = re.compile(r"^.+?\s-\s\d{4}\s-\s(.+)$")
15
+ _FILENAME_YEAR = re.compile(r"^.+?\s-\s(\d{4})\s-\s")
16
+
17
+
18
+ HF_DATASET_REPO = os.getenv(
19
+ "WATERSCOPE_DATASET_REPO",
20
+ "EnvironmentalAI/WaterScopeAI-Data",
21
  )
22
 
23
+ HF_SUMMARY_PREFIX = os.getenv(
24
+ "WATERSCOPE_SUMMARY_PREFIX",
25
+ "Summary",
26
+ )
27
 
28
+ HF_SUMMARY_CACHE = Path(
29
+ os.getenv(
30
+ "WATERSCOPE_SUMMARY_CACHE",
31
+ "/tmp/waterscope-summary-cache",
32
+ )
33
+ ).expanduser().resolve()
34
 
35
+ HF_SUMMARY_ON_DEMAND = os.getenv(
36
+ "WATERSCOPE_SUMMARY_ON_DEMAND",
37
+ "1",
38
+ ).lower() in {
39
+ "1",
40
+ "true",
41
+ "yes",
42
+ }
43
 
 
 
44
 
45
+ def _normalize_filename(file_name: str) -> str:
46
+ """
47
+ Normalize a filename so visually identical Unicode forms match.
48
 
49
+ Example:
50
+ Türkiye
51
+ Türkiye
52
 
53
+ NFKC is also used by the figure lookup code.
54
+ """
55
+ name = Path(file_name or "").name.strip()
56
+ return unicodedata.normalize("NFKC", name)
 
57
 
 
 
 
 
 
 
 
58
 
59
+ def _year_from_filename(file_name: str) -> str | None:
60
+ safe_name = _normalize_filename(file_name)
61
+ match = _FILENAME_YEAR.match(safe_name)
62
+ return match.group(1) if match else None
63
 
 
 
 
64
 
65
+ def _download_cloud_summary(file_name: str) -> Path | None:
66
+ """
67
+ Download one summary from the HF Dataset and cache it locally.
68
 
69
+ The filename is Unicode-normalized before constructing the
70
+ Summary/<year>/<filename> path.
71
+ """
72
+ if not HF_SUMMARY_ON_DEMAND:
73
+ return None
74
 
75
+ safe_name = _normalize_filename(file_name)
 
 
 
76
 
77
+ year = _year_from_filename(safe_name)
78
+ if not year:
79
+ return None
80
+
81
+ repo_file = f"{HF_SUMMARY_PREFIX}/{year}/{safe_name}"
82
+
83
+ try:
84
+ downloaded = hf_hub_download(
85
+ repo_id=HF_DATASET_REPO,
86
+ repo_type="dataset",
87
+ filename=repo_file,
88
+ local_dir=str(HF_SUMMARY_CACHE),
89
+ )
90
+ except Exception as exc:
91
+ print(
92
+ f"[SUMMARY DOWNLOAD ERROR] {repo_file}: {repr(exc)}",
93
+ flush=True,
94
+ )
95
+ return None
96
 
97
+ path = Path(downloaded)
98
+ return path if path.is_file() else None
99
 
 
 
 
 
 
100
 
101
+ def summary_search_dirs() -> list[Path]:
102
+ """Directories to search for summary .txt files (first match wins)."""
103
+ dirs: list[Path] = []
104
 
105
+ for raw in (os.getenv("PAPER_SUMMARIES_DIRS") or "").split(":"):
106
+ raw = raw.strip()
107
+ if raw:
108
+ dirs.append(
109
+ Path(raw).expanduser().resolve()
110
+ )
111
 
112
+ override = os.getenv("PAPER_SUMMARIES_DIR")
113
+ if override:
114
+ dirs.append(
115
+ Path(override).expanduser().resolve()
116
+ )
117
 
118
+ project_root = Path(__file__).resolve().parents[1]
119
+ dirs.append(
120
+ project_root / "paper_summaries"
121
+ )
122
 
123
+ unique: list[Path] = []
124
+ seen: set[str] = set()
125
 
126
+ for path in dirs:
127
+ key = str(path)
128
 
129
+ if key in seen:
 
130
  continue
131
 
132
+ seen.add(key)
133
+ unique.append(path)
 
 
 
 
134
 
135
+ return unique
136
 
137
 
138
+ def summaries_dir() -> Path:
139
+ for path in summary_search_dirs():
140
+ if path.exists():
141
+ return path
142
 
143
+ return (
144
+ Path(__file__).resolve().parents[1]
145
+ / "paper_summaries"
146
+ )
147
+
148
+
149
+ def _manifest_path(
150
+ config: LocalRagConfig | None = None,
151
+ ) -> Path:
152
+ cfg = config or LocalRagConfig.from_env()
153
+ return cfg.manifest_path
154
 
 
 
 
 
155
 
156
+ def load_manifest(
157
+ config: LocalRagConfig | None = None,
158
+ ) -> dict:
159
+ path = _manifest_path(config)
160
 
161
+ if not path.exists():
162
+ raise FileNotFoundError(
163
+ f"Manifest not found: {path}. "
164
+ "Rebuild local_rag_data/manifest.json first."
165
+ )
166
+
167
+ return json.loads(
168
+ path.read_text(encoding="utf-8")
169
  )
170
 
 
171
 
172
+ def title_from_filename(file_name: str) -> str:
173
+ stem = Path(file_name).stem
174
+
175
+ match = _FILENAME_TITLE.match(stem)
176
+
177
+ if match:
178
+ return match.group(1).strip()
179
+
180
+ return stem
181
+
182
+
183
+ def extract_title(
184
+ file_name: str,
185
+ summary: str | None = None,
186
+ ) -> str:
187
+ if summary:
188
+ match = TITLE_PATTERN.search(summary)
189
 
190
+ if match:
191
+ return match.group(1).strip()
 
 
 
 
 
 
192
 
193
+ return title_from_filename(file_name)
194
 
195
+
196
+ def _find_normalized_local_file(
197
+ directory: Path,
198
+ file_name: str,
199
+ ) -> Path | None:
200
+ """
201
+ Find a locally available/cached summary using exact
202
+ Unicode-normalized filename matching.
203
+
204
+ No fuzzy or prefix matching is used.
205
+ """
206
+ if not directory.is_dir():
207
  return None
208
 
209
+ safe_name = _normalize_filename(file_name)
 
210
 
211
+ # Fast exact lookup first.
212
+ candidate = directory / safe_name
 
213
 
214
+ if candidate.is_file():
215
+ return candidate
 
 
 
216
 
217
+ # Unicode-normalized fallback.
218
+ target = safe_name.casefold()
 
 
 
219
 
220
+ try:
221
+ for path in directory.iterdir():
222
+
223
+ if not path.is_file():
224
+ continue
225
+
226
+ existing_name = _normalize_filename(
227
+ path.name
228
+ )
229
+
230
+ if existing_name.casefold() == target:
231
+ return path
232
+
233
+ except OSError:
234
+ return None
235
 
236
  return None
237
 
238
 
239
+ def summary_path(file_name: str) -> Path | None:
240
+ """
241
+ Resolve the summary locally first, then fetch it from HF.
242
+
243
+ Unicode-normalized exact filename matching is used.
244
+ """
245
+ safe_name = _normalize_filename(file_name)
246
+
247
+ year = _year_from_filename(safe_name)
248
+
249
+ # Preserve existing local behavior first.
250
+ for root in summary_search_dirs():
251
+
252
+ # Flat layout:
253
+ # paper_summaries/<file>.txt
254
+ found = _find_normalized_local_file(
255
+ root,
256
+ safe_name,
257
+ )
258
+
259
+ if found is not None:
260
+ return found
261
+
262
+ # Also support year-organized local folders.
263
+ if year:
264
+ found = _find_normalized_local_file(
265
+ root / year,
266
+ safe_name,
267
+ )
268
+
269
+ if found is not None:
270
+ return found
271
+
272
+ # Cloud production fallback:
273
+ # Summary/<year>/<normalized filename>
274
+ return _download_cloud_summary(safe_name)
275
+
276
+
277
+ def load_summary(file_name: str) -> str | None:
278
+ path = summary_path(file_name)
279
+
280
+ if path is None:
281
+ return None
282
+
283
+ return path.read_text(
284
+ encoding="utf-8"
285
+ )
286
+
287
+
288
+ def list_papers(
289
+ config: LocalRagConfig | None = None,
290
+ ) -> list[dict]:
291
+ """List indexed papers without reading every summary file into memory."""
292
+ manifest = load_manifest(config)
293
+
294
+ papers: list[dict] = []
295
+
296
+ for entry in manifest.get("files", []):
297
+ file_name = entry["file_name"]
298
+
299
+ papers.append(
300
+ {
301
+ "document_id": entry["document_id"],
302
+ "file_name": file_name,
303
+ "title": title_from_filename(
304
+ file_name
305
+ ),
306
+ "chunk_count": entry.get(
307
+ "chunk_count",
308
+ 0,
309
+ ),
310
+ "has_summary": (
311
+ summary_path(file_name)
312
+ is not None
313
+ ),
314
+ }
315
+ )
316
+
317
+ papers.sort(
318
+ key=lambda item: item["title"].lower()
319
+ )
320
+
321
+ return papers
322
+
323
+
324
+ def summaries_for_documents(
325
+ document_ids: list[str],
326
+ config: LocalRagConfig | None = None,
327
+ ) -> list[dict]:
328
+ manifest = load_manifest(config)
329
+
330
+ by_id = {
331
+ entry["document_id"]: entry
332
+ for entry in manifest.get("files", [])
333
  }
334
+
335
+ selected: list[dict] = []
336
+
337
+ for document_id in document_ids:
338
+ entry = by_id.get(document_id)
339
+
340
+ if not entry:
341
+ continue
342
+
343
+ file_name = entry["file_name"]
344
+
345
+ summary = load_summary(file_name)
346
+
347
+ if not summary:
348
+ continue
349
+
350
+ selected.append(
351
+ {
352
+ "document_id": document_id,
353
+ "title": extract_title(
354
+ file_name,
355
+ summary,
356
+ ),
357
+ "file_name": file_name,
358
+ "text": summary,
359
+ }
360
+ )
361
+
362
+ return selected