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'' b'' b'' b'' b'' 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'
' f'Client Comment' f'

' f"{comment}

" ) 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'
' f"{img_html}" f'
' f'
{name_html}
{status_badge}
' + _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 + "
" # close content div + "
" # close card div ) return ( f'
{cards_html}
' if cards_html else "

No alternatives found.

" ) 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'{rej_name}' if rej_url.startswith("http") else f'{rej_name}' ) res_badge = ( f'' f"{'✅ Resolved' if resolved else '❌ Unresolved'}" ) comment = safe(first.get("COMMENT")) comment_block = ( ( f'
' f'Rejection Comment' f'{comment}' f"
" ) if comment else "" ) rej_html = ( f"
" f'' f'
REJECTED
' f'
{name_el}
' 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", "")) ) + "
" ) 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  |  " f"**{n_direct}** Direct Replacements  |  " f"**{n_not}** Not Direct  |  " 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()