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

Upload summaries.py

Browse files
Files changed (1) hide show
  1. local_rag/summaries.py +241 -35
local_rag/summaries.py CHANGED
@@ -3,40 +3,90 @@ from __future__ import annotations
3
  import json
4
  import os
5
  import re
 
6
  from pathlib import Path
7
 
8
  from huggingface_hub import hf_hub_download
9
  from local_rag.config import LocalRagConfig
10
 
 
11
  TITLE_PATTERN = re.compile(r"\*\*Title:\*\*\s*(.+)", re.MULTILINE)
12
  _FILENAME_TITLE = re.compile(r"^.+?\s-\s\d{4}\s-\s(.+)$")
13
  _FILENAME_YEAR = re.compile(r"^.+?\s-\s(\d{4})\s-\s")
14
 
15
- HF_DATASET_REPO = os.getenv("WATERSCOPE_DATASET_REPO", "EnvironmentalAI/WaterScopeAI-Data")
16
- HF_SUMMARY_PREFIX = os.getenv("WATERSCOPE_SUMMARY_PREFIX", "Summary")
 
 
 
 
 
 
 
 
 
17
  HF_SUMMARY_CACHE = Path(
18
- os.getenv("WATERSCOPE_SUMMARY_CACHE", "/tmp/waterscope-summary-cache")
 
 
 
19
  ).expanduser().resolve()
20
- HF_SUMMARY_ON_DEMAND = os.getenv("WATERSCOPE_SUMMARY_ON_DEMAND", "1").lower() in {
21
- "1", "true", "yes"
 
 
 
 
 
 
22
  }
23
 
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  def _year_from_filename(file_name: str) -> str | None:
26
- match = _FILENAME_YEAR.match(Path(file_name).name)
 
 
 
27
  return match.group(1) if match else None
28
 
29
 
30
  def _download_cloud_summary(file_name: str) -> Path | None:
31
- """Download one summary from the HF Dataset and cache it locally."""
 
 
 
 
 
32
  if not HF_SUMMARY_ON_DEMAND:
33
  return None
34
 
35
- year = _year_from_filename(file_name)
 
 
 
36
  if not year:
37
  return None
38
 
39
- repo_file = f"{HF_SUMMARY_PREFIX}/{year}/{Path(file_name).name}"
 
 
 
 
 
40
  try:
41
  downloaded = hf_hub_download(
42
  repo_id=HF_DATASET_REPO,
@@ -44,129 +94,285 @@ def _download_cloud_summary(file_name: str) -> Path | None:
44
  filename=repo_file,
45
  local_dir=str(HF_SUMMARY_CACHE),
46
  )
47
- except Exception:
 
 
 
 
 
48
  return None
49
 
50
  path = Path(downloaded)
 
51
  return path if path.is_file() else None
52
 
53
 
54
  def summary_search_dirs() -> list[Path]:
55
  """Directories to search for summary .txt files (first match wins)."""
56
  dirs: list[Path] = []
57
- for raw in (os.getenv("PAPER_SUMMARIES_DIRS") or "").split(":"):
 
 
 
58
  raw = raw.strip()
 
59
  if raw:
60
- dirs.append(Path(raw).expanduser().resolve())
 
 
61
 
62
  override = os.getenv("PAPER_SUMMARIES_DIR")
 
63
  if override:
64
- dirs.append(Path(override).expanduser().resolve())
 
 
65
 
66
  project_root = Path(__file__).resolve().parents[1]
67
- dirs.append(project_root / "paper_summaries")
 
 
 
68
 
69
  unique: list[Path] = []
70
  seen: set[str] = set()
 
71
  for path in dirs:
72
  key = str(path)
 
73
  if key in seen:
74
  continue
 
75
  seen.add(key)
76
  unique.append(path)
 
77
  return unique
78
 
79
 
80
  def summaries_dir() -> Path:
81
  for path in summary_search_dirs():
 
82
  if path.exists():
83
  return path
84
- return Path(__file__).resolve().parents[1] / "paper_summaries"
85
 
 
 
 
 
86
 
87
- def _manifest_path(config: LocalRagConfig | None = None) -> Path:
 
 
 
88
  cfg = config or LocalRagConfig.from_env()
 
89
  return cfg.manifest_path
90
 
91
 
92
- def load_manifest(config: LocalRagConfig | None = None) -> dict:
 
 
93
  path = _manifest_path(config)
 
94
  if not path.exists():
95
- raise FileNotFoundError(f"Manifest not found: {path}. Rebuild local_rag_data/manifest.json first.")
96
- return json.loads(path.read_text(encoding="utf-8"))
 
 
 
 
 
 
97
 
98
 
99
  def title_from_filename(file_name: str) -> str:
100
  stem = Path(file_name).stem
 
101
  match = _FILENAME_TITLE.match(stem)
 
102
  if match:
103
  return match.group(1).strip()
 
104
  return stem
105
 
106
 
107
- def extract_title(file_name: str, summary: str | None = None) -> str:
 
 
 
108
  if summary:
109
  match = TITLE_PATTERN.search(summary)
 
110
  if match:
111
  return match.group(1).strip()
 
112
  return title_from_filename(file_name)
113
 
114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  def summary_path(file_name: str) -> Path | None:
 
 
 
 
 
 
 
 
 
 
116
  # Preserve existing local behavior first.
117
  for root in summary_search_dirs():
118
- candidate = root / file_name
119
- if candidate.exists():
120
- return candidate
121
 
122
- # Cloud production fallback: fetch only the requested summary.
123
- return _download_cloud_summary(file_name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
 
125
 
126
  def load_summary(file_name: str) -> str | None:
127
  path = summary_path(file_name)
 
128
  if path is None:
129
  return None
130
- return path.read_text(encoding="utf-8")
 
 
 
131
 
132
 
133
- def list_papers(config: LocalRagConfig | None = None) -> list[dict]:
 
 
134
  """List indexed papers without reading every summary file into memory."""
135
  manifest = load_manifest(config)
 
136
  papers: list[dict] = []
 
137
  for entry in manifest.get("files", []):
138
  file_name = entry["file_name"]
 
139
  papers.append(
140
  {
141
  "document_id": entry["document_id"],
142
  "file_name": file_name,
143
- "title": title_from_filename(file_name),
144
- "chunk_count": entry.get("chunk_count", 0),
145
- "has_summary": summary_path(file_name) is not None,
 
 
 
 
 
 
 
 
146
  }
147
  )
148
- papers.sort(key=lambda item: item["title"].lower())
 
 
 
 
149
  return papers
150
 
151
 
152
- def summaries_for_documents(document_ids: list[str], config: LocalRagConfig | None = None) -> list[dict]:
 
 
 
153
  manifest = load_manifest(config)
154
- by_id = {entry["document_id"]: entry for entry in manifest.get("files", [])}
 
 
 
 
 
155
  selected: list[dict] = []
 
156
  for document_id in document_ids:
157
  entry = by_id.get(document_id)
 
158
  if not entry:
159
  continue
 
160
  file_name = entry["file_name"]
 
161
  summary = load_summary(file_name)
 
162
  if not summary:
163
  continue
 
164
  selected.append(
165
  {
166
  "document_id": document_id,
167
- "title": extract_title(file_name, summary),
 
 
 
168
  "file_name": file_name,
169
  "text": summary,
170
  }
171
  )
172
- return selected
 
 
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 filenames so visually identical Unicode forms match.
48
+
49
+ Example:
50
+ Türkiye
51
+ Türkiye
52
+
53
+ NFKC is also used by the paper figure matching 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
+
62
+ match = _FILENAME_YEAR.match(safe_name)
63
+
64
  return match.group(1) if match else None
65
 
66
 
67
  def _download_cloud_summary(file_name: str) -> Path | None:
68
+ """
69
+ Download one summary from the HF Dataset and cache it locally.
70
+
71
+ The requested filename is Unicode-normalized before constructing
72
+ the Hugging Face Dataset path.
73
+ """
74
  if not HF_SUMMARY_ON_DEMAND:
75
  return None
76
 
77
+ safe_name = _normalize_filename(file_name)
78
+
79
+ year = _year_from_filename(safe_name)
80
+
81
  if not year:
82
  return None
83
 
84
+ repo_file = (
85
+ f"{HF_SUMMARY_PREFIX}/"
86
+ f"{year}/"
87
+ f"{safe_name}"
88
+ )
89
+
90
  try:
91
  downloaded = hf_hub_download(
92
  repo_id=HF_DATASET_REPO,
 
94
  filename=repo_file,
95
  local_dir=str(HF_SUMMARY_CACHE),
96
  )
97
+
98
+ except Exception as exc:
99
+ print(
100
+ f"[SUMMARY DOWNLOAD ERROR] "
101
+ f"{repo_file}: {repr(exc)}"
102
+ )
103
  return None
104
 
105
  path = Path(downloaded)
106
+
107
  return path if path.is_file() else None
108
 
109
 
110
  def summary_search_dirs() -> list[Path]:
111
  """Directories to search for summary .txt files (first match wins)."""
112
  dirs: list[Path] = []
113
+
114
+ for raw in (
115
+ os.getenv("PAPER_SUMMARIES_DIRS") or ""
116
+ ).split(":"):
117
  raw = raw.strip()
118
+
119
  if raw:
120
+ dirs.append(
121
+ Path(raw).expanduser().resolve()
122
+ )
123
 
124
  override = os.getenv("PAPER_SUMMARIES_DIR")
125
+
126
  if override:
127
+ dirs.append(
128
+ Path(override).expanduser().resolve()
129
+ )
130
 
131
  project_root = Path(__file__).resolve().parents[1]
132
+
133
+ dirs.append(
134
+ project_root / "paper_summaries"
135
+ )
136
 
137
  unique: list[Path] = []
138
  seen: set[str] = set()
139
+
140
  for path in dirs:
141
  key = str(path)
142
+
143
  if key in seen:
144
  continue
145
+
146
  seen.add(key)
147
  unique.append(path)
148
+
149
  return unique
150
 
151
 
152
  def summaries_dir() -> Path:
153
  for path in summary_search_dirs():
154
+
155
  if path.exists():
156
  return path
 
157
 
158
+ return (
159
+ Path(__file__).resolve().parents[1]
160
+ / "paper_summaries"
161
+ )
162
 
163
+
164
+ def _manifest_path(
165
+ config: LocalRagConfig | None = None,
166
+ ) -> Path:
167
  cfg = config or LocalRagConfig.from_env()
168
+
169
  return cfg.manifest_path
170
 
171
 
172
+ def load_manifest(
173
+ config: LocalRagConfig | None = None,
174
+ ) -> dict:
175
  path = _manifest_path(config)
176
+
177
  if not path.exists():
178
+ raise FileNotFoundError(
179
+ f"Manifest not found: {path}. "
180
+ "Rebuild local_rag_data/manifest.json first."
181
+ )
182
+
183
+ return json.loads(
184
+ path.read_text(encoding="utf-8")
185
+ )
186
 
187
 
188
  def title_from_filename(file_name: str) -> str:
189
  stem = Path(file_name).stem
190
+
191
  match = _FILENAME_TITLE.match(stem)
192
+
193
  if match:
194
  return match.group(1).strip()
195
+
196
  return stem
197
 
198
 
199
+ def extract_title(
200
+ file_name: str,
201
+ summary: str | None = None,
202
+ ) -> str:
203
  if summary:
204
  match = TITLE_PATTERN.search(summary)
205
+
206
  if match:
207
  return match.group(1).strip()
208
+
209
  return title_from_filename(file_name)
210
 
211
 
212
+ def _find_normalized_local_file(
213
+ directory: Path,
214
+ file_name: str,
215
+ ) -> Path | None:
216
+ """
217
+ Find a locally cached/available summary using Unicode-normalized
218
+ exact filename matching.
219
+
220
+ No fuzzy or prefix matching is used.
221
+ """
222
+ if not directory.is_dir():
223
+ return None
224
+
225
+ safe_name = _normalize_filename(file_name)
226
+
227
+ # Fast exact lookup first.
228
+ candidate = directory / safe_name
229
+
230
+ if candidate.is_file():
231
+ return candidate
232
+
233
+ # Unicode-normalized fallback.
234
+ target = safe_name.casefold()
235
+
236
+ try:
237
+ for path in directory.iterdir():
238
+
239
+ if not path.is_file():
240
+ continue
241
+
242
+ existing_name = _normalize_filename(
243
+ path.name
244
+ )
245
+
246
+ if existing_name.casefold() == target:
247
+ return path
248
+
249
+ except OSError:
250
+ return None
251
+
252
+ return None
253
+
254
+
255
  def summary_path(file_name: str) -> Path | None:
256
+ """
257
+ Resolve a summary locally first, then fall back to the
258
+ Hugging Face Dataset.
259
+
260
+ Unicode normalization is applied to filename matching.
261
+ """
262
+ safe_name = _normalize_filename(file_name)
263
+
264
+ year = _year_from_filename(safe_name)
265
+
266
  # Preserve existing local behavior first.
267
  for root in summary_search_dirs():
 
 
 
268
 
269
+ # Flat folder layout.
270
+ found = _find_normalized_local_file(
271
+ root,
272
+ safe_name,
273
+ )
274
+
275
+ if found is not None:
276
+ return found
277
+
278
+ # Also support year-organized local/cache folders.
279
+ if year:
280
+ found = _find_normalized_local_file(
281
+ root / year,
282
+ safe_name,
283
+ )
284
+
285
+ if found is not None:
286
+ return found
287
+
288
+ # Cloud production fallback:
289
+ # fetch only the requested summary.
290
+ return _download_cloud_summary(safe_name)
291
 
292
 
293
  def load_summary(file_name: str) -> str | None:
294
  path = summary_path(file_name)
295
+
296
  if path is None:
297
  return None
298
+
299
+ return path.read_text(
300
+ encoding="utf-8"
301
+ )
302
 
303
 
304
+ def list_papers(
305
+ config: LocalRagConfig | None = None,
306
+ ) -> list[dict]:
307
  """List indexed papers without reading every summary file into memory."""
308
  manifest = load_manifest(config)
309
+
310
  papers: list[dict] = []
311
+
312
  for entry in manifest.get("files", []):
313
  file_name = entry["file_name"]
314
+
315
  papers.append(
316
  {
317
  "document_id": entry["document_id"],
318
  "file_name": file_name,
319
+ "title": title_from_filename(
320
+ file_name
321
+ ),
322
+ "chunk_count": entry.get(
323
+ "chunk_count",
324
+ 0,
325
+ ),
326
+ "has_summary": (
327
+ summary_path(file_name)
328
+ is not None
329
+ ),
330
  }
331
  )
332
+
333
+ papers.sort(
334
+ key=lambda item: item["title"].lower()
335
+ )
336
+
337
  return papers
338
 
339
 
340
+ def summaries_for_documents(
341
+ document_ids: list[str],
342
+ config: LocalRagConfig | None = None,
343
+ ) -> list[dict]:
344
  manifest = load_manifest(config)
345
+
346
+ by_id = {
347
+ entry["document_id"]: entry
348
+ for entry in manifest.get("files", [])
349
+ }
350
+
351
  selected: list[dict] = []
352
+
353
  for document_id in document_ids:
354
  entry = by_id.get(document_id)
355
+
356
  if not entry:
357
  continue
358
+
359
  file_name = entry["file_name"]
360
+
361
  summary = load_summary(file_name)
362
+
363
  if not summary:
364
  continue
365
+
366
  selected.append(
367
  {
368
  "document_id": document_id,
369
+ "title": extract_title(
370
+ file_name,
371
+ summary,
372
+ ),
373
  "file_name": file_name,
374
  "text": summary,
375
  }
376
  )
377
+
378
+ return selected