Upload 2 files
Browse files
page_files/categorized/Backend/category_push.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
category_push.py β route extracted property rows into the correct RDS category
|
| 3 |
+
table (Composites_materials / Fibers / Polymers), conforming to that table's
|
| 4 |
+
real 33-column schema, and preserve everything that doesn't fit.
|
| 5 |
+
|
| 6 |
+
Why this exists
|
| 7 |
+
---------------
|
| 8 |
+
The three category tables share ONE fixed schema that does NOT contain most of
|
| 9 |
+
what the pipeline emits (no source_text, no mapped_figure_*, no plot_image_path,
|
| 10 |
+
no pdf_stem, ...). A raw df.to_sql(..., append) therefore fails with
|
| 11 |
+
'column "source_text" does not exist'. This module:
|
| 12 |
+
|
| 13 |
+
* MAPS the pipeline columns onto the target schema (rename + parse), so the
|
| 14 |
+
INSERT matches the table ................................. (fixes the error)
|
| 15 |
+
* ROUTES to whichever of the 3 tables you pick ............ (composites/etc.)
|
| 16 |
+
* writes the leftover rich columns to a companion "<table>_extras" table so
|
| 17 |
+
nothing is lost ......................................... (the "other tab")
|
| 18 |
+
* copies each matched crop to a separate plots directory AND (optionally)
|
| 19 |
+
embeds the PNG bytes into the table's `image` column .... (plots elsewhere)
|
| 20 |
+
|
| 21 |
+
Idempotent per paper: keyed on source_pdf (= pdf_stem).
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import os
|
| 27 |
+
import re
|
| 28 |
+
import shutil
|
| 29 |
+
import logging
|
| 30 |
+
import datetime as _dt
|
| 31 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 32 |
+
|
| 33 |
+
import pandas as pd
|
| 34 |
+
|
| 35 |
+
CATEGORY_TABLES = ["Composites_materials", "Fibers", "Polymers"]
|
| 36 |
+
|
| 37 |
+
# the real 33 columns shared by all three tables (from information_schema)
|
| 38 |
+
TARGET_COLUMNS = [
|
| 39 |
+
"material_name", "material_abbreviation", "section", "property_name",
|
| 40 |
+
"value", "unit", "english", "test_condition", "comments", "image",
|
| 41 |
+
"material_key", "material_class", "trade_grade", "manufacturer", "matrix",
|
| 42 |
+
"fiber", "fiber_volume_fraction", "value_raw", "value_num", "value_min",
|
| 43 |
+
"value_max", "qualifier", "unit_canonical", "value_si", "source_pdf",
|
| 44 |
+
"source_sha1", "page", "source_quote", "status", "flag_reason", "model",
|
| 45 |
+
"prompt_version", "extracted_at",
|
| 46 |
+
]
|
| 47 |
+
|
| 48 |
+
# columns the category tables can't hold β preserved in <table>_extras
|
| 49 |
+
EXTRAS_COLUMNS = [
|
| 50 |
+
"source_pdf", "material_name", "property_name", "value", "unit", "section",
|
| 51 |
+
"pdf_stem", "source_table", "chunk_type", "source_text", "source_page",
|
| 52 |
+
"confirmed_by_gpt", "confirmed_by_claude", "verified_by", "match_layer",
|
| 53 |
+
"doi_url", "mapped_figure_id", "mapped_figure_caption", "mapped_figure_number",
|
| 54 |
+
"mapped_figure_page", "mapped_subplot", "map_score", "map_signals",
|
| 55 |
+
"plot_image_path",
|
| 56 |
+
]
|
| 57 |
+
|
| 58 |
+
_CLASS_OF = {"Composites_materials": "composite", "Fibers": "fiber", "Polymers": "polymer"}
|
| 59 |
+
_TRUE = {"true", "1", "yes", "t", "y"}
|
| 60 |
+
|
| 61 |
+
log = logging.getLogger(__name__)
|
| 62 |
+
|
| 63 |
+
# --- S3 image storage --------------------------------------------------------
|
| 64 |
+
# Same env config as mapper5 - one .env drives both. boto3 auto-reads
|
| 65 |
+
# AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_DEFAULT_REGION.
|
| 66 |
+
# Requires: pip install boto3
|
| 67 |
+
S3_BUCKET = os.getenv("S3_BUCKET", "")
|
| 68 |
+
S3_PREFIX = os.getenv("S3_PREFIX", "plots").strip("/")
|
| 69 |
+
S3_REGION = os.getenv("AWS_DEFAULT_REGION", os.getenv("AWS_REGION", "us-east-2"))
|
| 70 |
+
S3_PUBLIC = os.getenv("S3_PUBLIC", "false").lower() in ("1", "true", "yes")
|
| 71 |
+
|
| 72 |
+
_s3_client = None
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _s3_ready() -> bool:
|
| 76 |
+
return bool(S3_BUCKET)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _s3():
|
| 80 |
+
global _s3_client
|
| 81 |
+
if _s3_client is None:
|
| 82 |
+
import boto3
|
| 83 |
+
_s3_client = boto3.client("s3", region_name=S3_REGION)
|
| 84 |
+
return _s3_client
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _s3_key(category_table: str, stem: str, filename: str) -> str:
|
| 88 |
+
parts = [p for p in (S3_PREFIX, category_table, stem, filename) if p]
|
| 89 |
+
return "/".join(parts)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _s3_url(key: str) -> str:
|
| 93 |
+
if S3_PUBLIC:
|
| 94 |
+
return f"https://{S3_BUCKET}.s3.{S3_REGION}.amazonaws.com/{key}"
|
| 95 |
+
return f"s3://{S3_BUCKET}/{key}"
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _s3_put(data: bytes, key: str, content_type: str = "image/png") -> str:
|
| 99 |
+
_s3().put_object(Bucket=S3_BUCKET, Key=key, Body=data, ContentType=content_type)
|
| 100 |
+
return _s3_url(key)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _s3_get(key: str) -> bytes:
|
| 104 |
+
return _s3().get_object(Bucket=S3_BUCKET, Key=key)["Body"].read()
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _is_url(p: str) -> bool:
|
| 108 |
+
return p.startswith("s3://") or p.startswith("http://") or p.startswith("https://")
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _s3_key_from_url(url: str):
|
| 112 |
+
if url.startswith("s3://"):
|
| 113 |
+
rest = url[len("s3://"):]
|
| 114 |
+
return rest.split("/", 1)[1] if "/" in rest else None
|
| 115 |
+
m = re.match(r"https?://[^/]+/(.+)$", url)
|
| 116 |
+
return m.group(1) if m else None
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _s(v: Any) -> str:
|
| 120 |
+
if v is None:
|
| 121 |
+
return ""
|
| 122 |
+
try:
|
| 123 |
+
if pd.isna(v):
|
| 124 |
+
return ""
|
| 125 |
+
except (TypeError, ValueError):
|
| 126 |
+
pass
|
| 127 |
+
return str(v)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _page_int(v: Any) -> Optional[int]:
|
| 131 |
+
m = re.search(r"\d+", _s(v))
|
| 132 |
+
return int(m.group()) if m else None
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _parse_value(v: Any) -> Tuple[Optional[float], Optional[float], Optional[float]]:
|
| 136 |
+
"""(value_num, value_min, value_max). '5-30'->(None,5,30); '293 000'->(293000,..);
|
| 137 |
+
'0.496'->(0.496,None,None); '-40'->(-40,None,None)."""
|
| 138 |
+
s = _s(v).strip()
|
| 139 |
+
if not s:
|
| 140 |
+
return (None, None, None)
|
| 141 |
+
s2 = re.sub(r"(?<=\d)[ ,](?=\d)", "", s) # 293 000 / 1,234 -> plain
|
| 142 |
+
# range "A-B" / "A to B": both endpoints are positive; the dash is a separator
|
| 143 |
+
m = re.search(r"(\d+(?:\.\d+)?)\s*(?:-|\u2013|\u2014|to)\s*(\d+(?:\.\d+)?)", s2)
|
| 144 |
+
if m:
|
| 145 |
+
return (None, float(m.group(1)), float(m.group(2)))
|
| 146 |
+
m2 = re.search(r"-?\d+(?:\.\d+)?", s2) # single (possibly negative) value
|
| 147 |
+
if m2:
|
| 148 |
+
return (float(m2.group()), None, None)
|
| 149 |
+
return (None, None, None)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _model_str(row: pd.Series) -> str:
|
| 153 |
+
if _s(row.get("verified_by")):
|
| 154 |
+
return _s(row.get("verified_by"))
|
| 155 |
+
who = []
|
| 156 |
+
if _s(row.get("confirmed_by_gpt")).lower() in _TRUE:
|
| 157 |
+
who.append("gpt")
|
| 158 |
+
if _s(row.get("confirmed_by_claude")).lower() in _TRUE:
|
| 159 |
+
who.append("claude")
|
| 160 |
+
return "+".join(who)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def conform_to_category(
|
| 164 |
+
df_store: pd.DataFrame,
|
| 165 |
+
category_table: str,
|
| 166 |
+
out_dir: str,
|
| 167 |
+
stem: str,
|
| 168 |
+
plots_root: Optional[str] = None,
|
| 169 |
+
embed_image: bool = True,
|
| 170 |
+
) -> Tuple[pd.DataFrame, pd.DataFrame, int]:
|
| 171 |
+
"""Return (main_df on TARGET_COLUMNS, extras_df on EXTRAS_COLUMNS, n_plots).
|
| 172 |
+
Copies each matched crop into plots_root/<category>/<stem>/ and, if
|
| 173 |
+
embed_image, embeds its PNG bytes into the `image` column."""
|
| 174 |
+
if category_table not in CATEGORY_TABLES:
|
| 175 |
+
raise ValueError(f"category_table must be one of {CATEGORY_TABLES}")
|
| 176 |
+
df = df_store.reset_index(drop=True).copy()
|
| 177 |
+
material_class = _CLASS_OF[category_table]
|
| 178 |
+
now = _dt.datetime.now().isoformat(timespec="seconds")
|
| 179 |
+
|
| 180 |
+
plots_root = plots_root or os.path.join(out_dir, "rds_plots")
|
| 181 |
+
dest_dir = os.path.join(plots_root, category_table, stem)
|
| 182 |
+
|
| 183 |
+
main_rows: List[Dict[str, Any]] = []
|
| 184 |
+
extras_rows: List[Dict[str, Any]] = []
|
| 185 |
+
n_plots = 0
|
| 186 |
+
copied: set = set()
|
| 187 |
+
|
| 188 |
+
for _, row in df.iterrows():
|
| 189 |
+
num, vmin, vmax = _parse_value(row.get("value"))
|
| 190 |
+
|
| 191 |
+
# resolve -> S3 (preferred) or local copy -> optionally embed
|
| 192 |
+
img_bytes = None
|
| 193 |
+
stored_path = "" # what extras.plot_image_path records
|
| 194 |
+
rel = _s(row.get("plot_image_path"))
|
| 195 |
+
if rel and _is_url(rel):
|
| 196 |
+
# mapper already uploaded this crop to S3 - reuse the URL as-is
|
| 197 |
+
stored_path = rel
|
| 198 |
+
if rel not in copied:
|
| 199 |
+
copied.add(rel)
|
| 200 |
+
n_plots += 1
|
| 201 |
+
if embed_image and _s3_ready():
|
| 202 |
+
key = _s3_key_from_url(rel)
|
| 203 |
+
if key:
|
| 204 |
+
try:
|
| 205 |
+
img_bytes = _s3_get(key)
|
| 206 |
+
except Exception as e:
|
| 207 |
+
log.warning(f"category_push: S3 fetch failed for {rel} ({e})")
|
| 208 |
+
elif rel:
|
| 209 |
+
abs_src = rel if os.path.isabs(rel) else os.path.join(out_dir, rel)
|
| 210 |
+
if os.path.exists(abs_src):
|
| 211 |
+
fn = os.path.basename(abs_src)
|
| 212 |
+
with open(abs_src, "rb") as fh:
|
| 213 |
+
data = fh.read()
|
| 214 |
+
if embed_image:
|
| 215 |
+
img_bytes = data
|
| 216 |
+
if _s3_ready():
|
| 217 |
+
try:
|
| 218 |
+
stored_path = _s3_put(data, _s3_key(category_table, stem, fn))
|
| 219 |
+
except Exception as e:
|
| 220 |
+
log.warning(f"category_push: S3 upload failed for {fn} ({e}); copying locally.")
|
| 221 |
+
if not stored_path: # no S3, or upload failed -> local copy
|
| 222 |
+
os.makedirs(dest_dir, exist_ok=True)
|
| 223 |
+
shutil.copyfile(abs_src, os.path.join(dest_dir, fn))
|
| 224 |
+
stored_path = os.path.join(category_table, stem, fn)
|
| 225 |
+
if fn not in copied:
|
| 226 |
+
copied.add(fn)
|
| 227 |
+
n_plots += 1
|
| 228 |
+
|
| 229 |
+
main_rows.append({
|
| 230 |
+
"material_name": _s(row.get("material_name")),
|
| 231 |
+
"material_abbreviation": _s(row.get("material_abbreviation")),
|
| 232 |
+
"section": _s(row.get("section")),
|
| 233 |
+
"property_name": _s(row.get("property_name")),
|
| 234 |
+
"value": _s(row.get("value")),
|
| 235 |
+
"unit": _s(row.get("unit")),
|
| 236 |
+
"english": _s(row.get("english")),
|
| 237 |
+
"test_condition": _s(row.get("test_condition")),
|
| 238 |
+
"comments": _s(row.get("comments")),
|
| 239 |
+
"image": img_bytes,
|
| 240 |
+
"material_key": _s(row.get("material_name")).lower().replace(" ", "_"),
|
| 241 |
+
"material_class": material_class,
|
| 242 |
+
"trade_grade": "",
|
| 243 |
+
"manufacturer": _s(row.get("manufacturer")),
|
| 244 |
+
"matrix": "",
|
| 245 |
+
"fiber": "",
|
| 246 |
+
"fiber_volume_fraction": "",
|
| 247 |
+
"value_raw": _s(row.get("value")),
|
| 248 |
+
"value_num": num,
|
| 249 |
+
"value_min": vmin,
|
| 250 |
+
"value_max": vmax,
|
| 251 |
+
"qualifier": "",
|
| 252 |
+
"unit_canonical": _s(row.get("unit")),
|
| 253 |
+
"value_si": None,
|
| 254 |
+
"source_pdf": _s(row.get("pdf_stem")) or stem,
|
| 255 |
+
"source_sha1": "",
|
| 256 |
+
"page": _page_int(row.get("source_page")),
|
| 257 |
+
"source_quote": _s(row.get("source_text")),
|
| 258 |
+
"status": "extracted",
|
| 259 |
+
"flag_reason": "",
|
| 260 |
+
"model": _model_str(row),
|
| 261 |
+
"prompt_version": "",
|
| 262 |
+
"extracted_at": now,
|
| 263 |
+
})
|
| 264 |
+
|
| 265 |
+
extras_rows.append({
|
| 266 |
+
"source_pdf": _s(row.get("pdf_stem")) or stem,
|
| 267 |
+
"material_name": _s(row.get("material_name")),
|
| 268 |
+
"property_name": _s(row.get("property_name")),
|
| 269 |
+
"value": _s(row.get("value")),
|
| 270 |
+
"unit": _s(row.get("unit")),
|
| 271 |
+
"section": _s(row.get("section")),
|
| 272 |
+
"pdf_stem": _s(row.get("pdf_stem")) or stem,
|
| 273 |
+
"source_table": _s(row.get("source_table")),
|
| 274 |
+
"chunk_type": _s(row.get("chunk_type")),
|
| 275 |
+
"source_text": _s(row.get("source_text")),
|
| 276 |
+
"source_page": _s(row.get("source_page")),
|
| 277 |
+
"confirmed_by_gpt": _s(row.get("confirmed_by_gpt")),
|
| 278 |
+
"confirmed_by_claude": _s(row.get("confirmed_by_claude")),
|
| 279 |
+
"verified_by": _model_str(row),
|
| 280 |
+
"match_layer": _s(row.get("match_layer")),
|
| 281 |
+
"doi_url": _s(row.get("doi_url")),
|
| 282 |
+
"mapped_figure_id": _s(row.get("mapped_figure_id")),
|
| 283 |
+
"mapped_figure_caption": _s(row.get("mapped_figure_caption")),
|
| 284 |
+
"mapped_figure_number": _s(row.get("mapped_figure_number")),
|
| 285 |
+
"mapped_figure_page": _s(row.get("mapped_figure_page")),
|
| 286 |
+
"mapped_subplot": _s(row.get("mapped_subplot")),
|
| 287 |
+
"map_score": _s(row.get("map_score")),
|
| 288 |
+
"map_signals": _s(row.get("map_signals")),
|
| 289 |
+
"plot_image_path": stored_path,
|
| 290 |
+
})
|
| 291 |
+
|
| 292 |
+
main_df = pd.DataFrame(main_rows, columns=TARGET_COLUMNS)
|
| 293 |
+
extras_df = pd.DataFrame(extras_rows, columns=EXTRAS_COLUMNS)
|
| 294 |
+
return main_df, extras_df, n_plots
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
def push_by_category(
|
| 298 |
+
store: Dict[str, Any],
|
| 299 |
+
category_table: str,
|
| 300 |
+
engine,
|
| 301 |
+
embed_image: bool = True,
|
| 302 |
+
plots_root: Optional[str] = None,
|
| 303 |
+
) -> Dict[str, Any]:
|
| 304 |
+
"""Conform store['df_store'] to `category_table`, write the main rows there
|
| 305 |
+
and the leftover columns to '<category_table>_extras', idempotent on
|
| 306 |
+
source_pdf. `engine` is a SQLAlchemy engine (e.g. mapper.get_db_engine())."""
|
| 307 |
+
from sqlalchemy import text
|
| 308 |
+
df_store = store.get("df_store")
|
| 309 |
+
if df_store is None or df_store.empty:
|
| 310 |
+
return {"pushed": 0, "table": category_table}
|
| 311 |
+
out_dir = store.get("out_dir", ".")
|
| 312 |
+
stem = store.get("pdf_stem", "")
|
| 313 |
+
|
| 314 |
+
main_df, extras_df, n_plots = conform_to_category(
|
| 315 |
+
df_store, category_table, out_dir, stem,
|
| 316 |
+
plots_root=plots_root, embed_image=embed_image)
|
| 317 |
+
|
| 318 |
+
extras_table = f"{category_table}_extras"
|
| 319 |
+
# idempotency: clear this paper first (tables may not exist yet -> ignore)
|
| 320 |
+
with engine.begin() as conn:
|
| 321 |
+
for tbl, col in ((category_table, "source_pdf"), (extras_table, "source_pdf")):
|
| 322 |
+
try:
|
| 323 |
+
conn.execute(text(f'DELETE FROM "{tbl}" WHERE {col} = :s'), {"s": stem})
|
| 324 |
+
except Exception:
|
| 325 |
+
pass
|
| 326 |
+
|
| 327 |
+
main_df.to_sql(category_table, engine, if_exists="append", index=False)
|
| 328 |
+
extras_df.to_sql(extras_table, engine, if_exists="append", index=False)
|
| 329 |
+
if _s3_ready():
|
| 330 |
+
plots_dir = _s3_url(_s3_key(category_table, stem, "")).rstrip("/")
|
| 331 |
+
else:
|
| 332 |
+
plots_dir = os.path.join(plots_root or os.path.join(out_dir, "rds_plots"),
|
| 333 |
+
category_table, stem)
|
| 334 |
+
return {"pushed": len(main_df), "table": category_table,
|
| 335 |
+
"extras_table": extras_table, "extras": len(extras_df),
|
| 336 |
+
"plots_copied": n_plots, "plots_dir": plots_dir}
|
page_files/categorized/Backend/mapper5.py
ADDED
|
@@ -0,0 +1,1683 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Extraction mapping plots with properties
|
| 3 |
+
mapper.py β Plot β Property mapping + persistence
|
| 4 |
+
==========================================================================
|
| 5 |
+
Combines what used to be two files (Code 3 "mapping" + Code 4 "storage")
|
| 6 |
+
into one module. It sits on top of:
|
| 7 |
+
|
| 8 |
+
2.py β Code 1: extraction + tri-LLM consensus + source verification
|
| 9 |
+
(imported via importlib, since a module name can't start
|
| 10 |
+
with a digit)
|
| 11 |
+
image2.py β Code 2: plot/figure extraction + crop verification
|
| 12 |
+
|
| 13 |
+
WHAT IT DOES
|
| 14 |
+
------------
|
| 15 |
+
1. MAP β for each extracted property ROW (a Code 1 dataframe: Consensus /
|
| 16 |
+
Source-Verified / per-model) find the figure/subplot in the
|
| 17 |
+
Code 2 plot CROPS that depicts it, fusing four signals in
|
| 18 |
+
descending confidence:
|
| 19 |
+
(1) explicit "Fig. N"/"Fig. N(b)" citation in the row's
|
| 20 |
+
source_text/comments β the only grounding-strength signal
|
| 21 |
+
(2) page co-location (same page, or Β±1/Β±2 with less weight)
|
| 22 |
+
(3) SciBERT semantic similarity (caption β property), batched
|
| 23 |
+
into one propertyΓcaption matrix, reusing Code 1's embedder
|
| 24 |
+
(4) token overlap (cheap complement / embedding fallback)
|
| 25 |
+
Every produced link carries its score and which signals fired;
|
| 26 |
+
a row with no confident figure is left unmapped, not forced.
|
| 27 |
+
|
| 28 |
+
2. STORE β save each mapped crop's PNG to <out_dir>/<pdf_stem>/plots/,
|
| 29 |
+
write the property rows into a SQLite DB, and attach the image
|
| 30 |
+
file path(s) to each row (a `plot_image_path` column for the
|
| 31 |
+
best crop + a `property_images` link table for the full set).
|
| 32 |
+
Paths are stored RELATIVE to out_dir so the DB + plots/ zip is
|
| 33 |
+
portable. Re-running for the same pdf_stem is idempotent.
|
| 34 |
+
|
| 35 |
+
PUBLIC API
|
| 36 |
+
----------
|
| 37 |
+
map_plots_to_properties(df, plot_results, ...) -> (links_df, df_augmented)
|
| 38 |
+
build_figure_property_index(links_df) -> figure β props df
|
| 39 |
+
run_full_pipeline(pdf_bytes, source_table=...) -> map, from a PDF
|
| 40 |
+
store_properties_with_plots(df_aug, links_df, plot_results, out_dir, stem)
|
| 41 |
+
run_full_pipeline_to_db(pdf_bytes, out_dir, stem, source_table=...)
|
| 42 |
+
query_properties_with_plots(db_path, where_sql="", params=())
|
| 43 |
+
bundle_zip(out_dir, pdf_stem, db_filename)
|
| 44 |
+
|
| 45 |
+
CLI: streamlit run mapper.py (needs 2.py + image2.py on the import path)
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
from __future__ import annotations
|
| 49 |
+
|
| 50 |
+
import io
|
| 51 |
+
import json
|
| 52 |
+
import logging
|
| 53 |
+
import os
|
| 54 |
+
import re
|
| 55 |
+
import sqlite3
|
| 56 |
+
import zipfile
|
| 57 |
+
import typing
|
| 58 |
+
from typing import Any
|
| 59 |
+
from typing import Dict, List, Optional, Tuple
|
| 60 |
+
|
| 61 |
+
import numpy as np
|
| 62 |
+
import pandas as pd
|
| 63 |
+
import psycopg2
|
| 64 |
+
|
| 65 |
+
log = logging.getLogger(__name__)
|
| 66 |
+
if not logging.getLogger().handlers:
|
| 67 |
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s β %(message)s")
|
| 68 |
+
|
| 69 |
+
EMBED_MODEL_NAME = "allenai/scibert_scivocab_uncased" # must match Code 1 (2.py)
|
| 70 |
+
|
| 71 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 72 |
+
# DATABASE CONNECTION (RDS)
|
| 73 |
+
# Credentials are read from environment / .env β NEVER hard-code secrets here.
|
| 74 |
+
# Put these lines in a `.env` file next to this script and fill them in:
|
| 75 |
+
# DB_HOST=aimindesdatabase.cfomsym2qoqo.us-east-2.rds.amazonaws.com
|
| 76 |
+
# DB_PORT=5432 # 5432 = PostgreSQL, 3306 = MySQL
|
| 77 |
+
# DB_NAME=<your database name>
|
| 78 |
+
# DB_USER=<your username>
|
| 79 |
+
# DB_PASSWORD=<your password>
|
| 80 |
+
# DB_KIND=postgresql # or: mysql
|
| 81 |
+
# # optional full override (wins over the parts above):
|
| 82 |
+
# # DB_URL=postgresql+psycopg2://user:pass@host:5432/dbname
|
| 83 |
+
# Requires: pip install sqlalchemy python-dotenv
|
| 84 |
+
# + PostgreSQL: pip install psycopg2-binary (MySQL: pip install pymysql)
|
| 85 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 86 |
+
try:
|
| 87 |
+
from dotenv import load_dotenv
|
| 88 |
+
load_dotenv()
|
| 89 |
+
except Exception:
|
| 90 |
+
pass
|
| 91 |
+
|
| 92 |
+
DB_HOST = os.getenv("DB_HOST", "aimindesdatabase.cfomsym2qoqo.us-east-2.rds.amazonaws.com")
|
| 93 |
+
DB_PORT = os.getenv("DB_PORT", "5432") # <-- 5432 Postgres Β· 3306 MySQL
|
| 94 |
+
DB_NAME = os.getenv("DB_NAME", "") # <-- ENTER (or set in .env)
|
| 95 |
+
DB_USER = os.getenv("DB_USER", "") # <-- ENTER (or set in .env)
|
| 96 |
+
DB_PASSWORD = os.getenv("DB_PASSWORD", "") # <-- ENTER (or set in .env)
|
| 97 |
+
DB_KIND = os.getenv("DB_KIND", "postgresql") # "postgresql" or "mysql"
|
| 98 |
+
DB_URL = os.getenv("DB_URL", "") # full SQLAlchemy URL (optional override)
|
| 99 |
+
|
| 100 |
+
_db_engine = None
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def db_configured() -> bool:
|
| 104 |
+
"""True once enough credentials are present to attempt a connection."""
|
| 105 |
+
return bool(DB_URL or (DB_NAME and DB_USER and DB_PASSWORD))
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def get_db_engine():
|
| 109 |
+
"""Lazily build (and cache) the SQLAlchemy engine for the RDS instance."""
|
| 110 |
+
global _db_engine
|
| 111 |
+
if _db_engine is not None:
|
| 112 |
+
return _db_engine
|
| 113 |
+
from sqlalchemy import create_engine
|
| 114 |
+
if DB_URL:
|
| 115 |
+
url = DB_URL
|
| 116 |
+
elif DB_KIND.lower().startswith("mysql"):
|
| 117 |
+
url = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
| 118 |
+
else:
|
| 119 |
+
url = f"postgresql+psycopg2://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
| 120 |
+
_db_engine = create_engine(url, pool_pre_ping=True)
|
| 121 |
+
return _db_engine
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def db_healthcheck() -> Any:
|
| 125 |
+
"""Return 1 if the DB is reachable; raises on failure (surface the error)."""
|
| 126 |
+
from sqlalchemy import text
|
| 127 |
+
with get_db_engine().connect() as c:
|
| 128 |
+
return c.execute(text("SELECT 1")).scalar()
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def push_properties_to_db(store: Dict[str, Any], table: str = "material_properties",
|
| 132 |
+
replace_stem: bool = True) -> Dict[str, Any]:
|
| 133 |
+
"""
|
| 134 |
+
Push the stored property rows (store['df_store'], which carries
|
| 135 |
+
plot_image_path) to the RDS table. Adds pdf_stem + source_table columns so
|
| 136 |
+
rows are attributable, and (idempotently) deletes any prior rows for this
|
| 137 |
+
pdf_stem before inserting so re-running a paper doesn't duplicate.
|
| 138 |
+
|
| 139 |
+
NOTE: plot_image_path values are LOCAL relative paths. For a shared/cloud
|
| 140 |
+
database, migrate images to S3 (store the URL) or a BYTEA/BLOB column β
|
| 141 |
+
local paths won't resolve for other clients.
|
| 142 |
+
"""
|
| 143 |
+
from sqlalchemy import text
|
| 144 |
+
df = store.get("df_store")
|
| 145 |
+
if df is None or df.empty:
|
| 146 |
+
return {"pushed": 0, "table": table}
|
| 147 |
+
df = df.copy()
|
| 148 |
+
df["pdf_stem"] = store.get("pdf_stem", "")
|
| 149 |
+
if "source_table" not in df.columns:
|
| 150 |
+
df["source_table"] = store.get("source_table", "")
|
| 151 |
+
|
| 152 |
+
engine = get_db_engine()
|
| 153 |
+
if replace_stem:
|
| 154 |
+
try:
|
| 155 |
+
with engine.begin() as conn:
|
| 156 |
+
conn.execute(text(f"DELETE FROM {table} WHERE pdf_stem = :s"),
|
| 157 |
+
{"s": store.get("pdf_stem", "")})
|
| 158 |
+
except Exception as e:
|
| 159 |
+
log.info(f"push_properties_to_db: no prior rows to clear ({e})")
|
| 160 |
+
df.to_sql(table, engine, if_exists="append", index=False)
|
| 161 |
+
log.info(f"push_properties_to_db: wrote {len(df)} row(s) to '{table}'")
|
| 162 |
+
return {"pushed": len(df), "table": table}
|
| 163 |
+
|
| 164 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 165 |
+
# S3 IMAGE STORAGE
|
| 166 |
+
# Credentials from environment / .env β NEVER hard-code. boto3 auto-reads
|
| 167 |
+
# AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_DEFAULT_REGION.
|
| 168 |
+
# Requires: pip install boto3
|
| 169 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 170 |
+
S3_BUCKET = os.getenv("S3_BUCKET", "")
|
| 171 |
+
S3_PREFIX = os.getenv("S3_PREFIX", "plots").strip("/")
|
| 172 |
+
S3_REGION = os.getenv("AWS_DEFAULT_REGION", os.getenv("AWS_REGION", "us-east-2"))
|
| 173 |
+
S3_PUBLIC = os.getenv("S3_PUBLIC", "false").lower() in ("1", "true", "yes")
|
| 174 |
+
|
| 175 |
+
_s3_client = None
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def s3_configured() -> bool:
|
| 179 |
+
return bool(S3_BUCKET)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def get_s3_client():
|
| 183 |
+
global _s3_client
|
| 184 |
+
if _s3_client is None:
|
| 185 |
+
import boto3
|
| 186 |
+
_s3_client = boto3.client("s3", region_name=S3_REGION)
|
| 187 |
+
return _s3_client
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def s3_key_for(pdf_stem: str, filename: str) -> str:
|
| 191 |
+
"""One 'folder' per paper: <prefix>/<pdf_stem>/plots/<filename>."""
|
| 192 |
+
parts = [p for p in (S3_PREFIX, _safe(pdf_stem), "plots", filename) if p]
|
| 193 |
+
return "/".join(parts)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def s3_url_for(key: str) -> str:
|
| 197 |
+
if S3_PUBLIC:
|
| 198 |
+
return f"https://{S3_BUCKET}.s3.{S3_REGION}.amazonaws.com/{key}"
|
| 199 |
+
return f"s3://{S3_BUCKET}/{key}" # private bucket β store the URI
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def upload_bytes_to_s3(data: bytes, key: str, content_type: str = "image/png") -> str:
|
| 203 |
+
get_s3_client().put_object(Bucket=S3_BUCKET, Key=key,
|
| 204 |
+
Body=data, ContentType=content_type)
|
| 205 |
+
return s3_url_for(key)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def s3_presigned_url(key: str, expires: int = 3600) -> str:
|
| 209 |
+
"""Temporary GET URL for viewing/downloading a private-bucket object."""
|
| 210 |
+
return get_s3_client().generate_presigned_url(
|
| 211 |
+
"get_object", Params={"Bucket": S3_BUCKET, "Key": key}, ExpiresIn=expires)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 215 |
+
# TUNABLES (module constants so the paper can cite exact values)
|
| 216 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 217 |
+
|
| 218 |
+
SAME_PAGE_SCORE = 1.00
|
| 219 |
+
ADJ_PAGE_SCORE = 0.40 # |Ξpage| == 1
|
| 220 |
+
NEAR_PAGE_SCORE = 0.15 # |Ξpage| == 2
|
| 221 |
+
PAGE_WINDOW = 2
|
| 222 |
+
|
| 223 |
+
W_PAGE = 0.35 # fallback (non-citation) weights β sum to 1.0
|
| 224 |
+
W_EMBED = 0.45
|
| 225 |
+
W_TOKEN = 0.20
|
| 226 |
+
W_PAGE_NOEMBED = 0.40 # redistribution when the embedder is unavailable
|
| 227 |
+
W_TOKEN_NOEMBED = 0.60
|
| 228 |
+
|
| 229 |
+
CITATION_BASE_SCORE = 0.90
|
| 230 |
+
CITATION_SUBPLOT_BONUS = 0.05
|
| 231 |
+
CITATION_PAGE_BONUS = 0.05
|
| 232 |
+
|
| 233 |
+
MAP_MIN_SCORE = 0.45 # fallback links below this are dropped (citation kept)
|
| 234 |
+
TOP_K = 1
|
| 235 |
+
|
| 236 |
+
_PROP_ID_FIELDS = [
|
| 237 |
+
"material_name", "material_abbreviation", "manufacturer",
|
| 238 |
+
"section", "property_name", "value", "unit", "test_condition",
|
| 239 |
+
"source_page", "chunk_type", "verified_by", "match_layer",
|
| 240 |
+
]
|
| 241 |
+
|
| 242 |
+
# Property fields stored as explicit, queryable DB columns (whichever exist)
|
| 243 |
+
_PROP_COLUMNS = [
|
| 244 |
+
"material_name", "material_abbreviation", "manufacturer",
|
| 245 |
+
"section", "property_name", "value", "unit", "english",
|
| 246 |
+
"test_condition", "comments", "source_text", "source_page", "chunk_type",
|
| 247 |
+
"verified_by", "match_layer", "doi_url",
|
| 248 |
+
]
|
| 249 |
+
|
| 250 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 251 |
+
# SHARED HELPERS
|
| 252 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 253 |
+
|
| 254 |
+
def _norm(s: str) -> str:
|
| 255 |
+
return re.sub(r"[^a-z0-9]", "", str(s).lower().strip())
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def _tokenize(s: str) -> List[str]:
|
| 259 |
+
s = re.sub(r"[^a-z0-9]+", " ", str(s).lower().strip())
|
| 260 |
+
return [t for t in s.split() if len(t) >= 2]
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def _token_overlap(a: str, b: str) -> float:
|
| 264 |
+
sa, sb = set(_tokenize(a)), set(_tokenize(b))
|
| 265 |
+
if not sa and not sb:
|
| 266 |
+
return 1.0
|
| 267 |
+
if not sa or not sb:
|
| 268 |
+
return 0.0
|
| 269 |
+
return len(sa & sb) / len(sa | sb)
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def _safe(s: Any) -> str:
|
| 273 |
+
out = re.sub(r"[^A-Za-z0-9._-]+", "_", str(s)).strip("_")
|
| 274 |
+
return out or "x"
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def _to_float(x: Any) -> Optional[float]:
|
| 279 |
+
try:
|
| 280 |
+
if x is None or x == "":
|
| 281 |
+
return None
|
| 282 |
+
return float(x)
|
| 283 |
+
except (TypeError, ValueError):
|
| 284 |
+
return None
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def _canonical_path(info: Dict[str, Any]) -> str:
|
| 288 |
+
"""Prefer the S3 URL; fall back to the local relative path."""
|
| 289 |
+
return info.get("s3_url") or info.get("rel", "")
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def _rowdict(row: pd.Series) -> Dict[str, Any]:
|
| 293 |
+
d: Dict[str, Any] = {}
|
| 294 |
+
for k, v in row.items():
|
| 295 |
+
try:
|
| 296 |
+
if pd.isna(v):
|
| 297 |
+
v = ""
|
| 298 |
+
except (TypeError, ValueError):
|
| 299 |
+
pass
|
| 300 |
+
d[k] = v
|
| 301 |
+
return d
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
_embed_model: Optional[Any] = None
|
| 305 |
+
_embed_tried: bool = False
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def _get_shared_embed_model() -> Optional[Any]:
|
| 309 |
+
"""Reuse Code 1's (2.py) already-loaded SciBERT if importable; else load
|
| 310 |
+
our own copy; else None (β token-only matching)."""
|
| 311 |
+
global _embed_model, _embed_tried
|
| 312 |
+
if _embed_model is not None or _embed_tried:
|
| 313 |
+
return _embed_model
|
| 314 |
+
_embed_tried = True
|
| 315 |
+
try:
|
| 316 |
+
import importlib
|
| 317 |
+
_embed_model = importlib.import_module("2")._get_embed_model() # Code 1 = 2.py
|
| 318 |
+
log.info("mapper: reusing Code 1's SciBERT embedding model.")
|
| 319 |
+
return _embed_model
|
| 320 |
+
except Exception as e:
|
| 321 |
+
log.info(f"mapper: Code 1 embedder unavailable ({e}); loading own copy.")
|
| 322 |
+
try:
|
| 323 |
+
from sentence_transformers import SentenceTransformer # type: ignore
|
| 324 |
+
_embed_model = SentenceTransformer(EMBED_MODEL_NAME)
|
| 325 |
+
log.info("mapper: loaded standalone SciBERT embedding model.")
|
| 326 |
+
except Exception as e:
|
| 327 |
+
log.warning(f"mapper: no embedding model ({e}) β page + token-overlap only.")
|
| 328 |
+
_embed_model = None
|
| 329 |
+
return _embed_model
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 333 |
+
# FIGURE-REFERENCE PARSING
|
| 334 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 335 |
+
|
| 336 |
+
_CAPTION_FIGNUM_RE = re.compile(r"\bfig(?:ure)?s?\b\.?\s*(\d+)", re.IGNORECASE)
|
| 337 |
+
_FIG_ANCHOR_RE = re.compile(r"\bfig(?:ure)?s?\b\.?", re.IGNORECASE)
|
| 338 |
+
_FIG_ITEM_RE = re.compile(
|
| 339 |
+
r"\s*(\d+)\s*(?:\(\s*([a-h])\s*\)|([a-h])(?![a-z0-9]))?", re.IGNORECASE
|
| 340 |
+
)
|
| 341 |
+
_FIG_SEP_RE = re.compile(r"^\s*(?:,|and|&|-|\u2013|\u2014|to)\s*", re.IGNORECASE)
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def _caption_figure_number(caption: str) -> Optional[int]:
|
| 345 |
+
m = _CAPTION_FIGNUM_RE.search(caption or "")
|
| 346 |
+
return int(m.group(1)) if m else None
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def _parse_figure_refs(text: str) -> List[Tuple[int, Optional[str]]]:
|
| 350 |
+
"""Every (figure_number, optional_subplot_letter) the text cites.
|
| 351 |
+
Handles 'Fig. 3', 'Figure 3(b)', 'Figs. 3 and 4', 'Figs 3, 4', 'Fig. 3-5'."""
|
| 352 |
+
if not text:
|
| 353 |
+
return []
|
| 354 |
+
refs: List[Tuple[int, Optional[str]]] = []
|
| 355 |
+
for anchor in _FIG_ANCHOR_RE.finditer(text):
|
| 356 |
+
cursor = anchor.end()
|
| 357 |
+
for _ in range(8):
|
| 358 |
+
m = _FIG_ITEM_RE.match(text, cursor)
|
| 359 |
+
if not m:
|
| 360 |
+
break
|
| 361 |
+
num = int(m.group(1))
|
| 362 |
+
letter = m.group(2) or m.group(3)
|
| 363 |
+
refs.append((num, letter.lower() if letter else None))
|
| 364 |
+
cursor = m.end()
|
| 365 |
+
sep = _FIG_SEP_RE.match(text, cursor)
|
| 366 |
+
if not sep:
|
| 367 |
+
break
|
| 368 |
+
cursor = sep.end()
|
| 369 |
+
seen: set = set()
|
| 370 |
+
out: List[Tuple[int, Optional[str]]] = []
|
| 371 |
+
for r in refs:
|
| 372 |
+
if r not in seen:
|
| 373 |
+
seen.add(r)
|
| 374 |
+
out.append(r)
|
| 375 |
+
return out
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
def _page_int(page_label: Any) -> Optional[int]:
|
| 379 |
+
"""Code 1 stores 'Page 3' / 'Unknown'; Code 2 stores an int."""
|
| 380 |
+
if page_label is None:
|
| 381 |
+
return None
|
| 382 |
+
m = re.search(r"\d+", str(page_label))
|
| 383 |
+
return int(m.group()) if m else None
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 387 |
+
# PLOT CATALOG
|
| 388 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 389 |
+
|
| 390 |
+
def _index_plots(plot_results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 391 |
+
groups: List[Dict[str, Any]] = []
|
| 392 |
+
for gi, g in enumerate(plot_results or []):
|
| 393 |
+
caption = g.get("caption", "") or ""
|
| 394 |
+
page = g.get("page")
|
| 395 |
+
fig_num = _caption_figure_number(caption)
|
| 396 |
+
images = g.get("image_data", []) or []
|
| 397 |
+
subplot_caps = " ".join(str(im.get("subplot_caption") or "") for im in images)
|
| 398 |
+
groups.append({
|
| 399 |
+
"group_idx": gi,
|
| 400 |
+
"figure_id": f"p{page}_fig{fig_num if fig_num is not None else 'x' + str(gi)}",
|
| 401 |
+
"caption": caption,
|
| 402 |
+
"page": page,
|
| 403 |
+
"figure_number": fig_num,
|
| 404 |
+
"n_images": len(images),
|
| 405 |
+
"subplot_labels": [im.get("subplot_label") for im in images],
|
| 406 |
+
"text_for_embed": f"{caption} {subplot_caps}".strip(),
|
| 407 |
+
})
|
| 408 |
+
return groups
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
def _subplot_image_index(group: Dict[str, Any], letter: str) -> Optional[int]:
|
| 412 |
+
for idx, lab in enumerate(group.get("subplot_labels", [])):
|
| 413 |
+
if lab and lab.strip("()").lower() == letter:
|
| 414 |
+
return idx
|
| 415 |
+
return None
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 419 |
+
# SIMILARITY + SCORING
|
| 420 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 421 |
+
|
| 422 |
+
def _embed_matrix(prop_texts: List[str], group_texts: List[str]) -> Optional[np.ndarray]:
|
| 423 |
+
model = _get_shared_embed_model()
|
| 424 |
+
if model is None or not prop_texts or not group_texts:
|
| 425 |
+
return None
|
| 426 |
+
try:
|
| 427 |
+
pv = model.encode(prop_texts, normalize_embeddings=True,
|
| 428 |
+
batch_size=128, show_progress_bar=False)
|
| 429 |
+
gv = model.encode(group_texts, normalize_embeddings=True,
|
| 430 |
+
batch_size=128, show_progress_bar=False)
|
| 431 |
+
return np.asarray(pv) @ np.asarray(gv).T # [n_props, n_groups] cosine
|
| 432 |
+
except Exception as e:
|
| 433 |
+
log.warning(f"mapper: embedding matrix failed ({e}) β token-only.")
|
| 434 |
+
return None
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
def _page_score(pp: Optional[int], pg: Optional[int]) -> float:
|
| 438 |
+
if pp is None or pg is None:
|
| 439 |
+
return 0.0
|
| 440 |
+
d = abs(pp - pg)
|
| 441 |
+
if d == 0:
|
| 442 |
+
return SAME_PAGE_SCORE
|
| 443 |
+
if d == 1:
|
| 444 |
+
return ADJ_PAGE_SCORE
|
| 445 |
+
if d <= PAGE_WINDOW:
|
| 446 |
+
return NEAR_PAGE_SCORE
|
| 447 |
+
return 0.0
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
def _fallback_score(pscore: float, tscore: float,
|
| 451 |
+
esim: Optional[float]) -> Tuple[float, List[str]]:
|
| 452 |
+
signals: List[str] = []
|
| 453 |
+
if esim is None:
|
| 454 |
+
score = W_PAGE_NOEMBED * pscore + W_TOKEN_NOEMBED * tscore
|
| 455 |
+
else:
|
| 456 |
+
e = max(0.0, float(esim))
|
| 457 |
+
score = W_PAGE * pscore + W_EMBED * e + W_TOKEN * tscore
|
| 458 |
+
if e > 0:
|
| 459 |
+
signals.append("embedding")
|
| 460 |
+
if pscore > 0:
|
| 461 |
+
signals.append("page")
|
| 462 |
+
if tscore > 0:
|
| 463 |
+
signals.append("tokens")
|
| 464 |
+
return score, signals
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 468 |
+
# CORE MAPPING
|
| 469 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 470 |
+
|
| 471 |
+
def map_plots_to_properties(
|
| 472 |
+
df: pd.DataFrame,
|
| 473 |
+
plot_results: List[Dict[str, Any]],
|
| 474 |
+
top_k: int = TOP_K,
|
| 475 |
+
min_score: float = MAP_MIN_SCORE,
|
| 476 |
+
) -> Tuple[pd.DataFrame, pd.DataFrame]:
|
| 477 |
+
"""
|
| 478 |
+
Map each property row in `df` (any Code 1 output) to figure crop(s) in
|
| 479 |
+
`plot_results` (Code 2 output).
|
| 480 |
+
|
| 481 |
+
Returns:
|
| 482 |
+
links_df β one row per accepted (property, figure) link.
|
| 483 |
+
df_augmentedβ `df` (index reset) plus mapped_figure_* / map_score /
|
| 484 |
+
map_signals columns for the single best link per row.
|
| 485 |
+
"""
|
| 486 |
+
empty_links = pd.DataFrame()
|
| 487 |
+
if df is None or df.empty or not plot_results:
|
| 488 |
+
aug = df.reset_index(drop=True).copy() if df is not None else pd.DataFrame()
|
| 489 |
+
for col in ("mapped_figure_id", "mapped_figure_caption", "mapped_figure_page",
|
| 490 |
+
"mapped_figure_number", "mapped_subplot", "map_score", "map_signals"):
|
| 491 |
+
if not aug.empty:
|
| 492 |
+
aug[col] = ""
|
| 493 |
+
return empty_links, aug
|
| 494 |
+
|
| 495 |
+
dfx = df.reset_index(drop=True).copy()
|
| 496 |
+
groups = _index_plots(plot_results)
|
| 497 |
+
|
| 498 |
+
num_index: Dict[int, List[int]] = {}
|
| 499 |
+
for g in groups:
|
| 500 |
+
if g["figure_number"] is not None:
|
| 501 |
+
num_index.setdefault(g["figure_number"], []).append(g["group_idx"])
|
| 502 |
+
|
| 503 |
+
prop_texts = [
|
| 504 |
+
" ".join(str(r.get(f, "")) for f in
|
| 505 |
+
("property_name", "material_name", "section", "test_condition"))
|
| 506 |
+
for _, r in dfx.iterrows()
|
| 507 |
+
]
|
| 508 |
+
group_texts = [g["text_for_embed"] for g in groups]
|
| 509 |
+
sim = _embed_matrix(prop_texts, group_texts)
|
| 510 |
+
|
| 511 |
+
id_fields = [f for f in _PROP_ID_FIELDS if f in dfx.columns]
|
| 512 |
+
|
| 513 |
+
link_rows: List[Dict[str, Any]] = []
|
| 514 |
+
best_by_row: Dict[int, Dict[str, Any]] = {}
|
| 515 |
+
|
| 516 |
+
for i, row in dfx.iterrows():
|
| 517 |
+
prop_page = _page_int(row.get("source_page"))
|
| 518 |
+
prop_name = str(row.get("property_name", ""))
|
| 519 |
+
material = str(row.get("material_name", ""))
|
| 520 |
+
cite_text = f"{row.get('source_text', '')} {row.get('comments', '')}"
|
| 521 |
+
refs = _parse_figure_refs(cite_text)
|
| 522 |
+
|
| 523 |
+
candidates: List[Dict[str, Any]] = []
|
| 524 |
+
|
| 525 |
+
# Signal 1: explicit figure citation (near-decisive)
|
| 526 |
+
for num, letter in refs:
|
| 527 |
+
for gi in num_index.get(num, []):
|
| 528 |
+
g = groups[gi]
|
| 529 |
+
score = CITATION_BASE_SCORE
|
| 530 |
+
signals = ["figure_citation"]
|
| 531 |
+
img_idx: Optional[int] = None
|
| 532 |
+
subplot_lab: Optional[str] = None
|
| 533 |
+
if letter is not None:
|
| 534 |
+
idx = _subplot_image_index(g, letter)
|
| 535 |
+
if idx is not None:
|
| 536 |
+
img_idx = idx
|
| 537 |
+
subplot_lab = f"({letter})"
|
| 538 |
+
score += CITATION_SUBPLOT_BONUS
|
| 539 |
+
signals.append("subplot")
|
| 540 |
+
if prop_page is not None and g["page"] == prop_page:
|
| 541 |
+
score += CITATION_PAGE_BONUS
|
| 542 |
+
signals.append("page_confirm")
|
| 543 |
+
candidates.append({
|
| 544 |
+
"group": g, "image_index": img_idx, "subplot_label": subplot_lab,
|
| 545 |
+
"score": min(score, 1.0), "signals": signals,
|
| 546 |
+
})
|
| 547 |
+
|
| 548 |
+
# Signals 2-4: page + semantic + token fallback
|
| 549 |
+
if not candidates:
|
| 550 |
+
for gi, g in enumerate(groups):
|
| 551 |
+
pscore = _page_score(prop_page, g["page"])
|
| 552 |
+
tscore = _token_overlap(f"{prop_name} {material}", g["caption"])
|
| 553 |
+
esim = float(sim[i, gi]) if sim is not None else None
|
| 554 |
+
score, signals = _fallback_score(pscore, tscore, esim)
|
| 555 |
+
if score < min_score:
|
| 556 |
+
continue
|
| 557 |
+
img_idx = 0 if g["n_images"] == 1 else None
|
| 558 |
+
subplot_lab = (g["subplot_labels"][0] if g["n_images"] == 1 else None)
|
| 559 |
+
candidates.append({
|
| 560 |
+
"group": g, "image_index": img_idx, "subplot_label": subplot_lab,
|
| 561 |
+
"score": round(score, 4), "signals": signals,
|
| 562 |
+
})
|
| 563 |
+
|
| 564 |
+
if not candidates:
|
| 565 |
+
continue
|
| 566 |
+
|
| 567 |
+
candidates.sort(key=lambda c: -c["score"])
|
| 568 |
+
for rank, cand in enumerate(candidates[:max(1, top_k)]):
|
| 569 |
+
g = cand["group"]
|
| 570 |
+
base = {f: row.get(f, "") for f in id_fields}
|
| 571 |
+
link = {
|
| 572 |
+
**base,
|
| 573 |
+
"prop_row": int(i),
|
| 574 |
+
"group_idx": g["group_idx"],
|
| 575 |
+
"figure_id": g["figure_id"],
|
| 576 |
+
"figure_caption": g["caption"],
|
| 577 |
+
"figure_page": g["page"],
|
| 578 |
+
"figure_number": g["figure_number"],
|
| 579 |
+
"matched_image_index": cand["image_index"],
|
| 580 |
+
"matched_subplot": cand["subplot_label"],
|
| 581 |
+
"match_score": round(float(cand["score"]), 4),
|
| 582 |
+
"match_signals": ", ".join(cand["signals"]),
|
| 583 |
+
"match_rank": rank + 1,
|
| 584 |
+
}
|
| 585 |
+
link_rows.append(link)
|
| 586 |
+
if rank == 0:
|
| 587 |
+
best_by_row[int(i)] = link
|
| 588 |
+
|
| 589 |
+
links_df = pd.DataFrame(link_rows)
|
| 590 |
+
|
| 591 |
+
aug = dfx.copy()
|
| 592 |
+
aug["mapped_figure_id"] = [best_by_row.get(i, {}).get("figure_id", "") for i in range(len(aug))]
|
| 593 |
+
aug["mapped_figure_caption"] = [best_by_row.get(i, {}).get("figure_caption", "") for i in range(len(aug))]
|
| 594 |
+
aug["mapped_figure_page"] = [best_by_row.get(i, {}).get("figure_page", "") for i in range(len(aug))]
|
| 595 |
+
aug["mapped_figure_number"] = [best_by_row.get(i, {}).get("figure_number", "") for i in range(len(aug))]
|
| 596 |
+
aug["mapped_subplot"] = [best_by_row.get(i, {}).get("matched_subplot", "") for i in range(len(aug))]
|
| 597 |
+
aug["map_score"] = [best_by_row.get(i, {}).get("match_score", "") for i in range(len(aug))]
|
| 598 |
+
aug["map_signals"] = [best_by_row.get(i, {}).get("match_signals", "") for i in range(len(aug))]
|
| 599 |
+
|
| 600 |
+
n_mapped = len(best_by_row)
|
| 601 |
+
n_cited = int(links_df["match_signals"].str.contains("figure_citation").sum()) if not links_df.empty else 0
|
| 602 |
+
log.info(f"mapper: {n_mapped}/{len(dfx)} rows mapped ({n_cited} via citation, "
|
| 603 |
+
f"rest page+semantic); {len(links_df)} total link(s).")
|
| 604 |
+
return links_df, aug
|
| 605 |
+
|
| 606 |
+
|
| 607 |
+
def build_figure_property_index(links_df: pd.DataFrame) -> pd.DataFrame:
|
| 608 |
+
"""Collapse links to one row per figure, listing the properties on it."""
|
| 609 |
+
if links_df is None or links_df.empty:
|
| 610 |
+
return pd.DataFrame()
|
| 611 |
+
out_rows: List[Dict[str, Any]] = []
|
| 612 |
+
for fig_id, grp in links_df.groupby("figure_id"):
|
| 613 |
+
props = []
|
| 614 |
+
for _, r in grp.iterrows():
|
| 615 |
+
unit = str(r.get("unit", "")).strip()
|
| 616 |
+
val = str(r.get("value", "")).strip()
|
| 617 |
+
sub = f" {r['matched_subplot']}" if r.get("matched_subplot") else ""
|
| 618 |
+
props.append(
|
| 619 |
+
f"{r.get('property_name', '')}={val}{(' ' + unit) if unit else ''}"
|
| 620 |
+
f" [{r.get('material_name', '')}]{sub}"
|
| 621 |
+
)
|
| 622 |
+
first = grp.iloc[0]
|
| 623 |
+
out_rows.append({
|
| 624 |
+
"figure_id": fig_id,
|
| 625 |
+
"figure_number": first.get("figure_number", ""),
|
| 626 |
+
"figure_page": first.get("figure_page", ""),
|
| 627 |
+
"figure_caption": first.get("figure_caption", ""),
|
| 628 |
+
"n_properties": len(grp),
|
| 629 |
+
"mean_match_score": round(float(grp["match_score"].mean()), 4),
|
| 630 |
+
"properties": " ; ".join(props),
|
| 631 |
+
})
|
| 632 |
+
return pd.DataFrame(out_rows).sort_values(
|
| 633 |
+
["figure_page", "figure_number"], na_position="last"
|
| 634 |
+
).reset_index(drop=True)
|
| 635 |
+
|
| 636 |
+
|
| 637 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 638 |
+
# MAP FROM A PDF (Code 1 β Code 2 β map)
|
| 639 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 640 |
+
|
| 641 |
+
def run_full_pipeline(
|
| 642 |
+
pdf_bytes: bytes,
|
| 643 |
+
source_table: str = "consensus",
|
| 644 |
+
doi_override: str = "",
|
| 645 |
+
top_k: int = TOP_K,
|
| 646 |
+
min_score: float = MAP_MIN_SCORE,
|
| 647 |
+
verify_engines: Optional[List[str]] = None,
|
| 648 |
+
check_missed_plots: bool = True,
|
| 649 |
+
verify_crops: bool = True,
|
| 650 |
+
) -> Dict[str, Any]:
|
| 651 |
+
"""Run Code 1 (2.py) extraction, Code 2 (image2.py) plots, then map.
|
| 652 |
+
`source_table` β {consensus, verified, consensus_verified, gemini, gpt, claude}."""
|
| 653 |
+
import importlib
|
| 654 |
+
run_pipeline = importlib.import_module("2").run_pipeline # Code 1 = 2.py
|
| 655 |
+
from image2 import extract_and_verify_plots # Code 2 = image2.py
|
| 656 |
+
|
| 657 |
+
(df_consensus, df_verified, df_consensus_verified,
|
| 658 |
+
df_gemini, df_gpt, df_claude,
|
| 659 |
+
_chunks, api_errors, meta) = run_pipeline(pdf_bytes, doi_override=doi_override)
|
| 660 |
+
|
| 661 |
+
table_map = {
|
| 662 |
+
"consensus": df_consensus,
|
| 663 |
+
"verified": df_verified,
|
| 664 |
+
"consensus_verified": df_consensus_verified,
|
| 665 |
+
"gemini": df_gemini,
|
| 666 |
+
"gpt": df_gpt,
|
| 667 |
+
"claude": df_claude,
|
| 668 |
+
}
|
| 669 |
+
source_df = table_map.get(source_table, df_consensus)
|
| 670 |
+
|
| 671 |
+
plot_results, coverage_report = extract_and_verify_plots(
|
| 672 |
+
pdf_bytes,
|
| 673 |
+
check_missed_plots=check_missed_plots,
|
| 674 |
+
verify_crops=verify_crops,
|
| 675 |
+
verify_engines=verify_engines or ["gemini"],
|
| 676 |
+
)
|
| 677 |
+
|
| 678 |
+
links_df, df_aug = map_plots_to_properties(
|
| 679 |
+
source_df, plot_results, top_k=top_k, min_score=min_score
|
| 680 |
+
)
|
| 681 |
+
figure_index = build_figure_property_index(links_df)
|
| 682 |
+
|
| 683 |
+
return {
|
| 684 |
+
"links_df": links_df,
|
| 685 |
+
"df_augmented": df_aug,
|
| 686 |
+
"figure_index": figure_index,
|
| 687 |
+
"plot_results": plot_results,
|
| 688 |
+
"coverage_report": coverage_report,
|
| 689 |
+
"source_df": source_df,
|
| 690 |
+
"source_table": source_table,
|
| 691 |
+
"meta": meta,
|
| 692 |
+
"api_errors": api_errors,
|
| 693 |
+
}
|
| 694 |
+
|
| 695 |
+
|
| 696 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 697 |
+
# SAVE CROPS TO DISK
|
| 698 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 699 |
+
|
| 700 |
+
def save_plot_images(
|
| 701 |
+
plot_results: List[Dict[str, Any]],
|
| 702 |
+
out_dir: str,
|
| 703 |
+
pdf_stem: str,
|
| 704 |
+
group_idxs: Optional[set] = None,
|
| 705 |
+
upload_s3: Optional[bool] = None,
|
| 706 |
+
write_local: bool = True,
|
| 707 |
+
) -> Tuple[Dict[Tuple[int, int], Dict[str, Any]], List[Dict[str, Any]]]:
|
| 708 |
+
"""Write each figure crop's PNG bytes to <out_dir>/<pdf_stem>/plots/.
|
| 709 |
+
Returns (path_map keyed by (group_idx, image_index), groups catalog).
|
| 710 |
+
Paths in path_map are POSIX-style relative to out_dir."""
|
| 711 |
+
groups = _index_plots(plot_results)
|
| 712 |
+
rel_root = f"{pdf_stem}/plots"
|
| 713 |
+
abs_root = os.path.join(out_dir, pdf_stem, "plots")
|
| 714 |
+
os.makedirs(abs_root, exist_ok=True)
|
| 715 |
+
if upload_s3 is None:
|
| 716 |
+
upload_s3 = s3_configured()
|
| 717 |
+
|
| 718 |
+
path_map: Dict[Tuple[int, int], Dict[str, Any]] = {}
|
| 719 |
+
used_names: set = set()
|
| 720 |
+
|
| 721 |
+
for g in groups:
|
| 722 |
+
gi = g["group_idx"]
|
| 723 |
+
if group_idxs is not None and gi not in group_idxs:
|
| 724 |
+
continue
|
| 725 |
+
for idx, img in enumerate(plot_results[gi].get("image_data", []) or []):
|
| 726 |
+
data = img.get("bytes")
|
| 727 |
+
if not data:
|
| 728 |
+
continue
|
| 729 |
+
sub = img.get("subplot_label")
|
| 730 |
+
suffix = _safe(sub.strip("()")) if sub else f"img{idx}"
|
| 731 |
+
base = f"{_safe(g['figure_id'])}_{suffix}"
|
| 732 |
+
name = f"{base}.png"
|
| 733 |
+
k = 1
|
| 734 |
+
while name in used_names:
|
| 735 |
+
name = f"{base}_{k}.png"
|
| 736 |
+
k += 1
|
| 737 |
+
used_names.add(name)
|
| 738 |
+
|
| 739 |
+
entry = {
|
| 740 |
+
"rel": f"{rel_root}/{name}",
|
| 741 |
+
"subplot": sub,
|
| 742 |
+
"source": img.get("source"),
|
| 743 |
+
"verification": img.get("verification"),
|
| 744 |
+
}
|
| 745 |
+
if write_local:
|
| 746 |
+
abs_path = os.path.join(abs_root, name)
|
| 747 |
+
with open(abs_path, "wb") as fh:
|
| 748 |
+
fh.write(data)
|
| 749 |
+
entry["abs"] = abs_path
|
| 750 |
+
if upload_s3:
|
| 751 |
+
try:
|
| 752 |
+
key = s3_key_for(pdf_stem, name)
|
| 753 |
+
entry["s3_key"] = key
|
| 754 |
+
entry["s3_url"] = upload_bytes_to_s3(data, key)
|
| 755 |
+
except Exception as e:
|
| 756 |
+
log.warning(f"mapper: S3 upload failed for {name} ({e}); using local path.")
|
| 757 |
+
path_map[(gi, idx)] = entry
|
| 758 |
+
log.info(f"mapper: saved {len(path_map)} plot crop(s) to {abs_root}")
|
| 759 |
+
return path_map, groups
|
| 760 |
+
|
| 761 |
+
|
| 762 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 763 |
+
# DATABASE
|
| 764 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 765 |
+
|
| 766 |
+
_SCHEMA = """
|
| 767 |
+
CREATE TABLE IF NOT EXISTS figures (
|
| 768 |
+
pdf_stem TEXT,
|
| 769 |
+
figure_id TEXT,
|
| 770 |
+
page INTEGER,
|
| 771 |
+
figure_number INTEGER,
|
| 772 |
+
caption TEXT,
|
| 773 |
+
PRIMARY KEY (pdf_stem, figure_id)
|
| 774 |
+
);
|
| 775 |
+
CREATE TABLE IF NOT EXISTS figure_images (
|
| 776 |
+
image_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 777 |
+
pdf_stem TEXT,
|
| 778 |
+
figure_id TEXT,
|
| 779 |
+
subplot_label TEXT,
|
| 780 |
+
source TEXT,
|
| 781 |
+
verification_action TEXT,
|
| 782 |
+
file_path TEXT
|
| 783 |
+
);
|
| 784 |
+
CREATE TABLE IF NOT EXISTS properties (
|
| 785 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 786 |
+
pdf_stem TEXT,
|
| 787 |
+
source_table TEXT,
|
| 788 |
+
material_name TEXT,
|
| 789 |
+
material_abbreviation TEXT,
|
| 790 |
+
manufacturer TEXT,
|
| 791 |
+
section TEXT,
|
| 792 |
+
property_name TEXT,
|
| 793 |
+
value TEXT,
|
| 794 |
+
unit TEXT,
|
| 795 |
+
english TEXT,
|
| 796 |
+
test_condition TEXT,
|
| 797 |
+
comments TEXT,
|
| 798 |
+
source_text TEXT,
|
| 799 |
+
source_page TEXT,
|
| 800 |
+
chunk_type TEXT,
|
| 801 |
+
verified_by TEXT,
|
| 802 |
+
match_layer TEXT,
|
| 803 |
+
doi_url TEXT,
|
| 804 |
+
mapped_figure_id TEXT,
|
| 805 |
+
mapped_figure_number TEXT,
|
| 806 |
+
mapped_figure_page TEXT,
|
| 807 |
+
mapped_subplot TEXT,
|
| 808 |
+
map_score REAL,
|
| 809 |
+
map_signals TEXT,
|
| 810 |
+
plot_image_path TEXT,
|
| 811 |
+
plot_image_paths TEXT,
|
| 812 |
+
row_json TEXT
|
| 813 |
+
);
|
| 814 |
+
CREATE TABLE IF NOT EXISTS property_images (
|
| 815 |
+
property_id INTEGER,
|
| 816 |
+
image_id INTEGER,
|
| 817 |
+
relation TEXT
|
| 818 |
+
);
|
| 819 |
+
CREATE INDEX IF NOT EXISTS ix_props_stem ON properties(pdf_stem);
|
| 820 |
+
CREATE INDEX IF NOT EXISTS ix_props_fig ON properties(mapped_figure_id);
|
| 821 |
+
CREATE INDEX IF NOT EXISTS ix_figimg_stem ON figure_images(pdf_stem, figure_id);
|
| 822 |
+
"""
|
| 823 |
+
|
| 824 |
+
|
| 825 |
+
def _insert(cur: sqlite3.Cursor, table: str, dct: Dict[str, Any]) -> int:
|
| 826 |
+
cols = list(dct.keys())
|
| 827 |
+
placeholders = ",".join(["?"] * len(cols))
|
| 828 |
+
cur.execute(f"INSERT INTO {table} ({','.join(cols)}) VALUES ({placeholders})",
|
| 829 |
+
[dct[c] for c in cols])
|
| 830 |
+
return int(cur.lastrowid)
|
| 831 |
+
|
| 832 |
+
|
| 833 |
+
def store_properties_with_plots(
|
| 834 |
+
df_augmented: pd.DataFrame,
|
| 835 |
+
links_df: pd.DataFrame,
|
| 836 |
+
plot_results: List[Dict[str, Any]],
|
| 837 |
+
out_dir: str,
|
| 838 |
+
pdf_stem: str,
|
| 839 |
+
source_table: str = "consensus",
|
| 840 |
+
db_filename: Optional[str] = None,
|
| 841 |
+
save_all_group_images: bool = False,
|
| 842 |
+
) -> Dict[str, Any]:
|
| 843 |
+
"""Persist `df_augmented` into a SQLite DB under `out_dir`, save mapped
|
| 844 |
+
crops to <out_dir>/<pdf_stem>/plots/, and attach the file path(s) to each
|
| 845 |
+
row. Idempotent per pdf_stem. Returns a summary dict."""
|
| 846 |
+
os.makedirs(out_dir, exist_ok=True)
|
| 847 |
+
df = df_augmented.reset_index(drop=True).copy()
|
| 848 |
+
|
| 849 |
+
mapped_gis = set(int(g) for g in links_df["group_idx"].tolist()) if not links_df.empty else set()
|
| 850 |
+
group_idxs = None if save_all_group_images else mapped_gis
|
| 851 |
+
|
| 852 |
+
path_map, groups = save_plot_images(plot_results, out_dir, pdf_stem, group_idxs=group_idxs)
|
| 853 |
+
group_by_idx = {g["group_idx"]: g for g in groups}
|
| 854 |
+
|
| 855 |
+
best_link: Dict[int, pd.Series] = {}
|
| 856 |
+
if not links_df.empty:
|
| 857 |
+
for _, l in links_df[links_df["match_rank"] == 1].iterrows():
|
| 858 |
+
best_link[int(l["prop_row"])] = l
|
| 859 |
+
|
| 860 |
+
db_filename = db_filename or f"{pdf_stem}.db"
|
| 861 |
+
db_path = os.path.join(out_dir, db_filename)
|
| 862 |
+
|
| 863 |
+
conn = sqlite3.connect(db_path)
|
| 864 |
+
cur = conn.cursor()
|
| 865 |
+
cur.executescript(_SCHEMA)
|
| 866 |
+
|
| 867 |
+
# idempotency
|
| 868 |
+
cur.execute("SELECT id FROM properties WHERE pdf_stem = ?", (pdf_stem,))
|
| 869 |
+
old_prop_ids = [r[0] for r in cur.fetchall()]
|
| 870 |
+
if old_prop_ids:
|
| 871 |
+
cur.executemany("DELETE FROM property_images WHERE property_id = ?",
|
| 872 |
+
[(pid,) for pid in old_prop_ids])
|
| 873 |
+
cur.execute("DELETE FROM properties WHERE pdf_stem = ?", (pdf_stem,))
|
| 874 |
+
cur.execute("DELETE FROM figure_images WHERE pdf_stem = ?", (pdf_stem,))
|
| 875 |
+
cur.execute("DELETE FROM figures WHERE pdf_stem = ?", (pdf_stem,))
|
| 876 |
+
|
| 877 |
+
# figures + figure_images
|
| 878 |
+
img_id_map: Dict[Tuple[int, int], int] = {}
|
| 879 |
+
seen_fig: set = set()
|
| 880 |
+
for (gi, idx), info in path_map.items():
|
| 881 |
+
g = group_by_idx[gi]
|
| 882 |
+
if gi not in seen_fig:
|
| 883 |
+
seen_fig.add(gi)
|
| 884 |
+
cur.execute(
|
| 885 |
+
"INSERT OR REPLACE INTO figures (pdf_stem, figure_id, page, figure_number, caption) "
|
| 886 |
+
"VALUES (?,?,?,?,?)",
|
| 887 |
+
(pdf_stem, g["figure_id"], g["page"], g["figure_number"], g["caption"]),
|
| 888 |
+
)
|
| 889 |
+
verdict = info.get("verification")
|
| 890 |
+
action = verdict.get("majority_action", "") if isinstance(verdict, dict) else ""
|
| 891 |
+
img_id_map[(gi, idx)] = _insert(cur, "figure_images", {
|
| 892 |
+
"pdf_stem": pdf_stem,
|
| 893 |
+
"figure_id": g["figure_id"],
|
| 894 |
+
"subplot_label": info.get("subplot") or "",
|
| 895 |
+
"source": info.get("source") or "",
|
| 896 |
+
"verification_action": action,
|
| 897 |
+
"file_path": _canonical_path(info),
|
| 898 |
+
})
|
| 899 |
+
|
| 900 |
+
# properties + property_images
|
| 901 |
+
n_props = 0
|
| 902 |
+
n_linked = 0
|
| 903 |
+
store_paths: List[str] = []
|
| 904 |
+
for prow, row in df.iterrows():
|
| 905 |
+
prow = int(prow)
|
| 906 |
+
link = best_link.get(prow)
|
| 907 |
+
|
| 908 |
+
primary_rel = ""
|
| 909 |
+
all_rels: List[str] = []
|
| 910 |
+
primary_images: List[Tuple[int, str]] = []
|
| 911 |
+
|
| 912 |
+
if link is not None:
|
| 913 |
+
gi = int(link["group_idx"])
|
| 914 |
+
mii = link.get("matched_image_index")
|
| 915 |
+
has_specific = mii is not None and not (isinstance(mii, float) and pd.isna(mii))
|
| 916 |
+
if has_specific and (gi, int(mii)) in path_map:
|
| 917 |
+
key = (gi, int(mii))
|
| 918 |
+
primary_rel = _canonical_path(path_map[key])
|
| 919 |
+
all_rels = [primary_rel]
|
| 920 |
+
primary_images = [(img_id_map[key], "primary")]
|
| 921 |
+
else:
|
| 922 |
+
group_imgs = sorted((k for k in path_map if k[0] == gi), key=lambda k: k[1])
|
| 923 |
+
all_rels = [_canonical_path(path_map[k]) for k in group_imgs]
|
| 924 |
+
primary_rel = all_rels[0] if all_rels else ""
|
| 925 |
+
primary_images = [(img_id_map[k], "group") for k in group_imgs]
|
| 926 |
+
|
| 927 |
+
record = {"pdf_stem": pdf_stem, "source_table": source_table}
|
| 928 |
+
for col in _PROP_COLUMNS:
|
| 929 |
+
record[col] = "" if col not in df.columns else ("" if pd.isna(row.get(col)) else row.get(col))
|
| 930 |
+
record.update({
|
| 931 |
+
"mapped_figure_id": row.get("mapped_figure_id", "") if "mapped_figure_id" in df.columns else "",
|
| 932 |
+
"mapped_figure_number": str(row.get("mapped_figure_number", "")) if "mapped_figure_number" in df.columns else "",
|
| 933 |
+
"mapped_figure_page": str(row.get("mapped_figure_page", "")) if "mapped_figure_page" in df.columns else "",
|
| 934 |
+
"mapped_subplot": row.get("mapped_subplot", "") if "mapped_subplot" in df.columns else "",
|
| 935 |
+
"map_score": _to_float(row.get("map_score")) if "map_score" in df.columns else None,
|
| 936 |
+
"map_signals": row.get("map_signals", "") if "map_signals" in df.columns else "",
|
| 937 |
+
"plot_image_path": primary_rel,
|
| 938 |
+
"plot_image_paths": " ; ".join(all_rels),
|
| 939 |
+
"row_json": json.dumps(_rowdict(row), default=str, ensure_ascii=False),
|
| 940 |
+
})
|
| 941 |
+
|
| 942 |
+
prop_id = _insert(cur, "properties", record)
|
| 943 |
+
n_props += 1
|
| 944 |
+
store_paths.append(primary_rel)
|
| 945 |
+
for image_id, relation in primary_images:
|
| 946 |
+
cur.execute("INSERT INTO property_images (property_id, image_id, relation) VALUES (?,?,?)",
|
| 947 |
+
(prop_id, image_id, relation))
|
| 948 |
+
n_linked += 1
|
| 949 |
+
|
| 950 |
+
conn.commit()
|
| 951 |
+
conn.close()
|
| 952 |
+
|
| 953 |
+
df_store = df.copy()
|
| 954 |
+
df_store["plot_image_path"] = store_paths
|
| 955 |
+
n_with_image = sum(1 for p in store_paths if p)
|
| 956 |
+
log.info(f"mapper: stored {n_props} propert(ies) β {db_path}; {n_with_image} with a "
|
| 957 |
+
f"plot, {len(img_id_map)} image file(s), {n_linked} propertyβimage link(s).")
|
| 958 |
+
|
| 959 |
+
return {
|
| 960 |
+
"db_path": db_path,
|
| 961 |
+
"db_filename": db_filename,
|
| 962 |
+
"image_dir": os.path.join(out_dir, pdf_stem, "plots"),
|
| 963 |
+
"out_dir": out_dir,
|
| 964 |
+
"pdf_stem": pdf_stem,
|
| 965 |
+
"n_properties": n_props,
|
| 966 |
+
"n_properties_with_plot": n_with_image,
|
| 967 |
+
"n_images_saved": len(img_id_map),
|
| 968 |
+
"n_property_image_links": n_linked,
|
| 969 |
+
"df_store": df_store,
|
| 970 |
+
}
|
| 971 |
+
|
| 972 |
+
|
| 973 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 974 |
+
# READ-BACK + BUNDLE
|
| 975 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 976 |
+
|
| 977 |
+
def query_properties_with_plots(db_path: str, where_sql: str = "",
|
| 978 |
+
params: tuple = ()) -> pd.DataFrame:
|
| 979 |
+
"""Read stored property rows back (each carries plot_image_path)."""
|
| 980 |
+
conn = sqlite3.connect(db_path)
|
| 981 |
+
try:
|
| 982 |
+
q = "SELECT * FROM properties"
|
| 983 |
+
if where_sql:
|
| 984 |
+
q += f" WHERE {where_sql}"
|
| 985 |
+
return pd.read_sql_query(q, conn, params=params)
|
| 986 |
+
finally:
|
| 987 |
+
conn.close()
|
| 988 |
+
|
| 989 |
+
|
| 990 |
+
def fetch_property_image_paths(db_path: str, property_id: int) -> List[Dict[str, Any]]:
|
| 991 |
+
"""All image files linked to one stored property."""
|
| 992 |
+
conn = sqlite3.connect(db_path)
|
| 993 |
+
try:
|
| 994 |
+
cur = conn.cursor()
|
| 995 |
+
cur.execute(
|
| 996 |
+
"SELECT fi.file_path, fi.subplot_label, fi.verification_action, pi.relation "
|
| 997 |
+
"FROM property_images pi JOIN figure_images fi ON fi.image_id = pi.image_id "
|
| 998 |
+
"WHERE pi.property_id = ?",
|
| 999 |
+
(property_id,),
|
| 1000 |
+
)
|
| 1001 |
+
return [
|
| 1002 |
+
{"file_path": r[0], "subplot_label": r[1],
|
| 1003 |
+
"verification_action": r[2], "relation": r[3]}
|
| 1004 |
+
for r in cur.fetchall()
|
| 1005 |
+
]
|
| 1006 |
+
finally:
|
| 1007 |
+
conn.close()
|
| 1008 |
+
|
| 1009 |
+
|
| 1010 |
+
def bundle_zip(out_dir: str, pdf_stem: str, db_filename: str) -> bytes:
|
| 1011 |
+
"""Zip the DB + pdf_stem/plots/ into one portable archive (paths relative
|
| 1012 |
+
to out_dir, so the DB's stored image paths resolve after unzip)."""
|
| 1013 |
+
buf = io.BytesIO()
|
| 1014 |
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
| 1015 |
+
db_path = os.path.join(out_dir, db_filename)
|
| 1016 |
+
if os.path.exists(db_path):
|
| 1017 |
+
z.write(db_path, arcname=db_filename)
|
| 1018 |
+
plots_dir = os.path.join(out_dir, pdf_stem, "plots")
|
| 1019 |
+
for root, _dirs, files in os.walk(plots_dir):
|
| 1020 |
+
for fn in files:
|
| 1021 |
+
fp = os.path.join(root, fn)
|
| 1022 |
+
z.write(fp, arcname=os.path.relpath(fp, out_dir))
|
| 1023 |
+
buf.seek(0)
|
| 1024 |
+
return buf.getvalue()
|
| 1025 |
+
|
| 1026 |
+
|
| 1027 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1028 |
+
# FULL PIPELINE β DB
|
| 1029 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1030 |
+
|
| 1031 |
+
def run_full_pipeline_to_db(
|
| 1032 |
+
pdf_bytes: bytes,
|
| 1033 |
+
out_dir: str,
|
| 1034 |
+
pdf_stem: str,
|
| 1035 |
+
source_table: str = "consensus",
|
| 1036 |
+
doi_override: str = "",
|
| 1037 |
+
top_k: int = TOP_K,
|
| 1038 |
+
min_score: float = MAP_MIN_SCORE,
|
| 1039 |
+
verify_engines: Optional[List[str]] = None,
|
| 1040 |
+
save_all_group_images: bool = False,
|
| 1041 |
+
) -> Dict[str, Any]:
|
| 1042 |
+
"""Code 1 (extract) β Code 2 (plots) β map β store. Returns the map
|
| 1043 |
+
result dict plus a 'store' key with the persistence summary."""
|
| 1044 |
+
res = run_full_pipeline(
|
| 1045 |
+
pdf_bytes, source_table=source_table, doi_override=doi_override,
|
| 1046 |
+
top_k=top_k, min_score=min_score, verify_engines=verify_engines,
|
| 1047 |
+
)
|
| 1048 |
+
res["store"] = store_properties_with_plots(
|
| 1049 |
+
res["df_augmented"], res["links_df"], res["plot_results"],
|
| 1050 |
+
out_dir=out_dir, pdf_stem=pdf_stem, source_table=source_table,
|
| 1051 |
+
save_all_group_images=save_all_group_images,
|
| 1052 |
+
)
|
| 1053 |
+
return res
|
| 1054 |
+
|
| 1055 |
+
|
| 1056 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1057 |
+
# NON-PLOT GATE + EXTRACTION CACHE (glue only β never mutates 2.py/image2.py output)
|
| 1058 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1059 |
+
|
| 1060 |
+
# Captions that name a photo / micrograph / spectrum / logo β not a data plot.
|
| 1061 |
+
_NONPLOT_CAPTION_RE = re.compile(
|
| 1062 |
+
r"\b(sem|tem|afm|micrograph|micrographs|photograph|photographs|photo|"
|
| 1063 |
+
r"optical image|optical micrograph|schematic|logo)\b",
|
| 1064 |
+
re.IGNORECASE,
|
| 1065 |
+
)
|
| 1066 |
+
|
| 1067 |
+
|
| 1068 |
+
def select_real_plots(plot_results, use_caption=True, use_verifier=True,
|
| 1069 |
+
drop_unlabeled=False):
|
| 1070 |
+
"""Return a FILTERED COPY of image2.py's plot_results, keeping only real
|
| 1071 |
+
data plots. This is pure selection β the upstream outputs are untouched,
|
| 1072 |
+
so results stay identical to what 2.py / image2.py produced.
|
| 1073 |
+
|
| 1074 |
+
Drops:
|
| 1075 |
+
- figure groups whose caption names a photo/micrograph/logo (caption gate)
|
| 1076 |
+
- individual crops the verifier's aggregated verdict marked "discard"
|
| 1077 |
+
(keeps every "keep" and "recrop" β recrop is a real plot, not a non-plot)
|
| 1078 |
+
- optionally, unlabeled detections with no 'Fig. N' caption
|
| 1079 |
+
"""
|
| 1080 |
+
kept = []
|
| 1081 |
+
for g in plot_results or []:
|
| 1082 |
+
cap = g.get("caption", "") or ""
|
| 1083 |
+
if use_caption and _NONPLOT_CAPTION_RE.search(cap):
|
| 1084 |
+
continue
|
| 1085 |
+
if drop_unlabeled and _caption_figure_number(cap) is None:
|
| 1086 |
+
continue
|
| 1087 |
+
imgs = []
|
| 1088 |
+
for im in g.get("image_data", []) or []:
|
| 1089 |
+
if use_verifier:
|
| 1090 |
+
# Drop ONLY on the verifier's aggregated "discard" verdict β the same
|
| 1091 |
+
# label shown in the mirror view. This removes logos / photos / SEM /
|
| 1092 |
+
# schematics (majority_action == "discard") while KEEPING every "keep"
|
| 1093 |
+
# and "recrop" crop. "recrop" is a real plot with merged panels, not a
|
| 1094 |
+
# non-plot, so it must survive; dropping on a single dissenting engine
|
| 1095 |
+
# was wrongly pruning genuine multi-panel figures (e.g. the stacked DMA
|
| 1096 |
+
# and 2x2 enthalpy grids).
|
| 1097 |
+
v = im.get("verification") or {}
|
| 1098 |
+
if isinstance(v, dict) and v.get("majority_action") == "discard":
|
| 1099 |
+
continue
|
| 1100 |
+
imgs.append(im)
|
| 1101 |
+
if imgs:
|
| 1102 |
+
kept.append({**g, "image_data": imgs})
|
| 1103 |
+
return kept
|
| 1104 |
+
|
| 1105 |
+
|
| 1106 |
+
CACHE_DIR = os.getenv("MAPPER_CACHE_DIR", ".mapper_cache")
|
| 1107 |
+
|
| 1108 |
+
|
| 1109 |
+
def _pdf_hash(pdf_bytes: bytes) -> str:
|
| 1110 |
+
import hashlib
|
| 1111 |
+
return hashlib.sha256(pdf_bytes).hexdigest()[:16]
|
| 1112 |
+
|
| 1113 |
+
|
| 1114 |
+
def _extract(pdf_bytes: bytes, doi_override: str = "",
|
| 1115 |
+
verify_engines=None) -> Dict[str, Any]:
|
| 1116 |
+
"""Run Code 1 (2.py) + Code 2 (image2.py) ONCE. Returns a bundle with all
|
| 1117 |
+
six property tables + plot_results + coverage + meta + errors."""
|
| 1118 |
+
import importlib
|
| 1119 |
+
run_pipeline = importlib.import_module("2").run_pipeline
|
| 1120 |
+
from image2 import extract_and_verify_plots
|
| 1121 |
+
(dfc, dfv, dfcv, dfg, dfp, dfcl, _chunks, errors, meta) = run_pipeline(
|
| 1122 |
+
pdf_bytes, doi_override=doi_override)
|
| 1123 |
+
plot_results, coverage = extract_and_verify_plots(
|
| 1124 |
+
pdf_bytes, verify_engines=verify_engines or ["gemini"])
|
| 1125 |
+
return {
|
| 1126 |
+
"table_map": {"consensus": dfc, "verified": dfv, "consensus_verified": dfcv,
|
| 1127 |
+
"gemini": dfg, "gpt": dfp, "claude": dfcl},
|
| 1128 |
+
"plot_results": plot_results,
|
| 1129 |
+
"coverage": coverage,
|
| 1130 |
+
"meta": meta,
|
| 1131 |
+
"api_errors": errors,
|
| 1132 |
+
}
|
| 1133 |
+
|
| 1134 |
+
|
| 1135 |
+
def get_or_extract(pdf_bytes: bytes, doi_override: str = "", verify_engines=None,
|
| 1136 |
+
use_disk_cache: bool = True, force: bool = False):
|
| 1137 |
+
"""Extraction cached by the PDF's CONTENT HASH β never by source_table.
|
| 1138 |
+
On a cache hit, 2.py and image2.py are NOT re-run, so switching tables /
|
| 1139 |
+
pruning / re-mapping is free. Returns (pdf_hash, bundle)."""
|
| 1140 |
+
import pickle
|
| 1141 |
+
h = _pdf_hash(pdf_bytes)
|
| 1142 |
+
path = os.path.join(CACHE_DIR, f"{h}.pkl")
|
| 1143 |
+
if use_disk_cache and not force and os.path.exists(path):
|
| 1144 |
+
try:
|
| 1145 |
+
with open(path, "rb") as f:
|
| 1146 |
+
bundle = pickle.load(f)
|
| 1147 |
+
bundle["_cache"] = "disk"
|
| 1148 |
+
return h, bundle
|
| 1149 |
+
except Exception as e:
|
| 1150 |
+
log.warning(f"mapper: cache read failed ({e}); re-extracting.")
|
| 1151 |
+
bundle = _extract(pdf_bytes, doi_override=doi_override, verify_engines=verify_engines)
|
| 1152 |
+
bundle["_cache"] = "fresh"
|
| 1153 |
+
if use_disk_cache:
|
| 1154 |
+
try:
|
| 1155 |
+
os.makedirs(CACHE_DIR, exist_ok=True)
|
| 1156 |
+
with open(path, "wb") as f:
|
| 1157 |
+
pickle.dump(bundle, f)
|
| 1158 |
+
except Exception as e:
|
| 1159 |
+
log.warning(f"mapper: cache write failed ({e}).")
|
| 1160 |
+
return h, bundle
|
| 1161 |
+
|
| 1162 |
+
|
| 1163 |
+
def _apply_links(source_df, links_df):
|
| 1164 |
+
"""Rebuild mapped_* columns on the property dataframe from the CURRENT
|
| 1165 |
+
(possibly hand-pruned) links table. Best link per property = lowest
|
| 1166 |
+
match_rank; a property with no remaining link is left unmapped."""
|
| 1167 |
+
aug = source_df.reset_index(drop=True).copy()
|
| 1168 |
+
n = len(aug)
|
| 1169 |
+
best = {}
|
| 1170 |
+
if links_df is not None and not links_df.empty:
|
| 1171 |
+
ld = links_df.sort_values(["prop_row", "match_rank"], ascending=[True, True])
|
| 1172 |
+
for _, l in ld.iterrows():
|
| 1173 |
+
best.setdefault(int(l["prop_row"]), l)
|
| 1174 |
+
|
| 1175 |
+
def pick(field):
|
| 1176 |
+
return [best[i][field] if i in best else "" for i in range(n)]
|
| 1177 |
+
|
| 1178 |
+
aug["mapped_figure_id"] = pick("figure_id")
|
| 1179 |
+
aug["mapped_figure_caption"] = pick("figure_caption")
|
| 1180 |
+
aug["mapped_figure_page"] = pick("figure_page")
|
| 1181 |
+
aug["mapped_figure_number"] = pick("figure_number")
|
| 1182 |
+
aug["mapped_subplot"] = pick("matched_subplot")
|
| 1183 |
+
aug["map_score"] = pick("match_score")
|
| 1184 |
+
aug["map_signals"] = pick("match_signals")
|
| 1185 |
+
return aug
|
| 1186 |
+
|
| 1187 |
+
|
| 1188 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1189 |
+
# STREAMLIT UI (extract-once β prune β map β prune links β store)
|
| 1190 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1191 |
+
|
| 1192 |
+
def _run_streamlit() -> None:
|
| 1193 |
+
import streamlit as st
|
| 1194 |
+
|
| 1195 |
+
st.set_page_config(page_title="mapper β Plot β Property", page_icon="π", layout="wide")
|
| 1196 |
+
st.title("π mapper β Plot β Property mapping + database")
|
| 1197 |
+
st.caption("Runs 2.py + image2.py once (cached), auto-drops non-plots, lets you "
|
| 1198 |
+
"prune what's left, then attaches plots to properties on Map.")
|
| 1199 |
+
|
| 1200 |
+
ss = st.session_state
|
| 1201 |
+
|
| 1202 |
+
# ---- mutation helpers ---------------------------------------------------
|
| 1203 |
+
def _rebuild_aug():
|
| 1204 |
+
src = ss["bundle"]["table_map"][ss["source_table"]]
|
| 1205 |
+
ss["df_aug"] = _apply_links(src, ss["links_df"])
|
| 1206 |
+
|
| 1207 |
+
def _drop_group_links(gi):
|
| 1208 |
+
ld = ss.get("links_df")
|
| 1209 |
+
if ld is not None and not ld.empty:
|
| 1210 |
+
ss["links_df"] = ld[ld["group_idx"] != gi].reset_index(drop=True)
|
| 1211 |
+
|
| 1212 |
+
def _remove_image(gi, idx):
|
| 1213 |
+
try:
|
| 1214 |
+
del ss["plot_curated"][gi]["image_data"][idx]
|
| 1215 |
+
except Exception:
|
| 1216 |
+
pass
|
| 1217 |
+
_drop_group_links(gi)
|
| 1218 |
+
if ss.get("mapped"):
|
| 1219 |
+
_rebuild_aug()
|
| 1220 |
+
ss["map_stale"] = True
|
| 1221 |
+
st.rerun()
|
| 1222 |
+
|
| 1223 |
+
def _remove_figure(gi):
|
| 1224 |
+
ss["plot_curated"][gi]["image_data"] = []
|
| 1225 |
+
_drop_group_links(gi)
|
| 1226 |
+
if ss.get("mapped"):
|
| 1227 |
+
_rebuild_aug()
|
| 1228 |
+
ss["map_stale"] = True
|
| 1229 |
+
st.rerun()
|
| 1230 |
+
|
| 1231 |
+
def _remove_link(link_id):
|
| 1232 |
+
ld = ss["links_df"]
|
| 1233 |
+
ss["links_df"] = ld[ld["link_id"] != link_id].reset_index(drop=True)
|
| 1234 |
+
_rebuild_aug()
|
| 1235 |
+
st.rerun()
|
| 1236 |
+
|
| 1237 |
+
def _apply_filter():
|
| 1238 |
+
ss["plot_curated"] = select_real_plots(ss["bundle"]["plot_results"], **ss["gate"])
|
| 1239 |
+
ss["mapped"] = False
|
| 1240 |
+
ss["store"] = None
|
| 1241 |
+
ss["map_stale"] = False
|
| 1242 |
+
ss.pop("links_df", None)
|
| 1243 |
+
ss.pop("df_aug", None)
|
| 1244 |
+
|
| 1245 |
+
# ---- sidebar ------------------------------------------------------------
|
| 1246 |
+
with st.sidebar:
|
| 1247 |
+
st.header("βοΈ Settings")
|
| 1248 |
+
st.subheader("Extraction")
|
| 1249 |
+
use_gpt = st.checkbox("Also verify crops with GPT", value=False)
|
| 1250 |
+
use_claude = st.checkbox("Also verify crops with Claude", value=False)
|
| 1251 |
+
doi_manual = st.text_input("DOI override (optional)", placeholder="10.xxxx/...")
|
| 1252 |
+
use_disk_cache = st.checkbox("Use on-disk extraction cache", value=True,
|
| 1253 |
+
help="Skip 2.py + image2.py entirely when this exact "
|
| 1254 |
+
"PDF was extracted before.")
|
| 1255 |
+
st.divider()
|
| 1256 |
+
st.subheader("Auto figure filter")
|
| 1257 |
+
gate_caption = st.checkbox("Drop photo/micrograph/logo captions", value=True)
|
| 1258 |
+
gate_verifier = st.checkbox("Drop crops the verifier rejected", value=True)
|
| 1259 |
+
gate_unlabeled = st.checkbox("Drop unlabeled detections (no 'Fig. N')", value=False)
|
| 1260 |
+
st.divider()
|
| 1261 |
+
st.subheader("Mapping")
|
| 1262 |
+
source_table = st.selectbox(
|
| 1263 |
+
"Property table",
|
| 1264 |
+
["consensus", "verified", "consensus_verified", "gemini", "gpt", "claude"],
|
| 1265 |
+
)
|
| 1266 |
+
top_k = st.slider("Candidate figures per property", 1, 3, TOP_K)
|
| 1267 |
+
min_score = st.slider("Min fallback map score", 0.0, 1.0, MAP_MIN_SCORE, 0.05,
|
| 1268 |
+
help="Non-citation links below this are dropped. "
|
| 1269 |
+
"Explicit 'Fig. N' citations always kept.")
|
| 1270 |
+
st.divider()
|
| 1271 |
+
st.subheader("Persistence")
|
| 1272 |
+
out_dir = st.text_input("Output directory", value="./aim_efrc_db")
|
| 1273 |
+
save_all = st.checkbox("Save ALL detected crops", value=False)
|
| 1274 |
+
|
| 1275 |
+
ss["gate"] = {"use_caption": gate_caption, "use_verifier": gate_verifier,
|
| 1276 |
+
"drop_unlabeled": gate_unlabeled}
|
| 1277 |
+
|
| 1278 |
+
# ---- upload + hash-guarded extraction -----------------------------------
|
| 1279 |
+
uploaded = st.file_uploader("Upload PDF", type=["pdf"])
|
| 1280 |
+
if not uploaded:
|
| 1281 |
+
st.info("Upload a PDF to begin.")
|
| 1282 |
+
return
|
| 1283 |
+
|
| 1284 |
+
pdf_bytes = uploaded.getvalue()
|
| 1285 |
+
h = _pdf_hash(pdf_bytes)
|
| 1286 |
+
need_extract = ss.get("pdf_hash") != h
|
| 1287 |
+
|
| 1288 |
+
ec1, ec2 = st.columns([3, 1])
|
| 1289 |
+
do_extract = force = False
|
| 1290 |
+
if need_extract:
|
| 1291 |
+
do_extract = ec1.button(" Extract ", type="primary",
|
| 1292 |
+
use_container_width=True)
|
| 1293 |
+
else:
|
| 1294 |
+
ec1.success("β This paper is loaded β switch tables / prune / map freely; "
|
| 1295 |
+
"2.py and image2.py will NOT re-run.")
|
| 1296 |
+
force = ec2.button("β» Force re-extract", use_container_width=True)
|
| 1297 |
+
|
| 1298 |
+
if do_extract or force:
|
| 1299 |
+
engines = ["gemini"] + (["gpt"] if use_gpt else []) + (["claude"] if use_claude else [])
|
| 1300 |
+
with st.spinner("Running 2.py extraction/consensus + image2.py plot detectionβ¦"):
|
| 1301 |
+
_h, bundle = get_or_extract(pdf_bytes, doi_override=doi_manual,
|
| 1302 |
+
verify_engines=engines,
|
| 1303 |
+
use_disk_cache=use_disk_cache, force=force)
|
| 1304 |
+
ss["pdf_hash"] = h
|
| 1305 |
+
ss["bundle"] = bundle
|
| 1306 |
+
ss["stem"] = _safe(uploaded.name.rsplit(".", 1)[0])
|
| 1307 |
+
ss["extracted"] = True
|
| 1308 |
+
_apply_filter()
|
| 1309 |
+
|
| 1310 |
+
if not ss.get("extracted") or ss.get("pdf_hash") != h:
|
| 1311 |
+
st.info("Click **β Extract** β instant if this PDF was processed before.")
|
| 1312 |
+
return
|
| 1313 |
+
|
| 1314 |
+
bundle = ss["bundle"]
|
| 1315 |
+
stem = ss["stem"]
|
| 1316 |
+
if bundle.get("_cache") == "disk":
|
| 1317 |
+
st.caption("β© Loaded from on-disk cache β 2.py and image2.py were not re-run.")
|
| 1318 |
+
|
| 1319 |
+
# ---- three top-level views ---------------------------------------------
|
| 1320 |
+
view_props, view_plots, work = st.tabs(
|
| 1321 |
+
["π Extraction (2.py)", "πΌοΈ Plots (image2.py)", "π Map & curate"])
|
| 1322 |
+
|
| 1323 |
+
with view_props:
|
| 1324 |
+
if bundle.get("api_errors"):
|
| 1325 |
+
with st.expander(f"β οΈ {len(bundle['api_errors'])} API error(s)"):
|
| 1326 |
+
for e in bundle["api_errors"]:
|
| 1327 |
+
st.code(e)
|
| 1328 |
+
meta = bundle.get("meta", {})
|
| 1329 |
+
st.caption(f"DOI: {meta.get('doi', 'β')} Β· tables={meta.get('chunks_tables', '?')} Β· "
|
| 1330 |
+
f"text={meta.get('chunks_text', '?')} Β· batches={meta.get('batches', '?')}")
|
| 1331 |
+
for label, df in bundle["table_map"].items():
|
| 1332 |
+
with st.expander(f"{label} ({len(df)})", expanded=(label == "consensus")):
|
| 1333 |
+
st.dataframe(df.drop(columns=["doi_url"], errors="ignore"),
|
| 1334 |
+
use_container_width=True, hide_index=True)
|
| 1335 |
+
|
| 1336 |
+
with view_plots:
|
| 1337 |
+
cov = bundle.get("coverage", {})
|
| 1338 |
+
raw_n = sum(1 for g in bundle["plot_results"] if g.get("image_data"))
|
| 1339 |
+
kept_groups = [g for g in ss["plot_curated"] if g.get("image_data")]
|
| 1340 |
+
st.caption(f"image2.py output β keep-only. Detected {cov.get('total_detected', '?')}, "
|
| 1341 |
+
f"recovered {cov.get('total_recovered', '?')}; showing {len(kept_groups)} plot "
|
| 1342 |
+
f"group(s) of {raw_n} after dropping non-plots/discards.")
|
| 1343 |
+
if not kept_groups:
|
| 1344 |
+
st.info("No real plots left after filtering. Loosen the auto-filter toggles in the "
|
| 1345 |
+
"sidebar if this removed too much.")
|
| 1346 |
+
for g in kept_groups:
|
| 1347 |
+
with st.container(border=True):
|
| 1348 |
+
st.markdown(f"**Page {g.get('page')}** β {g.get('caption', '')}")
|
| 1349 |
+
icols = st.columns(min(len(g["image_data"]), 4) or 1)
|
| 1350 |
+
for i, img in enumerate(g["image_data"]):
|
| 1351 |
+
with icols[i % len(icols)]:
|
| 1352 |
+
try:
|
| 1353 |
+
st.image(img["array"], channels="BGR", width=190)
|
| 1354 |
+
except Exception:
|
| 1355 |
+
st.caption("(image unavailable)")
|
| 1356 |
+
v = img.get("verification") or {}
|
| 1357 |
+
act = v.get("majority_action") if isinstance(v, dict) else ""
|
| 1358 |
+
cap = f"{img.get('subplot_label') or ''} Β· {act}".strip(" Β·")
|
| 1359 |
+
if cap:
|
| 1360 |
+
st.caption(cap)
|
| 1361 |
+
|
| 1362 |
+
# ---- workflow: prune β map β prune links β store ------------------------
|
| 1363 |
+
with work:
|
| 1364 |
+
plot_curated = ss["plot_curated"]
|
| 1365 |
+
live_groups = [(gi, g) for gi, g in enumerate(plot_curated) if g.get("image_data")]
|
| 1366 |
+
raw_count = sum(1 for g in bundle["plot_results"] if g.get("image_data"))
|
| 1367 |
+
|
| 1368 |
+
fc1, fc2 = st.columns([3, 1])
|
| 1369 |
+
fc1.caption(f"Auto-filter kept **{len(live_groups)}** plot group(s) of {raw_count} "
|
| 1370 |
+
f"detected (caption={ss['gate']['use_caption']}, "
|
| 1371 |
+
f"verifier={ss['gate']['use_verifier']}, "
|
| 1372 |
+
f"unlabeled_dropped={ss['gate']['drop_unlabeled']}).")
|
| 1373 |
+
if fc2.button("β» Re-apply auto-filter", use_container_width=True):
|
| 1374 |
+
_apply_filter()
|
| 1375 |
+
st.rerun()
|
| 1376 |
+
|
| 1377 |
+
st.subheader("β‘ Review figures β remove anything that isn't a data plot")
|
| 1378 |
+
st.caption("x removes one image Β· π removes the whole figure. Non-plots the "
|
| 1379 |
+
"auto-filter missed (or wrongly kept) can be pruned here by hand.")
|
| 1380 |
+
with st.expander("Show figures to review", expanded=not ss.get("mapped")):
|
| 1381 |
+
if not live_groups:
|
| 1382 |
+
st.info("No plot figures left.")
|
| 1383 |
+
for gi, group in live_groups:
|
| 1384 |
+
with st.container(border=True):
|
| 1385 |
+
hc1, hc2 = st.columns([6, 1])
|
| 1386 |
+
hc1.markdown(f"**Page {group.get('page')}** β {group.get('caption', '')}")
|
| 1387 |
+
if hc2.button("π Remove figure", key=f"rmfig_{gi}"):
|
| 1388 |
+
_remove_figure(gi)
|
| 1389 |
+
images = group.get("image_data", [])
|
| 1390 |
+
icols = st.columns(min(len(images), 4) or 1)
|
| 1391 |
+
for idx, img in enumerate(images):
|
| 1392 |
+
with icols[idx % len(icols)]:
|
| 1393 |
+
try:
|
| 1394 |
+
st.image(img["array"], channels="BGR", width=180)
|
| 1395 |
+
except Exception:
|
| 1396 |
+
st.caption("(image unavailable)")
|
| 1397 |
+
if img.get("subplot_label"):
|
| 1398 |
+
st.caption(img["subplot_label"])
|
| 1399 |
+
if st.button("x remove", key=f"rmimg_{gi}_{idx}"):
|
| 1400 |
+
_remove_image(gi, idx)
|
| 1401 |
+
|
| 1402 |
+
map_label = "β’ Map β attach plots to properties" + (" (re-map)" if ss.get("mapped") else "")
|
| 1403 |
+
if st.button(map_label, type="primary", use_container_width=True):
|
| 1404 |
+
source_df = bundle["table_map"][source_table]
|
| 1405 |
+
with st.spinner("Mapping property rows to figuresβ¦"):
|
| 1406 |
+
links_df, df_aug = map_plots_to_properties(
|
| 1407 |
+
source_df, ss["plot_curated"], top_k=top_k, min_score=min_score)
|
| 1408 |
+
if not links_df.empty:
|
| 1409 |
+
links_df = links_df.reset_index(drop=True)
|
| 1410 |
+
links_df["link_id"] = range(len(links_df))
|
| 1411 |
+
ss["links_df"] = links_df
|
| 1412 |
+
ss["df_aug"] = df_aug
|
| 1413 |
+
ss["source_table"] = source_table
|
| 1414 |
+
ss["mapped"] = True
|
| 1415 |
+
ss["map_stale"] = False
|
| 1416 |
+
ss["store"] = None
|
| 1417 |
+
|
| 1418 |
+
if not ss.get("mapped"):
|
| 1419 |
+
st.info("Prune figures above, then click **β’ Map**.")
|
| 1420 |
+
return
|
| 1421 |
+
|
| 1422 |
+
if ss.get("map_stale"):
|
| 1423 |
+
st.warning("Figures changed since mapping β affected links were removed. "
|
| 1424 |
+
"Click **β’ Map (re-map)** to rescore against the current figures.")
|
| 1425 |
+
|
| 1426 |
+
links_df = ss["links_df"]
|
| 1427 |
+
df_aug = ss["df_aug"]
|
| 1428 |
+
store = ss.get("store")
|
| 1429 |
+
|
| 1430 |
+
n_rows = len(df_aug)
|
| 1431 |
+
n_mapped = int((df_aug["mapped_figure_id"] != "").sum()) if "mapped_figure_id" in df_aug.columns else 0
|
| 1432 |
+
n_cited = int(links_df["match_signals"].str.contains("figure_citation").sum()) if not links_df.empty else 0
|
| 1433 |
+
|
| 1434 |
+
mc = st.columns(6 if store else 5)
|
| 1435 |
+
mc[0].metric("Property rows", n_rows)
|
| 1436 |
+
mc[1].metric("Rows mapped", n_mapped)
|
| 1437 |
+
mc[2].metric("Via citation", n_cited)
|
| 1438 |
+
mc[3].metric("Live figures", len(live_groups))
|
| 1439 |
+
mc[4].metric("Links", len(links_df))
|
| 1440 |
+
if store:
|
| 1441 |
+
mc[5].metric("Images saved", store["n_images_saved"])
|
| 1442 |
+
st.success(f"Stored to `{store['db_path']}` Β· crops under `{store['image_dir']}`")
|
| 1443 |
+
|
| 1444 |
+
def _images_for_prop(prow: int):
|
| 1445 |
+
"""Crop array(s) matched to property row `prow`, pulled from the
|
| 1446 |
+
live (possibly hand-pruned) links + curated plots in session.
|
| 1447 |
+
Returns a list of (bgr_array, caption)."""
|
| 1448 |
+
ld = ss.get("links_df")
|
| 1449 |
+
pc = ss.get("plot_curated", []) or []
|
| 1450 |
+
out = []
|
| 1451 |
+
if ld is None or ld.empty:
|
| 1452 |
+
return out
|
| 1453 |
+
rows = ld[(ld["prop_row"] == prow) & (ld["match_rank"] == 1)]
|
| 1454 |
+
for _, l in rows.iterrows():
|
| 1455 |
+
gi = int(l["group_idx"])
|
| 1456 |
+
if gi >= len(pc):
|
| 1457 |
+
continue
|
| 1458 |
+
imgs = pc[gi].get("image_data", []) or []
|
| 1459 |
+
mii = l.get("matched_image_index")
|
| 1460 |
+
if mii is not None and not (isinstance(mii, float) and pd.isna(mii)):
|
| 1461 |
+
idxs = [int(mii)]
|
| 1462 |
+
else:
|
| 1463 |
+
idxs = list(range(len(imgs))) # whole figure if no specific subplot
|
| 1464 |
+
for ix in idxs:
|
| 1465 |
+
if 0 <= ix < len(imgs):
|
| 1466 |
+
sub = imgs[ix].get("subplot_label") or ""
|
| 1467 |
+
cap = f"{l.get('figure_id', '')} {sub}".strip()
|
| 1468 |
+
out.append((imgs[ix].get("array"), cap))
|
| 1469 |
+
return out
|
| 1470 |
+
|
| 1471 |
+
names = ["πΌοΈ Figures & attached rows (remove links)",
|
| 1472 |
+
f"π All links ({len(links_df)})",
|
| 1473 |
+
f"𧬠Augmented ({n_rows})",
|
| 1474 |
+
"π€ Store / Export"]
|
| 1475 |
+
if store:
|
| 1476 |
+
names.insert(3, f"ποΈ Stored ({store['n_properties']})")
|
| 1477 |
+
names.append("π Properties + image")
|
| 1478 |
+
wt = st.tabs(names)
|
| 1479 |
+
prop_img_tab = wt[-1]
|
| 1480 |
+
|
| 1481 |
+
with prop_img_tab:
|
| 1482 |
+
st.caption("Search a property row and see its matched plot rendered inline β "
|
| 1483 |
+
"not just the path. Reflects your current link edits.")
|
| 1484 |
+
pc1, pc2 = st.columns([3, 1])
|
| 1485 |
+
q = pc1.text_input("Filter by property or material name",
|
| 1486 |
+
key="propimg_q",
|
| 1487 |
+
placeholder="e.g. half-life, PBSA").strip().lower()
|
| 1488 |
+
only_mapped = pc2.checkbox("Only matched rows", value=False, key="propimg_only")
|
| 1489 |
+
shown = 0
|
| 1490 |
+
for prow, row in df_aug.iterrows():
|
| 1491 |
+
pname = str(row.get("property_name", ""))
|
| 1492 |
+
mat = str(row.get("material_name", ""))
|
| 1493 |
+
if q and q not in pname.lower() and q not in mat.lower():
|
| 1494 |
+
continue
|
| 1495 |
+
imgs = _images_for_prop(int(prow))
|
| 1496 |
+
if only_mapped and not imgs:
|
| 1497 |
+
continue
|
| 1498 |
+
shown += 1
|
| 1499 |
+
with st.container(border=True):
|
| 1500 |
+
left, right = st.columns([2, 1])
|
| 1501 |
+
with left:
|
| 1502 |
+
st.markdown(f"**{pname}** β {row.get('value', '')} {row.get('unit', '')}")
|
| 1503 |
+
st.caption(f"{mat} Β· {row.get('section', '')} Β· {row.get('source_page', '')}")
|
| 1504 |
+
fid = str(row.get("mapped_figure_id", ""))
|
| 1505 |
+
if fid:
|
| 1506 |
+
st.caption(f"figure {fid} {row.get('mapped_subplot', '') or ''} Β· "
|
| 1507 |
+
f"score {row.get('map_score', '')} Β· "
|
| 1508 |
+
f"{row.get('map_signals', '')}")
|
| 1509 |
+
with right:
|
| 1510 |
+
if imgs:
|
| 1511 |
+
for arr, cap in imgs:
|
| 1512 |
+
try:
|
| 1513 |
+
st.image(arr, channels="BGR", width=230)
|
| 1514 |
+
except Exception:
|
| 1515 |
+
st.caption("(image unavailable)")
|
| 1516 |
+
if cap:
|
| 1517 |
+
st.caption(cap)
|
| 1518 |
+
else:
|
| 1519 |
+
st.caption("β no plot matched β")
|
| 1520 |
+
if shown == 0:
|
| 1521 |
+
st.info("No property rows match your filter.")
|
| 1522 |
+
|
| 1523 |
+
with wt[0]:
|
| 1524 |
+
st.caption("Each figure with its attached property rows. x removes a wrong "
|
| 1525 |
+
"propertyβfigure link.")
|
| 1526 |
+
if links_df.empty:
|
| 1527 |
+
st.info("No links remain.")
|
| 1528 |
+
else:
|
| 1529 |
+
for gi, group in live_groups:
|
| 1530 |
+
grp = links_df[links_df["group_idx"] == gi]
|
| 1531 |
+
if grp.empty:
|
| 1532 |
+
continue
|
| 1533 |
+
with st.container(border=True):
|
| 1534 |
+
st.markdown(f"**Page {group.get('page')}** β {group.get('caption', '')}")
|
| 1535 |
+
images = group.get("image_data", [])
|
| 1536 |
+
if images:
|
| 1537 |
+
icols = st.columns(min(len(images), 4) or 1)
|
| 1538 |
+
for pos, img in enumerate(images):
|
| 1539 |
+
with icols[pos % len(icols)]:
|
| 1540 |
+
try:
|
| 1541 |
+
st.image(img["array"], channels="BGR", width=170)
|
| 1542 |
+
except Exception:
|
| 1543 |
+
pass
|
| 1544 |
+
hdr = st.columns([3, 1.3, 1, 2, 1.2, 1, 0.7])
|
| 1545 |
+
for hh, tt in zip(hdr, ["property", "value", "unit", "material",
|
| 1546 |
+
"subplot", "score", ""]):
|
| 1547 |
+
hh.caption(tt)
|
| 1548 |
+
for _, l in grp.iterrows():
|
| 1549 |
+
c = st.columns([3, 1.3, 1, 2, 1.2, 1, 0.7])
|
| 1550 |
+
c[0].write(str(l.get("property_name", "")))
|
| 1551 |
+
c[1].write(str(l.get("value", "")))
|
| 1552 |
+
c[2].write(str(l.get("unit", "")))
|
| 1553 |
+
c[3].write(str(l.get("material_name", "")))
|
| 1554 |
+
c[4].write(str(l.get("matched_subplot") or ""))
|
| 1555 |
+
c[5].write(str(l.get("match_score", "")))
|
| 1556 |
+
if c[6].button("x", key=f"rmlink_{int(l['link_id'])}"):
|
| 1557 |
+
_remove_link(int(l["link_id"]))
|
| 1558 |
+
|
| 1559 |
+
with wt[1]:
|
| 1560 |
+
st.caption("Every remaining (property, figure) link with its score and signals.")
|
| 1561 |
+
if links_df.empty:
|
| 1562 |
+
st.info("No links remain.")
|
| 1563 |
+
else:
|
| 1564 |
+
st.dataframe(links_df.drop(columns=["link_id"], errors="ignore"),
|
| 1565 |
+
use_container_width=True, hide_index=True)
|
| 1566 |
+
|
| 1567 |
+
with wt[2]:
|
| 1568 |
+
st.caption(f"The `{ss['source_table']}` table plus mapped_figure_* / map_score / "
|
| 1569 |
+
f"map_signals, reflecting your link edits.")
|
| 1570 |
+
st.dataframe(df_aug.drop(columns=["doi_url"], errors="ignore"),
|
| 1571 |
+
use_container_width=True, hide_index=True)
|
| 1572 |
+
|
| 1573 |
+
export_tab = wt[4] if store else wt[3]
|
| 1574 |
+
if store:
|
| 1575 |
+
with wt[3]:
|
| 1576 |
+
st.caption("Rows as written to the DB, each with its attached plot_image_path.")
|
| 1577 |
+
dfs = store["df_store"]
|
| 1578 |
+
show = [c for c in ("property_name", "value", "unit", "material_name",
|
| 1579 |
+
"section", "mapped_figure_id", "mapped_subplot",
|
| 1580 |
+
"map_score", "plot_image_path") if c in dfs.columns]
|
| 1581 |
+
st.dataframe(dfs[show] if show else dfs, use_container_width=True, hide_index=True)
|
| 1582 |
+
|
| 1583 |
+
with export_tab:
|
| 1584 |
+
st.markdown("**Store the curated result to the database**")
|
| 1585 |
+
st.caption("Writes the pruned figures + edited links; each stored row points at "
|
| 1586 |
+
"its saved crop on disk. Re-running for the same PDF overwrites cleanly.")
|
| 1587 |
+
if st.button("πΎ Store to database", type="primary", use_container_width=True):
|
| 1588 |
+
with st.spinner("Saving crops and writing SQLiteβ¦"):
|
| 1589 |
+
ss["store"] = store_properties_with_plots(
|
| 1590 |
+
ss["df_aug"], ss["links_df"], ss["plot_curated"],
|
| 1591 |
+
out_dir=out_dir, pdf_stem=stem, source_table=ss["source_table"],
|
| 1592 |
+
save_all_group_images=save_all)
|
| 1593 |
+
st.rerun()
|
| 1594 |
+
|
| 1595 |
+
st.divider()
|
| 1596 |
+
st.markdown("**βοΈ Push to RDS database**")
|
| 1597 |
+
if not db_configured():
|
| 1598 |
+
st.caption("Set DB_NAME / DB_USER / DB_PASSWORD in your `.env` "
|
| 1599 |
+
"(host is already configured) to enable the RDS push.")
|
| 1600 |
+
else:
|
| 1601 |
+
import category_push
|
| 1602 |
+
category = st.selectbox(
|
| 1603 |
+
"Category table (routes by material type)",
|
| 1604 |
+
category_push.CATEGORY_TABLES,
|
| 1605 |
+
help="Rows conform to that table's 33-col schema; columns it "
|
| 1606 |
+
"can't hold go to <table>_extras; matched crops are copied "
|
| 1607 |
+
"to rds_plots/<category>/<stem>/.")
|
| 1608 |
+
embed_img = st.checkbox("Embed matched plot into the table's image column",
|
| 1609 |
+
value=True)
|
| 1610 |
+
cA, cB = st.columns(2)
|
| 1611 |
+
if cA.button("π Test connection", use_container_width=True):
|
| 1612 |
+
try:
|
| 1613 |
+
db_healthcheck()
|
| 1614 |
+
st.success(f"Connected to {DB_HOST} ({DB_KIND}).")
|
| 1615 |
+
except Exception as e:
|
| 1616 |
+
st.error(f"Connection failed: {e}")
|
| 1617 |
+
push_ready = bool(store) and store.get("n_properties", 0) > 0
|
| 1618 |
+
if cB.button(f"β¬οΈ Push to {category}", type="primary",
|
| 1619 |
+
use_container_width=True, disabled=not push_ready):
|
| 1620 |
+
try:
|
| 1621 |
+
with st.spinner(f"Conforming + writing rows to '{category}'β¦"):
|
| 1622 |
+
res = category_push.push_by_category(
|
| 1623 |
+
store, category, get_db_engine(), embed_image=embed_img)
|
| 1624 |
+
st.success(
|
| 1625 |
+
f"Pushed {res['pushed']} row(s) to '{res['table']}', "
|
| 1626 |
+
f"{res['extras']} to '{res['extras_table']}', "
|
| 1627 |
+
f"{res['plots_copied']} crop(s) β {res['plots_dir']}.")
|
| 1628 |
+
except Exception as e:
|
| 1629 |
+
st.error(f"Push failed: {e}")
|
| 1630 |
+
if not push_ready:
|
| 1631 |
+
st.caption("Click **πΎ Store to database** first β the RDS push "
|
| 1632 |
+
"uploads those stored rows.")
|
| 1633 |
+
|
| 1634 |
+
st.divider()
|
| 1635 |
+
st.markdown("**Downloads**")
|
| 1636 |
+
d1, d2, d3 = st.columns(3)
|
| 1637 |
+
d1.download_button(
|
| 1638 |
+
"β¬οΈ Links CSV",
|
| 1639 |
+
links_df.drop(columns=["link_id"], errors="ignore").to_csv(index=False).encode()
|
| 1640 |
+
if not links_df.empty else b"",
|
| 1641 |
+
f"{stem}_links.csv", "text/csv", use_container_width=True, disabled=links_df.empty)
|
| 1642 |
+
d2.download_button(
|
| 1643 |
+
"β¬οΈ Augmented CSV",
|
| 1644 |
+
df_aug.drop(columns=["doi_url"], errors="ignore").to_csv(index=False).encode(),
|
| 1645 |
+
f"{stem}_properties_mapped.csv", "text/csv", use_container_width=True)
|
| 1646 |
+
if store:
|
| 1647 |
+
d3.download_button(
|
| 1648 |
+
"β¬οΈ DB + plots (ZIP)",
|
| 1649 |
+
data=bundle_zip(store["out_dir"], store["pdf_stem"], store["db_filename"]),
|
| 1650 |
+
file_name=f"{stem}_db_bundle.zip", mime="application/zip",
|
| 1651 |
+
use_container_width=True)
|
| 1652 |
+
else:
|
| 1653 |
+
d3.caption("Store first to enable the DB bundle download.")
|
| 1654 |
+
|
| 1655 |
+
|
| 1656 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1657 |
+
# ENTRY POINT
|
| 1658 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1659 |
+
|
| 1660 |
+
if __name__ == "__main__":
|
| 1661 |
+
_in_streamlit = False
|
| 1662 |
+
try:
|
| 1663 |
+
import streamlit.runtime.scriptrunner as _sr
|
| 1664 |
+
if _sr.get_script_run_ctx() is not None:
|
| 1665 |
+
_in_streamlit = True
|
| 1666 |
+
except Exception:
|
| 1667 |
+
pass
|
| 1668 |
+
|
| 1669 |
+
if _in_streamlit:
|
| 1670 |
+
_run_streamlit()
|
| 1671 |
+
else:
|
| 1672 |
+
print(
|
| 1673 |
+
"\nmapper.py β Plot β Property mapping + persistence\n"
|
| 1674 |
+
" streamlit run mapper.py\n\n"
|
| 1675 |
+
"Requires on the import path (same directory):\n"
|
| 1676 |
+
" 2.py (Code 1 β extraction + consensus)\n"
|
| 1677 |
+
" image2.py (Code 2 β plot extraction)\n\n"
|
| 1678 |
+
"Library use (map + store outputs you already have in memory):\n"
|
| 1679 |
+
" from mapper import map_plots_to_properties, store_properties_with_plots\n"
|
| 1680 |
+
" links_df, df_aug = map_plots_to_properties(df_consensus, plot_results)\n"
|
| 1681 |
+
" store_properties_with_plots(df_aug, links_df, plot_results,\n"
|
| 1682 |
+
" out_dir='./aim_efrc_db', pdf_stem='paper1')\n"
|
| 1683 |
+
)
|