mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
221 lines
7.1 KiB
Python
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()
|