| 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 |
|
|
| |
|
|
| |
| |
| |
| 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", |
| ] |
|
|
| |
|
|
| with open("README.md") as _f: |
| _readme_raw = _f.read() |
| |
| README_MD = re.sub(r"^---.*?---\s*", "", _readme_raw, flags=re.DOTALL) |
|
|
| |
|
|
| _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("") |
| |
| _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 |
|
|
|
|
| |
|
|
| 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", |
| } |
| ) |
|
|
| |
|
|
|
|
| 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 |
|
|
|
|
| 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() |
|
|
|
|
| |
|
|
|
|
| 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_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 |
|
|
|
|
| |
|
|
|
|
| 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 |
|
|
|
|
| |
|
|
| _LABEL_STYLES = { |
| "true": ("β Direct Replacement", "#2ECC71"), |
| "false": ("β Not Direct", "#E74C3C"), |
| "sme": ("? Needs SME", "#F39C12"), |
| } |
|
|
| |
| _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>" |
| + "</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 |
|
|
|
|
| |
|
|
|
|
| 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) |
|
|
|
|
| |
|
|
|
|
| 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, |
| ) |
|
|
|
|
| |
|
|
| 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(): |
| |
| with gr.Tab("Guide"): |
| gr.Markdown(README_MD) |
|
|
| |
| 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], |
| ) |
|
|
| |
| 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() |
|
|