AbhijitClemson commited on
Commit
2090e4e
·
verified ·
1 Parent(s): ed6014d

Upload 2 files

Browse files
page_files/categorized/Backend/plot_mapping_ui.py ADDED
@@ -0,0 +1,397 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ plot_mapping_ui.py
3
+ ------------------
4
+ Drop-in replacement for the "Extracted Plots" tab (tab2) in Upload_Data.py.
5
+
6
+ HOW TO INTEGRATE
7
+ ────────────────
8
+ 1. Copy plot_property_mapper.py next to upload_backend.py.
9
+ 2. In Upload_Data.py, add at the top:
10
+
11
+ from plot_mapping_ui import render_plot_mapping_tab
12
+
13
+ 3. Replace the entire `with tab2:` block with:
14
+
15
+ with tab2:
16
+ render_plot_mapping_tab(pdf_path, paper_id)
17
+
18
+ That's it. The function reads everything it needs from st.session_state
19
+ (which your existing tab1 code already populates).
20
+ """
21
+
22
+ import json
23
+ import os
24
+
25
+ import cv2
26
+ import numpy as np
27
+ import streamlit as st
28
+
29
+ from plot_property_mapper import (
30
+ batch_map_plots,
31
+ fetch_properties_for_material,
32
+ save_plot_image_mapping,
33
+ )
34
+
35
+ # ── tiny helper ───────────────────────────────────────────────────────────────
36
+
37
+ def _confidence_badge(conf: str) -> str:
38
+ colors = {"high": "#16a34a", "medium": "#d97706", "low": "#dc2626"}
39
+ c = colors.get(conf.lower(), "#6b7280")
40
+ return (
41
+ f"<span style='background:{c};color:#fff;padding:2px 10px;"
42
+ f"border-radius:99px;font-size:0.78rem;font-weight:700'>{conf.upper()}</span>"
43
+ )
44
+
45
+
46
+ # ── main render function ───────────────────────────────────────────────────────
47
+
48
+ def render_plot_mapping_tab(pdf_path: str, paper_id: str):
49
+ """Render the full Extracted Plots + Property Mapping tab."""
50
+
51
+ st.subheader("Extracted Plot Images & Property Mapping")
52
+
53
+ # ── session-state keys ────────────────────────────────────────────────────
54
+ for key, default in [
55
+ ("pdf_processed", False),
56
+ ("image_results", []),
57
+ ("mapped_results", []),
58
+ ("mapping_done", False),
59
+ ("saved_image_mapping", {}),
60
+ ("pdf_extracted_df", __import__("pandas").DataFrame()),
61
+ ("pdf_extracted_meta", {}),
62
+ ]:
63
+ if key not in st.session_state:
64
+ st.session_state[key] = default
65
+
66
+ # ── 1. Extract plots if not done yet ─────────────────────────────────────
67
+ if not st.session_state.pdf_processed:
68
+ with st.spinner("Extracting plots from PDF…"):
69
+ import fitz
70
+ from upload_backend import extract_images
71
+
72
+ doc = fitz.open(pdf_path)
73
+ st.session_state.image_results = extract_images(doc)
74
+ doc.close()
75
+ st.session_state.pdf_processed = True
76
+ st.session_state.mapping_done = False # reset mapping on new PDF
77
+
78
+ image_results = st.session_state.image_results
79
+
80
+ if not image_results:
81
+ st.warning("No plots found in this PDF.")
82
+ return
83
+
84
+ # ── 2. Info bar ───────────────────────────────────────────────────────────
85
+ has_data = not st.session_state.pdf_extracted_df.empty
86
+ material_class = st.session_state.get("selected_material_class") # set below
87
+
88
+ if has_data:
89
+ df = st.session_state.pdf_extracted_df
90
+ mat_abbr = df.iloc[0]["material_abbreviation"]
91
+ st.info(
92
+ f"**{len(image_results)} plots** extracted | "
93
+ f"Material: **{mat_abbr}** | "
94
+ f"{len(df['property_name'].unique())} DB properties available"
95
+ )
96
+ else:
97
+ st.warning(
98
+ "Extract material data in the **Material Data** tab first "
99
+ "to enable AI property mapping."
100
+ )
101
+
102
+ st.divider()
103
+
104
+ # ── 3. Download buttons (always visible) ──────────────────────────────────
105
+ from upload_backend import create_zip
106
+
107
+ col_img, col_json, col_all = st.columns(3)
108
+ with col_img:
109
+ img_zip = create_zip(image_results, include_json=False)
110
+ st.download_button(
111
+ "⬇ Download Images",
112
+ data=img_zip,
113
+ file_name=f"{paper_id}_images.zip",
114
+ mime="application/zip",
115
+ use_container_width=True,
116
+ key="dl_images",
117
+ )
118
+ with col_json:
119
+ json_data = [
120
+ {"caption": r["caption"], "page": r["page"],
121
+ "image_count": len(r["image_data"])}
122
+ for r in image_results
123
+ ]
124
+ st.download_button(
125
+ "⬇ Download JSON",
126
+ data=json.dumps(json_data, indent=4),
127
+ file_name=f"{paper_id}_metadata.json",
128
+ mime="application/json",
129
+ use_container_width=True,
130
+ key="dl_json",
131
+ )
132
+ with col_all:
133
+ full_zip = create_zip(image_results, include_json=True)
134
+ st.download_button(
135
+ "⬇ Download All",
136
+ data=full_zip,
137
+ file_name=f"{paper_id}_complete.zip",
138
+ mime="application/zip",
139
+ use_container_width=True,
140
+ key="dl_all",
141
+ )
142
+
143
+ st.divider()
144
+
145
+ # ── 4. AI mapping panel (only when data is extracted) ────────────────────
146
+ if has_data:
147
+ from db import fetch_all # your existing db module
148
+
149
+ df = st.session_state.pdf_extracted_df
150
+ mat_abbr = df.iloc[0]["material_abbreviation"]
151
+ extracted_json = st.session_state.get("pdf_extracted_meta", {})
152
+
153
+ # Material class selector (needed to route to the right table)
154
+ material_class = st.selectbox(
155
+ "Material class (for DB lookup)",
156
+ ["Polymer", "Fiber", "Composite"],
157
+ index=0,
158
+ key="selected_material_class",
159
+ help="Determines which PostgreSQL table to fetch properties from.",
160
+ )
161
+
162
+ run_mapping = st.button(
163
+ "🤖 Run AI Property Mapping",
164
+ type="primary",
165
+ disabled=st.session_state.mapping_done,
166
+ help="Sends each plot + caption + extracted JSON to Gemini for matching.",
167
+ )
168
+
169
+ if run_mapping:
170
+ # Fetch DB properties
171
+ with st.spinner("Fetching properties from PostgreSQL…"):
172
+ try:
173
+ db_properties = fetch_properties_for_material(
174
+ mat_abbr, material_class, fetch_all
175
+ )
176
+ except Exception as exc:
177
+ st.error(f"DB error: {exc}")
178
+ db_properties = []
179
+
180
+ if not db_properties:
181
+ st.warning(
182
+ f"No properties found for **{mat_abbr}** in the "
183
+ f"**{material_class}** table. Mapping will use all properties."
184
+ )
185
+
186
+ # Run batch mapping with progress bar
187
+ progress_bar = st.progress(0, text="Mapping plots…")
188
+
189
+ def _update(i, total, caption):
190
+ pct = int((i / max(total, 1)) * 100)
191
+ progress_bar.progress(
192
+ pct,
193
+ text=f"Mapping {i+1}/{total}: {caption[:60]}…",
194
+ )
195
+
196
+ with st.spinner("AI is analysing plots…"):
197
+ mapped = batch_map_plots(
198
+ image_results=image_results,
199
+ extracted_json=extracted_json,
200
+ db_properties=db_properties,
201
+ progress_callback=_update,
202
+ )
203
+
204
+ progress_bar.progress(100, text="Done!")
205
+ st.session_state.mapped_results = mapped
206
+ st.session_state.mapping_done = True
207
+ st.success(f"✅ Mapped {len(mapped)} plots")
208
+ st.rerun()
209
+
210
+ if st.session_state.mapping_done and st.session_state.mapped_results:
211
+ st.caption("Mapping complete. Review & confirm each match below.")
212
+
213
+ st.divider()
214
+
215
+ # ── 5. Plot cards ─────────────────────────────────────────────────────────
216
+ use_mapped = (
217
+ has_data
218
+ and st.session_state.mapping_done
219
+ and bool(st.session_state.mapped_results)
220
+ )
221
+
222
+ display_list = (
223
+ st.session_state.mapped_results if use_mapped else image_results
224
+ )
225
+
226
+ for idx, item in enumerate(display_list):
227
+ caption = item.get("caption", f"Figure {idx+1}")
228
+ page = item.get("page", "?")
229
+ img_list = item.get("image_data", [])
230
+ mapping = item.get("mapping_result") if use_mapped else None
231
+
232
+ with st.container(border=True):
233
+
234
+ # — header row —
235
+ col_cap, col_del = st.columns([0.88, 0.12])
236
+ col_cap.markdown(f"**Page {page}** — {caption}")
237
+ if col_del.button("🗑 Delete", key=f"del_group_{idx}"):
238
+ if use_mapped:
239
+ st.session_state.mapped_results.pop(idx)
240
+ else:
241
+ st.session_state.image_results.pop(idx)
242
+ st.rerun()
243
+
244
+ # — AI mapping result banner —
245
+ if mapping:
246
+ prop_name = mapping.get("property_name", "")
247
+ section = mapping.get("section", "")
248
+ confidence = mapping.get("confidence", "low")
249
+ reasoning = mapping.get("reasoning", "")
250
+ db_row = mapping.get("db_row")
251
+ candidates = mapping.get("all_candidates", [])
252
+
253
+ badge = _confidence_badge(confidence)
254
+ if prop_name:
255
+ st.markdown(
256
+ f"🔗 **AI Match:** `{section}` › **{prop_name}** &nbsp; {badge}",
257
+ unsafe_allow_html=True,
258
+ )
259
+ if reasoning:
260
+ st.caption(f"💬 {reasoning}")
261
+
262
+ # DB row details
263
+ if db_row:
264
+ with st.expander("📋 Matched DB row", expanded=False):
265
+ col_v, col_u, col_c = st.columns(3)
266
+ col_v.metric("Value", db_row.get("value", "—"))
267
+ col_u.metric("Unit", db_row.get("unit", "—"))
268
+ col_c.metric("Condition", db_row.get("test_condition", "—"))
269
+ if db_row.get("comments"):
270
+ st.caption(f"Comments: {db_row['comments']}")
271
+ if db_row.get("english"):
272
+ st.caption(f"English units: {db_row['english']}")
273
+
274
+ # Alternative candidates
275
+ if candidates:
276
+ with st.expander("🔄 All candidates", expanded=False):
277
+ for c in candidates:
278
+ rank = c.get("rank", "?")
279
+ cn = c.get("confidence", "low")
280
+ st.markdown(
281
+ f"{rank}. `{c.get('section','?')}` › "
282
+ f"**{c.get('property_name','?')}** "
283
+ f"&nbsp; {_confidence_badge(cn)}",
284
+ unsafe_allow_html=True,
285
+ )
286
+ else:
287
+ st.warning("⚠️ AI could not match this plot to any DB property.")
288
+
289
+ # — sub-images —
290
+ for p_idx, img_data in enumerate(img_list):
291
+ bgr = img_data.get("array")
292
+ if bgr is None:
293
+ continue
294
+
295
+ img_key = f"{idx}_{p_idx}_{page}"
296
+
297
+ # Show the plot
298
+ st.image(bgr, channels="BGR", width=420)
299
+
300
+ # — mapping controls —
301
+ if has_data:
302
+ df = st.session_state.pdf_extracted_df
303
+ mat_abbr = df.iloc[0]["material_abbreviation"]
304
+ property_list = df["property_name"].unique().tolist()
305
+
306
+ # Pre-select the AI suggestion if available
307
+ ai_suggestion = mapping.get("property_name", "") if mapping else ""
308
+ default_idx = 0
309
+ options = ["— Select property —"] + property_list
310
+ if ai_suggestion in property_list:
311
+ default_idx = property_list.index(ai_suggestion) + 1
312
+
313
+ col_sel, col_sec, col_save, col_rem = st.columns(
314
+ [0.42, 0.18, 0.20, 0.20]
315
+ )
316
+
317
+ with col_sel:
318
+ selected = st.selectbox(
319
+ "Property",
320
+ options=options,
321
+ index=default_idx,
322
+ key=f"prop_sel_{img_key}",
323
+ label_visibility="collapsed",
324
+ )
325
+
326
+ with col_sec:
327
+ section_override = st.text_input(
328
+ "Section",
329
+ value=mapping.get("section", "") if mapping else "",
330
+ key=f"sec_{img_key}",
331
+ label_visibility="collapsed",
332
+ placeholder="Section",
333
+ )
334
+
335
+ with col_save:
336
+ if st.button("💾 Save", key=f"save_{img_key}"):
337
+ if selected and selected != "— Select property —":
338
+ filepath = save_plot_image_mapping(
339
+ mat_abbr,
340
+ selected,
341
+ section_override,
342
+ bgr,
343
+ save_dir="images",
344
+ )
345
+ st.session_state.saved_image_mapping[img_key] = {
346
+ "property": selected,
347
+ "section": section_override,
348
+ "caption": caption,
349
+ "filename": os.path.basename(filepath),
350
+ "path": filepath,
351
+ }
352
+ st.success(f"Saved → `{os.path.basename(filepath)}`")
353
+ st.rerun()
354
+ else:
355
+ st.warning("Select a property first.")
356
+
357
+ with col_rem:
358
+ if st.button("✕ Remove", key=f"rem_{img_key}"):
359
+ img_list.pop(p_idx)
360
+ if not img_list:
361
+ if use_mapped:
362
+ st.session_state.mapped_results.pop(idx)
363
+ else:
364
+ st.session_state.image_results.pop(idx)
365
+ st.rerun()
366
+
367
+ # Saved badge
368
+ if img_key in st.session_state.saved_image_mapping:
369
+ m = st.session_state.saved_image_mapping[img_key]
370
+ st.info(f"✅ Saved as **{m['property']}** → `{m['filename']}`")
371
+
372
+ else:
373
+ # No data extracted yet — just allow removal
374
+ col_msg, col_rem = st.columns([0.80, 0.20])
375
+ col_msg.caption(
376
+ "Extract material data in the **Material Data** tab to enable mapping."
377
+ )
378
+ if col_rem.button("✕ Remove", key=f"rem_nodata_{img_key}"):
379
+ img_list.pop(p_idx)
380
+ if not img_list:
381
+ st.session_state.image_results.pop(idx)
382
+ st.rerun()
383
+
384
+ st.divider()
385
+
386
+ # ── 6. Saved-mappings summary ─────────────────────────────────────────────
387
+ if st.session_state.saved_image_mapping:
388
+ with st.expander(
389
+ f"📁 Saved mappings ({len(st.session_state.saved_image_mapping)})",
390
+ expanded=False,
391
+ ):
392
+ for key, info in st.session_state.saved_image_mapping.items():
393
+ st.markdown(
394
+ f"**{info['property']}** &nbsp;›&nbsp; `{info['filename']}` \n"
395
+ f"<small>Caption: {info['caption']}</small>",
396
+ unsafe_allow_html=True,
397
+ )
page_files/categorized/Backend/plot_property_mapper.py ADDED
@@ -0,0 +1,385 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ plot_property_mapper.py
3
+ -----------------------
4
+ Maps extracted plot images to material properties stored in PostgreSQL.
5
+
6
+ Strategy:
7
+ 1. Fetch all properties for the material from the DB (Polymers / Fibers / Composites_materials)
8
+ 2. For each plot: send image + caption + extracted JSON data to Gemini
9
+ 3. Gemini returns the best-matching property_name + confidence reasoning
10
+ 4. Caller can confirm/override and persist the match
11
+
12
+ DB schema (per table: Polymers, Fibers, Composites_materials):
13
+ material_name, material_abbreviation, section,
14
+ property_name, value, unit, english, test_condition, comments
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import json
21
+ import os
22
+ import re
23
+ from io import BytesIO
24
+ from typing import Any
25
+
26
+ import cv2
27
+ import numpy as np
28
+ import requests
29
+ from PIL import Image
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Gemini config (re-uses the same key / model you already use)
33
+ # ---------------------------------------------------------------------------
34
+ _GEMINI_KEY = os.getenv(
35
+ "GEMINI_API_KEY",
36
+ "AIzaSyAuI-qwSCRpdAcGTvNNaS70NkxlLMSrWF0", # fallback – prefer env var
37
+ )
38
+ _GEMINI_MODEL = "gemini-2.5-flash-preview-09-2025"
39
+ _GEMINI_URL = (
40
+ f"https://generativelanguage.googleapis.com/v1beta/"
41
+ f"models/{_GEMINI_MODEL}:generateContent?key={_GEMINI_KEY}"
42
+ )
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Table routing (mirrors data_loader.py)
46
+ # ---------------------------------------------------------------------------
47
+ TABLE_MAP = {
48
+ "Polymer": "Polymers",
49
+ "Fiber": "Fibers",
50
+ "Composite": "Composites_materials",
51
+ }
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # DB helpers (thin wrappers – import your existing fetch_all from db.py)
55
+ # ---------------------------------------------------------------------------
56
+
57
+ def fetch_properties_for_material(
58
+ material_abbr: str,
59
+ material_class: str,
60
+ fetch_all_fn, # pass db.fetch_all so we don't re-import
61
+ ) -> list[dict]:
62
+ """
63
+ Return all property rows for a given material abbreviation.
64
+ Falls back to all rows in the table if nothing matches the abbreviation.
65
+ """
66
+ table = TABLE_MAP.get(material_class)
67
+ if not table:
68
+ raise ValueError(f"Unknown material_class: {material_class!r}")
69
+
70
+ query = f"""
71
+ SELECT
72
+ material_name,
73
+ material_abbreviation,
74
+ section,
75
+ property_name,
76
+ value,
77
+ unit,
78
+ english,
79
+ test_condition,
80
+ comments
81
+ FROM "{table}"
82
+ WHERE LOWER(material_abbreviation) = LOWER(:abbr)
83
+ ORDER BY section, property_name
84
+ """
85
+ rows = fetch_all_fn(query, {"abbr": material_abbr})
86
+
87
+ # fallback: try by name fragment
88
+ if not rows:
89
+ query2 = f"""
90
+ SELECT
91
+ material_name, material_abbreviation, section,
92
+ property_name, value, unit, english, test_condition, comments
93
+ FROM "{table}"
94
+ ORDER BY section, property_name
95
+ """
96
+ rows = fetch_all_fn(query2)
97
+
98
+ return rows
99
+
100
+
101
+ def save_plot_image_mapping(
102
+ material_abbr: str,
103
+ property_name: str,
104
+ section: str,
105
+ image_array: np.ndarray, # BGR numpy array from cv2
106
+ save_dir: str = "images",
107
+ ) -> str:
108
+ """
109
+ Save the plot image to disk as <save_dir>/<abbr>_<safe_property>.png
110
+ Returns the file path.
111
+ """
112
+ os.makedirs(save_dir, exist_ok=True)
113
+ safe_prop = re.sub(r"[^\w\s-]", "", property_name).strip().replace(" ", "_")
114
+ filename = f"{material_abbr}_{safe_prop}.png"
115
+ filepath = os.path.join(save_dir, filename)
116
+ cv2.imwrite(filepath, image_array)
117
+ return filepath
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Core: Gemini image + data → property match
122
+ # ---------------------------------------------------------------------------
123
+
124
+ def _encode_image_bgr(bgr: np.ndarray) -> tuple[str, str]:
125
+ """Convert BGR numpy array → base64 PNG string."""
126
+ rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
127
+ pil = Image.fromarray(rgb)
128
+ buf = BytesIO()
129
+ pil.save(buf, format="PNG")
130
+ b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
131
+ return b64, "image/png"
132
+
133
+
134
+ def map_plot_to_property(
135
+ image_bgr: np.ndarray,
136
+ caption: str,
137
+ extracted_json: dict[str, Any], # full Gemini data-extraction output
138
+ db_properties: list[dict], # rows from fetch_properties_for_material
139
+ gemini_api_key: str | None = None,
140
+ ) -> dict:
141
+ """
142
+ Ask Gemini which DB property best matches this plot.
143
+
144
+ Returns
145
+ -------
146
+ {
147
+ "property_name": str, # best match from DB
148
+ "section": str,
149
+ "confidence": "high"|"medium"|"low",
150
+ "reasoning": str,
151
+ "db_row": dict | None, # full matching DB row
152
+ "all_candidates": list[dict], # top-3 ranked by Gemini
153
+ }
154
+ """
155
+ key = gemini_api_key or _GEMINI_KEY
156
+ url = (
157
+ f"https://generativelanguage.googleapis.com/v1beta/"
158
+ f"models/{_GEMINI_MODEL}:generateContent?key={key}"
159
+ )
160
+
161
+ # Build a compact list of DB properties for the prompt
162
+ prop_list_text = "\n".join(
163
+ f" - [{row['section']}] {row['property_name']} "
164
+ f"(value={row['value']}, unit={row['unit']})"
165
+ for row in db_properties[:80] # cap at 80 to stay within context
166
+ )
167
+
168
+ # Compact extracted JSON (just material + property names + values)
169
+ extracted_summary = {
170
+ "material_name": extracted_json.get("material_name", ""),
171
+ "material_abbreviation": extracted_json.get("material_abbreviation", ""),
172
+ "properties": [
173
+ {
174
+ "section": p.get("section"),
175
+ "property_name": p.get("property_name"),
176
+ "value": p.get("value"),
177
+ "unit": p.get("unit"),
178
+ }
179
+ for p in extracted_json.get("mechanical_properties", [])[:40]
180
+ ],
181
+ }
182
+
183
+ prompt = f"""You are an expert materials scientist.
184
+
185
+ TASK: Identify which property from the DATABASE LIST best matches the provided plot image.
186
+
187
+ PLOT CAPTION:
188
+ "{caption}"
189
+
190
+ EXTRACTED TEXT DATA FROM THE SAME PDF (JSON summary):
191
+ {json.dumps(extracted_summary, indent=2)}
192
+
193
+ DATABASE PROPERTIES FOR THIS MATERIAL:
194
+ {prop_list_text}
195
+
196
+ INSTRUCTIONS:
197
+ 1. Examine the plot image carefully (axes labels, units, curve shapes, legend text).
198
+ 2. Use the caption AND the extracted JSON data as additional context.
199
+ 3. Select the TOP 3 best-matching property names from the DATABASE PROPERTIES list above.
200
+ 4. For each candidate give a confidence: high / medium / low.
201
+ 5. Return ONLY valid JSON — no markdown, no explanation outside the JSON.
202
+
203
+ REQUIRED JSON FORMAT:
204
+ {{
205
+ "best_match": {{
206
+ "property_name": "<exact name from DB list>",
207
+ "section": "<section from DB list>",
208
+ "confidence": "high|medium|low",
209
+ "reasoning": "<1-2 sentence explanation>"
210
+ }},
211
+ "candidates": [
212
+ {{"rank": 1, "property_name": "...", "section": "...", "confidence": "..."}},
213
+ {{"rank": 2, "property_name": "...", "section": "...", "confidence": "..."}},
214
+ {{"rank": 3, "property_name": "...", "section": "...", "confidence": "..."}}
215
+ ]
216
+ }}
217
+ """
218
+
219
+ img_b64, img_mime = _encode_image_bgr(image_bgr)
220
+
221
+ payload = {
222
+ "contents": [
223
+ {
224
+ "parts": [
225
+ {"text": prompt},
226
+ {"inlineData": {"mimeType": img_mime, "data": img_b64}},
227
+ ]
228
+ }
229
+ ],
230
+ "generationConfig": {
231
+ "temperature": 0.0,
232
+ "responseMimeType": "application/json",
233
+ },
234
+ }
235
+
236
+ try:
237
+ resp = requests.post(url, json=payload, timeout=120)
238
+ resp.raise_for_status()
239
+ raw = resp.json()
240
+
241
+ parts = raw.get("candidates", [{}])[0].get("content", {}).get("parts", [])
242
+ json_text = ""
243
+ for p in parts:
244
+ t = p.get("text", "")
245
+ if t.strip().startswith("{"):
246
+ json_text = t
247
+ break
248
+
249
+ if not json_text:
250
+ return _empty_result("Gemini returned no JSON")
251
+
252
+ result = json.loads(json_text)
253
+
254
+ except Exception as exc:
255
+ return _empty_result(str(exc))
256
+
257
+ # Attach full DB row to best_match
258
+ best = result.get("best_match", {})
259
+ matched_prop = best.get("property_name", "")
260
+ db_row = next(
261
+ (r for r in db_properties if r["property_name"] == matched_prop),
262
+ None,
263
+ )
264
+ best["db_row"] = db_row
265
+
266
+ return {
267
+ "property_name": matched_prop,
268
+ "section": best.get("section", ""),
269
+ "confidence": best.get("confidence", "low"),
270
+ "reasoning": best.get("reasoning", ""),
271
+ "db_row": db_row,
272
+ "all_candidates": result.get("candidates", []),
273
+ }
274
+
275
+
276
+ def _empty_result(error: str) -> dict:
277
+ return {
278
+ "property_name": "",
279
+ "section": "",
280
+ "confidence": "low",
281
+ "reasoning": f"Error: {error}",
282
+ "db_row": None,
283
+ "all_candidates": [],
284
+ }
285
+
286
+
287
+ # ---------------------------------------------------------------------------
288
+ # Batch mapper (call once per PDF after extraction)
289
+ # ---------------------------------------------------------------------------
290
+
291
+ def batch_map_plots(
292
+ image_results: list[dict], # from extract_images() in upload_backend.py
293
+ extracted_json: dict, # from call_gemini_from_bytes()
294
+ db_properties: list[dict], # from fetch_properties_for_material()
295
+ gemini_api_key: str | None = None,
296
+ progress_callback=None, # optional fn(current, total, caption)
297
+ ) -> list[dict]:
298
+ """
299
+ Map every extracted plot to a DB property.
300
+
301
+ Returns a list parallel to image_results:
302
+ [
303
+ {
304
+ "caption": str,
305
+ "page": int,
306
+ "image_data": [...], # original image_data list
307
+ "mapping_result": { ...map_plot_to_property output... }
308
+ },
309
+ ...
310
+ ]
311
+ """
312
+ total = len(image_results)
313
+ out = []
314
+
315
+ for i, item in enumerate(image_results):
316
+ caption = item.get("caption", "")
317
+ page = item.get("page", 0)
318
+
319
+ if progress_callback:
320
+ progress_callback(i, total, caption)
321
+
322
+ # Use first sub-image for mapping (usually there's only one per caption)
323
+ img_list = item.get("image_data", [])
324
+ if not img_list:
325
+ out.append({**item, "mapping_result": _empty_result("No image data")})
326
+ continue
327
+
328
+ bgr = img_list[0].get("array")
329
+ if bgr is None:
330
+ out.append({**item, "mapping_result": _empty_result("Missing array")})
331
+ continue
332
+
333
+ result = map_plot_to_property(
334
+ image_bgr=bgr,
335
+ caption=caption,
336
+ extracted_json=extracted_json,
337
+ db_properties=db_properties,
338
+ gemini_api_key=gemini_api_key,
339
+ )
340
+
341
+ out.append({
342
+ "caption": caption,
343
+ "page": page,
344
+ "image_data": img_list,
345
+ "mapping_result": result,
346
+ })
347
+
348
+ return out
349
+
350
+ def save_plot_image_to_db(
351
+ material_abbr: str,
352
+ property_name: str,
353
+ image_bgr,
354
+ material_class: str,
355
+ execute_query_fn,
356
+ ) -> bool:
357
+ """Save plot image as BYTEA into the matching property row in PostgreSQL."""
358
+
359
+ table_map = {
360
+ "Polymer": "Polymers",
361
+ "Fiber": "Fibers",
362
+ "Composite": "Composites_materials",
363
+ }
364
+ table = table_map.get(material_class)
365
+ if not table:
366
+ return False
367
+
368
+ _, buffer = cv2.imencode(".png", image_bgr)
369
+ image_bytes = buffer.tobytes()
370
+
371
+ query = f"""
372
+ UPDATE "{table}"
373
+ SET image = :image
374
+ WHERE LOWER(material_abbreviation) = LOWER(:abbr)
375
+ AND LOWER(property_name) = LOWER(:prop)
376
+ """
377
+ rows_updated = execute_query_fn(
378
+ query,
379
+ {
380
+ "image": image_bytes,
381
+ "abbr": material_abbr,
382
+ "prop": property_name,
383
+ }
384
+ )
385
+ return rows_updated > 0