Update page_files/categorized/Backend/category_push.py
Browse files
page_files/categorized/Backend/category_push.py
CHANGED
|
@@ -124,7 +124,15 @@ def _s(v: Any) -> str:
|
|
| 124 |
return ""
|
| 125 |
except (TypeError, ValueError):
|
| 126 |
pass
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
|
| 130 |
def _page_int(v: Any) -> Optional[int]:
|
|
@@ -180,6 +188,12 @@ def conform_to_category(
|
|
| 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
|
|
@@ -252,7 +266,7 @@ def conform_to_category(
|
|
| 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",
|
|
@@ -315,6 +329,22 @@ def push_by_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:
|
|
@@ -324,13 +354,33 @@ def push_by_category(
|
|
| 324 |
except Exception:
|
| 325 |
pass
|
| 326 |
|
| 327 |
-
|
| 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":
|
| 335 |
"extras_table": extras_table, "extras": len(extras_df),
|
| 336 |
-
"plots_copied": n_plots, "plots_dir": plots_dir}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
return ""
|
| 125 |
except (TypeError, ValueError):
|
| 126 |
pass
|
| 127 |
+
s = str(v)
|
| 128 |
+
# Postgres text/varchar columns cannot contain NUL (0x00). PDF text
|
| 129 |
+
# extraction occasionally emits embedded nulls (and other C0 control
|
| 130 |
+
# chars) inside quotes/comments — strip them so the INSERT doesn't fail
|
| 131 |
+
# with "A string literal cannot contain NUL (0x00) characters".
|
| 132 |
+
if "\x00" in s or any(ord(c) < 32 and c not in "\t\n\r" for c in s):
|
| 133 |
+
s = s.replace("\x00", "")
|
| 134 |
+
s = "".join(c for c in s if ord(c) >= 32 or c in "\t\n\r")
|
| 135 |
+
return s
|
| 136 |
|
| 137 |
|
| 138 |
def _page_int(v: Any) -> Optional[int]:
|
|
|
|
| 188 |
plots_root = plots_root or os.path.join(out_dir, "rds_plots")
|
| 189 |
dest_dir = os.path.join(plots_root, category_table, stem)
|
| 190 |
|
| 191 |
+
# source_sha1 scopes the DB dedup index to THIS paper. It was previously ""
|
| 192 |
+
# for every row, which collapsed the unique key across all papers. A stable
|
| 193 |
+
# hash of the stem keeps re-pushes of the same paper consistent.
|
| 194 |
+
import hashlib
|
| 195 |
+
source_sha1 = hashlib.sha1((stem or "").encode("utf-8")).hexdigest()
|
| 196 |
+
|
| 197 |
main_rows: List[Dict[str, Any]] = []
|
| 198 |
extras_rows: List[Dict[str, Any]] = []
|
| 199 |
n_plots = 0
|
|
|
|
| 266 |
"unit_canonical": _s(row.get("unit")),
|
| 267 |
"value_si": None,
|
| 268 |
"source_pdf": _s(row.get("pdf_stem")) or stem,
|
| 269 |
+
"source_sha1": source_sha1,
|
| 270 |
"page": _page_int(row.get("source_page")),
|
| 271 |
"source_quote": _s(row.get("source_text")),
|
| 272 |
"status": "extracted",
|
|
|
|
| 329 |
df_store, category_table, out_dir, stem,
|
| 330 |
plots_root=plots_root, embed_image=embed_image)
|
| 331 |
|
| 332 |
+
# keep-first dedup on the exact columns of ix_..._pipeline_dedup, so two
|
| 333 |
+
# identical measurements in one paper collapse to one row instead of
|
| 334 |
+
# tripping the unique constraint mid-batch.
|
| 335 |
+
_DEDUP_KEYS = ["source_sha1", "material_key", "section",
|
| 336 |
+
"property_name", "test_condition", "value_raw"]
|
| 337 |
+
keys = [k for k in _DEDUP_KEYS if k in main_df.columns]
|
| 338 |
+
if keys:
|
| 339 |
+
before = len(main_df)
|
| 340 |
+
main_df = main_df.drop_duplicates(subset=keys, keep="first").reset_index(drop=True)
|
| 341 |
+
dropped = before - len(main_df)
|
| 342 |
+
if dropped:
|
| 343 |
+
log.info(f"category_push: dropped {dropped} in-batch duplicate row(s) on {keys}")
|
| 344 |
+
# keep extras aligned to the surviving main rows (1:1 by position)
|
| 345 |
+
extras_df = extras_df.loc[main_df.index].reset_index(drop=True) \
|
| 346 |
+
if len(extras_df) == before else extras_df
|
| 347 |
+
|
| 348 |
extras_table = f"{category_table}_extras"
|
| 349 |
# idempotency: clear this paper first (tables may not exist yet -> ignore)
|
| 350 |
with engine.begin() as conn:
|
|
|
|
| 354 |
except Exception:
|
| 355 |
pass
|
| 356 |
|
| 357 |
+
pushed = _insert_idempotent(engine, category_table, main_df)
|
| 358 |
extras_df.to_sql(extras_table, engine, if_exists="append", index=False)
|
| 359 |
if _s3_ready():
|
| 360 |
plots_dir = _s3_url(_s3_key(category_table, stem, "")).rstrip("/")
|
| 361 |
else:
|
| 362 |
plots_dir = os.path.join(plots_root or os.path.join(out_dir, "rds_plots"),
|
| 363 |
category_table, stem)
|
| 364 |
+
return {"pushed": pushed, "table": category_table,
|
| 365 |
"extras_table": extras_table, "extras": len(extras_df),
|
| 366 |
+
"plots_copied": n_plots, "plots_dir": plots_dir}
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def _insert_idempotent(engine, table: str, df: pd.DataFrame) -> int:
|
| 370 |
+
"""Append rows one INSERT ... ON CONFLICT DO NOTHING at a time, so a row that
|
| 371 |
+
still collides with the table's unique dedup index is skipped instead of
|
| 372 |
+
rolling back the whole batch. Returns the number of rows actually inserted."""
|
| 373 |
+
from sqlalchemy import text
|
| 374 |
+
if df is None or df.empty:
|
| 375 |
+
return 0
|
| 376 |
+
cols = list(df.columns)
|
| 377 |
+
collist = ", ".join(f'"{c}"' for c in cols)
|
| 378 |
+
params = ", ".join(f":{c}" for c in cols)
|
| 379 |
+
sql = text(f'INSERT INTO "{table}" ({collist}) VALUES ({params}) ON CONFLICT DO NOTHING')
|
| 380 |
+
inserted = 0
|
| 381 |
+
with engine.begin() as conn:
|
| 382 |
+
for _, r in df.iterrows():
|
| 383 |
+
payload = {c: (None if pd.isna(v) else v) for c, v in r.items()}
|
| 384 |
+
res = conn.execute(sql, payload)
|
| 385 |
+
inserted += (res.rowcount or 0)
|
| 386 |
+
return inserted
|