Lavender825's picture
Diversify merchant negative spotlight
34059b2
Raw
History Blame Contribute Delete
70.8 kB
"""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:
@staticmethod
def get_token():
try:
return _hf_hub.get_token()
except Exception:
return None
@staticmethod
def save_token(token):
return None
@staticmethod
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
@lru_cache(maxsize=1)
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")),
}
@lru_cache(maxsize=64)
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)