import base64
import os
import re
from datetime import datetime
import gradio as gr
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from huggingface_hub import HfApi, hf_hub_download
# ── Constants ────────────────────────────────────────────────────────────────
# Set HF_DATASET_REPO to e.g. "your-username/rejected-items-annotations" in Space secrets.
# Set HF_TOKEN to a token with write access to that repo.
# If neither is set the app falls back to a local CSV (good for local dev).
HF_DATASET_REPO = os.environ.get("HF_DATASET_REPO")
HF_TOKEN = os.environ.get("HF_TOKEN")
ANNOTATION_FILE = "annotated_product_replacements.csv"
ANNOTATION_COLS = [
"REJECTED_ITEM_ID",
"APPROVED_ITEM_ID",
"is_direct_replacement",
"reasoning",
"timestamp",
]
# ── README ───────────────────────────────────────────────────────────────────
with open("README.md") as _f:
_readme_raw = _f.read()
# Strip YAML frontmatter (content between the first pair of --- lines)
README_MD = re.sub(r"^---.*?---\s*", "", _readme_raw, flags=re.DOTALL)
# ── Images ───────────────────────────────────────────────────────────────────
_BLANK_IMG = (
"data:image/svg+xml;base64,"
+ base64.b64encode(
b'"
).decode()
)
_item_images: dict = {}
if os.path.exists("alternative-product-discovery-product-images.csv"):
_pimg_df = pd.read_csv(
"alternative-product-discovery-product-images.csv", dtype=str
).fillna("")
_item_images = dict(zip(_pimg_df["section_item_id"], _pimg_df["image_url"]))
if os.path.exists("item_images.csv"):
_img_df = pd.read_csv("item_images.csv", dtype=str).fillna("")
# Local paths take priority over CDN URLs
_item_images.update(dict(zip(_img_df["ITEM_ID"], _img_df["image_path"])))
def _item_specs(item_id):
"""Return (dims_str, specs_str) from schedule_section_items for a given item ID."""
row = _section_items_lookup.get(str(item_id), {})
dims = " × ".join(
f"{row[k]}{s}"
for k, s in [("WIDTH", "W"), ("LENGTH", "L"), ("HEIGHT", "H"), ("DEPTH", "D")]
if row.get(k, "") not in ("", "nan", "None")
)
specs = " · ".join(
row[k]
for k in ("COLOUR", "FINISH", "MATERIAL")
if row.get(k, "") not in ("", "nan", "None")
)
return dims, specs
def get_item_image(item_id) -> str:
path = _item_images.get(str(item_id), "")
if not path:
return _BLANK_IMG
if path.startswith("http"):
return path
if os.path.exists(path):
with open(path, "rb") as f:
data = base64.b64encode(f.read()).decode()
ext = path.rsplit(".", 1)[-1].lower()
mime = {
"jpg": "jpeg",
"jpeg": "jpeg",
"png": "png",
"gif": "gif",
"webp": "webp",
}.get(ext, "png")
return f"data:image/{mime};base64,{data}"
return _BLANK_IMG
# ── Load & clean data ────────────────────────────────────────────────────────
df = pd.read_csv("approved_after_with_comments_for_analysis.csv")
_section_items_df = pd.DataFrame()
_section_items_lookup: dict = {}
if os.path.exists("schedule_section_items.csv"):
_section_items_df = pd.read_csv("schedule_section_items.csv", dtype=str).fillna("")
_section_items_lookup = _section_items_df.set_index("ID").to_dict("index")
STATUS_LABELS = {
0: "Draft",
1: "In Review",
2: "Selected",
3: "Quoting",
4: "Re-submit",
5: "Rejected",
7: "Approved",
8: "Ordered",
9: "Payment Due",
10: "In Production",
11: "In Transit",
12: "Installed",
13: "Delivered",
14: "Closed",
15: "Client Review",
16: "Hidden",
17: "Invoiced",
18: "Partial Payment",
19: "Paid",
}
STATUS_COLORS = {
"Draft": "#BDC3C7",
"In Review": "#3498DB",
"Selected": "#1ABC9C",
"Quoting": "#F39C12",
"Re-submit": "#E67E22",
"Rejected": "#E74C3C",
"Approved": "#2ECC71",
"Ordered": "#27AE60",
"Payment Due": "#F1C40F",
"In Production": "#8E44AD",
"In Transit": "#2980B9",
"Installed": "#16A085",
"Delivered": "#4C9BE8",
"Closed": "#7F8C8D",
"Client Review": "#9B59B6",
"Hidden": "#95A5A6",
"Invoiced": "#D35400",
"Partial Payment": "#E74C3C",
"Paid": "#2ECC71",
}
def parse_timedelta(s):
if not isinstance(s, str):
return None
m = re.match(r"(-?\d+) days \+?(-?\d+):(\d+):(\d+)", s)
if not m:
return None
days, hours, minutes, seconds = int(m[1]), int(m[2]), int(m[3]), float(m[4])
return days * 24 + hours + minutes / 60 + seconds / 3600
df["approval_hours"] = df["time_to_approval"].apply(parse_timedelta)
df["approval_days"] = df["approval_hours"].apply(
lambda h: round(h / 24, 1) if h is not None else None
)
df["added_days"] = (
df["time_to_approved_added"]
.apply(parse_timedelta)
.apply(lambda h: round(h / 24, 1) if h is not None else None)
)
df["status_label"] = (
df["APPROVED_ITEM_STATUS"]
.map(STATUS_LABELS)
.fillna(df["APPROVED_ITEM_STATUS"].astype(str))
)
df["rejected_top_cat"] = df["REJECTED_ITEM_CATEGORY"].str.split(":").str[0]
df["approved_top_cat"] = df["APPROVED_ITEM_CATEGORY"].str.split(":").str[0]
unique_rejected = df.drop_duplicates("REJECTED_ITEM_ID")
n_rejections = df["REJECTED_ITEM_ID"].nunique()
n_approved_alts = df["APPROVED_ITEM_ID"].nunique()
n_resolved = unique_rejected["RESOLVED"].astype(str).str.lower().eq("true").sum()
n_rooms = df["SUBSECTION_NAME"].nunique()
TABLE_COLS = [
"REJECTED_ITEM_NAME",
"REJECTED_ITEM_CATEGORY",
"COMMENT",
"reason",
"APPROVED_ITEM_NAME",
"APPROVED_ITEM_CATEGORY",
"status_label",
"APPROVED_ITEM_COMMENT",
"approval_days",
"SUBSECTION_NAME",
"confidence",
]
table_df = df[TABLE_COLS].rename(
columns={
"REJECTED_ITEM_NAME": "Rejected Item",
"REJECTED_ITEM_CATEGORY": "Rejected Category",
"COMMENT": "Rejection Comment",
"reason": "Reason",
"APPROVED_ITEM_NAME": "Approved Alternative",
"APPROVED_ITEM_CATEGORY": "Approved Category",
"status_label": "Status",
"APPROVED_ITEM_COMMENT": "Approval Comment",
"approval_days": "Days to Approval",
"SUBSECTION_NAME": "Room",
"confidence": "Confidence",
}
)
# ── Annotation helpers ───────────────────────────────────────────────────────
def _pull_from_hub():
"""Download the annotation CSV from the HF dataset repo into the local file."""
if not (HF_DATASET_REPO and HF_TOKEN):
return
try:
path = hf_hub_download(
repo_id=HF_DATASET_REPO,
filename=ANNOTATION_FILE,
repo_type="dataset",
token=HF_TOKEN,
)
pd.read_csv(path, dtype=str).fillna("").to_csv(ANNOTATION_FILE, index=False)
except Exception:
pass # file doesn't exist yet on the hub — that's fine
def _push_to_hub():
"""Upload the local annotation CSV to the HF dataset repo."""
if not (HF_DATASET_REPO and HF_TOKEN):
return
try:
api = HfApi(token=HF_TOKEN)
api.upload_file(
path_or_fileobj=ANNOTATION_FILE,
path_in_repo=ANNOTATION_FILE,
repo_id=HF_DATASET_REPO,
repo_type="dataset",
commit_message="Update annotations",
)
except Exception as e:
print(f"Hub push failed: {e}")
def load_annotations():
_pull_from_hub()
if not os.path.exists(ANNOTATION_FILE):
return {}
try:
ann_df = pd.read_csv(ANNOTATION_FILE, dtype=str).fillna("")
return {
f"{row['REJECTED_ITEM_ID']}|{row['APPROVED_ITEM_ID']}": {
"is_direct": row["is_direct_replacement"],
"reasoning": row.get("reasoning", ""),
"timestamp": row.get("timestamp", ""),
}
for _, row in ann_df.iterrows()
}
except Exception:
return {}
def write_annotation(rejected_id, approved_id, is_direct, reasoning):
timestamp = datetime.now().isoformat(timespec="seconds")
new_row = {
"REJECTED_ITEM_ID": str(rejected_id),
"APPROVED_ITEM_ID": str(approved_id),
"is_direct_replacement": is_direct,
"reasoning": reasoning or "",
"timestamp": timestamp,
}
if os.path.exists(ANNOTATION_FILE):
ann_df = pd.read_csv(ANNOTATION_FILE, dtype=str)
mask = (ann_df["REJECTED_ITEM_ID"] == str(rejected_id)) & (
ann_df["APPROVED_ITEM_ID"] == str(approved_id)
)
if mask.any():
for col, val in new_row.items():
ann_df.loc[mask, col] = val
else:
ann_df = pd.concat([ann_df, pd.DataFrame([new_row])], ignore_index=True)
else:
ann_df = pd.DataFrame([new_row], columns=ANNOTATION_COLS)
ann_df.to_csv(ANNOTATION_FILE, index=False)
_push_to_hub()
# ── Overview charts (built once at startup) ──────────────────────────────────
def build_overview_charts():
reason_counts = (
df.groupby("reason")["REJECTED_ITEM_ID"]
.nunique()
.sort_values(ascending=True)
.tail(20)
)
fig_reasons = px.bar(
x=reason_counts.values,
y=reason_counts.index,
orientation="h",
labels={"x": "# Rejected Items", "y": ""},
title="Top Rejection Reasons",
color=reason_counts.values,
color_continuous_scale="Blues",
)
fig_reasons.update_layout(
coloraxis_showscale=False, margin=dict(l=10, r=20, t=40, b=10), height=500
)
status_counts = df["status_label"].value_counts()
fig_status = px.pie(
values=status_counts.values,
names=status_counts.index,
title="Approved Alternative Status",
hole=0.45,
color_discrete_sequence=px.colors.qualitative.Pastel,
)
fig_status.update_layout(margin=dict(l=10, r=10, t=40, b=10), height=360)
fig_time = px.histogram(
df["approval_days"].dropna(),
nbins=40,
title="Time to Approval (days)",
labels={"value": "Days"},
color_discrete_sequence=["#4C9BE8"],
)
fig_time.update_layout(
showlegend=False,
margin=dict(l=10, r=10, t=40, b=10),
height=320,
xaxis_title="Days to Approval",
yaxis_title="Count",
)
sankey_df = (
df.groupby(["rejected_top_cat", "approved_top_cat"])
.size()
.reset_index(name="count")
.query("count >= 2")
)
all_nodes = list(
pd.unique(
sankey_df["rejected_top_cat"].tolist()
+ sankey_df["approved_top_cat"].tolist()
)
)
node_idx = {n: i for i, n in enumerate(all_nodes)}
n_rej_nodes = len(sankey_df["rejected_top_cat"].unique())
fig_sankey = go.Figure(
go.Sankey(
node=dict(
label=all_nodes,
pad=12,
thickness=18,
color=["#4C9BE8"] * n_rej_nodes
+ ["#F4A261"] * (len(all_nodes) - n_rej_nodes),
),
link=dict(
source=[node_idx[r] for r in sankey_df["rejected_top_cat"]],
target=[node_idx[a] for a in sankey_df["approved_top_cat"]],
value=sankey_df["count"].tolist(),
color="rgba(76,155,232,0.25)",
),
)
)
fig_sankey.update_layout(
title="Rejected → Approved Category Flow",
height=420,
margin=dict(l=10, r=10, t=40, b=10),
)
room_counts = (
df.groupby("SUBSECTION_NAME")["REJECTED_ITEM_ID"]
.nunique()
.sort_values(ascending=False)
.head(15)
)
fig_rooms = px.bar(
x=room_counts.index,
y=room_counts.values,
title="Rejections by Room / Subsection",
labels={"x": "", "y": "# Rejected Items"},
color=room_counts.values,
color_continuous_scale="Teal",
)
fig_rooms.update_layout(
coloraxis_showscale=False,
margin=dict(l=10, r=10, t=40, b=30),
height=320,
xaxis_tickangle=-35,
)
return fig_reasons, fig_status, fig_time, fig_sankey, fig_rooms
fig_reasons, fig_status, fig_time, fig_sankey, fig_rooms = build_overview_charts()
# ── Item detail helpers ───────────────────────────────────────────────────────
item_options_df = (
df[["SCHEDULE_ITEM_ID", "REJECTED_ITEM_NAME", "SUBSECTION_NAME", "RESOLVED"]]
.drop_duplicates("SCHEDULE_ITEM_ID")
.sort_values("REJECTED_ITEM_NAME")
)
def build_item_choices(filter_category=True, resolved_filter="All"):
annotations = load_annotations()
choices = []
for row in item_options_df.itertuples():
if resolved_filter != "All":
is_resolved = str(getattr(row, "RESOLVED", "")).lower() == "true"
if resolved_filter == "Resolved only" and not is_resolved:
continue
if resolved_filter == "Unresolved only" and is_resolved:
continue
sid = str(row.SCHEDULE_ITEM_ID)
name = row.REJECTED_ITEM_NAME or "Unknown"
room = row.SUBSECTION_NAME or ""
s_rows = df[df["SCHEDULE_ITEM_ID"] == row.SCHEDULE_ITEM_ID]
if filter_category:
rej_cat = s_rows.iloc[0]["REJECTED_ITEM_CATEGORY"]
s_rows = s_rows[s_rows["APPROVED_ITEM_CATEGORY"] == rej_cat]
n_total = len(s_rows)
keys = {
f"{str(r['REJECTED_ITEM_ID'])}|{str(r['APPROVED_ITEM_ID'])}"
for _, r in s_rows.iterrows()
}
n_annotated = len(keys & annotations.keys())
label = f"{name} — {room} ({n_annotated}/{n_total})"
choices.append((label, sid))
return choices
# ── Overview filter ───────────────────────────────────────────────────────────
def filter_table(search, statuses, rooms):
filtered = table_df.copy()
if search:
mask = (
filtered["Rejected Item"].str.contains(search, case=False, na=False)
| filtered["Approved Alternative"].str.contains(
search, case=False, na=False
)
| filtered["Reason"].str.contains(search, case=False, na=False)
| filtered["Rejection Comment"].str.contains(search, case=False, na=False)
)
filtered = filtered[mask]
if statuses:
filtered = filtered[filtered["Status"].isin(statuses)]
if rooms:
filtered = filtered[filtered["Room"].isin(rooms)]
return filtered
# ── Item detail helpers ───────────────────────────────────────────────────────
_LABEL_STYLES = {
"true": ("✓ Direct Replacement", "#2ECC71"),
"false": ("✗ Not Direct", "#E74C3C"),
"sme": ("? Needs SME", "#F39C12"),
}
# JS injected into each card to update the hidden textbox when clicked
_CARD_CLICK_JS = (
"(function(aid){{"
"var w=document.getElementById('clicked_card_id');"
"var el=w&&(w.querySelector('textarea')||w.querySelector('input'));"
"if(el){{el.value=aid;el.dispatchEvent(new Event('input',{{bubbles:true}}));el.dispatchEvent(new Event('change',{{bubbles:true}}));}}"
"}})('{aid}')"
)
def _cf(label, value):
if not value and value != 0:
return ""
return (
f'
'
f'{label}'
f'{value}'
f"
"
)
def _dv(v):
return str(v)[:10] if v and str(v) not in ("nan", "None", "") else ""
def build_section_items_html(subsection_id, exclude_ids=None, status_filter=None):
if _section_items_df.empty or not subsection_id:
return ""
rows = _section_items_df[
_section_items_df["SCHEDULE_SECTION_ID"] == str(subsection_id)
]
if exclude_ids:
rows = rows[~rows["ID"].isin({str(i) for i in exclude_ids})]
def _resolve_status(s):
try:
return STATUS_LABELS.get(int(s), s)
except (ValueError, TypeError):
return s
rows = rows[rows["STATUS"].apply(_resolve_status) != "Hidden"]
if status_filter:
rows = rows[rows["STATUS"].apply(_resolve_status).isin(status_filter)]
if rows.empty:
return ""
cards = ""
for _, r in rows.iterrows():
img_src = get_item_image(r.get("ID", ""))
name = r.get("PRODUCT_NAME", "") or r.get("PRODUCT_DETAILS", "") or "Unknown"
status = r.get("STATUS", "")
try:
status = STATUS_LABELS.get(int(status), status)
except (ValueError, TypeError):
pass
status_color = STATUS_COLORS.get(status, "#95A5A6")
dims = " × ".join(
f"{r[k]}{s}"
for k, s in [("WIDTH", "W"), ("LENGTH", "L"), ("HEIGHT", "H"), ("DEPTH", "D")]
if r.get(k, "") not in ("", "nan", "None")
)
specs = " · ".join(
r[k]
for k in ("COLOUR", "FINISH", "MATERIAL")
if r.get(k, "") not in ("", "nan", "None")
)
url = r.get("PRODUCT_WEBSITE", "") or ""
name_html = (
f'{name}'
if url and str(url).startswith("http")
else f'{name}'
)
category = r.get("CATEGORY", "[no CATEGORY column]") or "[empty]"
cards += (
f'
'
f'
'
f''
f'
'
f'
'
f'{name_html}'
f'{status}'
f'
ID: {r.get("ID", "")}
'
f'
{category}
'
+ (f'
{dims}
' if dims else "")
+ (f'
{specs}
' if specs else "")
+ "
"
)
n = len(rows)
section_name_rows = df[df["SUBSECTION_ID"].astype(str) == str(subsection_id)]
section_name = section_name_rows.iloc[0]["SUBSECTION_NAME"] if not section_name_rows.empty else "this section"
return (
f'
'
f'
All items in {section_name} ({n})
'
f'
'
f'{cards}'
f'
'
)
def build_alts_html(rows, annotations, selected_aid=None):
cards_html = ""
for _, r in rows.iterrows():
aid = int(r["APPROVED_ITEM_ID"])
rid = int(r["REJECTED_ITEM_ID"])
key = f"{rid}|{aid}"
ann = annotations.get(key, {})
annotated = bool(ann)
is_selected = (
str(aid) == str(selected_aid) if selected_aid is not None else False
)
status = r.get("status_label", "")
status_color = STATUS_COLORS.get(status, "#95A5A6")
img_src = get_item_image(aid)
img_html = (
f'
'
f''
f'
'
)
url = r.get("APPROVED_ITEM_URL", "") or ""
name = r.get("APPROVED_ITEM_NAME", "Unknown")
name_html = (
f'{name}'
if url and str(url).startswith("http")
else f'{name}'
)
status_badge = (
f'{status}'
)
ann_badge = ""
if annotated:
is_d = ann.get("is_direct", "")
lbl, color = _LABEL_STYLES.get(is_d, ("Unknown", "#aaa"))
ts = ann.get("timestamp", "")[:10]
ann_badge = (
f'
'
f'{lbl}'
f'annotated {ts}'
+ (
f'
'
f"{ann.get('reasoning', '')}
"
if ann.get("reasoning")
else ""
)
+ "
"
)
comment = r.get("APPROVED_ITEM_COMMENT", "") or ""
comment_html = ""
if comment and str(comment) not in ("nan", "None", ""):
comment_html = (
f'