"""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", ''
if any(k in text for k in ["shoe", "sneaker", "boot", "slipper"]):
return "#e0f2fe", "#075985", ''
if any(k in text for k in ["wallet", "card case", "money"]):
return "#f1f5f9", "#334155", ''
if any(k in text for k in ["dress", "cocktail", "apron"]):
return "#fce7f3", "#9d174d", ''
if any(k in text for k in ["short", "leggings", "pants", "jeans"]):
return "#dcfce7", "#166534", ''
return "#eff6ff", "#1f2937", ''
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""""""
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'
Prediction could not run.Technical detail
{_esc(detail)}
'
def _chip(label: str) -> str:
bg = LABEL_BG.get(label, "#f1f5f9")
fg = LABEL_FG.get(label, "#334155")
return f'{_esc(label)}'
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"\1", safe, flags=re.IGNORECASE)
return f'
{safe}
'
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'
{name}
{val:.2f}
')
fallback = '
Fallback inference mode used for this sample.
' if result.get("fallback") else ""
return f'
Overall sentiment
{_chip(label)} confidence {conf:.2f}
{"".join(bars)}{fallback}
'
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'
{title}
'
f'{_esc(sentence)}'
f'
This explanation is generated from the live model output: overall sentiment, six aspect labels, and confidence scores.
'
)
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'{_esc(h)}' for h in hits) or 'No explicit keyword evidence.'
src = sources.get(aspect, {}) if isinstance(sources, dict) else {}
src_line = f'
Top metadata source: {_esc(src.get("source", "-"))} ({_safe_float(src.get("weight")):.2f})
' if src else ""
cls = "focus" if selected_aspect in ("All", aspect) else "dim"
cards.append(f'
{aspect}conf {conf:.2f}
{_chip(label)}
{_esc(ASPECT_DESCRIPTIONS.get(aspect, ""))}
{evidence}
{src_line}
')
return '
' + "".join(cards) + '
'
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''
)
store = product.get("store") or "Amazon"
asin = product.get("parent_asin") or "-"
detail = (
f'
Proposed Overall Accuracy{_pct(proposed_overall.get("accuracy"))}overall head
Proposed Aspect Accuracy{_pct(proposed_aspect.get("mean_accuracy"))}six-aspect mean
Metadata F1 Gain+{gain:.4f}vs no-metadata BERT ACSA
'
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'
{aspect}
'
'
No strong negative sample
'
)
continue
conf, product, reason = best
used_products.add(product["name"])
img = f''
cards.append(
f'
{aspect}
'
f'
{img}
{_esc(_short_text(product["name"], 42))}'
f'
{_esc(product["category"])} | conf {conf:.2f}
'
f'
{_esc(_short_text(reason, 56))}
'
)
return '
' + "".join(cards[:limit]) + '
'
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'
New product risk focus: {_esc(summary)} This is a metadata screening tool, not a replacement for real review evaluation.
', 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 '
Upload a CSV or JSON file first.
', []
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'
', 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 '
Upload a CSV or JSON file first.
', "", []
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'
Explore overall sentiment, six aspect-level opinions, metadata-driven risks, and updated 10W experiment reports in a compact customer decision-support prototype.