Spaces:
Sleeping
Sleeping
| """Dual-interface Hugging Face Space for clothing aspect-level sentiment analysis.""" | |
| from __future__ import annotations | |
| import csv | |
| import html | |
| import json | |
| import re | |
| import traceback | |
| from functools import lru_cache | |
| from pathlib import Path | |
| import tempfile | |
| from typing import Any, Dict, List, Tuple | |
| from urllib.parse import quote | |
| 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 | |
| from src import config as cfg | |
| from src.inference import AspectPredictor | |
| ROOT = Path(__file__).resolve().parent | |
| REPORT_DIR = ROOT / "reports" | |
| DATA_DIR = ROOT / "data" | |
| 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"} | |
| FALLBACK_PRODUCTS = [ | |
| {"name": "Demo - 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"], "source": "curated demo fallback", "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": "Demo - 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"], "source": "curated demo fallback", "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": "Demo - Oversized Denim Jacket", "category": "Outerwear", "features": "Denim Cotton Blend, Oversized Fit, Button Front, Distressed Wash", "categories": "Clothing > Women > Jackets > Denim Jackets", "price": 58.00, "average_rating": 4.0, "rating_number": 447, "tags": ["denim", "oversized", "jacket"], "source": "curated demo fallback", "review": "The oversized style is cute and the color looks like the photo. It is heavier than expected and the buttons feel a little cheap."}, | |
| {"name": "Demo - 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"], "source": "curated demo fallback", "review": "The dress looks beautiful and the floral print is exactly as shown. The waist is a bit tight and the fabric wrinkles easily."}, | |
| {"name": "Demo - Fleece Pullover Hoodie", "category": "Tops", "features": "Cotton Polyester Fleece, Regular Fit, Kangaroo Pocket, Ribbed Cuffs", "categories": "Clothing > Unisex > Hoodies > Pullover Hoodies", "price": 42.99, "average_rating": 4.6, "rating_number": 2214, "tags": ["fleece", "hoodie", "warm"], "source": "curated demo fallback", "review": "Very warm and soft hoodie. The quality feels good for the price, though the sleeves are a little long for me."}, | |
| {"name": "Demo - Linen Button-Up Shirt", "category": "Tops", "features": "Linen Cotton Blend, Relaxed Fit, Button Front, Breathable Fabric", "categories": "Clothing > Men > Shirts > Button-Up Shirts", "price": 34.99, "average_rating": 3.9, "rating_number": 186, "tags": ["linen", "breathable", "shirt"], "source": "curated demo fallback", "review": "The shirt is breathable and stylish, but it wrinkles badly and the stitching near one button came loose."}, | |
| ] | |
| TAG_CANDIDATES = [ | |
| "cotton", "polyester", "linen", "denim", "fleece", "leather", "stretch", "soft", | |
| "breathable", "warm", "shirt", "dress", "jacket", "shorts", "sneakers", | |
| "wallet", "jewelry", "casual", "formal", "activewear", "print", "floral", | |
| "slim fit", "relaxed fit", "oversized", "high waist", "plus size", | |
| ] | |
| def _safe_float(value, default=0.0): | |
| try: | |
| if value in (None, ""): | |
| return default | |
| return float(value) | |
| except Exception: | |
| return default | |
| def _parse_numeric_blob(blob): | |
| text = str(blob or "") | |
| out = {} | |
| for key in ("price", "average_rating", "rating_number"): | |
| match = re.search(rf"{key}\s*=\s*([-+]?\d+(?:\.\d+)?)", text) | |
| if match: | |
| out[key] = _safe_float(match.group(1)) | |
| return out | |
| def _tags_from_text(text): | |
| low = str(text or "").lower() | |
| tags = [tag for tag in TAG_CANDIDATES if tag in low] | |
| return list(dict.fromkeys(tags))[:8] | |
| def _load_products_from_json(): | |
| path = DATA_DIR / "demo_products.json" | |
| try: | |
| if path.exists(): | |
| products = json.loads(path.read_text(encoding="utf-8")) | |
| if isinstance(products, list) and products: | |
| return [_coerce_product(p, i + 1) for i, p in enumerate(products)] | |
| except Exception: | |
| pass | |
| return [] | |
| def _load_products_from_catalog(): | |
| path = DATA_DIR / "product_catalog.json" | |
| try: | |
| if path.exists(): | |
| products = json.loads(path.read_text(encoding="utf-8")) | |
| if isinstance(products, list) and products: | |
| out = [] | |
| for i, item in enumerate(products): | |
| item = dict(item) | |
| item.setdefault("source", "real product catalog") | |
| out.append(_coerce_product(item, i + 1)) | |
| return out | |
| except Exception: | |
| pass | |
| return [] | |
| def _load_products_from_explanation_csv(): | |
| path = REPORT_DIR / "explanation_attention_summary.csv" | |
| if not path.exists(): | |
| return [] | |
| products = {} | |
| try: | |
| with path.open("r", encoding="utf-8", newline="") as fh: | |
| for row in csv.DictReader(fh): | |
| example_id = str(row.get("example") or "").strip() | |
| if not example_id or example_id in products: | |
| continue | |
| numeric = _parse_numeric_blob(row.get("numeric")) | |
| category = str(row.get("category") or "Clothing").strip() or "Clothing" | |
| features = str(row.get("features") or "").strip() | |
| categories = str(row.get("categories") or category).strip() | |
| review = str(row.get("text") or "").strip() | |
| source = "real held-out explanation example" | |
| item = { | |
| "name": f"Real Review {int(float(example_id)):02d} - {category}", | |
| "category": category, | |
| "features": features[:700], | |
| "categories": categories[:300], | |
| "price": numeric.get("price", 0.0), | |
| "average_rating": numeric.get("average_rating", _safe_float(row.get("rating"), 0.0)), | |
| "rating_number": numeric.get("rating_number", 0.0), | |
| "review": review, | |
| "source": source, | |
| } | |
| item["tags"] = _tags_from_text(" ".join([features, categories, review])) | |
| products[example_id] = _coerce_product(item, int(float(example_id))) | |
| except Exception: | |
| return [] | |
| return list(products.values()) | |
| def _coerce_product(item, idx=0): | |
| features = str(item.get("features") or item.get("features_text") or "") | |
| categories = str(item.get("categories") or item.get("categories_text") or "") | |
| review = str(item.get("review") or item.get("review_text") or "") | |
| category = str(item.get("category") or (categories.split(">")[-1].strip() if categories else "Clothing")) | |
| name = str(item.get("name") or item.get("title") or f"Product Example {idx:02d}") | |
| tags = item.get("tags") or _tags_from_text(" ".join([features, categories, review])) | |
| return { | |
| "name": name, | |
| "category": category, | |
| "features": features, | |
| "categories": categories, | |
| "image_url": str(item.get("image_url") or item.get("image") or item.get("main_image") or ""), | |
| "store": str(item.get("store") or item.get("brand") or "Amazon"), | |
| "parent_asin": str(item.get("parent_asin") or item.get("asin") or "-"), | |
| "price": _safe_float(item.get("price")), | |
| "average_rating": _safe_float(item.get("average_rating")), | |
| "rating_number": _safe_float(item.get("rating_number")), | |
| "review": review, | |
| "tags": list(tags) if isinstance(tags, (list, tuple)) else _tags_from_text(tags), | |
| "source": str(item.get("source") or "demo product"), | |
| } | |
| def _load_products(): | |
| products = _load_products_from_catalog() or _load_products_from_json() or _load_products_from_explanation_csv() | |
| if products: | |
| return products | |
| 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 _short_text(value: Any, limit: int = 220) -> str: | |
| text = re.sub(r"\s+", " ", str(value or "")).strip() | |
| if len(text) <= limit: | |
| return text | |
| return text[: limit - 3].rstrip() + "..." | |
| 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 _load_csv_rows(name: str, columns: List[str], limit: int | None = None) -> List[List[Any]]: | |
| path = REPORT_DIR / name | |
| if not path.exists(): | |
| return [] | |
| rows = [] | |
| try: | |
| with path.open("r", encoding="utf-8", newline="") as fh: | |
| for row in csv.DictReader(fh): | |
| rows.append([row.get(col, "") for col in columns]) | |
| if limit and len(rows) >= limit: | |
| break | |
| except Exception: | |
| return [] | |
| return rows | |
| def _report_image(name: str): | |
| path = REPORT_DIR / name | |
| return str(path) if path.exists() else None | |
| def _write_csv_download(name: str, headers: List[str], rows): | |
| path = Path(tempfile.gettempdir()) / name | |
| normalized = _normalize_table_rows(rows) | |
| with path.open("w", encoding="utf-8-sig", newline="") as fh: | |
| writer = csv.writer(fh) | |
| writer.writerow(headers) | |
| writer.writerows(normalized) | |
| return str(path) | |
| def _download_update(path: str): | |
| return gr.update(value=path, visible=True) | |
| def _normalize_table_rows(rows) -> List[List[Any]]: | |
| if rows is None: | |
| return [] | |
| if hasattr(rows, "values") and hasattr(rows, "columns"): | |
| return rows.fillna("").values.tolist() | |
| if isinstance(rows, dict): | |
| data = rows.get("data") or rows.get("values") or [] | |
| return data if isinstance(data, list) else [] | |
| if isinstance(rows, tuple): | |
| rows = list(rows) | |
| if not isinstance(rows, list): | |
| return [] | |
| out = [] | |
| for row in rows: | |
| if isinstance(row, dict): | |
| out.append(list(row.values())) | |
| elif isinstance(row, (list, tuple)): | |
| out.append(list(row)) | |
| else: | |
| out.append([row]) | |
| return out | |
| def _predictor() -> AspectPredictor: | |
| return AspectPredictor(checkpoint_dir=CHECKPOINT_DIR) | |
| def _product_names() -> List[str]: | |
| return [p["name"] for p in PRODUCTS] | |
| def _product_names_for_category(category: str) -> List[str]: | |
| if category == "All": | |
| return _product_names() | |
| names = [p["name"] for p in PRODUCTS if p.get("category") == category] | |
| return names or _product_names() | |
| def _tags_for_category(category: str) -> List[str]: | |
| products = PRODUCTS if category == "All" else [p for p in PRODUCTS if p.get("category") == category] | |
| return sorted({tag for p in products for tag in p.get("tags", [])}) | |
| def _category_to_metadata_text(category: str) -> str: | |
| category = str(category or "").strip() | |
| if not category: | |
| return "Clothing" | |
| for product in PRODUCTS: | |
| if product.get("category") == category and product.get("categories"): | |
| return product["categories"] | |
| return f"Clothing > {category}" | |
| def update_product_choices(category: str): | |
| names = _product_names_for_category(category) | |
| return gr.update(choices=names, value=names[0] if names else None) | |
| def update_filter_tags(category: str): | |
| return gr.update(choices=_tags_for_category(category), value=[]) | |
| def consumer_category_view(category: str, selected_aspect: str): | |
| names = _product_names_for_category(category) | |
| product_name = names[0] if names else _product_names()[0] | |
| detail, aspects, evidence, rows = consumer_product_view(product_name, selected_aspect) | |
| return gr.update(choices=names, value=product_name), detail, aspects, evidence, rows | |
| def _get_product(name: str) -> Dict[str, Any]: | |
| return next((p for p in PRODUCTS if p["name"] == name), PRODUCTS[0]) | |
| def _product_visual(product: Dict[str, Any]): | |
| text = " ".join([ | |
| str(product.get("name", "")), | |
| str(product.get("category", "")), | |
| str(product.get("categories", "")), | |
| " ".join(map(str, product.get("tags", []))), | |
| ]).lower() | |
| if any(k in text for k in ["necklace", "bracelet", "jewelry", "strands", "identification"]): | |
| return "#fef3c7", "#92400e", '<circle cx="160" cy="150" r="58" fill="none" stroke="#92400e" stroke-width="14"/><circle cx="160" cy="214" r="18" fill="#f97316"/><circle cx="115" cy="184" r="10" fill="#f59e0b"/><circle cx="205" cy="184" r="10" fill="#f59e0b"/>' | |
| if any(k in text for k in ["shoe", "sneaker", "boot", "slipper"]): | |
| return "#e0f2fe", "#075985", '<path d="M72 222 C106 226 136 212 166 184 C180 206 220 220 258 226 C264 242 253 258 228 258 L92 258 C70 258 58 244 72 222 Z" fill="#075985"/><path d="M132 202 L190 218" stroke="#38bdf8" stroke-width="8" stroke-linecap="round"/>' | |
| if any(k in text for k in ["wallet", "card case", "money"]): | |
| return "#f1f5f9", "#334155", '<rect x="72" y="128" width="176" height="122" rx="18" fill="#334155"/><rect x="92" y="152" width="72" height="18" rx="8" fill="#f97316"/><circle cx="218" cy="190" r="13" fill="#cbd5e1"/>' | |
| if any(k in text for k in ["dress", "cocktail", "apron"]): | |
| return "#fce7f3", "#9d174d", '<path d="M132 86 L188 86 L208 144 L238 292 L82 292 L112 144 Z" fill="#9d174d"/><path d="M136 92 C146 118 174 118 184 92" fill="none" stroke="#f97316" stroke-width="8" stroke-linecap="round"/>' | |
| if any(k in text for k in ["short", "leggings", "pants", "jeans"]): | |
| return "#dcfce7", "#166534", '<path d="M112 88 L154 88 L150 294 L104 294 Z" fill="#166534"/><path d="M166 88 L208 88 L216 294 L170 294 Z" fill="#166534"/><path d="M112 88 L208 88 L208 122 L112 122 Z" fill="#22c55e"/>' | |
| return "#eff6ff", "#1f2937", '<path d="M116 90 C128 120 192 120 204 90 L238 124 L214 166 L202 150 L202 300 L118 300 L118 150 L106 166 L82 124 Z" fill="#1f2937"/><path d="M126 96 C140 114 180 114 194 96" fill="none" stroke="#f97316" stroke-width="8" stroke-linecap="round"/>' | |
| def _product_image_url(product: Dict[str, Any]) -> str: | |
| image_url = str(product.get("image_url") or "").strip() | |
| if image_url: | |
| return image_url | |
| category = _short_text(product.get("category") or "Clothing", 28) | |
| tag = _short_text(product.get("tags", ["fashion"])[0] if product.get("tags") else "fashion", 18) | |
| bg, ink, shape = _product_visual(product) | |
| svg = f"""<svg xmlns="http://www.w3.org/2000/svg" width="320" height="420" viewBox="0 0 320 420"> | |
| <defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="{bg}"/><stop offset="1" stop-color="#fff7ed"/></linearGradient></defs> | |
| <rect width="320" height="420" rx="24" fill="url(#g)"/> | |
| <rect x="62" y="58" width="196" height="250" rx="30" fill="#ffffff" stroke="#dbe3ef" stroke-width="4"/> | |
| {shape} | |
| <text x="160" y="350" text-anchor="middle" font-family="Arial, sans-serif" font-size="24" font-weight="700" fill="{ink}">{html.escape(category)}</text> | |
| <text x="160" y="382" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" fill="#475569">{html.escape(tag)}</text> | |
| </svg>""" | |
| return "data:image/svg+xml;charset=utf-8," + quote(svg) | |
| 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": _category_to_metadata_text(categories), "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>') | |
| fallback = '<div class="small-label">Fallback inference mode used for this sample.</div>' if result.get("fallback") else "" | |
| 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)}{fallback}</div>' | |
| def _join_aspects(items: List[str]) -> str: | |
| if not items: | |
| return "-" | |
| if len(items) == 1: | |
| return items[0] | |
| if len(items) == 2: | |
| return f"{items[0]} and {items[1]}" | |
| return ", ".join(items[:-1]) + f", and {items[-1]}" | |
| def _recommendation_sentence(result: Dict[str, Any]) -> str: | |
| details = result.get("aspect_details", {}) | |
| positive = [] | |
| negative = [] | |
| low_risk = [] | |
| for aspect in ASPECTS: | |
| d = details.get(aspect, {}) | |
| label = d.get("label", result.get("aspects", {}).get(aspect, "Unknown")) | |
| conf = _safe_float(d.get("confidence")) | |
| if label == "Positive": | |
| positive.append(aspect) | |
| elif label == "Negative": | |
| negative.append(aspect) | |
| elif label in {"Neutral", "Not_Mentioned"} or conf < 0.60: | |
| low_risk.append(aspect) | |
| if positive and negative: | |
| return ( | |
| f"This product is recommended because {_join_aspects(positive[:3])} " | |
| f"{'is' if len(positive[:3]) == 1 else 'are'} positive, while " | |
| f"{_join_aspects(negative[:2])} should be checked as potential risk." | |
| ) | |
| if positive: | |
| remaining = [a for a in ASPECTS if a not in positive] | |
| return ( | |
| f"This product is recommended because {_join_aspects(positive[:3])} " | |
| f"{'is' if len(positive[:3]) == 1 else 'are'} positive, while " | |
| f"{_join_aspects((low_risk or remaining)[:3])} has low risk." | |
| ) | |
| if negative: | |
| return ( | |
| f"This product is not a strong recommendation because " | |
| f"{_join_aspects(negative[:3])} shows negative sentiment risk." | |
| ) | |
| overall = result.get("overall", {}).get("label", "Neutral") | |
| return f"This product is a cautious recommendation because the overall signal is {overall} and no strong aspect risk dominates." | |
| def _recommendation_html(result: Dict[str, Any], product_name: str = "") -> str: | |
| sentence = _recommendation_sentence(result) | |
| title = f"Recommendation reason for {_esc(product_name)}" if product_name else "Recommendation reason" | |
| return ( | |
| f'<div class="recommendation-card"><div class="small-label">{title}</div>' | |
| f'<b>{_esc(sentence)}</b>' | |
| f'<p class="muted">This explanation is generated from the live model output: overall sentiment, six aspect labels, and confidence scores.</p></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_html = ( | |
| f'<img class="product-img" src="{_esc(_product_image_url(product))}" ' | |
| f'alt="{_esc(product["name"])} product image" loading="lazy">' | |
| ) | |
| store = product.get("store") or "Amazon" | |
| asin = product.get("parent_asin") or "-" | |
| detail = ( | |
| f'<div class="product-card"><div class="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>' | |
| f'<span>ASIN: {_esc(asin)}</span>' | |
| f'<span>Source: {_esc(product.get("source", "demo"))}</span>' | |
| f'<span>Price: ${_safe_float(product["price"]):.2f}</span>' | |
| f'<span>Rating: {_safe_float(product["average_rating"]):.1f}</span>' | |
| f'<span>Reviews: {int(_safe_float(product["rating_number"]))}</span></div>' | |
| f'<p class="metadata-summary"><b>Metadata:</b> {_esc(_short_text(product["features"], 130))}</p>' | |
| f'</div></div><div class="decision-layout">{_overall_html(result)}' | |
| f'{_recommendation_html(result, product["name"])}</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 | |
| for product in PRODUCTS: | |
| if category != "All" and product["category"] != category: | |
| continue | |
| if tags and not set(tags).issubset(set(product.get("tags", []))): | |
| continue | |
| if _safe_float(product["average_rating"]) < _safe_float(min_rating): | |
| continue | |
| 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"]) | |
| if best is None or score > best[0]: | |
| best = (score, product["name"], label, conf, result) | |
| summary = '<div class="note-card">No product matched the current filters.</div>' | |
| if best: | |
| summary = ( | |
| f'<div class="note-card"><b>Best match:</b> {_esc(best[1])} - ' | |
| f'{_esc(aspect)} is {_esc(best[2])} with confidence {best[3]:.2f}.' | |
| f'{_recommendation_html(best[4], best[1])}</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]]: | |
| csv_rows = _load_csv_rows("overall_model_comparison.csv", ["model", "macro_f1", "accuracy"]) | |
| if csv_rows: | |
| return [[r[0], _num(r[1]), _num(r[2])] for r in csv_rows] | |
| 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]]: | |
| csv_rows = _load_csv_rows( | |
| "aspect_level_proposed_vs_no_meta.csv", | |
| ["aspect", "acsa_no_meta_macro_f1", "proposed_macro_f1", "delta_macro_f1", "acsa_no_meta_accuracy", "proposed_accuracy", "delta_accuracy"], | |
| ) | |
| if csv_rows: | |
| return [[r[0], _num(r[1]), _num(r[2]), f"{_safe_float(r[3]):+.4f}", _num(r[4]), _num(r[5]), f"{_safe_float(r[6]):+.4f}"] for r in csv_rows] | |
| 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 meta_source_rows() -> List[List[Any]]: | |
| rows = _load_csv_rows( | |
| "explanation_attention_meta_source_summary.csv", | |
| ["aspect", "top_meta_source", "source_share", "mean_top_meta_weight", "mean_attention_focus"], | |
| ) | |
| if not rows: | |
| rows = _load_csv_rows( | |
| "explanation_ig_meta_source_summary.csv", | |
| ["aspect", "top_meta_source", "source_share", "mean_top_meta_weight", "mean_attention_focus"], | |
| ) | |
| return [[r[0], r[1], _pct(r[2]), _num(r[3]), _num(r[4])] for r in rows] | |
| def report_asset_rows() -> List[List[Any]]: | |
| groups = [ | |
| ("Evaluation", "overall_model_comparison.csv, aspect_level_proposed_vs_no_meta.csv"), | |
| ("Confusion matrices", "confusion_matrix_proposed_overall_head.png and per-aspect PNGs"), | |
| ("Ablation", "ablation_summary.json, ablation_A1/A2/A3.json"), | |
| ("Visualization", "aspect_distribution.png and category_aspect_*_heatmap.png"), | |
| ("Explanation", "explanation_*_summary.csv and explanation_*_meta_source_summary.csv"), | |
| ] | |
| return [[name, assets] for name, assets in groups] | |
| def refresh_research_outputs(): | |
| return ( | |
| research_cards_html(), | |
| overall_metric_rows(), | |
| aspect_metric_rows(), | |
| ablation_rows(), | |
| meta_source_rows(), | |
| ) | |
| def merchant_product_scores(metric: str) -> List[List[Any]]: | |
| rows = [] | |
| for product in PRODUCTS: | |
| 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 merchant_aspect_overview() -> List[List[Any]]: | |
| rows = [] | |
| for aspect in ASPECTS: | |
| neg_count, top_name, top_conf, top_reason = 0, "-", 0.0, "-" | |
| for product in PRODUCTS: | |
| try: | |
| result = _predict_product(product["name"]) | |
| except Exception: | |
| continue | |
| d = result.get("aspect_details", {}).get(aspect, {}) | |
| if d.get("label") != "Negative": | |
| continue | |
| neg_count += 1 | |
| conf = _safe_float(d.get("confidence")) | |
| if conf >= top_conf: | |
| hits = _keyword_hits(product.get("review", ""), aspect) | |
| top_name = product["name"] | |
| top_conf = conf | |
| top_reason = ", ".join(hits) if hits else _short_text(product.get("review") or product.get("features"), 90) | |
| rows.append([aspect, neg_count, top_name, round(top_conf, 4), top_reason]) | |
| return rows | |
| def merchant_score_filter(aspect: str, prediction: str, category: str) -> List[List[Any]]: | |
| rows = [] | |
| for product in PRODUCTS: | |
| if category != "All" and product.get("category") != category: | |
| continue | |
| try: | |
| result = _predict_product(product["name"]) | |
| except Exception: | |
| continue | |
| d = result.get("overall", {}) if aspect == "Overall" else result.get("aspect_details", {}).get(aspect, {}) | |
| label = d.get("label", "Unknown") | |
| if prediction != "Any" and label != prediction: | |
| continue | |
| rows.append([ | |
| product["name"], product["category"], aspect, label, | |
| round(_safe_float(d.get("confidence")), 4), | |
| product["price"], product["average_rating"], | |
| ]) | |
| return rows or [["No matching products", category, aspect, prediction, 0.0, 0.0, 0.0]] | |
| def export_merchant_scores(rows): | |
| return _download_update(_write_csv_download( | |
| "merchant_product_scores.csv", | |
| ["Product", "Category", "Metric", "Prediction", "Confidence", "Price", "Rating"], | |
| rows, | |
| )) | |
| def negative_product_spotlight(limit: int = 6) -> str: | |
| cards = [] | |
| used_products = set() | |
| for aspect in ASPECTS: | |
| candidates = [] | |
| for product in PRODUCTS: | |
| try: | |
| result = _predict_product(product["name"]) | |
| except Exception: | |
| continue | |
| d = result.get("aspect_details", {}).get(aspect, {}) | |
| if d.get("label") != "Negative": | |
| continue | |
| conf = _safe_float(d.get("confidence")) | |
| hits = _keyword_hits(product.get("review", ""), aspect) | |
| reason = ", ".join(hits[:3]) if hits else _short_text(product.get("review") or product.get("features"), 48) | |
| candidates.append((conf, product, reason)) | |
| candidates.sort(key=lambda x: x[0], reverse=True) | |
| best = next((item for item in candidates if item[1]["name"] not in used_products), None) | |
| if best is None and candidates: | |
| best = candidates[0] | |
| if best is None: | |
| cards.append( | |
| f'<div class="negative-card compact-negative"><h4>{aspect}</h4>' | |
| '<div class="empty-negative">No strong negative sample</div></div>' | |
| ) | |
| continue | |
| conf, product, reason = best | |
| used_products.add(product["name"]) | |
| img = f'<img class="mini-product-img" src="{_esc(_product_image_url(product))}" alt="{_esc(product["name"])}">' | |
| cards.append( | |
| f'<div class="negative-card compact-negative"><h4>{aspect}</h4>' | |
| f'<div class="negative-body">{img}<div><b>{_esc(_short_text(product["name"], 42))}</b>' | |
| f'<div class="small-label">{_esc(product["category"])} | conf {conf:.2f}</div>' | |
| f'<p>{_esc(_short_text(reason, 56))}</p></div></div></div>' | |
| ) | |
| return '<div class="negative-grid">' + "".join(cards[:limit]) + '</div>' | |
| 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 import_new_product_payload(file_obj): | |
| if not file_obj: | |
| return gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update() | |
| path = Path(getattr(file_obj, "name", file_obj)) | |
| try: | |
| if path.suffix.lower() == ".json": | |
| data = json.loads(path.read_text(encoding="utf-8")) | |
| else: | |
| with path.open("r", encoding="utf-8-sig", newline="") as fh: | |
| data = next(csv.DictReader(fh), {}) | |
| except Exception: | |
| data = {} | |
| return ( | |
| data.get("features") or data.get("features_text") or "", | |
| data.get("categories") or data.get("categories_text") or "", | |
| _safe_float(data.get("price"), 0.0), | |
| _safe_float(data.get("average_rating") or data.get("rating"), 4.0), | |
| _safe_float(data.get("rating_number") or data.get("rating_count"), 0.0), | |
| data.get("focus_aspect") or data.get("focus") or "All", | |
| ) | |
| def export_risk_rows(rows): | |
| return _download_update(_write_csv_download( | |
| "new_product_metadata_risk.csv", | |
| ["Aspect", "Risk Level", "Model Signal", "Confidence", "Reason"], | |
| rows, | |
| )) | |
| def _read_uploaded_records(file_obj) -> List[Dict[str, Any]]: | |
| if not file_obj: | |
| return [] | |
| path = Path(getattr(file_obj, "name", file_obj)) | |
| try: | |
| if path.suffix.lower() == ".json": | |
| data = json.loads(path.read_text(encoding="utf-8")) | |
| if isinstance(data, dict): | |
| data = data.get("items") or data.get("data") or [data] | |
| return data if isinstance(data, list) else [] | |
| with path.open("r", encoding="utf-8-sig", newline="") as fh: | |
| return list(csv.DictReader(fh)) | |
| except Exception: | |
| return [] | |
| def download_new_product_template(): | |
| rows = [[ | |
| "Cotton Polyester Blend, Slim Fit, Graphic Print, Machine Wash", | |
| "T-Shirts", | |
| 29.99, | |
| 4.1, | |
| 35, | |
| "All", | |
| ]] | |
| return _download_update(_write_csv_download( | |
| "new_product_metadata_template.csv", | |
| ["features", "category", "price", "average_rating", "rating_number", "focus_aspect"], | |
| rows, | |
| )) | |
| def batch_screen_new_products(file_obj) -> Tuple[str, List[List[Any]]]: | |
| records = _read_uploaded_records(file_obj) | |
| if not records: | |
| return '<div class="note-card">Upload a CSV or JSON file first.</div>', [] | |
| rows = [] | |
| for i, data in enumerate(records, 1): | |
| features = data.get("features") or data.get("features_text") or "" | |
| categories = data.get("category") or data.get("categories") or data.get("categories_text") or "" | |
| price = _safe_float(data.get("price"), 0.0) | |
| rating = _safe_float(data.get("average_rating") or data.get("rating"), 4.0) | |
| count = _safe_float(data.get("rating_number") or data.get("rating_count"), 0.0) | |
| focus = data.get("focus_aspect") or data.get("focus") or "All" | |
| _, detail_rows = screen_new_product(features, categories, price, rating, count, focus) | |
| high = [r[0] for r in detail_rows if r[1] == "High"] | |
| rows.append([ | |
| f"Product {i}", | |
| "High" if high else "Low", | |
| focus, | |
| len(high), | |
| f"{_short_text(categories, 55)} | high-risk aspects: {', '.join(high) or 'None'}", | |
| ]) | |
| return f'<div class="note-card"><b>Batch screening completed:</b> {len(rows)} products analyzed.</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 import_external_review_payload(file_obj): | |
| if not file_obj: | |
| return gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update() | |
| path = Path(getattr(file_obj, "name", file_obj)) | |
| try: | |
| if path.suffix.lower() == ".json": | |
| data = json.loads(path.read_text(encoding="utf-8")) | |
| else: | |
| with path.open("r", encoding="utf-8-sig", newline="") as fh: | |
| data = next(csv.DictReader(fh), {}) | |
| except Exception: | |
| data = {} | |
| return ( | |
| data.get("review") or data.get("review_text") or "", | |
| data.get("features") or data.get("features_text") or "", | |
| data.get("categories") or data.get("categories_text") or "", | |
| _safe_float(data.get("price"), 0.0), | |
| _safe_float(data.get("average_rating") or data.get("rating"), 4.0), | |
| _safe_float(data.get("rating_number") or data.get("rating_count"), 0.0), | |
| data.get("highlight_aspect") or data.get("aspect") or "All", | |
| ) | |
| def export_external_rows(rows): | |
| return _download_update(_write_csv_download( | |
| "external_review_prediction.csv", | |
| ["Aspect", "Prediction", "Confidence", "Key review evidence"], | |
| rows, | |
| )) | |
| def download_external_review_template(): | |
| rows = [[ | |
| "The fabric is soft and the color looks good, but it runs small.", | |
| "Cotton Blend, Slim Fit, Zipper Closure", | |
| "Jackets", | |
| 39.99, | |
| 4.2, | |
| 312, | |
| "All", | |
| ]] | |
| return _download_update(_write_csv_download( | |
| "external_review_template.csv", | |
| ["review", "features", "category", "price", "average_rating", "rating_number", "highlight_aspect"], | |
| rows, | |
| )) | |
| def batch_external_review_predict(file_obj) -> Tuple[str, str, List[List[Any]]]: | |
| records = _read_uploaded_records(file_obj) | |
| if not records: | |
| return '<div class="note-card">Upload a CSV or JSON file first.</div>', "", [] | |
| rows = [] | |
| for i, data in enumerate(records, 1): | |
| review = data.get("review") or data.get("review_text") or "" | |
| features = data.get("features") or data.get("features_text") or "" | |
| categories = data.get("category") or data.get("categories") or data.get("categories_text") or "" | |
| price = _safe_float(data.get("price"), 0.0) | |
| rating = _safe_float(data.get("average_rating") or data.get("rating"), 4.0) | |
| count = _safe_float(data.get("rating_number") or data.get("rating_count"), 0.0) | |
| aspect = data.get("highlight_aspect") or data.get("aspect") or "All" | |
| overall_html, _, detail_rows = external_review_predict(review, features, categories, price, rating, count, aspect) | |
| negative = [r[0] for r in detail_rows if r[1] == "Negative"] | |
| rows.append([ | |
| f"Review {i}", | |
| f"Negative: {', '.join(negative)}" if negative else "No major negative", | |
| len(negative), | |
| f"{_short_text(review, 70)} | {_short_text(categories, 45)}", | |
| ]) | |
| return f'<div class="note-card"><b>Batch review prediction completed:</b> {len(rows)} reviews analyzed.</div>', "", rows | |
| def toggle_analysis_mode(mode: str): | |
| single = mode.startswith("Single") | |
| return gr.update(visible=single), gr.update(visible=not single) | |
| 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> 10W0716 checkpoint and metadata encoder are available.</div>' | |
| CSS = """ | |
| :root { --accent:#f97316; --ink:#0f172a; --muted:#475569; --line:#dbe3ef; --panel:#ffffff; } | |
| .gradio-container { max-width:1240px !important; margin:auto !important; color:var(--ink); } | |
| #hero { border:1px solid #bfdbfe; background:#eff6ff; border-left:6px solid var(--accent); border-radius:8px; padding:18px 22px; margin:8px 0 14px; box-shadow:0 1px 6px rgba(15,23,42,.06); } | |
| #hero .eyebrow { margin:0 0 7px; color:#9a3412; font-size:13px; font-weight:800; letter-spacing:.04em; text-transform:uppercase; } | |
| #hero h1 { margin:0 0 8px; font-size:28px; line-height:1.15; color:#0f172a; font-weight:800; } | |
| #hero p { margin:0; color:#1e3a8a; max-width:920px; } | |
| #hero .hero-pills { display:flex; flex-wrap:wrap; gap:8px; margin-top:12px; } | |
| #hero .hero-pills span { color:#1e293b; background:#ffffff; border:1px solid #bfdbfe; border-radius:999px; padding:5px 10px; font-size:13px; font-weight:600; } | |
| button.primary, .gradio-button.primary { background:var(--accent) !important; border-color:var(--accent) !important; color:white !important; font-weight:700 !important; } | |
| .status { border-radius:8px; padding:10px 13px; margin:6px 0 14px; border:1px solid var(--line); } | |
| .status.ok { background:#ecfdf5; border-color:#86efac; color:#065f46; } | |
| .status.bad { background:#fff1f2; border-color:#fda4af; color:#991b1b; } | |
| .product-card, .overall-card, .note-card { border:1px solid var(--line); border-radius:8px; padding:16px; background:white; } | |
| .product-layout { display:grid; grid-template-columns:96px 1fr; gap:12px; align-items:start; } | |
| .product-img { width:96px; height:118px; object-fit:cover; border-radius:8px; border:1px solid var(--line); background:#f8fafc; } | |
| .product-card h3 { margin:0 0 6px; font-size:18px; line-height:1.25; } | |
| .product-card p { margin:7px 0; } | |
| .metadata-summary { color:#334155; font-size:13px; line-height:1.45; } | |
| .decision-layout { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:10px; align-items:stretch; } | |
| .decision-layout .overall-card, .decision-layout .recommendation-card { margin:0; padding:12px; } | |
| .decision-layout .recommendation-card .small-label { display:none; } | |
| .decision-layout .recommendation-card p { display:none; } | |
| .recommendation-card { border:1px solid #fed7aa; border-left:5px solid var(--accent); background:#fff7ed; border-radius:8px; padding:13px 14px; margin:12px 0; } | |
| .recommendation-card p { margin:7px 0 0; } | |
| .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:5px 9px; font-size:13px; } | |
| .chip { display:inline-block; border-radius:999px; padding:5px 10px; font-weight:700; font-size:13px; } | |
| .conf { color:#334155; font-size:13px; margin-left:8px; } | |
| .small-label { color:#475569; font-size:13px; margin-bottom:8px; } | |
| .prob-row { display:grid; grid-template-columns:82px 1fr 44px; gap:8px; align-items:center; font-size:13px; margin:6px 0; } | |
| .bar { height:8px; background:#e2e8f0; border-radius:999px; overflow:hidden; } | |
| .bar i { display:block; height:100%; background:var(--accent); } | |
| .aspect-grid { display:grid; grid-template-columns:repeat(3, minmax(0, 1fr)); gap:10px; } | |
| .aspect-card { border:1px solid var(--line); border-top:4px solid #64748b; border-radius:8px; padding:12px; background:white; min-height:145px; } | |
| .aspect-card.focus { border-top-color:var(--accent); box-shadow:0 2px 10px rgba(15,23,42,.08); } | |
| .aspect-card.dim { opacity:.66; } | |
| .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:8px; padding:14px; line-height:1.6; } | |
| mark { background:#fde68a; color:#111827; border-radius:4px; padding:1px 3px; } | |
| .metric-grid { display:grid; grid-template-columns:repeat(3, 1fr); gap:12px; margin:8px 0 12px; } | |
| .metric { border:1px solid var(--line); border-radius:8px; padding:14px; background:white; } | |
| .metric span { display:block; color:#334155; font-size:13px; } | |
| .metric b { display:block; font-size:28px; margin:6px 0; } | |
| .metric small { color:#475569; } | |
| .table-wrap { border:1px solid var(--line); border-radius:8px; overflow:hidden; background:white; } | |
| .kv-table { width:100%; border-collapse:collapse; } | |
| .kv-table td { border-bottom:1px solid #e2e8f0; padding:9px 12px; } | |
| .kv-table td:first-child { width:220px; color:#334155; font-weight:700; background:#f8fafc; } | |
| .compact-note { color:#475569; font-size:13px; margin:4px 0 10px; } | |
| .module-head { display:flex; justify-content:space-between; align-items:center; gap:12px; margin:0 0 10px; } | |
| .info-tip { position:relative; display:inline-flex; align-items:center; justify-content:center; width:24px; height:24px; border-radius:999px; border:1px solid #bfdbfe; background:#eff6ff; color:#1e40af; font-weight:800; cursor:help; } | |
| .info-tip .tip-content { display:none; position:absolute; right:0; top:30px; z-index:20; width:420px; max-width:80vw; background:white; border:1px solid var(--line); border-radius:8px; padding:10px; box-shadow:0 12px 30px rgba(15,23,42,.16); } | |
| .info-tip:hover .tip-content { display:block; } | |
| .negative-grid { display:grid; grid-template-columns:repeat(6, minmax(0, 1fr)); gap:10px; margin:8px 0 14px; } | |
| .negative-card { display:grid; grid-template-columns:64px 1fr; gap:10px; border:1px solid #fecaca; border-left:4px solid #ef4444; border-radius:8px; padding:10px; background:#fffafa; } | |
| .negative-card p { margin:5px 0 0; color:#334155; font-size:13px; } | |
| .compact-negative { display:block; min-height:178px; } | |
| .compact-negative h4 { margin:0 0 8px; color:#b91c1c; font-size:14px; letter-spacing:.02em; } | |
| .negative-body { display:grid; grid-template-columns:54px 1fr; gap:8px; align-items:start; } | |
| .negative-body b { display:block; font-size:13px; line-height:1.25; } | |
| .negative-body p { font-size:12px; line-height:1.35; } | |
| .empty-negative { color:#64748b; font-size:12px; border:1px dashed #fecaca; border-radius:8px; padding:14px 8px; background:white; } | |
| .mini-product-img { width:64px; height:76px; object-fit:cover; border-radius:6px; border:1px solid var(--line); background:#f8fafc; } | |
| .negative-body .mini-product-img { width:54px; height:64px; } | |
| @media (max-width:1100px) { .negative-grid { grid-template-columns:repeat(3, minmax(0, 1fr)); } } | |
| @media (max-width:860px) { .aspect-grid, .metric-grid, .product-layout, .decision-layout, .negative-grid { grid-template-columns:1fr; } .product-img { width:100%; height:180px; } } | |
| """ | |
| def build_app() -> gr.Blocks: | |
| categories = ["All"] + sorted({p["category"] for p in PRODUCTS}) | |
| merchant_categories = categories[1:] or ["Clothing"] | |
| default_merchant_category = merchant_categories[0] | |
| tags = sorted({tag for p in PRODUCTS for tag in p.get("tags", [])}) | |
| with gr.Blocks(css=CSS, title="Clothing Sentiment Analysis") as demo: | |
| gr.HTML('<div id="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 updated 10W experiment reports in a compact customer decision-support prototype.</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 Interface"): | |
| with gr.Row(): | |
| with gr.Column(scale=4): | |
| consumer_category = gr.Dropdown(categories, value="All", label="Choose category") | |
| 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() | |
| with gr.Column(scale=6): | |
| aspect_html = gr.HTML() | |
| evidence_html = gr.HTML() | |
| consumer_table = gr.Dataframe(headers=["Aspect", "Prediction", "Confidence", "Key review evidence"], datatype=["str", "str", "number", "str"], label="Aspect-level result table", interactive=False) | |
| with gr.Accordion("Product Finder filters", open=False): | |
| gr.HTML('<div class="compact-note">Optional: filter products by aspect sentiment, category, tags, and minimum rating.</div>') | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| 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") | |
| filter_category = gr.Dropdown(categories, value="All", label="Category") | |
| with gr.Column(scale=1): | |
| filter_tags = gr.CheckboxGroup(tags, label="Required metadata tags") | |
| min_rating = gr.Slider(3.0, 5.0, value=4.0, step=0.1, label="Minimum product rating") | |
| filter_btn = gr.Button("Filter 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") | |
| consumer_category.change(consumer_category_view, [consumer_category, consumer_aspect], [product_select, product_detail, aspect_html, evidence_html, consumer_table]) | |
| 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_category.change(update_filter_tags, filter_category, filter_tags) | |
| filter_btn.click(filter_products, [filter_aspect, filter_sentiment, filter_category, filter_tags, min_rating], [filter_table, filter_summary]) | |
| with gr.Tab("Merchant Interface"): | |
| gr.HTML('<div class="module-head"><h3>Product Score Monitor</h3><span class="info-tip">i<span class="tip-content">' + model_info_html() + '</span></span></div>') | |
| gr.HTML('<div class="small-label">Most negative products across the six aspects</div>') | |
| negative_spotlight = gr.HTML('<div class="note-card">Loading most negative products...</div>') | |
| with gr.Accordion("Score filters and export", open=True): | |
| with gr.Row(): | |
| merchant_metric = gr.Dropdown(["Overall"] + ASPECTS, value="Overall", label="Aspect") | |
| merchant_prediction = gr.Dropdown(["Any", "Positive", "Negative", "Not_Mentioned", "Neutral"], value="Any", label="Prediction") | |
| merchant_category = gr.Dropdown(categories, value="All", label="Category") | |
| merchant_scores = gr.Dataframe(headers=["Product", "Category", "Metric", "Prediction", "Confidence", "Price", "Rating"], value=[["Click Apply Score Filter", "", "", "", 0.0, 0.0, 0.0]], datatype=["str", "str", "str", "str", "number", "number", "number"], interactive=False) | |
| with gr.Row(): | |
| refresh_scores = gr.Button("Apply Score Filter", variant="primary") | |
| export_scores = gr.Button("Export Score List") | |
| merchant_scores_file = gr.File(label="Downloaded score CSV", interactive=False, visible=False) | |
| with gr.Accordion("New Product Metadata Risk Screening", open=False): | |
| risk_mode = gr.Radio(["Single product analysis", "Batch import analysis"], value="Single product analysis", label="Analysis mode") | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| with gr.Group(visible=True) as risk_single_group: | |
| new_features = gr.Textbox("Cotton Polyester Blend, Slim Fit, Graphic Print, Machine Wash", label="New product features", lines=4) | |
| new_categories = gr.Dropdown(merchant_categories, value=default_merchant_category, label="New product category") | |
| with gr.Row(): | |
| new_price = gr.Number(29.99, label="Price") | |
| new_rating = gr.Number(4.1, label="Expected or early average rating") | |
| with gr.Row(): | |
| 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") | |
| with gr.Group(visible=False) as risk_batch_group: | |
| new_import = gr.File(label="Import product metadata JSON/CSV") | |
| with gr.Row(): | |
| new_template = gr.Button("Download Import Template") | |
| batch_screen_btn = gr.Button("Batch Analyze Metadata", variant="primary") | |
| new_template_file = gr.File(label="Template CSV", interactive=False, visible=False) | |
| with gr.Column(scale=1): | |
| 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) | |
| export_risk = gr.Button("Export Risk Result") | |
| risk_file = gr.File(label="Downloaded risk CSV", interactive=False, visible=False) | |
| with gr.Accordion("External Review Prediction", open=False): | |
| ext_mode = gr.Radio(["Single product analysis", "Batch import analysis"], value="Single product analysis", label="Analysis mode") | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| with gr.Group(visible=True) as ext_single_group: | |
| 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) | |
| with gr.Row(): | |
| ext_features = gr.Textbox("Cotton Blend, Slim Fit, Zipper Closure", label="Product features", lines=3) | |
| ext_categories = gr.Dropdown(merchant_categories, value=default_merchant_category, label="Product category") | |
| with gr.Row(): | |
| ext_price = gr.Number(39.99, label="Price") | |
| ext_rating = gr.Number(4.2, label="Average rating") | |
| with gr.Row(): | |
| 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.Group(visible=False) as ext_batch_group: | |
| ext_import = gr.File(label="Import review metadata JSON/CSV") | |
| with gr.Row(): | |
| ext_template = gr.Button("Download Import Template") | |
| batch_external_btn = gr.Button("Batch Analyze Reviews", variant="primary") | |
| ext_template_file = gr.File(label="Template CSV", interactive=False, visible=False) | |
| with gr.Column(scale=1): | |
| ext_overall = gr.HTML() | |
| ext_aspects = gr.HTML() | |
| 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) | |
| export_ext = gr.Button("Export Review Result") | |
| ext_file = gr.File(label="Downloaded review CSV", interactive=False, visible=False) | |
| refresh_scores.click(merchant_score_filter, [merchant_metric, merchant_prediction, merchant_category], merchant_scores) | |
| export_scores.click(export_merchant_scores, merchant_scores, merchant_scores_file) | |
| merchant_metric.change(merchant_score_filter, [merchant_metric, merchant_prediction, merchant_category], merchant_scores) | |
| merchant_prediction.change(merchant_score_filter, [merchant_metric, merchant_prediction, merchant_category], merchant_scores) | |
| merchant_category.change(merchant_score_filter, [merchant_metric, merchant_prediction, merchant_category], merchant_scores) | |
| risk_mode.change(toggle_analysis_mode, risk_mode, [risk_single_group, risk_batch_group]) | |
| screen_btn.click(screen_new_product, [new_features, new_categories, new_price, new_rating, new_count, new_focus], [risk_summary, risk_table]) | |
| new_template.click(download_new_product_template, None, new_template_file) | |
| batch_screen_btn.click(batch_screen_new_products, new_import, [risk_summary, risk_table]) | |
| export_risk.click(export_risk_rows, risk_table, risk_file) | |
| ext_mode.change(toggle_analysis_mode, ext_mode, [ext_single_group, ext_batch_group]) | |
| 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]) | |
| ext_template.click(download_external_review_template, None, ext_template_file) | |
| batch_external_btn.click(batch_external_review_predict, ext_import, [ext_overall, ext_aspects, ext_table]) | |
| export_ext.click(export_external_rows, ext_table, ext_file) | |
| with gr.Tab("Research Metrics"): | |
| gr.Markdown("Metrics are loaded from the updated 10W experiment reports. Tables use CSV outputs when available, with JSON fallback.") | |
| research_cards = gr.HTML(research_cards_html()) | |
| with gr.Accordion("Performance comparison", open=True): | |
| overall_table = gr.Dataframe(headers=["Model", "Macro-F1", "Accuracy"], value=overall_metric_rows(), interactive=False) | |
| 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) | |
| with gr.Accordion("Ablation and metadata source summary", open=False): | |
| ablation_table = gr.Dataframe(headers=["Variant", "Mean Macro-F1", "Mean Accuracy"], value=ablation_rows(), interactive=False) | |
| meta_source_table = gr.Dataframe(headers=["Aspect", "Top Metadata Source", "Source Share", "Mean Weight", "Mean Focus"], value=meta_source_rows(), interactive=False) | |
| with gr.Accordion("Figures from updated reports", open=False): | |
| with gr.Row(): | |
| gr.Image(value=_report_image("aspect_distribution.png"), label="Aspect sentiment distribution", interactive=False) | |
| gr.Image(value=_report_image("category_aspect_negative_heatmap.png"), label="Negative share by category x aspect", interactive=False) | |
| with gr.Row(): | |
| gr.Image(value=_report_image("category_aspect_positive_heatmap.png"), label="Positive share by category x aspect", interactive=False) | |
| gr.Image(value=_report_image("confusion_matrix_proposed_overall_head.png"), label="Proposed overall confusion matrix", interactive=False) | |
| with gr.Accordion("Report file groups", open=False): | |
| gr.Dataframe(headers=["Group", "Files"], value=report_asset_rows(), interactive=False) | |
| refresh_research = gr.Button("Refresh Research Metrics", variant="primary") | |
| refresh_research.click(refresh_research_outputs, outputs=[research_cards, overall_table, aspect_table, ablation_table, meta_source_table]) | |
| demo.load(consumer_product_view, [product_select, consumer_aspect], [product_detail, aspect_html, evidence_html, consumer_table]) | |
| demo.load(negative_product_spotlight, outputs=negative_spotlight) | |
| demo.load(merchant_score_filter, [merchant_metric, merchant_prediction, merchant_category], merchant_scores) | |
| demo.load(filter_products, [filter_aspect, filter_sentiment, filter_category, filter_tags, min_rating], [filter_table, filter_summary]) | |
| return demo | |
| demo = build_app() | |
| if __name__ == "__main__": | |
| demo.launch(ssr_mode=False) | |