grantforge-api / backend /core /search /deadline_utils.py
GrantForge Bot
Deploy sha-565ad85979610064f6d1c18ab3b6404357d61073 — source build (no GHCR)
ce8f04a
Raw
History Blame Contribute Delete
25.6 kB
"""
Anty-halucynacyjna normalizacja i ekstrakcja terminów naborów.
Zasady:
- Nigdy nie wstawiamy domyślnego 31.12 „na ślepo” bez kontekstu.
- Preferujemy jawne pole termin_do / deadline.
- Z tekstu wyciągamy daty tylko przy frazach naborowych (do / termin / nabór …).
- Format wyjściowy zawsze YYYY-MM-DD lub pusty string.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from typing import Any, Dict, List, Optional, Sequence, Tuple
PL_MONTHS = {
"stycznia": 1,
"styczeń": 1,
"styczen": 1,
"sty": 1,
"lutego": 2,
"luty": 2,
"lut": 2,
"marca": 3,
"marzec": 3,
"mar": 3,
"kwietnia": 4,
"kwiecień": 4,
"kwiecien": 4,
"kwi": 4,
"maja": 5,
"maj": 5,
"czerwca": 6,
"czerwiec": 6,
"cze": 6,
"lipca": 7,
"lipiec": 7,
"lip": 7,
"sierpnia": 8,
"sierpień": 8,
"sierpien": 8,
"sie": 8,
"września": 9,
"wrzesnia": 9,
"wrzesień": 9,
"wrzesien": 9,
"wrz": 9,
"października": 10,
"pazdziernika": 10,
"październik": 10,
"pazdziernik": 10,
"paź": 10,
"paz": 10,
"listopada": 11,
"listopad": 11,
"lis": 11,
"grudnia": 12,
"grudzień": 12,
"grudzien": 12,
"gru": 12,
}
# Prefer end-of-range when a range is present
_RANGE_RE = re.compile(
r"(\d{1,2}[./\-]\d{1,2}[./\-]\d{2,4}|\d{1,2}\s+\w+\s+\d{4}|\d{4}-\d{2}-\d{2})"
r"\s*[-–—do]+\s*"
r"(\d{1,2}[./\-]\d{1,2}[./\-]\d{2,4}|\d{1,2}\s+\w+\s+\d{4}|\d{4}-\d{2}-\d{2})",
re.IGNORECASE,
)
_EXPLICIT_PHRASE_RE = re.compile(
r"(?:"
r"termin(?:\s+naboru|\s+składania|\s+zgłoszeń|\s+wniosków)?|"
r"nabór\s+(?:trwa\s+)?do|"
r"nabor\s+(?:trwa\s+)?do|"
r"składanie\s+wniosków\s+do|"
r"wnioski\s+do|"
r"do\s+dnia|"
r"zamknięcie\s+naboru|"
r"deadline|"
r"do\b"
r")"
r"\s*[:\-]?\s*"
r"("
r"\d{4}-\d{2}-\d{2}"
r"|\d{1,2}[./\-]\d{1,2}[./\-]\d{2,4}"
r"|\d{1,2}\s+(?:" + "|".join(re.escape(k) for k in sorted(PL_MONTHS, key=len, reverse=True)) + r")\s+\d{4}"
r")",
re.IGNORECASE,
)
_PL_WORD_DATE_RE = re.compile(
r"\b(\d{1,2})\s+("
+ "|".join(re.escape(k) for k in sorted(PL_MONTHS, key=len, reverse=True))
+ r")\s+(\d{4})\b",
re.IGNORECASE,
)
_ISO_RE = re.compile(r"\b(20\d{2})-(\d{2})-(\d{2})\b")
_EU_DOT_RE = re.compile(r"\b(\d{1,2})[./\-](\d{1,2})[./\-](\d{2,4})\b")
_QX_RE = re.compile(r"\bQ([1-4])\s*(20\d{2})\b", re.IGNORECASE)
# Fields considered primary (high confidence)
PRIMARY_DEADLINE_KEYS = (
"termin_do",
"deadline",
"data_zakonczenia",
"data_zakończenia",
"data_konca",
"data_końca",
"end_date",
"closing_date",
"application_deadline",
"termin_skladania",
"termin_składania",
"termin_naboru",
)
SECONDARY_DEADLINE_KEYS = (
"termin",
"termin_od",
"start_date",
"data_rozpoczecia",
"data_rozpoczęcia",
)
TEXT_KEYS = (
"description",
"opis_krotki",
"name",
"nazwa",
"eligibility_criteria",
"warunki_wejscia",
"program_goals",
"beneficjenci",
"status",
)
@dataclass(frozen=True)
class DeadlineHit:
iso: str # YYYY-MM-DD
confidence: float # 0..1
source: str # field name or "text:phrase"
raw: str = ""
def as_dict(self) -> Dict[str, Any]:
return {
"deadline": self.iso,
"confidence_on_dates": self.confidence,
"deadline_source": self.source,
"deadline_raw": self.raw,
}
def _safe_date(year: int, month: int, day: int) -> Optional[date]:
try:
if month == 2:
day = min(day, 29 if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) else 28)
elif month in (4, 6, 9, 11):
day = min(day, 30)
else:
day = min(day, 31)
if not (1 <= month <= 12) or day < 1:
return None
if year < 2000 or year > 2040:
return None
return date(year, month, day)
except (ValueError, TypeError):
return None
def _year_norm(y: str | int) -> int:
yi = int(y)
if yi < 100:
return 2000 + yi if yi < 50 else 1900 + yi
return yi
def _month_from_token(mon: str) -> Optional[int]:
mon_l = mon.strip().lower().replace("ę", "e").replace("ą", "a").replace("ś", "s").replace("ć", "c").replace("ń", "n").replace("ó", "o").replace("ź", "z").replace("ż", "z")
# try original first
if mon.isdigit():
m = int(mon)
return m if 1 <= m <= 12 else None
mon_raw = mon.strip().lower()
if mon_raw in PL_MONTHS:
return PL_MONTHS[mon_raw]
for k, v in PL_MONTHS.items():
if mon_raw.startswith(k[:3]) or mon_l.startswith(k[:3].replace("ę", "e")):
return v
# ascii-folded keys already in map partially
folded = {
"stycznia": 1,
"styczen": 1,
"lutego": 2,
"marca": 3,
"kwietnia": 4,
"maja": 5,
"czerwca": 6,
"lipca": 7,
"sierpnia": 8,
"wrzesnia": 9,
"pazdziernika": 10,
"listopada": 11,
"grudnia": 12,
}
return folded.get(mon_l)
def parse_deadline_robust(deadline_raw: str, context_text: str = "") -> Optional[date]:
"""
Robust parser for a single deadline candidate string.
Supports ISO, EU dots/slashes, Polish month names, Qx YYYY, ranges (end date).
"""
if deadline_raw and str(deadline_raw).strip():
dl = str(deadline_raw).strip()
dl_l = dl.lower()
# Continuous / open — no concrete date
if any(
k in dl_l
for k in (
"ciągły",
"ciagly",
"continuous",
"beztermin",
"n/a",
"brak",
"open-ended",
"cały rok",
"caly rok",
)
):
return None
# Truncated years like 19.06.202 (3-digit year) — refuse
if re.search(r"\b\d{1,2}[./]\d{1,2}[./]\d{3}\b", dl) and not re.search(
r"\b\d{1,2}[./]\d{1,2}[./]\d{4}\b", dl
):
return None
# Prefer full ISO (and ISO datetime) BEFORE loose EU patterns — avoids
# matching "26-07-01" inside "2026-07-01T23:59:59" as day=26 month=07 year=01.
m_iso = _ISO_RE.search(dl)
if m_iso:
y, mo, d = m_iso.groups()
parsed = _safe_date(int(y), int(mo), int(d))
if parsed:
return parsed
# ISO datetime prefix YYYY-MM-DDTHH:MM…
m_iso_dt = re.search(r"\b(20\d{2})-(\d{2})-(\d{2})[T\s]", dl)
if m_iso_dt:
y, mo, d = m_iso_dt.groups()
parsed = _safe_date(int(y), int(mo), int(d))
if parsed:
return parsed
# Year-Month only → last day of month (e.g. 2026-03, 03.2026)
ym = re.fullmatch(r"(20\d{2})[-/.](\d{1,2})", dl.strip())
if ym:
year, month = int(ym.group(1)), int(ym.group(2))
if 1 <= month <= 12:
if month == 12:
return date(year, 12, 31)
return date(year, month + 1, 1) - timedelta(days=1)
# Month.Year only → last day of month (e.g. 12.2026)
my = re.fullmatch(r"(\d{1,2})[./](20\d{2})", dl.strip())
if my:
month, year = int(my.group(1)), int(my.group(2))
if 1 <= month <= 12:
# last calendar day
if month == 12:
return date(year, 12, 31)
return date(year, month + 1, 1).fromordinal(
date(year, month + 1, 1).toordinal() - 1
)
# Range first → end date
rm = _RANGE_RE.search(dl)
if rm:
end = parse_deadline_robust(rm.group(2), "")
if end:
return end
# Direct strptime formats
candidates = [dl, dl[:10], dl.split()[0] if " " in dl else dl]
for fmt in (
"%Y-%m-%d",
"%d.%m.%Y",
"%d-%m-%Y",
"%d/%m/%Y",
"%Y.%m.%d",
"%d.%m.%y",
"%d/%m/%y",
"%d-%m-%y",
):
for candidate in candidates:
try:
return datetime.strptime(candidate.strip(), fmt).date()
except Exception:
continue
# Polish "15 maja 2026" / EN "15 March 2026"
m = _PL_WORD_DATE_RE.search(dl)
if m:
day, mon, year = m.groups()
month = _month_from_token(mon)
if month:
d = _safe_date(_year_norm(year), month, int(day))
if d:
return d
m_en = re.search(
r"\b(\d{1,2})\s+(january|february|march|april|may|june|july|august|september|october|november|december)\s+(20\d{2})\b",
dl_l,
)
if m_en:
day_i = int(m_en.group(1))
mon_map = {
"january": 1, "february": 2, "march": 3, "april": 4, "may": 5, "june": 6,
"july": 7, "august": 8, "september": 9, "october": 10, "november": 11, "december": 12,
}
parsed = _safe_date(int(m_en.group(3)), mon_map[m_en.group(2)], day_i)
if parsed:
return parsed
# "do 30.06.2026" / "30 czerwca 2026" loose — only if day token is 1–31 and
# not a 4-digit year fragment (block matching inside YYYY-MM-DD leftovers).
m = re.search(
r"(?:do|termin|naboru|do\s+dnia|deadline|closes?)?\s*[:\-]?\s*(\d{1,2})[.\s/\-]+(\d{1,2}|\w+)[.\s/\-]+(\d{2,4})",
dl_l,
re.IGNORECASE,
)
if m:
d, mon, y = m.groups()
day_i = int(d)
# Reject year-leading fragments (e.g. 20 from 2026)
if day_i <= 31 and not (len(d) == 2 and day_i >= 20 and str(y).isdigit() and int(y) <= 31):
month = _month_from_token(mon)
if month:
parsed = _safe_date(_year_norm(y), month, day_i)
if parsed:
return parsed
m = _EU_DOT_RE.search(dl)
if m:
d, mo, y = m.groups()
# guard: day/month swap if day>12 already EU style
day_i, mon_i = int(d), int(mo)
if mon_i > 12 and day_i <= 12:
day_i, mon_i = mon_i, day_i
# Refuse absurd year fragments from partial ISO leftovers
yi = _year_norm(y)
if 2000 <= yi <= 2040 and 1 <= mon_i <= 12 and 1 <= day_i <= 31:
parsed = _safe_date(yi, mon_i, day_i)
if parsed:
return parsed
qm = _QX_RE.search(dl)
if qm:
q, y = int(qm.group(1)), int(qm.group(2))
month = {1: 3, 2: 6, 3: 9, 4: 12}[q]
return date(y, month, 15)
# Year-only with deadline wording → Dec 31
if re.search(r"(do|termin|nabor)", dl_l) and re.search(r"\b(20[2-3]\d)\b", dl_l):
if not re.search(r"\d{1,2}[./]\d{1,2}", dl_l):
y = int(re.search(r"\b(20[2-3]\d)\b", dl_l).group(1))
return date(y, 12, 31)
ctx = (context_text or "").strip()
if ctx:
hit = extract_deadline_from_text(ctx)
if hit:
try:
return datetime.strptime(hit.iso, "%Y-%m-%d").date()
except Exception:
return None
return None
def extract_deadline_from_text(text: str) -> Optional[DeadlineHit]:
"""Extract deadline from free text using explicit nabor phrases first."""
if not (text or "").strip():
return None
t = text
lower = t.lower()
# Prefer "do X" / "termin …" phrases
for m in _EXPLICIT_PHRASE_RE.finditer(t):
raw = m.group(1)
parsed = parse_deadline_robust(raw, "")
if parsed:
conf = 0.82 if "termin" in m.group(0).lower() or "nabór" in m.group(0).lower() else 0.72
return DeadlineHit(parsed.isoformat(), conf, "text:phrase", raw)
# Range in text → end date
rm = _RANGE_RE.search(t)
if rm:
end = parse_deadline_robust(rm.group(2), "")
if end:
return DeadlineHit(end.isoformat(), 0.75, "text:range", rm.group(0))
# Horizon / EU style: "2026-05-28 2026-08-27 Open" → take last ISO as end
iso_dates = list(_ISO_RE.finditer(t))
if len(iso_dates) >= 2 and any(k in lower for k in ("open", "horizon", "call", "grant", "nabór", "nabor")):
y, mo, d = iso_dates[-1].groups()
parsed = _safe_date(int(y), int(mo), int(d))
if parsed:
return DeadlineHit(parsed.isoformat(), 0.7, "text:iso_range_end", iso_dates[-1].group(0))
# Polish word date near nabor keywords (or title-prefix news-date for nabor pages)
naborish = any(
k in lower
for k in (
"termin",
"nabór",
"nabor",
"wniosk",
"składan",
"deadline",
"harmonogram",
"dofinans",
"grant",
"konkurs",
"call",
)
)
pl_hits = list(_PL_WORD_DATE_RE.finditer(t))
for m in pl_hits:
window = lower[max(0, m.start() - 40) : m.end() + 15]
near = any(
k in window
for k in ("termin", "do ", "nabór", "nabor", "wniosk", "składan", "deadline", "harmonogram")
)
if near or (naborish and m.start() < 40):
day, mon, year = m.groups()
month = _month_from_token(mon)
if month:
d = _safe_date(_year_norm(year), month, int(day))
if d:
conf = 0.72 if near else 0.58
return DeadlineHit(d.isoformat(), conf, "text:pl_month", m.group(0))
# EU numeric dates near keywords
for m in _EU_DOT_RE.finditer(t):
window = lower[max(0, m.start() - 35) : m.end() + 10]
if any(k in window for k in ("termin", "do ", "nabór", "nabor", "wniosk", "deadline", "do\u00a0")):
d, mo, y = m.groups()
day_i, mon_i = int(d), int(mo)
if mon_i > 12 and day_i <= 12:
day_i, mon_i = mon_i, day_i
parsed = _safe_date(_year_norm(y), mon_i, day_i)
if parsed:
return DeadlineHit(parsed.isoformat(), 0.7, "text:eu_date", m.group(0))
# Single clear future date in short nabor-ish blurb (last resort, low confidence)
if naborish:
candidates: List[date] = []
raws: List[str] = []
for m in list(_ISO_RE.finditer(t)) + list(_EU_DOT_RE.finditer(t)) + list(_PL_WORD_DATE_RE.finditer(t)):
parsed = parse_deadline_robust(m.group(0), "")
if parsed and parsed.year >= date.today().year - 1:
candidates.append(parsed)
raws.append(m.group(0))
if candidates:
# Prefer latest date as closing (common in "open from … to …")
best_i = max(range(len(candidates)), key=lambda i: candidates[i])
return DeadlineHit(
candidates[best_i].isoformat(),
0.55,
"text:fallback_latest",
raws[best_i],
)
return None
def extract_grant_deadline(
record: Dict[str, Any],
*,
prefer_end_of_range: bool = True,
allow_text_fallback: bool = True,
) -> Optional[DeadlineHit]:
"""
Unified deadline extraction for ingest/mapper/post-import.
Order:
1. Primary fields (termin_do, deadline, …)
2. Secondary (termin, termin_od only if range end elsewhere)
3. Free-text extraction with nabor phrases
"""
if not record:
return None
# Continuous enrollment → no concrete deadline
status = str(record.get("status") or "").lower()
blob_early = " ".join(
str(record.get(k) or "") for k in ("status", "name", "nazwa", "description", "opis_krotki")
).lower()
if any(k in status or k in blob_early for k in ("ciągły", "ciagly", "beztermin", "continuous")):
# still allow explicit termin_do if present
pass
# 1) Primary fields
for key in PRIMARY_DEADLINE_KEYS:
raw = record.get(key)
if raw is None or str(raw).strip() == "":
continue
raw_s = str(raw).strip()
# Range in field → end
if prefer_end_of_range:
rm = _RANGE_RE.search(raw_s)
if rm:
end = parse_deadline_robust(rm.group(2), "")
if end:
return DeadlineHit(end.isoformat(), 0.9, key + ":range_end", raw_s)
parsed = parse_deadline_robust(raw_s, "")
if parsed:
conf = 0.92 if key in ("termin_do", "deadline", "application_deadline") else 0.85
# year-only from bare field is weaker
if re.fullmatch(r"20\d{2}", raw_s.strip()):
conf = 0.45
return DeadlineHit(parsed.isoformat(), conf, key, raw_s)
# 2) Secondary — only if looks like end date or ISO
for key in SECONDARY_DEADLINE_KEYS:
raw = record.get(key)
if not raw:
continue
raw_s = str(raw).strip()
if key == "termin_od":
# start date alone is not deadline; skip unless paired later
continue
parsed = parse_deadline_robust(raw_s, "")
if parsed:
return DeadlineHit(parsed.isoformat(), 0.65, key, raw_s)
# 3) Text blobs
if allow_text_fallback:
parts: List[str] = []
for key in TEXT_KEYS:
val = record.get(key)
if val:
parts.append(str(val))
# also nested raw_data
raw_data = record.get("raw_data") if isinstance(record.get("raw_data"), dict) else {}
for key in TEXT_KEYS + PRIMARY_DEADLINE_KEYS:
val = raw_data.get(key) if raw_data else None
if val:
parts.append(str(val))
text = "\n".join(parts)
hit = extract_deadline_from_text(text)
if hit:
return hit
return None
def deadline_to_iso(value: Any, context: str = "") -> str:
"""Convenience: any value → YYYY-MM-DD or ''."""
if value is None:
return ""
if isinstance(value, date) and not isinstance(value, datetime):
return value.isoformat()
if isinstance(value, datetime):
return value.date().isoformat()
s = str(value).strip()
if not s:
return ""
parsed = parse_deadline_robust(s, context)
return parsed.isoformat() if parsed else ""
def apply_deadline_to_grant_dict(grant: Dict[str, Any]) -> Dict[str, Any]:
"""
Mutates a copy of grant dict with deadline fields filled from multi-source extract.
Safe for continuous enrollments (leaves empty).
"""
g = dict(grant)
status_l = str(g.get("status") or "").lower()
continuous = any(k in status_l for k in ("ciągły", "ciagly", "continuous", "beztermin"))
existing = str(g.get("deadline") or "").strip()
existing_parsed = parse_deadline_robust(existing, "") if existing else None
if existing_parsed:
g["deadline"] = existing_parsed.isoformat()
g.setdefault("confidence_on_dates", 0.9)
g.setdefault("deadline_source", "existing")
return g
# Build extract record from grant + common PL aliases
record = dict(g)
raw = g.get("raw_data") if isinstance(g.get("raw_data"), dict) else {}
for k, v in raw.items():
record.setdefault(k, v)
hit = extract_grant_deadline(record, allow_text_fallback=not continuous)
if hit:
g["deadline"] = hit.iso
g["confidence_on_dates"] = hit.confidence
g["deadline_source"] = hit.source
g["deadline_raw"] = hit.raw
elif continuous:
g["deadline"] = ""
g["confidence_on_dates"] = 0.35
g["deadline_source"] = "continuous"
elif not existing:
g["deadline"] = ""
g.setdefault("confidence_on_dates", 0.25)
g.setdefault("deadline_source", "missing")
return g
def normalize_grant_date_status(
grant: Dict[str, Any],
raw_context: str = "",
is_current_only: bool = False,
) -> Dict[str, Any]:
g = apply_deadline_to_grant_dict(dict(grant))
text = (raw_context or str(g.get("description", "")) + " " + str(g.get("name", ""))).lower()
name = (g.get("name") or "").lower()
deadline_raw = (g.get("deadline") or "").strip()
status_raw = (g.get("status") or "").strip().lower()
g["source_status_raw"] = status_raw or "unknown"
g.setdefault("confidence_on_dates", 0.6)
today = date.today()
closed_kw = [
"upłynął",
"nabór zakończony",
"zakończony nabór",
"archiwum",
"nie przyjmujemy już",
"zamknięty nabór",
"termin upłynął",
]
if any(kw in text or kw in name for kw in closed_kw):
g.update(
status="closed",
normalized_status="closed",
is_outdated_warning=True,
is_current_only=False,
confidence_on_dates=0.92,
)
return g
continuous_kw = ("ciągły", "ciagly", "continuous", "bezterminow")
if status_raw in continuous_kw or any(k in text for k in continuous_kw):
g.update(
normalized_status="active",
status="active",
deadline="",
confidence_on_dates=0.35,
is_current_only=True,
requires_manual_verification=True,
deadline_source="continuous",
)
return g
is_fin = g.get("instrument_type") in [
"loan",
"guarantee",
"interest_subsidy",
"other_financial",
"mixed",
]
if is_fin and (
not deadline_raw or "ciągły" in deadline_raw.lower() or "n/a" in deadline_raw.lower()
):
g.update(
normalized_status="active",
status="active",
confidence_on_dates=0.85,
is_current_only=True,
)
return g
parsed_deadline = parse_deadline_robust(deadline_raw, text + " " + name)
if parsed_deadline:
g["deadline"] = parsed_deadline.isoformat()
g["confidence_on_dates"] = max(float(g.get("confidence_on_dates") or 0), 0.88 if parsed_deadline.year >= 2025 else 0.75)
if parsed_deadline < today:
g.update(
normalized_status="closed",
status="closed",
is_outdated_warning=True,
is_current_only=False,
)
return g
elif any(kw in text for kw in ["upłyn", "zakończ", "archiw"]) or status_raw == "closed":
g.update(
normalized_status="closed",
status="closed",
is_outdated_warning=True,
is_current_only=False,
confidence_on_dates=0.7,
)
return g
planned_kw = ["planowany", "rusza od", "wkrótce", "w przygotowaniu", "potrwa od"]
if any(kw in text for kw in planned_kw) and (not parsed_deadline or parsed_deadline > today):
g.update(
normalized_status="planned",
status="planned",
confidence_on_dates=max(float(g.get("confidence_on_dates") or 0), 0.74),
is_current_only=False,
)
if is_current_only:
g["is_outdated_warning"] = True
return g
final_status = g.get("status", "active") or "active"
g["normalized_status"] = final_status
g["status"] = final_status
is_current = (final_status in ("active", "planned")) and not g.get("is_outdated_warning", False)
g["is_current_only"] = bool(is_current)
if is_current_only and not is_current:
g.update(is_outdated_warning=True, normalized_status="closed", status="closed")
if not deadline_raw and float(g.get("confidence_on_dates") or 0) >= 0.6:
g["confidence_on_dates"] = 0.3
return g
def backfill_deadlines_report(
records: Sequence[Dict[str, Any]],
) -> Dict[str, Any]:
"""Stats helper for tests/ops — how many deadlines we can fill."""
total = len(records)
before_raw = sum(1 for r in records if str(r.get("deadline") or r.get("termin_do") or "").strip())
before_parseable = 0
for r in records:
raw = str(r.get("deadline") or r.get("termin_do") or "").strip()
if raw and parse_deadline_robust(raw, ""):
before_parseable += 1
filled = 0
improved = 0
sources: Dict[str, int] = {}
for r in records:
raw = str(r.get("deadline") or r.get("termin_do") or "").strip()
had_parseable = bool(raw and parse_deadline_robust(raw, ""))
hit = extract_grant_deadline(r)
if hit:
filled += 1
src_key = hit.source.split(":")[0]
sources[src_key] = sources.get(src_key, 0) + 1
if not had_parseable:
improved += 1
return {
"total": total,
"with_deadline_before": before_raw,
"with_deadline_before_parseable": before_parseable,
"with_deadline_after": filled,
"newly_filled": improved,
"pct_before": round(100 * before_raw / max(total, 1), 1),
"pct_before_parseable": round(100 * before_parseable / max(total, 1), 1),
"pct_after": round(100 * filled / max(total, 1), 1),
"by_source": sources,
}