Spaces:
Sleeping
Sleeping
Upload app.py with huggingface_hub
Browse files
app.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
import json
|
|
|
|
| 4 |
from html import escape
|
| 5 |
from pathlib import Path
|
| 6 |
|
|
@@ -26,6 +28,7 @@ from src.totem_workbook import (
|
|
| 26 |
from src.codex_extractor import process_upload, format_fingerprint_report
|
| 27 |
|
| 28 |
ORIGINAL_WORKBOOK_PATH = "data/order69_macmillan_totem_rebuilt.xlsx"
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
def _patch_gradio_schema_bool_compat() -> None:
|
|
@@ -833,6 +836,164 @@ def dashboard_html(path: Path, notice: str = "") -> str:
|
|
| 833 |
|
| 834 |
# ββ CODEX EXTRACTOR FUNCTIONS βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 835 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 836 |
def run_codex_extraction(
|
| 837 |
file_obj,
|
| 838 |
author_name: str,
|
|
@@ -858,11 +1019,7 @@ def run_codex_extraction(
|
|
| 858 |
)
|
| 859 |
|
| 860 |
# Gradio may pass a filepath string or a file-like payload depending on runtime.
|
| 861 |
-
file_path = file_obj
|
| 862 |
-
if isinstance(file_obj, dict):
|
| 863 |
-
file_path = file_obj.get("path") or file_obj.get("name")
|
| 864 |
-
elif hasattr(file_obj, "name"):
|
| 865 |
-
file_path = file_obj.name
|
| 866 |
|
| 867 |
if not file_path:
|
| 868 |
return (
|
|
@@ -1073,7 +1230,7 @@ with gr.Blocks(title="TOTEM Studio", css=CSS) as demo:
|
|
| 1073 |
color: #5d4037;
|
| 1074 |
">
|
| 1075 |
<b>File requirements:</b><br>
|
| 1076 |
-
β’
|
| 1077 |
β’ Minimum 1,000 words for HIGH confidence fingerprint<br>
|
| 1078 |
β’ Combine multiple works in one file to increase sample size<br>
|
| 1079 |
β’ Visual-primary books (Van Allsburg, Jeffers) will flag LOW confidence
|
|
@@ -1145,13 +1302,13 @@ with gr.Blocks(title="TOTEM Studio", css=CSS) as demo:
|
|
| 1145 |
queue=False,
|
| 1146 |
trigger_mode="multiple",
|
| 1147 |
)
|
| 1148 |
-
#
|
| 1149 |
codex_file.upload(
|
| 1150 |
-
|
| 1151 |
inputs=[codex_file, codex_author_name, codex_author_id, codex_works],
|
| 1152 |
-
outputs=[
|
| 1153 |
queue=False,
|
| 1154 |
-
trigger_mode="
|
| 1155 |
)
|
| 1156 |
codex_clear_btn.click(
|
| 1157 |
clear_codex_form,
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import hashlib
|
| 4 |
import json
|
| 5 |
+
import re
|
| 6 |
from html import escape
|
| 7 |
from pathlib import Path
|
| 8 |
|
|
|
|
| 28 |
from src.codex_extractor import process_upload, format_fingerprint_report
|
| 29 |
|
| 30 |
ORIGINAL_WORKBOOK_PATH = "data/order69_macmillan_totem_rebuilt.xlsx"
|
| 31 |
+
CODEX_CATALOGUE_PATH = Path("data/codex_catalogue.xlsx")
|
| 32 |
|
| 33 |
|
| 34 |
def _patch_gradio_schema_bool_compat() -> None:
|
|
|
|
| 836 |
|
| 837 |
# ββ CODEX EXTRACTOR FUNCTIONS βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 838 |
|
| 839 |
+
def _extract_uploaded_path(file_obj) -> str | None:
|
| 840 |
+
"""Handle Gradio file payload variants and return a filesystem path."""
|
| 841 |
+
if file_obj is None:
|
| 842 |
+
return None
|
| 843 |
+
if isinstance(file_obj, str):
|
| 844 |
+
return file_obj
|
| 845 |
+
if isinstance(file_obj, dict):
|
| 846 |
+
return file_obj.get("path") or file_obj.get("name")
|
| 847 |
+
if hasattr(file_obj, "name"):
|
| 848 |
+
return file_obj.name
|
| 849 |
+
return None
|
| 850 |
+
|
| 851 |
+
|
| 852 |
+
def _normalise_lookup_key(value: str) -> str:
|
| 853 |
+
text = str(value or "").lower()
|
| 854 |
+
text = re.sub(r"[_\-]+", " ", text)
|
| 855 |
+
text = re.sub(r"[^a-z0-9 ]+", " ", text)
|
| 856 |
+
return re.sub(r"\s+", " ", text).strip()
|
| 857 |
+
|
| 858 |
+
|
| 859 |
+
def _generate_codex_author_id(author_name: str) -> str:
|
| 860 |
+
"""
|
| 861 |
+
Deterministic fallback author ID when missing in catalogue.
|
| 862 |
+
Format: CA-<3 letters>-<3 digits>
|
| 863 |
+
"""
|
| 864 |
+
cleaned = re.sub(r"[^A-Za-z]", "", author_name or "").upper()
|
| 865 |
+
prefix = (cleaned[:3] or "AUT").ljust(3, "X")
|
| 866 |
+
digest = hashlib.md5((author_name or "").strip().lower().encode("utf-8")).hexdigest()
|
| 867 |
+
suffix = int(digest[:4], 16) % 1000
|
| 868 |
+
return f"CA-{prefix}-{suffix:03d}"
|
| 869 |
+
|
| 870 |
+
|
| 871 |
+
def _empty_catalogue_df() -> pd.DataFrame:
|
| 872 |
+
return pd.DataFrame(columns=["authour_id", "author_name", "title"])
|
| 873 |
+
|
| 874 |
+
|
| 875 |
+
def _load_codex_catalogue(path: Path = CODEX_CATALOGUE_PATH) -> pd.DataFrame:
|
| 876 |
+
"""
|
| 877 |
+
Load catalogue workbook with required columns:
|
| 878 |
+
`authour_id`, `author_name`, `title`
|
| 879 |
+
"""
|
| 880 |
+
if not path.exists():
|
| 881 |
+
return _empty_catalogue_df()
|
| 882 |
+
try:
|
| 883 |
+
raw = pd.read_excel(path)
|
| 884 |
+
except Exception:
|
| 885 |
+
return _empty_catalogue_df()
|
| 886 |
+
if raw is None or raw.empty:
|
| 887 |
+
return _empty_catalogue_df()
|
| 888 |
+
|
| 889 |
+
col_lookup = {str(col).strip().lower(): col for col in raw.columns}
|
| 890 |
+
id_col = col_lookup.get("authour_id") or col_lookup.get("author_id")
|
| 891 |
+
name_col = col_lookup.get("author_name")
|
| 892 |
+
title_col = col_lookup.get("title")
|
| 893 |
+
|
| 894 |
+
if name_col is None or title_col is None:
|
| 895 |
+
return _empty_catalogue_df()
|
| 896 |
+
|
| 897 |
+
if id_col is None:
|
| 898 |
+
raw["__authour_id"] = ""
|
| 899 |
+
id_col = "__authour_id"
|
| 900 |
+
|
| 901 |
+
cat = raw[[id_col, name_col, title_col]].copy()
|
| 902 |
+
cat.columns = ["authour_id", "author_name", "title"]
|
| 903 |
+
|
| 904 |
+
for col in ["authour_id", "author_name", "title"]:
|
| 905 |
+
cat[col] = cat[col].fillna("").astype(str).str.strip()
|
| 906 |
+
|
| 907 |
+
cat = cat[(cat["author_name"] != "") & (cat["title"] != "")]
|
| 908 |
+
return cat
|
| 909 |
+
|
| 910 |
+
|
| 911 |
+
def _match_catalogue_row(file_path: str, catalogue: pd.DataFrame) -> pd.Series | None:
|
| 912 |
+
if catalogue.empty:
|
| 913 |
+
return None
|
| 914 |
+
stem_key = _normalise_lookup_key(Path(file_path).stem)
|
| 915 |
+
if not stem_key:
|
| 916 |
+
return None
|
| 917 |
+
|
| 918 |
+
title_keys = catalogue["title"].map(_normalise_lookup_key)
|
| 919 |
+
|
| 920 |
+
exact = catalogue[title_keys == stem_key]
|
| 921 |
+
if not exact.empty:
|
| 922 |
+
return exact.iloc[0]
|
| 923 |
+
|
| 924 |
+
contains = catalogue[
|
| 925 |
+
title_keys.apply(lambda t: bool(t) and (t in stem_key or stem_key in t))
|
| 926 |
+
]
|
| 927 |
+
if not contains.empty:
|
| 928 |
+
return contains.assign(_key_len=contains["title"].map(lambda t: len(_normalise_lookup_key(t)))) \
|
| 929 |
+
.sort_values("_key_len", ascending=False) \
|
| 930 |
+
.iloc[0]
|
| 931 |
+
return None
|
| 932 |
+
|
| 933 |
+
|
| 934 |
+
def autofill_codex_details(
|
| 935 |
+
file_obj,
|
| 936 |
+
current_author_name: str,
|
| 937 |
+
current_author_id: str,
|
| 938 |
+
current_works: str,
|
| 939 |
+
) -> tuple[str, str, str, str]:
|
| 940 |
+
"""
|
| 941 |
+
Auto-populate author fields from data/codex_catalogue.xlsx on file upload.
|
| 942 |
+
Expected columns: authour_id, author_name, title.
|
| 943 |
+
"""
|
| 944 |
+
file_path = _extract_uploaded_path(file_obj)
|
| 945 |
+
if not file_path:
|
| 946 |
+
return (
|
| 947 |
+
current_author_name,
|
| 948 |
+
current_author_id or "CA-XXX",
|
| 949 |
+
current_works,
|
| 950 |
+
"Ready.",
|
| 951 |
+
)
|
| 952 |
+
|
| 953 |
+
catalogue = _load_codex_catalogue()
|
| 954 |
+
if catalogue.empty:
|
| 955 |
+
return (
|
| 956 |
+
current_author_name,
|
| 957 |
+
current_author_id or "CA-XXX",
|
| 958 |
+
current_works,
|
| 959 |
+
"No catalogue match: add rows to data/codex_catalogue.xlsx with authour_id, author_name, title.",
|
| 960 |
+
)
|
| 961 |
+
|
| 962 |
+
row = _match_catalogue_row(file_path, catalogue)
|
| 963 |
+
if row is None:
|
| 964 |
+
return (
|
| 965 |
+
current_author_name,
|
| 966 |
+
current_author_id or "CA-XXX",
|
| 967 |
+
current_works,
|
| 968 |
+
"No title match found in catalogue for this filename. You can still fill fields manually.",
|
| 969 |
+
)
|
| 970 |
+
|
| 971 |
+
author_name = str(row["author_name"]).strip()
|
| 972 |
+
author_id = str(row["authour_id"]).strip() or _generate_codex_author_id(author_name)
|
| 973 |
+
|
| 974 |
+
by_author = catalogue[
|
| 975 |
+
catalogue["author_name"].map(_normalise_lookup_key) == _normalise_lookup_key(author_name)
|
| 976 |
+
]
|
| 977 |
+
if author_id:
|
| 978 |
+
by_id = catalogue[
|
| 979 |
+
catalogue["authour_id"].fillna("").astype(str).str.strip() == author_id
|
| 980 |
+
]
|
| 981 |
+
if not by_id.empty:
|
| 982 |
+
by_author = by_id
|
| 983 |
+
|
| 984 |
+
works = [w for w in by_author["title"].astype(str).str.strip().tolist() if w]
|
| 985 |
+
works_sampled = ", ".join(pd.Series(works).drop_duplicates().tolist())
|
| 986 |
+
if not works_sampled:
|
| 987 |
+
works_sampled = str(row["title"]).strip()
|
| 988 |
+
|
| 989 |
+
return (
|
| 990 |
+
author_name,
|
| 991 |
+
author_id,
|
| 992 |
+
works_sampled,
|
| 993 |
+
f"Auto-filled from catalogue: {author_name} ({author_id}).",
|
| 994 |
+
)
|
| 995 |
+
|
| 996 |
+
|
| 997 |
def run_codex_extraction(
|
| 998 |
file_obj,
|
| 999 |
author_name: str,
|
|
|
|
| 1019 |
)
|
| 1020 |
|
| 1021 |
# Gradio may pass a filepath string or a file-like payload depending on runtime.
|
| 1022 |
+
file_path = _extract_uploaded_path(file_obj)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1023 |
|
| 1024 |
if not file_path:
|
| 1025 |
return (
|
|
|
|
| 1230 |
color: #5d4037;
|
| 1231 |
">
|
| 1232 |
<b>File requirements:</b><br>
|
| 1233 |
+
β’ Selectable text preferred; scanned PDFs are OCR-processed automatically<br>
|
| 1234 |
β’ Minimum 1,000 words for HIGH confidence fingerprint<br>
|
| 1235 |
β’ Combine multiple works in one file to increase sample size<br>
|
| 1236 |
β’ Visual-primary books (Van Allsburg, Jeffers) will flag LOW confidence
|
|
|
|
| 1302 |
queue=False,
|
| 1303 |
trigger_mode="multiple",
|
| 1304 |
)
|
| 1305 |
+
# Auto-fill author metadata from catalogue on file upload.
|
| 1306 |
codex_file.upload(
|
| 1307 |
+
autofill_codex_details,
|
| 1308 |
inputs=[codex_file, codex_author_name, codex_author_id, codex_works],
|
| 1309 |
+
outputs=[codex_author_name, codex_author_id, codex_works, codex_status],
|
| 1310 |
queue=False,
|
| 1311 |
+
trigger_mode="always_last",
|
| 1312 |
)
|
| 1313 |
codex_clear_btn.click(
|
| 1314 |
clear_codex_form,
|