Model/scripts/build_pashub_accuracy_fixtures.py

221 lines
7.1 KiB
Python

"""Build the pashub SAP-accuracy test fixtures from S3 + hubspot_deal_data.
One-time / rebuild-able provenance script for
`backend/documents_parser/tests/test_pashub_sap_accuracy.py`.
For the Guinness GMCA project it pulls every deal that has a `pre_sap`
rating and a pashub `rd_sap_site_note` PDF, downloads the PDF from S3,
strips the embedded survey photos (the extractor only ever reads the PDF
*text layer* via `page.get_text()`, so removing raster images leaves a
byte-identical token stream while shrinking each file ~25x), keeps only
pashub-format PDFs (Elmhurst-format notes route to a different extractor),
and writes them plus a `manifest.json` into the fixtures directory.
Requires DB + AWS credentials (reads `backend/.env`); NOT run in CI. The
committed fixtures + manifest are what the test consumes.
python scripts/build_pashub_accuracy_fixtures.py [--limit N] [--force]
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Optional
import boto3
import psycopg2
import pymupdf
from dotenv import dotenv_values
REPO_ROOT = Path(__file__).resolve().parents[1]
ENV_PATH = REPO_ROOT / "backend" / ".env"
_FIXTURES_ROOT = (
REPO_ROOT / "backend" / "documents_parser" / "tests" / "fixtures"
)
# Default cohort (Guinness GMCA); override per-portfolio with --project-code / --out-dir.
DEFAULT_FIXTURES_SUBDIR = "pashub_accuracy"
DEFAULT_PROJECT_CODE = (
"[The Guinness Partnership GMCA Bid - WH:SF - 052026 - 1219680020683]"
)
ELMHURST_MARKER = "Elmhurst Energy Systems"
# One row per deal: the newest pashub RdSAP site-note that has a pre_sap.
_DEAL_QUERY = """
select distinct on (h.deal_id)
h.deal_id, h.uprn, h.pre_sap, u.s3_file_bucket, u.s3_file_key
from hubspot_deal_data h
join uploaded_files u on u.hubspot_deal_id = h.deal_id
where h.project_code = %s
and h.pre_sap is not null and h.pre_sap <> ''
and u.file_type = 'rd_sap_site_note'
order by h.deal_id, u.s3_upload_timestamp desc
"""
@dataclass(frozen=True)
class ManifestEntry:
deal_id: str
uprn: Optional[int]
pre_sap_raw: str
pre_sap_score: int
pdf: str # filename relative to the fixtures dir
def _parse_pre_sap(raw: str) -> Optional[int]:
"""`'C73'` / `'73'` / `'SAP 73'` -> `73`; unparseable -> None."""
match = re.search(r"(\d{1,3})", raw)
if match is None:
return None
score = int(match.group(1))
return score if 1 <= score <= 100 else None
def _strip_images(pdf_bytes: bytes) -> bytes:
"""Remove embedded raster images; the text layer is untouched."""
doc: Any = pymupdf.open(stream=pdf_bytes, filetype="pdf")
try:
for page in doc:
for image in page.get_images(full=True):
try:
page.delete_image(image[0])
except Exception: # noqa: BLE001 - best-effort per image
pass
out: bytes = doc.tobytes(garbage=4, deflate=True, clean=True)
return out
finally:
doc.close()
def _is_elmhurst(pdf_bytes: bytes) -> bool:
doc: Any = pymupdf.open(stream=pdf_bytes, filetype="pdf")
try:
pages: list[str] = [page.get_text() for page in doc]
return ELMHURST_MARKER in "\n".join(pages)
finally:
doc.close()
def _fetch_deals(limit: Optional[int], project_code: str) -> list[tuple[Any, ...]]:
env = dotenv_values(ENV_PATH)
conn: Any = psycopg2.connect(
host=env["DB_HOST"],
port=env.get("DB_PORT", "5432"),
dbname=env["DB_NAME"],
user=env["DB_USERNAME"],
password=env["DB_PASSWORD"],
connect_timeout=10,
)
try:
cur: Any = conn.cursor()
cur.execute(_DEAL_QUERY, (project_code,))
rows: list[tuple[Any, ...]] = cur.fetchall()
finally:
conn.close()
return rows[:limit] if limit is not None else rows
def build(
limit: Optional[int], force: bool, project_code: str, fixtures_dir: Path
) -> None:
manifest_path = fixtures_dir / "manifest.json"
fixtures_dir.mkdir(parents=True, exist_ok=True)
rows = _fetch_deals(limit, project_code)
print(f"{len(rows)} candidate deals (pre_sap + rd_sap_site_note)")
s3: Any = boto3.client("s3") # pyright: ignore[reportUnknownMemberType]
entries: list[ManifestEntry] = []
skipped: dict[str, int] = {"no_pre_sap": 0, "elmhurst": 0, "s3_error": 0}
for deal_id, uprn, pre_sap_raw, bucket, key in rows:
score = _parse_pre_sap(pre_sap_raw)
if score is None:
skipped["no_pre_sap"] += 1
continue
pdf_name = f"{deal_id}.pdf"
pdf_path = fixtures_dir / pdf_name
if pdf_path.exists() and not force:
entries.append(
ManifestEntry(deal_id, uprn, pre_sap_raw, score, pdf_name)
)
continue
try:
raw: bytes = s3.get_object(Bucket=bucket, Key=key)["Body"].read()
except Exception as exc: # noqa: BLE001
print(f" {deal_id}: S3 error {exc}", file=sys.stderr)
skipped["s3_error"] += 1
continue
if _is_elmhurst(raw):
skipped["elmhurst"] += 1
continue
stripped = _strip_images(raw)
pdf_path.write_bytes(stripped)
entries.append(
ManifestEntry(deal_id, uprn, pre_sap_raw, score, pdf_name)
)
print(
f" {deal_id}: pre_sap={pre_sap_raw}->{score} "
f"{len(raw) / 1e6:.1f}MB -> {len(stripped) / 1e6:.2f}MB"
)
entries.sort(key=lambda e: e.deal_id)
manifest_path.write_text(
json.dumps(
{
"project_code": project_code,
"source": "hubspot_deal_data + uploaded_files (rd_sap_site_note) + S3",
"note": (
"pre_sap is pashub's own RdSAP rating; PDFs are image-"
"stripped (text layer preserved). Rebuild with "
"scripts/build_pashub_accuracy_fixtures.py"
),
"fixtures": [asdict(e) for e in entries],
},
indent=2,
)
+ "\n"
)
print(
f"\nwrote {len(entries)} fixtures + manifest to {fixtures_dir}\n"
f"skipped: {skipped}"
)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--limit", type=int, default=None)
parser.add_argument("--force", action="store_true")
parser.add_argument(
"--project-code",
default=DEFAULT_PROJECT_CODE,
help="hubspot_deal_data.project_code to select (default: Guinness GMCA)",
)
parser.add_argument(
"--out-dir",
default=DEFAULT_FIXTURES_SUBDIR,
help=(
"fixtures subdirectory name under tests/fixtures/ (default: "
f"{DEFAULT_FIXTURES_SUBDIR}); use a per-portfolio name to avoid "
"clobbering another cohort's manifest"
),
)
args = parser.parse_args()
build(
limit=args.limit,
force=args.force,
project_code=args.project_code,
fixtures_dir=_FIXTURES_ROOT / args.out_dir,
)
if __name__ == "__main__":
main()