gvlktejaswi commited on
Commit
a895626
Β·
verified Β·
1 Parent(s): 40cbdc5

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
+ )