brianleprograma's picture
Upload folder using huggingface_hub
c4d87e1 verified
Raw
History Blame Contribute Delete
48.2 kB
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'<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80">'
b'<rect width="80" height="80" fill="#f3f4f6" rx="4"/>'
b'<rect x="20" y="18" width="40" height="32" rx="3" fill="#e5e7eb"/>'
b'<circle cx="31" cy="29" r="5" fill="#d1d5db"/>'
b'<path d="M18 50 L34 34 L45 45 L52 37 L62 50Z" fill="#d1d5db"/>'
b"</svg>"
).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'<div style="margin-bottom:8px">'
f'<span style="font-size:10px;font-weight:600;opacity:0.5;text-transform:uppercase;'
f'letter-spacing:.05em;display:block;margin-bottom:2px">{label}</span>'
f'<span style="font-size:13px">{value}</span>'
f"</div>"
)
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'<a href="{url}" target="_blank" style="font-size:12px;font-weight:600;'
f'color:#4C9BE8;text-decoration:none;display:block;margin-bottom:4px;'
f'overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical">{name}</a>'
if url and str(url).startswith("http")
else f'<span style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;'
f'overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical">{name}</span>'
)
category = r.get("CATEGORY", "[no CATEGORY column]") or "[empty]"
cards += (
f'<div style="flex:0 0 150px;border-radius:8px;overflow:hidden;'
f'background:rgba(128,128,128,0.08);border-top:3px solid {status_color}">'
f'<div style="background:#f8f8f8;height:110px;display:flex;align-items:center;justify-content:center">'
f'<img src="{img_src}" style="max-width:100%;max-height:110px;object-fit:contain;display:block" />'
f'</div>'
f'<div style="padding:8px 10px">'
f'{name_html}'
f'<span style="background:{status_color};color:#fff;font-size:10px;font-weight:600;'
f'padding:2px 7px;border-radius:20px;display:inline-block;margin-bottom:5px">{status}</span>'
f'<div style="font-size:10px;opacity:0.4;margin-top:4px;margin-bottom:2px">ID: {r.get("ID", "")}</div>'
f'<div style="font-size:10px;opacity:0.5;margin-top:2px;margin-bottom:2px">{category}</div>'
+ (f'<div style="font-size:11px;opacity:0.55;margin-top:3px">{dims}</div>' if dims else "")
+ (f'<div style="font-size:11px;opacity:0.45;margin-top:2px">{specs}</div>' if specs else "")
+ "</div></div>"
)
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'<div style="padding-top:16px;border-top:1px solid rgba(128,128,128,0.2)">'
f'<div style="font-size:11px;font-weight:700;opacity:0.4;text-transform:uppercase;'
f'letter-spacing:.06em;margin-bottom:10px">All items in {section_name} ({n})</div>'
f'<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px">'
f'{cards}'
f'</div></div>'
)
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'<div style="background:#f8f8f8;height:200px;display:flex;align-items:center;justify-content:center">'
f'<img src="{img_src}" style="max-width:100%;max-height:200px;object-fit:contain;display:block" />'
f'</div>'
)
url = r.get("APPROVED_ITEM_URL", "") or ""
name = r.get("APPROVED_ITEM_NAME", "Unknown")
name_html = (
f'<a href="{url}" target="_blank" style="font-size:15px;font-weight:600;'
f'color:#4C9BE8;text-decoration:none">{name}</a>'
if url and str(url).startswith("http")
else f'<span style="font-size:15px;font-weight:600">{name}</span>'
)
status_badge = (
f'<span style="background:{status_color};color:#fff;font-size:11px;font-weight:600;'
f'padding:3px 10px;border-radius:20px;display:inline-block;margin-top:4px">{status}</span>'
)
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'<div style="margin-top:10px;padding-top:10px;border-top:1px solid rgba(128,128,128,0.2)">'
f'<span style="background:{color};color:#fff;font-size:11px;font-weight:600;'
f'padding:3px 10px;border-radius:20px;margin-right:8px">{lbl}</span>'
f'<span style="font-size:11px;opacity:0.5">annotated {ts}</span>'
+ (
f'<div style="font-size:12px;opacity:0.7;margin-top:6px;font-style:italic">'
f"{ann.get('reasoning', '')}</div>"
if ann.get("reasoning")
else ""
)
+ "</div>"
)
comment = r.get("APPROVED_ITEM_COMMENT", "") or ""
comment_html = ""
if comment and str(comment) not in ("nan", "None", ""):
comment_html = (
f'<div style="margin-bottom:8px">'
f'<span style="font-size:10px;font-weight:600;opacity:0.5;text-transform:uppercase;'
f'letter-spacing:.05em;display:block;margin-bottom:4px">Client Comment</span>'
f'<p style="font-size:13px;opacity:0.8;margin:0;line-height:1.5;font-style:italic">'
f"{comment}</p></div>"
)
if is_selected:
outline, bg = "3px solid #4C9BE8", "rgba(76,155,232,0.1)"
elif annotated:
outline, bg = "2px solid #2ECC71", "rgba(46,204,113,0.08)"
else:
outline, bg = "none", "rgba(128,128,128,0.08)"
onclick = _CARD_CLICK_JS.format(aid=aid)
cards_html += (
f'<div onclick="{onclick}" style="background:{bg};border-radius:8px;'
f'margin-bottom:12px;border-left:3px solid {status_color};outline:{outline};cursor:pointer;overflow:hidden">'
f"{img_html}"
f'<div style="padding:12px 14px">'
f'<div style="margin-bottom:8px"><div style="margin-bottom:4px">{name_html}</div>{status_badge}</div>'
+ _cf("Item ID", aid)
+ _cf("Category", r.get("APPROVED_ITEM_CATEGORY", ""))
+ (lambda d, s: _cf("Dimensions", d) + _cf("Colour / Finish / Material", s))(
*_item_specs(aid)
)
+ _cf("Added", _dv(r.get("APPROVED_ITEM_ADDED_AT", "")))
+ _cf("Approved", _dv(r.get("APPROVED_ITEM_APPROVED_AT", "")))
+ _cf(
"Days (rejection β†’ added)",
r.get("added_days")
if str(r.get("added_days", "")) not in ("nan", "None", "")
else "",
)
+ _cf(
"Days (rejection β†’ approved)",
r.get("approval_days")
if str(r.get("approval_days", "")) not in ("nan", "None", "")
else "",
)
+ comment_html
+ ann_badge
+ "</div>" # close content div
+ "</div>" # close card div
)
return (
f'<div style="padding-right:4px">{cards_html}</div>'
if cards_html
else "<p style='opacity:0.5'>No alternatives found.</p>"
)
def _get_filtered_rows(schedule_item_id, filter_on):
rows = df[df["SCHEDULE_ITEM_ID"].astype(str) == str(schedule_item_id)]
if rows.empty:
return rows
if filter_on:
rej_cat = rows.iloc[0]["REJECTED_ITEM_CATEGORY"]
rows = rows[rows["APPROVED_ITEM_CATEGORY"] == rej_cat]
return rows
# ── Item detail callbacks ─────────────────────────────────────────────────────
def on_item_select(schedule_item_id, filter_on):
if not schedule_item_id:
return "", "", gr.update(choices=[], value=None), None, "", "", "", None, []
rows = df[df["SCHEDULE_ITEM_ID"].astype(str) == str(schedule_item_id)]
if rows.empty:
return "", "", gr.update(choices=[], value=None), None, "", "", "", None, []
first = rows.iloc[0]
if filter_on:
rows = rows[
rows["APPROVED_ITEM_CATEGORY"] == first.get("REJECTED_ITEM_CATEGORY", "")
]
def safe(v):
return "" if pd.isna(v) or v is None else str(v)
resolved = str(first.get("RESOLVED", "")).lower() == "true"
rej_url = str(first.get("REJECTED_ITEM_URL", "") or "")
rej_name = first.get("REJECTED_ITEM_NAME", "Unknown")
img_src = get_item_image(first.get("REJECTED_ITEM_ID", ""))
name_el = (
f'<a href="{rej_url}" target="_blank" style="font-size:17px;font-weight:700;'
f'color:#E74C3C;text-decoration:none">{rej_name}</a>'
if rej_url.startswith("http")
else f'<span style="font-size:17px;font-weight:700">{rej_name}</span>'
)
res_badge = (
f'<span style="background:{"#2ECC71" if resolved else "#E74C3C"};color:#fff;font-size:11px;'
f'font-weight:600;padding:3px 10px;border-radius:20px;display:inline-block;margin:6px 0 12px">'
f"{'βœ… Resolved' if resolved else '❌ Unresolved'}</span>"
)
comment = safe(first.get("COMMENT"))
comment_block = (
(
f'<div style="margin:8px 0 12px;padding:10px 12px;background:rgba(231,76,60,0.12);'
f'border-left:3px solid #E74C3C;border-radius:4px">'
f'<span style="font-size:10px;font-weight:700;color:#E74C3C;text-transform:uppercase;'
f'letter-spacing:.05em;display:block;margin-bottom:4px">Rejection Comment</span>'
f'<span style="font-size:13px;line-height:1.5">{comment}</span>'
f"</div>"
)
if comment
else ""
)
rej_html = (
f"<div>"
f'<img src="{img_src}" style="width:100%;max-height:180px;object-fit:cover;border-radius:8px;margin-bottom:12px"/>'
f'<div style="font-size:10px;font-weight:700;color:#E74C3C;letter-spacing:.1em;margin-bottom:4px">REJECTED</div>'
f'<div style="margin-bottom:4px">{name_el}</div>'
f"{res_badge}"
f"{comment_block}"
+ _cf("Item ID", safe(first.get("REJECTED_ITEM_ID")))
+ _cf("Brand", safe(first.get("BRAND")))
+ _cf("Category", safe(first.get("REJECTED_ITEM_CATEGORY")))
+ _cf("Section", safe(first.get("SECTION_NAME")))
+ _cf("Schedule section", safe(first.get("SUBSECTION_NAME")))
+ _cf("Rejection Category", safe(first.get("category")))
+ _cf("Description", safe(first.get("PRODUCT_DESCRIPTION")))
+ _cf("Rejected", safe(first.get("REJECTED_AT"))[:10])
+ _cf("Reason (AI)", safe(first.get("reason")))
+ _cf("Alt. Criteria", safe(first.get("alternative")))
+ _cf("Confidence", safe(first.get("confidence")))
+ (lambda d, s: _cf("Dimensions", d) + _cf("Colour / Finish / Material", s))(
*_item_specs(first.get("REJECTED_ITEM_ID", ""))
)
+ "</div>"
)
annotations = load_annotations()
approved_choices = [
(
f"{r['APPROVED_ITEM_NAME']} (ID:{int(r['APPROVED_ITEM_ID'])})",
str(int(r["APPROVED_ITEM_ID"])),
)
for _, r in rows.iterrows()
]
alts = build_alts_html(rows, annotations, selected_aid=None)
subsection_id = safe(first.get("SUBSECTION_ID"))
approved_ids = rows["APPROVED_ITEM_ID"].tolist()
section_html = build_section_items_html(subsection_id, exclude_ids=approved_ids + [schedule_item_id])
return rej_html, alts, gr.update(choices=approved_choices, value=None), None, "", "", section_html, subsection_id, approved_ids
def on_approved_select(schedule_item_id, approved_id, filter_on):
if not schedule_item_id or not approved_id:
return None, "", "", gr.update()
rows = _get_filtered_rows(schedule_item_id, filter_on)
if rows.empty:
return None, "", "", gr.update()
rejected_id = str(int(rows.iloc[0]["REJECTED_ITEM_ID"]))
key = f"{rejected_id}|{approved_id}"
annotations = load_annotations()
ann = annotations.get(key, {})
label_map = {
"true": "βœ“ Direct Replacement",
"false": "βœ— Not Direct",
"sme": "? Needs SME",
}
is_direct_label = label_map.get(ann.get("is_direct", ""))
reasoning = ann.get("reasoning", "")
ts = ann.get("timestamp", "")
status_str = f"*Previously annotated at {ts}*" if ts else "*Not yet annotated*"
alts = build_alts_html(rows, annotations, selected_aid=approved_id)
return is_direct_label, reasoning, status_str, alts
def on_card_click(clicked_aid, schedule_item_id, filter_on):
if not clicked_aid or not schedule_item_id:
return gr.update(), None, "", "", gr.update()
rows = _get_filtered_rows(schedule_item_id, filter_on)
if rows.empty:
return gr.update(), None, "", "", gr.update()
rejected_id = str(int(rows.iloc[0]["REJECTED_ITEM_ID"]))
key = f"{rejected_id}|{clicked_aid}"
annotations = load_annotations()
ann = annotations.get(key, {})
label_map = {
"true": "βœ“ Direct Replacement",
"false": "βœ— Not Direct",
"sme": "? Needs SME",
}
is_direct_label = label_map.get(ann.get("is_direct", ""))
reasoning = ann.get("reasoning", "")
ts = ann.get("timestamp", "")
status_str = f"*Previously annotated at {ts}*" if ts else "*Not yet annotated*"
alts = build_alts_html(rows, annotations, selected_aid=clicked_aid)
return gr.update(value=clicked_aid), is_direct_label, reasoning, status_str, alts
def on_confirm(
schedule_item_id,
approved_id,
is_direct_label,
reasoning,
filter_on,
resolved_filter,
):
if not schedule_item_id or not approved_id or not is_direct_label:
return (
"⚠️ Select an item, an alternative, and an annotation value first.",
gr.update(),
)
rows = df[df["SCHEDULE_ITEM_ID"].astype(str) == str(schedule_item_id)]
if rows.empty:
return "Item not found.", gr.update()
rejected_id = str(int(rows.iloc[0]["REJECTED_ITEM_ID"]))
value_map = {
"βœ“ Direct Replacement": "true",
"βœ— Not Direct": "false",
"? Needs SME": "sme",
}
is_direct = value_map.get(is_direct_label, "sme")
write_annotation(rejected_id, approved_id, is_direct, reasoning)
new_choices = build_item_choices(
filter_category=filter_on, resolved_filter=resolved_filter
)
return f"βœ“ Saved at {datetime.now().strftime('%H:%M:%S')}", gr.update(
choices=new_choices, value=schedule_item_id
)
def on_filter_change(filter_on, resolved_filter):
choices = build_item_choices(
filter_category=filter_on, resolved_filter=resolved_filter
)
return gr.update(choices=choices, value=None)
# ── Analysis callbacks ────────────────────────────────────────────────────────
def build_analysis():
annotations = load_annotations()
if not annotations:
empty = pd.DataFrame()
msg = "No annotations yet β€” annotate items on the Item Detail tab first."
return msg, None, None, None, None, None, empty
ann_rows = []
for key, val in annotations.items():
rejected_id, approved_id = key.split("|")
ann_rows.append(
{
"REJECTED_ITEM_ID": int(rejected_id),
"APPROVED_ITEM_ID": int(approved_id),
"is_direct": val["is_direct"],
"reasoning": val["reasoning"],
"timestamp": val["timestamp"],
}
)
ann_df = pd.DataFrame(ann_rows)
merged = ann_df.merge(
df[
[
"REJECTED_ITEM_ID",
"APPROVED_ITEM_ID",
"REJECTED_ITEM_CATEGORY",
"APPROVED_ITEM_CATEGORY",
"reason",
"SUBSECTION_NAME",
"approval_days",
"status_label",
"REJECTED_ITEM_NAME",
"APPROVED_ITEM_NAME",
]
],
on=["REJECTED_ITEM_ID", "APPROVED_ITEM_ID"],
how="left",
)
merged["rejected_top_cat"] = merged["REJECTED_ITEM_CATEGORY"].str.split(":").str[0]
LABEL_MAP = {
"true": "Direct Replacement",
"false": "Not Direct",
"sme": "Needs SME",
}
COLOR_MAP = {
"Direct Replacement": "#2ECC71",
"Not Direct": "#E74C3C",
"Needs SME": "#F39C12",
}
merged["label"] = merged["is_direct"].map(LABEL_MAP).fillna("Unknown")
n_total = df["APPROVED_ITEM_ID"].nunique()
n_done = len(merged)
n_direct = int((merged["is_direct"] == "true").sum())
n_not = int((merged["is_direct"] == "false").sum())
n_sme = int((merged["is_direct"] == "sme").sum())
stats_md = (
f"**{n_done} / {n_total}** annotated &nbsp;|&nbsp; "
f"**{n_direct}** Direct Replacements &nbsp;|&nbsp; "
f"**{n_not}** Not Direct &nbsp;|&nbsp; "
f"**{n_sme}** Needs SME"
)
fig_donut = go.Figure(go.Pie(
values=[n_direct, n_not, n_sme, max(n_total - n_done, 0)],
labels=["Direct Replacement", "Not Direct", "Needs SME", "Unannotated"],
hole=0.55,
marker=dict(colors=["#2ECC71", "#E74C3C", "#F39C12", "#95A5A6"]),
))
fig_donut.update_layout(title="Annotation Breakdown", margin=dict(l=10, r=10, t=40, b=10), height=340)
cat_summary = (
merged.groupby(["rejected_top_cat", "label"]).size().reset_index(name="count")
)
fig_cat = px.bar(
cat_summary,
x="rejected_top_cat",
y="count",
color="label",
title="Annotation Result by Category",
labels={"rejected_top_cat": "", "count": "# Items", "label": ""},
color_discrete_map=COLOR_MAP,
barmode="stack",
)
fig_cat.update_layout(
margin=dict(l=10, r=10, t=40, b=60),
height=360,
xaxis_tickangle=-30,
legend=dict(orientation="h", y=1.1),
)
reason_summary = (
merged.groupby(["reason", "label"]).size().reset_index(name="count")
)
top_reasons = (
reason_summary.groupby("reason")["count"]
.sum()
.sort_values(ascending=False)
.head(15)
.index
)
reason_summary = reason_summary[reason_summary["reason"].isin(top_reasons)]
fig_reason = px.bar(
reason_summary,
x="count",
y="reason",
color="label",
orientation="h",
title="Annotation Result by Rejection Reason (top 15)",
labels={"reason": "", "count": "# Items", "label": ""},
color_discrete_map=COLOR_MAP,
barmode="stack",
)
fig_reason.update_layout(
margin=dict(l=10, r=20, t=40, b=10),
height=480,
legend=dict(orientation="h", y=1.05),
)
days_data = merged[merged["approval_days"].notna() & (merged["approval_days"] >= 0)]
fig_days = px.box(
days_data,
x="label",
y="approval_days",
color="label",
title="Days to Approval by Annotation Type",
labels={"label": "", "approval_days": "Days"},
color_discrete_map=COLOR_MAP,
points="outliers",
)
fig_days.update_layout(
showlegend=False,
margin=dict(l=10, r=10, t=40, b=10),
height=320,
)
direct_status = merged[merged["is_direct"] == "true"]["status_label"].value_counts()
fig_direct_status = px.bar(
x=direct_status.index,
y=direct_status.values,
title="Status of Direct Replacements",
labels={"x": "", "y": "# Items"},
color=direct_status.index,
color_discrete_map=STATUS_COLORS,
)
fig_direct_status.update_layout(
showlegend=False,
margin=dict(l=10, r=10, t=40, b=40),
height=320,
xaxis_tickangle=-20,
)
table_cols = [
"REJECTED_ITEM_NAME",
"APPROVED_ITEM_NAME",
"REJECTED_ITEM_CATEGORY",
"label",
"reasoning",
"status_label",
"approval_days",
"SUBSECTION_NAME",
"timestamp",
]
table_data = (
merged[table_cols]
.rename(
columns={
"REJECTED_ITEM_NAME": "Rejected Item",
"APPROVED_ITEM_NAME": "Approved Item",
"REJECTED_ITEM_CATEGORY": "Category",
"label": "Annotation",
"reasoning": "Reasoning",
"status_label": "Status",
"approval_days": "Days to Approval",
"SUBSECTION_NAME": "Room",
"timestamp": "Annotated At",
}
)
.sort_values("Annotation")
)
return (
stats_md,
fig_donut,
fig_days,
fig_cat,
fig_reason,
fig_direct_status,
table_data,
)
# ── Gradio App ────────────────────────────────────────────────────────────────
with gr.Blocks(
title="Replacement Products Annotation",
theme=gr.themes.Soft(),
css=(
"#clicked_card_id { display: none; }"
"#annotation_row { align-items: flex-start !important; }"
"#cards_col { overflow-y: scroll !important; max-height: calc(100vh - 60px) !important; }"
"#cards_col::-webkit-scrollbar { width: 4px; }"
"#cards_col::-webkit-scrollbar-track { background: transparent; }"
"#cards_col::-webkit-scrollbar-thumb { background: rgba(128,128,128,0.3); border-radius: 4px; }"
"#section_col { overflow-y: scroll !important; max-height: calc(100vh - 60px) !important; }"
"#section_col::-webkit-scrollbar { width: 4px; }"
"#section_col::-webkit-scrollbar-track { background: transparent; }"
"#section_col::-webkit-scrollbar-thumb { background: rgba(128,128,128,0.3); border-radius: 4px; }"
),
) as demo:
gr.Markdown("# Replacement Products Annotation")
with gr.Tabs():
# ── Guide ─────────────────────────────────────────────────────────────
with gr.Tab("Guide"):
gr.Markdown(README_MD)
# ── Annotation ────────────────────────────────────────────────────────
with gr.Tab("Annotation"):
with gr.Row():
item_dropdown = gr.Dropdown(
choices=build_item_choices(filter_category=True),
label="Select Rejected Item",
scale=4,
)
cat_filter_cb = gr.Checkbox(
label="Match category only", value=True, scale=1
)
resolved_radio = gr.Radio(
choices=["All", "Resolved only", "Unresolved only"],
value="All",
label="Resolution",
scale=2,
)
section_status_filter = gr.Dropdown(
choices=[
("Curation", ["Draft", "Hidden", "Quoting", "Selected", "In Review"]),
("Client Approval", ["Client Review", "Approved", "Rejected", "Re-submit", "Closed"]),
("Procurement", ["Ordered", "In Production", "In Transit", "Delivered", "Installed"]),
("Payment", ["Invoiced", "Payment Due", "Partial Payment", "Paid"]),
],
multiselect=True,
label="Filter section items by status",
value=None,
scale=2,
)
clicked_card = gr.Textbox(elem_id="clicked_card_id", label="")
subsection_id_state = gr.State(None)
approved_ids_state = gr.State([])
with gr.Row(elem_id="annotation_panel"):
with gr.Column(scale=2):
approved_dropdown = gr.Dropdown(
choices=[], label="Select Alternative to Annotate"
)
with gr.Column(scale=1):
is_direct_radio = gr.Radio(
choices=["βœ“ Direct Replacement", "βœ— Not Direct", "? Needs SME"],
label="Annotation",
)
with gr.Column(scale=2):
reasoning_input = gr.Textbox(label="Reasoning (optional)", lines=2)
with gr.Column(scale=1):
ann_status_md = gr.Markdown("")
confirm_btn = gr.Button("Confirm", variant="primary")
confirm_feedback = gr.Markdown("")
with gr.Row(elem_id="annotation_row"):
with gr.Column(scale=1):
rejected_info = gr.HTML("")
with gr.Column(scale=2, elem_id="cards_col"):
alts_html = gr.HTML()
with gr.Column(scale=2, elem_id="section_col"):
section_items_html = gr.HTML(elem_id="section_items_html")
item_dropdown.change(
on_item_select,
inputs=[item_dropdown, cat_filter_cb],
outputs=[
rejected_info,
alts_html,
approved_dropdown,
is_direct_radio,
reasoning_input,
confirm_feedback,
section_items_html,
subsection_id_state,
approved_ids_state,
],
)
def on_section_status_filter_change(status_filter, subsection_id, approved_ids, schedule_item_id):
return build_section_items_html(
subsection_id,
exclude_ids=(approved_ids or []) + ([schedule_item_id] if schedule_item_id else []),
status_filter=status_filter or None,
)
section_status_filter.change(
on_section_status_filter_change,
inputs=[section_status_filter, subsection_id_state, approved_ids_state, item_dropdown],
outputs=[section_items_html],
)
approved_dropdown.change(
on_approved_select,
inputs=[item_dropdown, approved_dropdown, cat_filter_cb],
outputs=[is_direct_radio, reasoning_input, ann_status_md, alts_html],
)
clicked_card.change(
on_card_click,
inputs=[clicked_card, item_dropdown, cat_filter_cb],
outputs=[
approved_dropdown,
is_direct_radio,
reasoning_input,
ann_status_md,
alts_html,
],
)
confirm_btn.click(
on_confirm,
inputs=[
item_dropdown,
approved_dropdown,
is_direct_radio,
reasoning_input,
cat_filter_cb,
resolved_radio,
],
outputs=[confirm_feedback, item_dropdown],
)
for control in [cat_filter_cb, resolved_radio]:
control.change(
on_filter_change,
inputs=[cat_filter_cb, resolved_radio],
outputs=[item_dropdown],
)
# ── Analysis ──────────────────────────────────────────────────────────
with gr.Tab("Analysis"):
with gr.Row():
refresh_btn = gr.Button("Refresh Analysis", variant="secondary")
download_btn = gr.Button(
"Download Annotations CSV", variant="secondary"
)
download_file = gr.File(label="Annotations CSV", visible=False)
analysis_stats = gr.Markdown("")
with gr.Row():
analysis_donut = gr.Plot()
analysis_days = gr.Plot()
analysis_cat = gr.Plot()
with gr.Row():
analysis_reason = gr.Plot()
analysis_direct_status = gr.Plot()
analysis_table = gr.DataFrame(label="Annotation Log", interactive=False)
analysis_outputs = [
analysis_stats,
analysis_donut,
analysis_days,
analysis_cat,
analysis_reason,
analysis_direct_status,
analysis_table,
]
refresh_btn.click(build_analysis, outputs=analysis_outputs)
demo.load(build_analysis, outputs=analysis_outputs)
def export_annotations_csv():
_pull_from_hub()
if not os.path.exists(ANNOTATION_FILE):
return gr.update(visible=False)
return gr.update(value=ANNOTATION_FILE, visible=True)
download_btn.click(export_annotations_csv, outputs=download_file)
if __name__ == "__main__":
demo.launch()