grant-radar / src /analyzer /data_loader_supporting.py
Riley Coleman
feat: add PDF & supporting materials integration to context
bfcc872
Raw
History Blame Contribute Delete
5.91 kB
# src/analyzer/data_loader_supporting.py
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional, Iterable, Union
import json, re
from .utils.text import to_number
@dataclass
class SupportingDoc:
grant_id: str
url: str
title: str
open_date: Optional[str]
close_date: Optional[str]
notify_date: Optional[str]
funding_min: Optional[float]
funding_max: Optional[float]
total_pot: Optional[float]
funding_rates: Optional[str]
duration_min: Optional[int]
duration_max: Optional[int]
text: str # flattened blob for retrieval
sections: Dict[str, str] # raw sections, if present
# Number parsing moved to utils.text.to_number()
# Keeping wrapper for backward compatibility
def _num(x):
return to_number(x)
def _int(x):
try:
return int(x) if x is not None else None
except Exception:
try:
return int(float(str(x).replace(",", "")))
except Exception:
return None
def _infer_grant_id(obj: dict, fallback_name: str = "") -> Optional[str]:
# 1) explicit fields
for k in ("grant_id","id","competition_id","competitionId"):
if obj.get(k):
return str(obj[k]).replace("competition-","").strip()
# 2) from URL: .../competition/2185/...
url = obj.get("url") or obj.get("source_url") or obj.get("page_url") or ""
m = re.search(r"/competition/(\d+)", url)
if m:
return m.group(1)
# 3) from filename
m2 = re.search(r"competition-(\d+)", fallback_name)
if m2:
return m2.group(1)
return None
def _make_text_blob(title: str, url: str, sections: Dict[str,str]) -> str:
parts = [f"TITLE: {title}", f"URL: {url}"]
for k in ("summary_raw","eligibility_raw","scope_raw","dates_raw","how_to_apply_raw","supporting_information_raw"):
v = sections.get(k)
if v:
parts.append(f"\n[{k}]\n{v}")
# also tolerate alt keys from other crawlers
for k in ("summary","eligibility","scope","dates","how_to_apply","supporting_information"):
v = sections.get(k)
if v and f"[{k}_raw]" not in "".join(parts):
parts.append(f"\n[{k}]\n{v}")
return "\n".join(parts)
def _read_obj(obj: dict, fallback_name: str = "") -> Optional[SupportingDoc]:
gid = _infer_grant_id(obj, fallback_name)
if not gid:
return None
url = obj.get("url") or obj.get("source_url") or obj.get("page_url") or ""
title = (obj.get("title") or obj.get("name") or "").strip()
# Common normalised fields
open_date = obj.get("open_date")
close_date = obj.get("close_date") or obj.get("deadline") or obj.get("closeDate")
notify_date = obj.get("notify_date")
# Funding block: either nested or flat
funding = obj.get("funding") or {}
fmin = _num(funding.get("min") or obj.get("funding_min") or obj.get("min_award") or obj.get("grant_min"))
fmax = _num(funding.get("max") or obj.get("funding_max") or obj.get("max_award") or obj.get("grant_max"))
total_pot = _num(funding.get("total_pot") or obj.get("total_pot") or obj.get("competition_total") or obj.get("total_funding"))
rates = funding.get("rates") if isinstance(funding.get("rates"), str) else obj.get("funding_rates")
# Duration block
dur = obj.get("duration_months") or {}
dmin = _int(dur.get("min") or obj.get("duration_min") or obj.get("project_duration_min_months"))
dmax = _int(dur.get("max") or obj.get("duration_max") or obj.get("project_duration_max_months"))
# Sections: tolerate both nested and flat naming
sections: Dict[str, str] = {}
for k in ("summary_raw","eligibility_raw","scope_raw","dates_raw","how_to_apply_raw","supporting_information_raw",
"summary","eligibility","scope","dates","how_to_apply","supporting_information"):
v = obj.get(k) or (obj.get("sections") or {}).get(k)
if isinstance(v, str) and v.strip():
sections[k] = v
text = _make_text_blob(title, url, sections)
return SupportingDoc(
grant_id=gid,
url=url,
title=title,
open_date=open_date,
close_date=close_date,
notify_date=notify_date,
funding_min=fmin,
funding_max=fmax,
total_pot=total_pot,
funding_rates=rates if isinstance(rates, str) else None,
duration_min=dmin,
duration_max=dmax,
text=text,
sections=sections,
)
def _read_json_file(p: Path) -> Optional[SupportingDoc]:
try:
obj = json.loads(p.read_text(encoding="utf-8"))
return _read_obj(obj, fallback_name=p.name)
except Exception:
return None
def _iter_jsonl(p: Path) -> Iterable[SupportingDoc]:
with p.open("r", encoding="utf-8") as f:
for i, line in enumerate(f, start=1):
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except Exception:
continue
doc = _read_obj(obj, fallback_name=f"{p.name}:{i}")
if doc:
yield doc
def iter_supporting_docs(folder: Path) -> Iterable[SupportingDoc]:
folder = Path(folder)
# Prefer explicit competition-*.json first
found = False
for p in sorted(folder.glob("competition-*.json")):
found = True
doc = _read_json_file(p)
if doc:
yield doc
# Then any *.json
if not found:
for p in sorted(folder.glob("*.json")):
doc = _read_json_file(p)
if doc:
yield doc
# Then *.jsonl (one object per line)
for p in sorted(folder.glob("*.jsonl")):
for doc in _iter_jsonl(p):
yield doc
def load_supporting_docs(folder: Path) -> List[SupportingDoc]:
return list(iter_supporting_docs(folder))