starfriend commited on
Commit
cbdf505
·
verified ·
1 Parent(s): a8091d5

Upload paper_linking.py

Browse files
Files changed (1) hide show
  1. backend/paper_linking.py +143 -306
backend/paper_linking.py CHANGED
@@ -1,362 +1,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
 
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
  }