Spaces:
Running on Zero
Running on Zero
| """Dual-interface Hugging Face Space for clothing aspect-level sentiment analysis.""" | |
| from __future__ import annotations | |
| import html | |
| import json | |
| import re | |
| import traceback | |
| from functools import lru_cache | |
| from pathlib import Path | |
| from typing import Any, Dict, List, Tuple | |
| try: | |
| import huggingface_hub as _hf_hub | |
| if not hasattr(_hf_hub, "HfFolder"): | |
| class _HfFolderCompat: | |
| def get_token(): | |
| try: | |
| return _hf_hub.get_token() | |
| except Exception: | |
| return None | |
| def save_token(token): | |
| return None | |
| def delete_token(): | |
| return None | |
| _hf_hub.HfFolder = _HfFolderCompat | |
| except Exception: | |
| pass | |
| import gradio as gr | |
| try: | |
| import spaces | |
| except Exception: | |
| class _SpacesCompat: | |
| def GPU(self, *args, **kwargs): | |
| if args and callable(args[0]) and len(args) == 1 and not kwargs: | |
| return args[0] | |
| def decorator(fn): | |
| return fn | |
| return decorator | |
| spaces = _SpacesCompat() | |
| from src import config as cfg | |
| from src.inference import AspectPredictor | |
| ROOT = Path(__file__).resolve().parent | |
| REPORT_DIR = ROOT / "reports" | |
| CHECKPOINT_DIR = ROOT / "checkpoints" / "meta_acsa" | |
| CHECKPOINT_PATH = CHECKPOINT_DIR / "best.pt" | |
| META_ENCODER_PATH = ROOT / "data" / "meta_encoder.pkl" | |
| ASPECTS = list(getattr(cfg, "ASPECTS", ["SIZE", "MATERIAL", "QUALITY", "APPEARANCE", "STYLE", "VALUE"])) | |
| ASPECT_DESCRIPTIONS = { | |
| "SIZE": "Fit, length, sizing accuracy, runs large or small.", | |
| "MATERIAL": "Fabric feel, thickness, breathability, and comfort.", | |
| "QUALITY": "Workmanship, durability, seams, and washing performance.", | |
| "APPEARANCE": "Color, print, pattern, image consistency, and visual look.", | |
| "STYLE": "Cut, silhouette, fashionability, and styling appeal.", | |
| "VALUE": "Price fairness, worthiness, return, and repurchase intent.", | |
| } | |
| ASPECT_KEYWORDS = { | |
| "SIZE": ["size", "fit", "fits", "small", "large", "tight", "loose", "xl", "medium", "waist", "length", "runs"], | |
| "MATERIAL": ["material", "fabric", "cotton", "polyester", "soft", "scratchy", "thin", "thick", "stretch", "breathable"], | |
| "QUALITY": ["quality", "stitch", "stitching", "seam", "wash", "durable", "cheap", "ripped", "tear", "button", "zipper"], | |
| "APPEARANCE": ["look", "looks", "color", "photo", "picture", "print", "design", "pattern", "beautiful", "cute"], | |
| "STYLE": ["style", "stylish", "flattering", "casual", "formal", "silhouette", "cut", "shape", "cropped"], | |
| "VALUE": ["price", "worth", "value", "money", "expensive", "cheap", "return", "buy", "recommend"], | |
| } | |
| LABEL_BG = {"Positive": "#dcfce7", "Negative": "#fee2e2", "Not_Mentioned": "#f1f5f9", "Neutral": "#e0f2fe"} | |
| LABEL_FG = {"Positive": "#15803d", "Negative": "#b91c1c", "Not_Mentioned": "#475569", "Neutral": "#0369a1"} | |
| PRODUCT_CATALOG_PATH = ROOT / "data" / "product_catalog.json" | |
| MAX_FINDER_PRODUCTS = 30 | |
| MAX_MONITOR_PRODUCTS = 30 | |
| FALLBACK_PRODUCTS = [ | |
| {"name": "Cotton Graphic Tee", "category": "Tops", "features": "100% Cotton, Slim Fit, Machine Wash Cold, Graphic Print", "categories": "Clothing > Men > T-Shirts > Graphic Tees", "price": 19.99, "average_rating": 4.2, "rating_number": 312, "tags": ["cotton", "casual", "print"], "review": "The size runs really small, I ordered an XL but it fits like a Medium. The fabric feels soft and the print looks great."}, | |
| {"name": "Stretch Yoga Leggings", "category": "Bottoms", "features": "Nylon Spandex Blend, High Waist, Four-Way Stretch, Moisture Wicking", "categories": "Clothing > Women > Activewear > Leggings", "price": 29.99, "average_rating": 4.5, "rating_number": 1280, "tags": ["stretch", "activewear", "high waist"], "review": "These leggings fit perfectly and the stretch is comfortable. The material is not see-through, but the seams started to loosen after washing."}, | |
| {"name": "Floral Summer Dress", "category": "Dresses", "features": "Rayon Blend, Floral Print, A-Line, Lightweight, V-Neck", "categories": "Clothing > Women > Dresses > Summer Dresses", "price": 36.50, "average_rating": 4.3, "rating_number": 864, "tags": ["floral", "summer", "dress"], "review": "The dress looks beautiful and the floral print is exactly as shown. The waist is a bit tight and the fabric wrinkles easily."}, | |
| ] | |
| def _load_products() -> List[Dict[str, Any]]: | |
| if PRODUCT_CATALOG_PATH.exists(): | |
| try: | |
| products = json.loads(PRODUCT_CATALOG_PATH.read_text(encoding="utf-8")) | |
| if isinstance(products, list) and products: | |
| return products | |
| except Exception: | |
| pass | |
| return FALLBACK_PRODUCTS | |
| PRODUCTS = _load_products() | |
| def _safe_float(value: Any, default: float = 0.0) -> float: | |
| try: | |
| if value in (None, ""): | |
| return default | |
| return float(value) | |
| except Exception: | |
| return default | |
| def _esc(value: Any) -> str: | |
| return html.escape(str(value)) | |
| def _pct(value: Any) -> str: | |
| try: | |
| return f"{float(value) * 100:.2f}%" | |
| except Exception: | |
| return "-" | |
| def _num(value: Any) -> str: | |
| try: | |
| return f"{float(value):.4f}" | |
| except Exception: | |
| return "-" | |
| def _load_json(name: str) -> Dict[str, Any]: | |
| path = REPORT_DIR / name | |
| try: | |
| return json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} | |
| except Exception: | |
| return {} | |
| def _predictor() -> AspectPredictor: | |
| return AspectPredictor(checkpoint_dir=CHECKPOINT_DIR) | |
| def _product_names() -> List[str]: | |
| return [p["name"] for p in PRODUCTS] | |
| def _get_product(name: str) -> Dict[str, Any]: | |
| return next((p for p in PRODUCTS if p["name"] == name), PRODUCTS[0]) | |
| def _rank_products(products: List[Dict[str, Any]]) -> List[Dict[str, Any]]: | |
| return sorted( | |
| products, | |
| key=lambda p: ( | |
| _safe_float(p.get("rating_number")), | |
| _safe_float(p.get("average_rating")), | |
| -_safe_float(p.get("price")), | |
| ), | |
| reverse=True, | |
| ) | |
| def _filtered_products(category: str = "All", tags: List[str] | None = None, min_rating: float = 0.0) -> List[Dict[str, Any]]: | |
| required = {str(tag).lower() for tag in (tags or [])} | |
| matches = [] | |
| for product in PRODUCTS: | |
| if category != "All" and product.get("category") != category: | |
| continue | |
| product_tags = {str(tag).lower() for tag in product.get("tags", [])} | |
| feature_text = f'{product.get("features", "")} {product.get("categories", "")}'.lower() | |
| if required and not all((tag in product_tags) or (tag in feature_text) for tag in required): | |
| continue | |
| if _safe_float(product.get("average_rating")) < _safe_float(min_rating): | |
| continue | |
| matches.append(product) | |
| return _rank_products(matches) | |
| def _meta(product_or_meta: Dict[str, Any]) -> Dict[str, Any]: | |
| return { | |
| "features_text": product_or_meta.get("features", product_or_meta.get("features_text", "")), | |
| "categories_text": product_or_meta.get("categories", product_or_meta.get("categories_text", "")), | |
| "price": _safe_float(product_or_meta.get("price")), | |
| "average_rating": _safe_float(product_or_meta.get("average_rating")), | |
| "rating_number": _safe_float(product_or_meta.get("rating_number")), | |
| } | |
| def _predict_product(product_name: str) -> Dict[str, Any]: | |
| product = _get_product(product_name) | |
| return _predictor().predict(product["review"], _meta(product)) | |
| def _predict_custom(review: str, features: str, categories: str, price: Any, rating: Any, count: Any) -> Dict[str, Any]: | |
| meta = {"features_text": features or "", "categories_text": categories or "", "price": _safe_float(price), "average_rating": _safe_float(rating), "rating_number": _safe_float(count)} | |
| return _predictor().predict(review or "No review text provided.", meta) | |
| def _error_html(detail: str) -> str: | |
| return f'<div class="status bad"><b>Prediction could not run.</b><details><summary>Technical detail</summary><pre>{_esc(detail)}</pre></details></div>' | |
| def _chip(label: str) -> str: | |
| bg = LABEL_BG.get(label, "#f1f5f9") | |
| fg = LABEL_FG.get(label, "#334155") | |
| return f'<span class="chip" style="background:{bg};color:{fg};">{_esc(label)}</span>' | |
| def _keyword_hits(text: str, aspect: str) -> List[str]: | |
| low = (text or "").lower() | |
| hits = [kw for kw in ASPECT_KEYWORDS.get(aspect, []) if kw.lower() in low] | |
| return list(dict.fromkeys(hits))[:8] | |
| def _highlight_review(text: str, aspect: str) -> str: | |
| safe = _esc(text) | |
| keys: List[str] = [] | |
| if aspect != "All": | |
| keys = ASPECT_KEYWORDS.get(aspect, []) | |
| else: | |
| for items in ASPECT_KEYWORDS.values(): | |
| keys.extend(items) | |
| for kw in sorted(set(keys), key=len, reverse=True): | |
| safe = re.sub(rf"\b({re.escape(kw)})\b", r"<mark>\1</mark>", safe, flags=re.IGNORECASE) | |
| return f'<div class="review-box">{safe}</div>' | |
| def _overall_html(result: Dict[str, Any]) -> str: | |
| overall = result.get("overall", {}) | |
| label = overall.get("label", "Unknown") | |
| conf = _safe_float(overall.get("confidence")) | |
| probs = overall.get("class_probs", {}) | |
| bars = [] | |
| for name in ["Negative", "Neutral", "Positive"]: | |
| val = _safe_float(probs.get(name)) | |
| bars.append(f'<div class="prob-row"><span>{name}</span><div class="bar"><i style="width:{val * 100:.1f}%"></i></div><b>{val:.2f}</b></div>') | |
| return f'<div class="overall-card"><div class="small-label">Overall sentiment</div><div class="overall-main">{_chip(label)} <span class="conf">confidence {conf:.2f}</span></div>{"".join(bars)}</div>' | |
| def _aspect_cards(result: Dict[str, Any], review: str, selected_aspect: str) -> str: | |
| details = result.get("aspect_details", {}) | |
| sources = result.get("top_meta_source_by_aspect", {}) | |
| cards = [] | |
| for aspect in ASPECTS: | |
| d = details.get(aspect, {}) | |
| label = d.get("label", result.get("aspects", {}).get(aspect, "Unknown")) | |
| conf = _safe_float(d.get("confidence")) | |
| hits = _keyword_hits(review, aspect) | |
| evidence = "".join(f'<span class="evidence-token">{_esc(h)}</span>' for h in hits) or '<span class="muted">No explicit keyword evidence.</span>' | |
| src = sources.get(aspect, {}) if isinstance(sources, dict) else {} | |
| src_line = f'<div class="source-line">Top metadata source: <b>{_esc(src.get("source", "-"))}</b> ({_safe_float(src.get("weight")):.2f})</div>' if src else "" | |
| cls = "focus" if selected_aspect in ("All", aspect) else "dim" | |
| cards.append(f'<div class="aspect-card {cls}"><div class="aspect-head"><b>{aspect}</b><span>conf {conf:.2f}</span></div>{_chip(label)}<p>{_esc(ASPECT_DESCRIPTIONS.get(aspect, ""))}</p><div class="evidence-row">{evidence}</div>{src_line}</div>') | |
| return '<div class="aspect-grid">' + "".join(cards) + '</div>' | |
| def _aspect_rows(result: Dict[str, Any], review: str) -> List[List[Any]]: | |
| rows = [] | |
| for aspect in ASPECTS: | |
| d = result.get("aspect_details", {}).get(aspect, {}) | |
| label = d.get("label", result.get("aspects", {}).get(aspect, "Unknown")) | |
| rows.append([aspect, label, round(_safe_float(d.get("confidence")), 4), ", ".join(_keyword_hits(review, aspect)) or "No explicit keyword evidence"]) | |
| return rows | |
| def consumer_product_view(product_name: str, selected_aspect: str) -> Tuple[str, str, str, List[List[Any]]]: | |
| product = _get_product(product_name) | |
| try: | |
| result = _predict_product(product_name) | |
| except Exception: | |
| return _error_html(traceback.format_exc(limit=5)), "", "", [] | |
| image_url = product.get("image_url") or "" | |
| image_html = f'<img class="product-img" src="{_esc(image_url)}" alt="product image">' if image_url else '<div class="product-img placeholder">No image</div>' | |
| store = product.get("store") or "Amazon" | |
| asin = product.get("parent_asin") or "-" | |
| detail = ( | |
| f'<div class="product-card product-layout">{image_html}<div>' | |
| f'<h3>{_esc(product["name"])}</h3>' | |
| f'<p class="muted">{_esc(product["categories"])}</p>' | |
| f'<div class="meta-pills"><span>Store: {_esc(store)}</span><span>ASIN: {_esc(asin)}</span><span>Price: ${_safe_float(product["price"]):.2f}</span><span>Rating: {_safe_float(product["average_rating"]):.1f}</span><span>Reviews: {int(_safe_float(product["rating_number"]))}</span></div>' | |
| f'<p><b>Metadata:</b> {_esc(product["features"])}</p>{_overall_html(result)}</div></div>' | |
| ) | |
| evidence = f'<h4>Key review evidence</h4>{_highlight_review(product["review"], selected_aspect)}' | |
| return detail, _aspect_cards(result, product["review"], selected_aspect), evidence, _aspect_rows(result, product["review"]) | |
| def filter_products(aspect: str, sentiment: str, category: str, tags: List[str], min_rating: float) -> Tuple[List[List[Any]], str]: | |
| rows = [] | |
| best = None | |
| all_candidates = _filtered_products(category, tags, min_rating) | |
| candidates = all_candidates[:MAX_FINDER_PRODUCTS] | |
| if not candidates: | |
| return [], f'<div class="note-card">No product matched the metadata filters in the real catalog of {len(PRODUCTS)} products.</div>' | |
| for product in candidates: | |
| try: | |
| result = _predict_product(product["name"]) | |
| except Exception: | |
| continue | |
| if aspect == "Overall": | |
| d = result.get("overall", {}) | |
| else: | |
| d = result.get("aspect_details", {}).get(aspect, {}) | |
| label = d.get("label", "Unknown") | |
| conf = _safe_float(d.get("confidence")) | |
| if sentiment != "Any" and label != sentiment: | |
| continue | |
| rows.append([product["name"], product["category"], label, round(conf, 4), product["average_rating"], product["price"], product["features"]]) | |
| score = conf + 0.03 * _safe_float(product["average_rating"]) + 0.000001 * _safe_float(product.get("rating_number")) | |
| if best is None or score > best[0]: | |
| best = (score, product["name"], label, conf) | |
| header = f'Scored top {len(candidates)} filtered products from {len(PRODUCTS)} real Amazon catalog items.' | |
| if len(all_candidates) > len(candidates): | |
| header += f' {len(all_candidates) - len(candidates)} additional metadata matches were not scored to keep the Space responsive.' | |
| summary = f'<div class="note-card"><b>{_esc(header)}</b><br>No product matched the target sentiment after model scoring.</div>' | |
| if best: | |
| summary = f'<div class="note-card"><b>Best match:</b> {_esc(best[1])} - {_esc(aspect)} is {_esc(best[2])} with confidence {best[3]:.2f}.<br><span class="muted">{_esc(header)}</span></div>' | |
| return rows, summary | |
| def _payload() -> Dict[str, Dict[str, Any]]: | |
| return {"eval": _load_json("evaluation_comparison.json"), "proposed": _load_json("per_aspect_proposed.json"), "no_meta": _load_json("per_aspect_acsa_no_meta.json"), "ablation": _load_json("ablation_summary.json")} | |
| def model_info_html() -> str: | |
| data = _payload() | |
| proposed = data["proposed"].get("overall", {}) | |
| ckpt_mb = CHECKPOINT_PATH.stat().st_size / (1024 * 1024) if CHECKPOINT_PATH.exists() else 0 | |
| rows = [("Training data", "Amazon 2023 Clothing, 100K review sample"), ("Backbone", getattr(cfg, "BERT_MODEL_NAME", "bert-base-uncased")), ("Core method", "Aspect-specific cross-attention metadata fusion"), ("Metadata", "features, categories, price, average rating, rating count"), ("Aspects", ", ".join(ASPECTS)), ("Best checkpoint", f"{ckpt_mb:.1f} MB"), ("Mean aspect F1", _pct(proposed.get("mean_macro_f1"))), ("Mean aspect accuracy", _pct(proposed.get("mean_accuracy")))] | |
| body = "".join(f"<tr><td>{_esc(k)}</td><td>{_esc(v)}</td></tr>" for k, v in rows) | |
| return f'<div class="table-wrap"><table class="kv-table">{body}</table></div>' | |
| def research_cards_html() -> str: | |
| data = _payload() | |
| cmp = data["eval"].get("overall_3class_comparison", {}) | |
| proposed_aspect = data["proposed"].get("overall", {}) | |
| no_meta_aspect = data["no_meta"].get("overall", {}) | |
| proposed_overall = cmp.get("Proposed_BERT_Meta_Fusion__overall_head", {}) | |
| gain = _safe_float(proposed_aspect.get("mean_macro_f1")) - _safe_float(no_meta_aspect.get("mean_macro_f1")) | |
| return f'<div class="metric-grid"><div class="metric"><span>Proposed Overall Accuracy</span><b>{_pct(proposed_overall.get("accuracy"))}</b><small>overall head</small></div><div class="metric"><span>Proposed Aspect Accuracy</span><b>{_pct(proposed_aspect.get("mean_accuracy"))}</b><small>six-aspect mean</small></div><div class="metric"><span>Metadata F1 Gain</span><b>+{gain:.4f}</b><small>vs no-metadata BERT ACSA</small></div></div>' | |
| def overall_metric_rows() -> List[List[Any]]: | |
| cmp = _payload()["eval"].get("overall_3class_comparison", {}) | |
| pairs = [("TF-IDF + Logistic Regression", "Baseline_1_TFIDF_LogReg"), ("BERT Overall Classifier", "Baseline_2_BERT_overall_3class"), ("Proposed BERT + Metadata Fusion", "Proposed_BERT_Meta_Fusion__overall_head")] | |
| return [[name, _num(cmp.get(key, {}).get("macro_f1")), _num(cmp.get(key, {}).get("accuracy"))] for name, key in pairs] | |
| def aspect_metric_rows() -> List[List[Any]]: | |
| data = _payload() | |
| proposed = data["proposed"].get("per_aspect", {}) | |
| no_meta = data["no_meta"].get("per_aspect", {}) | |
| rows = [] | |
| for aspect in ASPECTS: | |
| p = proposed.get(aspect, {}) | |
| b = no_meta.get(aspect, {}) | |
| f1_delta = _safe_float(p.get("macro_f1")) - _safe_float(b.get("macro_f1")) | |
| acc_delta = _safe_float(p.get("accuracy")) - _safe_float(b.get("accuracy")) | |
| rows.append([aspect, _num(b.get("macro_f1")), _num(p.get("macro_f1")), f"{f1_delta:+.4f}", _num(b.get("accuracy")), _num(p.get("accuracy")), f"{acc_delta:+.4f}"]) | |
| return rows | |
| def ablation_rows() -> List[List[Any]]: | |
| ab = _payload()["ablation"] | |
| labels = {"Proposed": "Proposed cross-attention fusion", "A1_no_text_meta": "A1 remove text metadata", "A2_no_numeric_meta": "A2 remove numerical metadata", "A3_concat_fusion": "A3 concat fusion"} | |
| return [[labels.get(k, k), _num(ab.get(k, {}).get("mean_macro_f1")), _num(ab.get(k, {}).get("mean_accuracy"))] for k in labels if k in ab] | |
| def merchant_product_scores(metric: str) -> List[List[Any]]: | |
| rows = [] | |
| monitored = _rank_products(PRODUCTS)[:MAX_MONITOR_PRODUCTS] | |
| for product in monitored: | |
| try: | |
| result = _predict_product(product["name"]) | |
| except Exception as exc: | |
| return [["ERROR", "Model loading failed", metric, type(exc).__name__, 0.0, 0.0, 0.0]] | |
| d = result.get("overall", {}) if metric == "Overall" else result.get("aspect_details", {}).get(metric, {}) | |
| rows.append([product["name"], product["category"], metric, d.get("label", "Unknown"), round(_safe_float(d.get("confidence")), 4), product["price"], product["average_rating"]]) | |
| return rows or [["No rows", "Try Refresh", metric, "-", 0.0, 0.0, 0.0]] | |
| def _metadata_risks(features: str, categories: str, price: Any, rating: Any, count: Any) -> Dict[str, str]: | |
| text = f"{features} {categories}".lower() | |
| risks = {} | |
| if any(x in text for x in ["slim", "oversized", "cropped", "one size", "tight", "relaxed"]): | |
| risks["SIZE"] = "Fit wording may create sizing expectation risk." | |
| if any(x in text for x in ["polyester", "synthetic", "faux", "thin", "lightweight"]): | |
| risks["MATERIAL"] = "Material description may affect comfort perception." | |
| if any(x in text for x in ["delicate", "hand wash", "button", "zipper", "distressed"]) or _safe_float(rating, 4.0) < 4.0: | |
| risks["QUALITY"] = "Durability or construction may need QA attention." | |
| if any(x in text for x in ["print", "floral", "color", "washed", "distressed"]): | |
| risks["APPEARANCE"] = "Visual consistency should be checked against product photos." | |
| if any(x in text for x in ["oversized", "slim", "cropped", "a-line", "v-neck"]): | |
| risks["STYLE"] = "Style-specific expectations may split customer opinions." | |
| if _safe_float(price) > 60 or _safe_float(rating, 4.0) < 4.0 or _safe_float(count) < 50: | |
| risks["VALUE"] = "Price, low rating, or low review volume may raise value risk." | |
| return risks | |
| def screen_new_product(features: str, categories: str, price: Any, rating: Any, count: Any, focus: str) -> Tuple[str, List[List[Any]]]: | |
| review = "This is a new clothing item. Customers may comment on fit, fabric, quality, appearance, style, and value." | |
| try: | |
| result = _predict_custom(review, features, categories, price, rating, count) | |
| except Exception: | |
| return _error_html(traceback.format_exc(limit=5)), [] | |
| rules = _metadata_risks(features, categories, price, rating, count) | |
| rows, high = [], [] | |
| for aspect in ASPECTS: | |
| if focus != "All" and aspect != focus: | |
| continue | |
| d = result.get("aspect_details", {}).get(aspect, {}) | |
| label = d.get("label", "Unknown") | |
| conf = _safe_float(d.get("confidence")) | |
| risk = "High" if label == "Negative" or aspect in rules else "Medium" if conf < 0.65 else "Low" | |
| if risk == "High": | |
| high.append(aspect) | |
| rows.append([aspect, risk, label, round(conf, 4), rules.get(aspect, "No strong metadata risk signal.")]) | |
| summary = ", ".join(high) if high else "No high-risk aspect detected from metadata." | |
| return f'<div class="note-card"><b>New product risk focus:</b> {_esc(summary)}<br><span class="muted">This is a metadata screening tool, not a replacement for real review evaluation.</span></div>', rows | |
| def external_review_predict(review: str, features: str, categories: str, price: Any, rating: Any, count: Any, selected_aspect: str) -> Tuple[str, str, List[List[Any]]]: | |
| try: | |
| result = _predict_custom(review, features, categories, price, rating, count) | |
| except Exception: | |
| return _error_html(traceback.format_exc(limit=5)), "", [] | |
| return _overall_html(result), _aspect_cards(result, review or "", selected_aspect), _aspect_rows(result, review or "") | |
| def _status_html() -> str: | |
| missing = [] | |
| if not CHECKPOINT_PATH.exists() or CHECKPOINT_PATH.stat().st_size < 1024 * 1024: | |
| missing.append("checkpoints/meta_acsa/best.pt") | |
| if not META_ENCODER_PATH.exists() or META_ENCODER_PATH.stat().st_size < 1024: | |
| missing.append("data/meta_encoder.pkl") | |
| if missing: | |
| return '<div class="status bad"><b>Model artifacts missing:</b> ' + _esc(", ".join(missing)) + '</div>' | |
| return '<div class="status ok"><b>Model ready.</b> 10W0715 checkpoint and metadata encoder are available.</div>' | |
| CSS = """ | |
| :root { | |
| --bg:#f5f7fb; | |
| --surface:#ffffff; | |
| --surface-2:#f8fafc; | |
| --ink:#0f172a; | |
| --muted:#64748b; | |
| --line:#d9e2ef; | |
| --accent:#f97316; | |
| --accent-2:#fb923c; | |
| --green:#16a34a; | |
| --red:#dc2626; | |
| --blue:#2563eb; | |
| --shadow:0 18px 45px rgba(15,23,42,.08); | |
| } | |
| body, .gradio-container { background:var(--bg) !important; color:var(--ink); } | |
| .gradio-container { max-width:1280px !important; margin:auto !important; font-family:Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important; } | |
| footer { display:none !important; } | |
| #app-hero { margin:12px 0 18px; padding:24px 28px; border-radius:22px; background:linear-gradient(135deg,#111827 0%,#1e293b 55%,#f97316 160%); color:white; box-shadow:var(--shadow); } | |
| #app-hero .eyebrow { text-transform:uppercase; letter-spacing:.12em; font-size:12px; color:#fed7aa; font-weight:800; margin-bottom:8px; } | |
| #app-hero h1 { margin:0; font-size:32px; line-height:1.12; letter-spacing:-.02em; } | |
| #app-hero p { margin:10px 0 0; color:#e2e8f0; max-width:840px; } | |
| #app-hero .hero-pills { display:flex; gap:8px; flex-wrap:wrap; margin-top:16px; } | |
| #app-hero .hero-pills span { background:rgba(255,255,255,.12); border:1px solid rgba(255,255,255,.18); border-radius:999px; padding:7px 11px; font-size:13px; } | |
| .status { border-radius:14px; padding:12px 14px; margin:8px 0 18px; border:1px solid var(--line); } | |
| .status.ok { background:#ecfdf5; border-color:#86efac; color:#065f46; } | |
| .status.bad { background:#fff1f2; border-color:#fda4af; color:#991b1b; } | |
| .app-section { display:flex; align-items:flex-end; justify-content:space-between; gap:16px; margin:8px 0 14px; } | |
| .app-section h2 { margin:0; font-size:24px; letter-spacing:-.02em; } | |
| .app-section p { margin:5px 0 0; color:var(--muted); } | |
| .app-badge { background:#fff7ed; color:#c2410c; border:1px solid #fed7aa; border-radius:999px; padding:7px 12px; font-weight:800; font-size:13px; } | |
| .app-card, .product-card, .overall-card, .note-card { border:1px solid var(--line); border-radius:18px; padding:18px; background:var(--surface); box-shadow:0 10px 24px rgba(15,23,42,.05); } | |
| .input-card { border:1px solid var(--line); border-radius:18px; padding:16px; background:var(--surface); box-shadow:0 10px 24px rgba(15,23,42,.05); } | |
| .product-layout { display:grid; grid-template-columns:132px 1fr; gap:16px; align-items:start; } | |
| .product-img { width:132px; height:166px; object-fit:cover; border-radius:16px; border:1px solid var(--line); background:#f8fafc; } | |
| .product-img.placeholder { display:flex; align-items:center; justify-content:center; color:var(--muted); font-size:13px; } | |
| .panel-title { margin:0 0 12px; font-size:17px; font-weight:850; } | |
| .muted { color:var(--muted); } | |
| .meta-pills { display:flex; flex-wrap:wrap; gap:8px; margin:10px 0; } | |
| .meta-pills span { background:#f1f5f9; border:1px solid #e2e8f0; border-radius:999px; padding:6px 10px; font-size:13px; } | |
| .chip { display:inline-block; border-radius:999px; padding:5px 10px; font-weight:800; font-size:13px; } | |
| .conf { color:#334155; font-size:13px; margin-left:8px; } | |
| .small-label { color:#475569; font-size:13px; margin-bottom:8px; } | |
| .overall-card { margin-top:14px; background:linear-gradient(180deg,#ffffff 0%,#f8fafc 100%); } | |
| .overall-main { display:flex; align-items:center; gap:8px; margin-bottom:10px; } | |
| .prob-row { display:grid; grid-template-columns:82px 1fr 44px; gap:8px; align-items:center; font-size:13px; margin:7px 0; } | |
| .bar { height:9px; background:#e2e8f0; border-radius:999px; overflow:hidden; } | |
| .bar i { display:block; height:100%; background:linear-gradient(90deg,var(--accent),var(--accent-2)); } | |
| .aspect-grid { display:grid; grid-template-columns:repeat(3, minmax(0, 1fr)); gap:14px; } | |
| .aspect-card { border:1px solid var(--line); border-top:5px solid #64748b; border-radius:18px; padding:15px; background:white; min-height:172px; box-shadow:0 10px 22px rgba(15,23,42,.05); } | |
| .aspect-card.focus { border-top-color:var(--accent); box-shadow:0 18px 34px rgba(249,115,22,.13); } | |
| .aspect-card.dim { opacity:.58; } | |
| .aspect-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; } | |
| .aspect-head span, .source-line { color:#475569; font-size:12px; } | |
| .aspect-card p { margin:8px 0; color:#334155; font-size:13px; } | |
| .evidence-row { display:flex; flex-wrap:wrap; gap:5px; margin-top:8px; } | |
| .evidence-token { color:#1d4ed8; background:#eef2ff; border:1px solid #bfdbfe; border-radius:999px; padding:3px 8px; font-size:12px; } | |
| .review-box { border:1px dashed #cbd5e1; background:#f8fafc; border-radius:14px; padding:15px; line-height:1.65; } | |
| mark { background:#fde68a; color:#111827; border-radius:5px; padding:1px 4px; } | |
| .metric-grid { display:grid; grid-template-columns:repeat(3, 1fr); gap:14px; margin:8px 0 18px; } | |
| .metric { border:1px solid var(--line); border-radius:18px; padding:18px; background:white; box-shadow:0 10px 24px rgba(15,23,42,.05); } | |
| .metric span { display:block; color:#334155; font-size:13px; } | |
| .metric b { display:block; font-size:30px; margin:6px 0; letter-spacing:-.03em; } | |
| .metric small { color:#475569; } | |
| .table-wrap { border:1px solid var(--line); border-radius:16px; overflow:hidden; background:white; box-shadow:0 10px 24px rgba(15,23,42,.05); } | |
| .kv-table { width:100%; border-collapse:collapse; } | |
| .kv-table td { border-bottom:1px solid #e2e8f0; padding:11px 13px; } | |
| .kv-table td:first-child { width:220px; color:#334155; font-weight:800; background:#f8fafc; } | |
| button.primary, .gradio-button.primary { background:linear-gradient(135deg,var(--accent),var(--accent-2)) !important; border:none !important; color:white !important; font-weight:850 !important; border-radius:13px !important; min-height:44px !important; box-shadow:0 12px 24px rgba(249,115,22,.22) !important; } | |
| .gradio-tabs { border-radius:18px !important; } | |
| .tab-nav button { font-weight:750 !important; } | |
| .gradio-dataframe, .wrap.svelte-1lcyrx4, .dataframe-container { border-radius:16px !important; overflow:hidden !important; } | |
| textarea, input, select { border-radius:12px !important; } | |
| @media (max-width:920px) { .aspect-grid, .metric-grid { grid-template-columns:1fr; } .product-layout { grid-template-columns:1fr; } .product-img { width:100%; height:220px; } #app-hero h1 { font-size:26px; } } | |
| """ | |
| def build_app() -> gr.Blocks: | |
| categories = ["All"] + sorted({p["category"] for p in PRODUCTS}) | |
| tags = sorted({tag for p in PRODUCTS for tag in p.get("tags", [])}) | |
| with gr.Blocks(css=CSS, title="Clothing Sentiment Intelligence") as demo: | |
| gr.HTML( | |
| '<div id="app-hero">' | |
| '<div class="eyebrow">BERT + Metadata Cross-Attention</div>' | |
| '<h1>Clothing Review Sentiment Intelligence App</h1>' | |
| '<p>Explore overall sentiment, six aspect-level opinions, metadata-driven risks, and research metrics from the 10W0715 experiment with a real Amazon product catalog.</p>' | |
| '<div class="hero-pills"><span>Consumer decision support</span><span>Merchant diagnostics</span><span>Research dashboard</span></div>' | |
| '</div>' | |
| ) | |
| gr.HTML(_status_html()) | |
| with gr.Tabs(): | |
| with gr.Tab("Consumer App"): | |
| gr.HTML('<div class="app-section"><div><h2>Shopping Review Analyzer</h2><p>Select a product, inspect overall sentiment, and compare aspect-level strengths and weaknesses.</p></div><span class="app-badge">Consumer view</span></div>') | |
| with gr.Row(): | |
| with gr.Column(scale=4, elem_classes=["input-card"]): | |
| gr.HTML('<div class="panel-title">Product context</div>') | |
| product_select = gr.Dropdown(_product_names(), value=_product_names()[0], label="Choose a product") | |
| consumer_aspect = gr.Dropdown(["All"] + ASPECTS, value="All", label="Highlight aspect evidence") | |
| product_detail = gr.HTML() | |
| evidence_html = gr.HTML() | |
| with gr.Column(scale=7): | |
| gr.HTML('<div class="panel-title">Aspect sentiment cards</div>') | |
| aspect_html = gr.HTML() | |
| with gr.Accordion("Technical aspect result table", open=False): | |
| consumer_table = gr.Dataframe(headers=["Aspect", "Prediction", "Confidence", "Key review evidence"], datatype=["str", "str", "number", "str"], interactive=False) | |
| gr.HTML('<div class="app-section"><div><h2>Product Finder</h2><p>Filter real Amazon catalog products by metadata and target sentiment signal.</p></div></div>') | |
| with gr.Row(): | |
| with gr.Column(scale=3, elem_classes=["input-card"]): | |
| filter_aspect = gr.Dropdown(["Overall"] + ASPECTS, value="Overall", label="Target metric") | |
| filter_sentiment = gr.Radio(["Any", "Positive", "Negative", "Not_Mentioned", "Neutral"], value="Any", label="Preferred prediction") | |
| with gr.Column(scale=4, elem_classes=["input-card"]): | |
| filter_category = gr.Dropdown(categories, value="All", label="Category") | |
| filter_tags = gr.CheckboxGroup(tags, label="Required metadata tags") | |
| with gr.Column(scale=3, elem_classes=["input-card"]): | |
| min_rating = gr.Slider(3.0, 5.0, value=4.0, step=0.1, label="Minimum product rating") | |
| filter_btn = gr.Button("Find Matching Products", variant="primary") | |
| filter_summary = gr.HTML() | |
| filter_table = gr.Dataframe(headers=["Product", "Category", "Prediction", "Confidence", "Rating", "Price", "Metadata"], datatype=["str", "str", "str", "number", "number", "number", "str"], interactive=False, label="Filtered product candidates") | |
| product_select.change(consumer_product_view, [product_select, consumer_aspect], [product_detail, aspect_html, evidence_html, consumer_table]) | |
| consumer_aspect.change(consumer_product_view, [product_select, consumer_aspect], [product_detail, aspect_html, evidence_html, consumer_table]) | |
| filter_btn.click(filter_products, [filter_aspect, filter_sentiment, filter_category, filter_tags, min_rating], [filter_table, filter_summary]) | |
| with gr.Tab("Merchant App"): | |
| gr.HTML('<div class="app-section"><div><h2>Merchant Sentiment Operations</h2><p>Monitor products, screen new metadata, and test external customer reviews.</p></div><span class="app-badge">Business view</span></div>') | |
| with gr.Row(): | |
| with gr.Column(scale=4): | |
| gr.HTML('<div class="panel-title">Model basic information</div>') | |
| gr.HTML(model_info_html()) | |
| with gr.Column(scale=6, elem_classes=["input-card"]): | |
| gr.HTML('<div class="panel-title">Product score monitor</div>') | |
| merchant_metric = gr.Dropdown(["Overall"] + ASPECTS, value="Overall", label="Choose overall or aspect") | |
| refresh_scores = gr.Button("Refresh Product Scores", variant="primary") | |
| merchant_scores = gr.Dataframe(headers=["Product", "Category", "Metric", "Prediction", "Confidence", "Price", "Rating"], value=[["Click Refresh Product Scores", "", "", "", 0.0, 0.0, 0.0]], datatype=["str", "str", "str", "str", "number", "number", "number"], interactive=False) | |
| gr.HTML('<div class="app-section"><div><h2>New Product Risk Screening</h2><p>Use metadata to preview which aspect may need QA or product-page clarification.</p></div></div>') | |
| with gr.Row(): | |
| with gr.Column(scale=6, elem_classes=["input-card"]): | |
| new_features = gr.Textbox("Cotton Polyester Blend, Slim Fit, Graphic Print, Machine Wash", label="New product features") | |
| new_categories = gr.Textbox("Clothing > Women > Tops > T-Shirts", label="New product categories") | |
| with gr.Column(scale=4, elem_classes=["input-card"]): | |
| new_price = gr.Number(29.99, label="Price") | |
| new_rating = gr.Number(4.1, label="Expected or early average rating") | |
| new_count = gr.Number(35, label="Expected or early rating count") | |
| new_focus = gr.Dropdown(["All"] + ASPECTS, value="All", label="Focus aspect") | |
| screen_btn = gr.Button("Predict Metadata Risk", variant="primary") | |
| risk_summary = gr.HTML() | |
| risk_table = gr.Dataframe(headers=["Aspect", "Risk Level", "Model Signal", "Confidence", "Reason"], value=[["Click Predict Metadata Risk", "", "", 0.0, ""]], datatype=["str", "str", "str", "number", "str"], interactive=False) | |
| gr.HTML('<div class="app-section"><div><h2>External Review Prediction</h2><p>Paste any review and metadata to get overall and aspect-level predictions.</p></div></div>') | |
| with gr.Row(): | |
| with gr.Column(scale=4, elem_classes=["input-card"]): | |
| external_review = gr.Textbox("The fabric is soft and the color looks good, but it runs small and the zipper feels weak.", label="External customer review", lines=5) | |
| ext_features = gr.Textbox("Cotton Blend, Slim Fit, Zipper Closure", label="Product features") | |
| ext_categories = gr.Textbox("Clothing > Women > Jackets", label="Product categories") | |
| with gr.Row(): | |
| ext_price = gr.Number(39.99, label="Price") | |
| ext_rating = gr.Number(4.2, label="Average rating") | |
| ext_count = gr.Number(312, label="Rating count") | |
| ext_aspect = gr.Dropdown(["All"] + ASPECTS, value="All", label="Highlight aspect") | |
| external_btn = gr.Button("Analyze External Review", variant="primary") | |
| with gr.Column(scale=6): | |
| ext_overall = gr.HTML() | |
| ext_aspects = gr.HTML() | |
| with gr.Accordion("External review raw table", open=False): | |
| ext_table = gr.Dataframe(headers=["Aspect", "Prediction", "Confidence", "Key review evidence"], value=[["Click Analyze External Review", "", 0.0, ""]], datatype=["str", "str", "number", "str"], interactive=False) | |
| refresh_scores.click(merchant_product_scores, merchant_metric, merchant_scores) | |
| merchant_metric.change(merchant_product_scores, merchant_metric, merchant_scores) | |
| screen_btn.click(screen_new_product, [new_features, new_categories, new_price, new_rating, new_count, new_focus], [risk_summary, risk_table]) | |
| external_btn.click(external_review_predict, [external_review, ext_features, ext_categories, ext_price, ext_rating, ext_count, ext_aspect], [ext_overall, ext_aspects, ext_table]) | |
| with gr.Tab("Research Metrics"): | |
| gr.HTML('<div class="app-section"><div><h2>Experiment Dashboard</h2><p>Metrics are loaded from the bundled 10W0715 reports on the same held-out test split.</p></div><span class="app-badge">Research view</span></div>') | |
| research_cards = gr.HTML(research_cards_html()) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| gr.Markdown("### Overall Sentiment Metrics") | |
| overall_table = gr.Dataframe(headers=["Model", "Macro-F1", "Accuracy"], value=overall_metric_rows(), interactive=False) | |
| with gr.Column(scale=1): | |
| gr.Markdown("### Ablation Comparison") | |
| ablation_table = gr.Dataframe(headers=["Variant", "Mean Macro-F1", "Mean Accuracy"], value=ablation_rows(), interactive=False) | |
| gr.Markdown("### Six-Aspect Comparison") | |
| aspect_table = gr.Dataframe(headers=["Aspect", "No-meta F1", "Proposed F1", "F1 Delta", "No-meta Acc", "Proposed Acc", "Acc Delta"], value=aspect_metric_rows(), interactive=False) | |
| refresh_research = gr.Button("Refresh Research Metrics", variant="primary") | |
| refresh_research.click(lambda: (research_cards_html(), overall_metric_rows(), aspect_metric_rows(), ablation_rows()), outputs=[research_cards, overall_table, aspect_table, ablation_table]) | |
| demo.load(consumer_product_view, [product_select, consumer_aspect], [product_detail, aspect_html, evidence_html, consumer_table]) | |
| return demo | |
| demo = build_app() | |
| if __name__ == "__main__": | |
| demo.launch(ssr_mode=False) | |