| """Polished Hugging Face Spaces frontend for ACSA Clothing. |
| |
| Two-end design: |
| 1. Customer-facing review analyzer. |
| 2. Research dashboard for metrics, ablation, and explanation evidence. |
| """ |
| from __future__ import annotations |
|
|
| import csv |
| import html |
| import json |
| import re |
| import traceback |
| from functools import lru_cache |
| from pathlib import Path |
| from typing import Any, Dict, Iterable, List, Tuple |
|
|
| |
| 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" |
| CHECKPOINT_DIR = ROOT / "checkpoints" / "meta_acsa" |
| CHECKPOINT_PATH = CHECKPOINT_DIR / "best.pt" |
| META_ENCODER_PATH = ROOT / "data" / "meta_encoder.pkl" |
|
|
| ASPECT_DESCRIPTIONS = { |
| "SIZE": "Fit, length, sizing accuracy, runs large or small.", |
| "MATERIAL": "Fabric feel, thickness, breathability, comfort.", |
| "QUALITY": "Workmanship, durability, seams, washing performance.", |
| "APPEARANCE": "Color, pattern, image consistency, visual look.", |
| "STYLE": "Cut, silhouette, fashionability, styling appeal.", |
| "VALUE": "Price fairness, worthiness, return or repurchase intent.", |
| } |
|
|
| LABEL_COLORS = { |
| "Positive": "#15803d", |
| "Negative": "#b91c1c", |
| "Not_Mentioned": "#64748b", |
| } |
|
|
| LABEL_BG = { |
| "Positive": "#dcfce7", |
| "Negative": "#fee2e2", |
| "Not_Mentioned": "#f1f5f9", |
| } |
|
|
| ASPECT_KEYWORDS = { |
| "SIZE": ["size", "fit", "fits", "fitting", "small", "large", "big", "tight", "loose", "xl", "medium", "waist", "length", "runs small", "runs large", "too small", "too large"], |
| "MATERIAL": ["material", "fabric", "cotton", "polyester", "soft", "scratchy", "thin", "thick", "stretch", "breathable", "comfortable"], |
| "QUALITY": ["quality", "stitch", "stitching", "seam", "wash", "washed", "durable", "cheap", "broke", "broken", "ripped", "tear", "torn", "defect", "zipper", "button"], |
| "APPEARANCE": ["look", "looks", "color", "colour", "photo", "picture", "beautiful", "cute", "print", "design", "pattern", "shown"], |
| "STYLE": ["style", "stylish", "flattering", "casual", "formal", "fashion", "silhouette", "cut", "shape"], |
| "VALUE": ["price", "worth", "value", "money", "expensive", "overpriced", "cheap", "discount", "buy again", "purchase", "return"], |
| } |
|
|
| NEGATIVE_CUES = { |
| "bad", "poor", "terrible", "awful", "cheap", "scratchy", "itchy", "thin", "small", "large", "tight", "loose", |
| "broke", "broken", "ripped", "torn", "overpriced", "return", "returned", "waste", "disappointed", "uncomfortable", |
| "shrunk", "defective", "wrong", |
| } |
|
|
| POSITIVE_CUES = { |
| "love", "loved", "great", "good", "perfect", "excellent", "nice", "soft", "comfortable", "beautiful", "pretty", |
| "flattering", "worth", "recommend", "amazing", "stylish", "durable", "cute", "buy again", |
| } |
|
|
| SIZE_NEGATIVE_PATTERNS = [ |
| r"\bruns?\s+(a\s+little\s+)?small\b", |
| r"\bruns?\s+(a\s+little\s+)?large\b", |
| r"\btoo\s+(small|large|big|tight|loose)\b", |
| r"\b(size|fit|fits|fitting)\b[^.!?]{0,35}\b(small|large|big|tight|loose)\b", |
| ] |
|
|
| def _safe_json(path: Path) -> Dict[str, Any]: |
| if not path.exists(): |
| return {} |
| try: |
| return json.loads(path.read_text(encoding="utf-8")) |
| except Exception: |
| return {} |
|
|
|
|
| def _safe_csv(path: Path, limit: int = 80) -> List[Dict[str, str]]: |
| if not path.exists(): |
| return [] |
| try: |
| with path.open(newline="", encoding="utf-8") as f: |
| return list(csv.DictReader(f))[:limit] |
| except Exception: |
| return [] |
|
|
|
|
| def _pct(value: Any) -> str: |
| try: |
| return f"{100 * float(value):.2f}%" |
| except Exception: |
| return "-" |
|
|
|
|
| def _num(value: Any, default: float = 0.0) -> float: |
| try: |
| if value in (None, ""): |
| return default |
| return float(value) |
| except Exception: |
| return default |
|
|
|
|
| def _int(value: Any, default: int = 0) -> int: |
| try: |
| if value in (None, ""): |
| return default |
| return int(float(value)) |
| except Exception: |
| return default |
|
|
|
|
| def _model_issues() -> List[str]: |
| issues = [] |
| if not CHECKPOINT_PATH.exists(): |
| issues.append("Missing checkpoints/meta_acsa/best.pt") |
| elif CHECKPOINT_PATH.stat().st_size < 1_000_000: |
| size = CHECKPOINT_PATH.stat().st_size |
| try: |
| head = CHECKPOINT_PATH.read_bytes()[:160].decode("utf-8", errors="replace") |
| except Exception as exc: |
| head = f"<could not read file head: {exc}>" |
| issues.append( |
| "checkpoints/meta_acsa/best.pt looks too small. " |
| f"Runtime path={CHECKPOINT_PATH}; runtime size={size} bytes; " |
| f"file head={head!r}. " |
| "It may be a Git LFS/Xet pointer or the running Space has not synced the real 303 MB model file." |
| ) |
| if not (CHECKPOINT_DIR / "tokenizer").exists(): |
| issues.append("Missing checkpoints/meta_acsa/tokenizer/") |
| if not META_ENCODER_PATH.exists(): |
| issues.append("Missing data/meta_encoder.pkl") |
| return issues |
|
|
|
|
| @lru_cache(maxsize=1) |
| def _predictor() -> AspectPredictor: |
| issues = _model_issues() |
| if issues: |
| raise FileNotFoundError("; ".join(issues)) |
| return AspectPredictor(checkpoint_dir=CHECKPOINT_DIR) |
|
|
|
|
| def _meta(features: str, categories: str, price: Any, avg_rating: Any, rating_count: Any) -> Dict[str, Any]: |
| return { |
| "features_text": features or "", |
| "categories_text": categories or "", |
| "price": _num(price), |
| "average_rating": _num(avg_rating), |
| "rating_number": _int(rating_count), |
| } |
|
|
|
|
| def _status_html() -> str: |
| issues = _model_issues() |
| if not issues: |
| return "<div class='status ok'><b>Model ready.</b> All required inference artifacts are available.</div>" |
| items = "".join(f"<li>{html.escape(x)}</li>" for x in issues) |
| return f"<div class='status warn'><b>Model artifacts need attention.</b><ul>{items}</ul></div>" |
|
|
|
|
| def _error_html(title: str, exc: Exception) -> str: |
| msg = html.escape(str(exc) or exc.__class__.__name__) |
| detail = html.escape(traceback.format_exc(limit=2)) |
| return ( |
| "<div class='error-card'>" |
| f"<h3>{html.escape(title)}</h3>" |
| f"<p>{msg}</p>" |
| f"<details><summary>Technical detail</summary><pre>{detail}</pre></details>" |
| "</div>" |
| ) |
|
|
|
|
|
|
| def _contains_phrase(text: str, phrase: str) -> bool: |
| if " " in phrase: |
| return phrase in text |
| return re.search(r"\b" + re.escape(phrase) + r"\b", text) is not None |
|
|
|
|
| def _extract_evidence(review_text: str) -> Dict[str, List[str]]: |
| text = str(review_text or "") |
| lower = text.lower() |
| result: Dict[str, List[str]] = {} |
| for aspect, terms in ASPECT_KEYWORDS.items(): |
| hits: List[str] = [] |
| for term in terms: |
| if _contains_phrase(lower, term.lower()) and term not in hits: |
| hits.append(term) |
| cue_hits = [] |
| for cue in sorted(POSITIVE_CUES | NEGATIVE_CUES): |
| if _contains_phrase(lower, cue.lower()): |
| cue_hits.append(cue) |
| if hits: |
| merged = hits + [c for c in cue_hits if c not in hits] |
| else: |
| merged = [] |
| result[aspect] = merged[:8] |
| return result |
|
|
|
|
| def _confidence(result: Dict[str, Any], aspect: str) -> float: |
| details = result.get("aspect_details", {}) or {} |
| val = details.get(aspect, {}).get("confidence") if isinstance(details, dict) else None |
| return _num(val, 0.0) |
|
|
|
|
| def _apply_demo_calibration(result: Dict[str, Any], review_text: str) -> Dict[str, Any]: |
| """Conservative display-time calibration for the HF demo. |
| |
| It prevents overall positivity from spreading to aspects with no textual |
| evidence, while preserving the raw model confidence in aspect_details. |
| """ |
| aspects = dict(result.get("aspects", {}) or {}) |
| evidence = _extract_evidence(review_text) |
| raw_aspects = dict(aspects) |
| lower = str(review_text or "").lower() |
|
|
| for aspect in cfg.ASPECTS: |
| hits = evidence.get(aspect, []) |
| if aspect == "SIZE" and any(re.search(p, lower) for p in SIZE_NEGATIVE_PATTERNS): |
| aspects[aspect] = "Negative" |
| if not hits: |
| evidence[aspect] = ["size issue"] |
| continue |
| if not hits: |
| |
| |
| aspects[aspect] = "Not_Mentioned" |
| elif aspects.get(aspect) == "Not_Mentioned" and _confidence(result, aspect) >= 0.90: |
| |
| aspects[aspect] = raw_aspects.get(aspect, "Not_Mentioned") |
|
|
| result["raw_model_aspects"] = raw_aspects |
| result["aspects"] = aspects |
| result["text_evidence_by_aspect"] = evidence |
| result["display_calibrated"] = True |
| return result |
| def _highlight_evidence(evidence: Any) -> str: |
| if not evidence: |
| return "<span class='muted'>No explicit text evidence returned.</span>" |
| if isinstance(evidence, str): |
| evidence = [evidence] |
| chips = [] |
| for item in list(evidence)[:5]: |
| text = str(item).strip() |
| if text: |
| chips.append(f"<span class='chip'>{html.escape(text)}</span>") |
| return "".join(chips) or "<span class='muted'>No explicit text evidence returned.</span>" |
|
|
|
|
| def _aspect_cards(result: Dict[str, Any]) -> str: |
| aspects = result.get("aspects", {}) or {} |
| evidence = result.get("text_evidence_by_aspect", {}) or {} |
| details = result.get("aspect_details", {}) or {} |
| parts = ["<div class='aspect-grid'>"] |
| for aspect in cfg.ASPECTS: |
| label = aspects.get(aspect, "Not_Mentioned") |
| color = LABEL_COLORS.get(label, "#64748b") |
| bg = LABEL_BG.get(label, "#f1f5f9") |
| conf = details.get(aspect, {}).get("confidence") if isinstance(details, dict) else None |
| conf_html = f"<span class='confidence'>conf {float(conf):.2f}</span>" if isinstance(conf, (int, float)) else "" |
| parts.append( |
| f"<section class='aspect-card' style='border-top-color:{color}'>" |
| f"<div class='aspect-top'><b>{html.escape(aspect)}</b>{conf_html}</div>" |
| f"<div class='label-pill' style='color:{color};background:{bg}'>{html.escape(label)}</div>" |
| f"<p>{html.escape(ASPECT_DESCRIPTIONS.get(aspect, ''))}</p>" |
| f"<div class='evidence'>{_highlight_evidence(evidence.get(aspect))}</div>" |
| "</section>" |
| ) |
| parts.append("</div>") |
| return "".join(parts) |
|
|
|
|
| def _flatten_attention(result: Dict[str, Any]) -> List[Tuple[str, str, float]]: |
| rows: List[Tuple[str, str, float]] = [] |
| by_aspect = result.get("meta_attention_by_aspect", {}) or {} |
| if isinstance(by_aspect, dict): |
| for aspect, weights in by_aspect.items(): |
| if isinstance(weights, dict): |
| for name, val in weights.items(): |
| rows.append((str(aspect), str(name), _num(val))) |
| global_attn = result.get("meta_attention", {}) or {} |
| if isinstance(global_attn, dict): |
| for name, val in global_attn.items(): |
| rows.append(("GLOBAL", str(name), _num(val))) |
| return rows |
|
|
|
|
| def _attention_html(result: Dict[str, Any]) -> str: |
| rows = _flatten_attention(result) |
| if not rows: |
| return "<div class='empty'>No metadata attention returned by this checkpoint.</div>" |
| max_v = max(v for _, _, v in rows) or 1.0 |
| grouped: Dict[str, List[Tuple[str, float]]] = {} |
| for aspect, name, val in rows: |
| grouped.setdefault(aspect, []).append((name, val)) |
| parts = ["<div class='attn-grid'>"] |
| for aspect in ["GLOBAL"] + [a for a in cfg.ASPECTS if a in grouped]: |
| if aspect not in grouped: |
| continue |
| parts.append(f"<section class='attn-card'><h4>{html.escape(aspect)} metadata use</h4>") |
| for name, val in sorted(grouped[aspect], key=lambda x: x[1], reverse=True): |
| width = max(5, min(100, 100 * val / max_v)) |
| parts.append( |
| f"<div class='bar-row'><span>{html.escape(name)}</span>" |
| f"<div class='bar-bg'><div class='bar-fill' style='width:{width:.1f}%'></div></div>" |
| f"<em>{val:.3f}</em></div>" |
| ) |
| parts.append("</section>") |
| parts.append("</div>") |
| return "".join(parts) |
|
|
|
|
| def _overall_label(result: Dict[str, Any]) -> str: |
| overall = result.get("overall") |
| if isinstance(overall, dict): |
| return str(overall.get("label") or overall.get("prediction") or overall) |
| if overall: |
| return str(overall) |
| labels = list((result.get("aspects") or {}).values()) |
| if labels.count("Negative") >= 1: |
| return "Negative" |
| if labels.count("Positive") >= 1: |
| return "Positive" |
| return "Neutral / Not enough aspect signal" |
|
|
|
|
| def _diagnosis_html(result: Dict[str, Any]) -> str: |
| aspects = result.get("aspects", {}) or {} |
| overall = _overall_label(result) |
| positives = [a for a, v in aspects.items() if v == "Positive"] |
| negatives = [a for a, v in aspects.items() if v == "Negative"] |
| meta_summary = result.get("metadata_summary") or {} |
| top_meta = result.get("top_meta_source") or "-" |
| risk = "High" if len(negatives) >= 2 or overall == "Negative" else "Medium" if negatives else "Low" |
| return ( |
| "<section class='diagnosis'>" |
| "<h3>Business Diagnosis</h3>" |
| f"<div class='diag-row'><span>Overall</span><b>{html.escape(overall)}</b></div>" |
| f"<div class='diag-row'><span>Risk level</span><b>{risk}</b></div>" |
| f"<div class='diag-row'><span>Positive drivers</span><b>{html.escape(', '.join(positives) or '-')}</b></div>" |
| f"<div class='diag-row'><span>Negative drivers</span><b>{html.escape(', '.join(negatives) or '-')}</b></div>" |
| f"<div class='diag-row'><span>Top metadata source</span><b>{html.escape(str(top_meta))}</b></div>" |
| f"<p>{html.escape(_recommendation(positives, negatives))}</p>" |
| f"<details><summary>Metadata summary</summary><pre>{html.escape(json.dumps(meta_summary, ensure_ascii=False, indent=2))}</pre></details>" |
| "</section>" |
| ) |
|
|
|
|
| def _recommendation(positives: Iterable[str], negatives: Iterable[str]) -> str: |
| positives = list(positives) |
| negatives = list(negatives) |
| if negatives: |
| return "Prioritize the negative aspects in PDP copy, sizing guidance, QA checks, and return-reason analysis." |
| if positives: |
| return "Use the positive aspect signals as merchandising highlights and customer-facing selling points." |
| return "The review does not contain a strong aspect-level sentiment signal; use it as low-priority qualitative feedback." |
|
|
|
|
| def analyze_review(review: str, features: str, categories: str, price: Any, avg_rating: Any, rating_count: Any): |
| if not str(review or "").strip(): |
| msg = "<div class='empty'>Enter a customer review, then click Analyze Review.</div>" |
| return msg, "", "", [] |
| try: |
| result = _predictor().predict(review, _meta(features, categories, price, avg_rating, rating_count)) |
| result = _apply_demo_calibration(result, review) |
| rows = [[a, result.get("aspects", {}).get(a, "Not_Mentioned"), ASPECT_DESCRIPTIONS.get(a, ""), ", ".join(result.get("text_evidence_by_aspect", {}).get(a, []))] for a in cfg.ASPECTS] |
| return _aspect_cards(result), _attention_html(result), _diagnosis_html(result), rows |
| except Exception as exc: |
| return _error_html("Prediction could not run", exc), "", "", [] |
|
|
|
|
| def _dashboard_payload() -> Tuple[Dict[str, Any], Dict[str, Any], Dict[str, Any]]: |
| comparison = _safe_json(REPORT_DIR / "evaluation_comparison.json") |
| overall = comparison.get("overall_3class_comparison", {}) if isinstance(comparison, dict) else {} |
| proposed = comparison.get("proposed_per_aspect", {}) if isinstance(comparison, dict) else {} |
| no_meta = comparison.get("acsa_no_meta_per_aspect", {}) if isinstance(comparison, dict) else {} |
| return overall, proposed, no_meta |
|
|
|
|
| def _metric_cards() -> str: |
| overall, proposed, no_meta = _dashboard_payload() |
| proposed_overall = overall.get("Proposed_BERT_Meta_Fusion__overall_head", {}) |
| proposed_aspect = proposed.get("overall", {}) |
| no_meta_aspect = no_meta.get("overall", {}) |
| metadata_acc_gain = _num(proposed_aspect.get("mean_accuracy")) - _num(no_meta_aspect.get("mean_accuracy")) |
| cards = [ |
| ("Proposed Overall Accuracy", _pct(proposed_overall.get("accuracy")), "Joint-trained overall head"), |
| ("Proposed Aspect Accuracy", _pct(proposed_aspect.get("mean_accuracy")), "Mean accuracy across six aspects"), |
| ("Metadata Accuracy Gain", f"{metadata_acc_gain:+.4f}", "Proposed vs baseline without metadata"), |
| ] |
| return "<div class='metric-card-grid'>" + "".join( |
| f"<section class='metric-card'><span>{html.escape(k)}</span><b>{html.escape(v)}</b><em>{html.escape(s)}</em></section>" |
| for k, v, s in cards |
| ) + "</div>" |
|
|
|
|
| def _overall_accuracy_rows() -> List[List[Any]]: |
| overall, _, _ = _dashboard_payload() |
| selected = [ |
| ("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 [[ |
| label, |
| round(_num(overall.get(key, {}).get("macro_f1")), 4), |
| round(_num(overall.get(key, {}).get("accuracy")), 4), |
| ] for label, key in selected] |
|
|
|
|
| def _aspect_overall_rows() -> List[List[Any]]: |
| _, proposed, no_meta = _dashboard_payload() |
| selected = [ |
| ("BERT ACSA (text-only)", no_meta.get("overall", {})), |
| ("Proposed BERT + Metadata Fusion", proposed.get("overall", {})), |
| ] |
| return [[label, round(_num(block.get("mean_macro_f1")), 4), round(_num(block.get("mean_accuracy")), 4)] for label, block in selected] |
|
|
|
|
| def _aspect_rows() -> List[List[Any]]: |
| _, proposed_block, no_meta_block = _dashboard_payload() |
| proposed = proposed_block.get("per_aspect", {}) |
| no_meta = no_meta_block.get("per_aspect", {}) |
| rows = [] |
| for aspect in cfg.ASPECTS: |
| proposed_metrics = proposed.get(aspect, {}) |
| text_metrics = no_meta.get(aspect, {}) |
| rows.append([ |
| aspect, |
| round(_num(text_metrics.get("macro_f1")), 4), |
| round(_num(proposed_metrics.get("macro_f1")), 4), |
| round(_num(proposed_metrics.get("macro_f1")) - _num(text_metrics.get("macro_f1")), 4), |
| round(_num(text_metrics.get("accuracy")), 4), |
| round(_num(proposed_metrics.get("accuracy")), 4), |
| round(_num(proposed_metrics.get("accuracy")) - _num(text_metrics.get("accuracy")), 4), |
| ]) |
| return rows |
|
|
|
|
| def _ablation_rows() -> List[List[Any]]: |
| data = _safe_json(REPORT_DIR / "ablation_summary.json") |
| labels = [ |
| ("Proposed", "Proposed"), |
| ("w/o Text Metadata (keep numeric)", "A1_no_text_meta"), |
| ("w/o Numeric Metadata", "A2_no_numeric_meta"), |
| ("w/o Cross-Attention (Concat Fusion)", "A3_concat_fusion"), |
| ] |
| return [ |
| [label, round(_num(data.get(key, {}).get("mean_macro_f1")), 4), round(_num(data.get(key, {}).get("mean_accuracy")), 4)] |
| for label, key in labels |
| ] |
|
|
|
|
| def refresh_dashboard(): |
| return _metric_cards(), _overall_accuracy_rows(), _aspect_overall_rows(), _aspect_rows(), _ablation_rows() |
|
|
|
|
| CSS = """' |
| :root { --orange: #f97316; --ink: #111827; --muted: #64748b; --line: #e5e7eb; --soft: #f8fafc; } |
| .gradio-container { max-width: 1240px !important; margin: 0 auto !important; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } |
| .hero { margin: 24px 0 18px; padding: 28px 30px; border: 1px solid #dbe3ee; border-radius: 8px; background: linear-gradient(135deg, #ffffff 0%, #f4f7fb 100%); } |
| .hero h1 { margin: 0 0 8px; font-size: 30px; line-height: 1.15; color: var(--ink); } |
| .hero p { margin: 0; color: #475569; font-size: 14px; } |
| .status { margin: 0 0 14px; padding: 12px 14px; border-radius: 8px; font-size: 13px; } |
| .status.ok { color: #166534; background: #ecfdf5; border: 1px solid #bbf7d0; } |
| .status.warn { color: #9a3412; background: #fff7ed; border: 1px solid #fed7aa; } |
| .input-panel, .output-panel { box-sizing: border-box; border: 1px solid var(--line); border-radius: 8px; padding: 14px; background: #fff; } |
| .output-panel { min-height: 470px; } |
| .result-placeholder { display: grid; min-height: 230px; place-items: center; border: 1px dashed #cbd5e1; border-radius: 8px; color: var(--muted); background: #fbfdff; font-size: 14px; } |
| .diagnosis-placeholder { margin-top: 12px; padding: 14px; border: 1px solid #e2e8f0; border-radius: 8px; color: var(--muted); background: #f8fafc; font-size: 13px; } |
| .analyze-button, .analyze-button button { display: flex !important; width: 100% !important; min-height: 44px !important; margin-top: 12px !important; align-items: center !important; justify-content: center !important; background: #f97316 !important; border: 1px solid #f97316 !important; border-radius: 6px !important; color: #ffffff !important; font-weight: 700 !important; } |
| .analyze-button button { color: #ffffff !important; } |
| .aspect-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } |
| .aspect-card { border: 1px solid var(--line); border-top: 4px solid #94a3b8; border-radius: 8px; padding: 13px; background: #fff; min-height: 150px; } |
| .aspect-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--ink); } |
| .confidence { color: var(--muted); font-size: 12px; font-weight: 500; } |
| .label-pill { display: inline-block; margin: 9px 0 8px; padding: 4px 9px; border-radius: 999px; font-weight: 800; font-size: 13px; } |
| .aspect-card p { margin: 0 0 8px; color: #475569; font-size: 12px; min-height: 34px; } |
| .chip { display: inline-block; margin: 2px 5px 2px 0; padding: 3px 8px; border: 1px solid #c7d2fe; border-radius: 999px; background: #eef2ff; color: #3730a3; font-size: 12px; } |
| .muted, .empty { color: var(--muted); font-size: 13px; } |
| .attn-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 4px; } |
| .attn-card { border: 1px solid var(--line); border-radius: 8px; padding: 12px; background: #fff; } |
| .attn-card h4 { margin: 0 0 8px; font-size: 14px; color: var(--ink); } |
| .bar-row { display: grid; grid-template-columns: 112px 1fr 48px; gap: 8px; align-items: center; margin-top: 7px; color: #475569; font-size: 12px; } |
| .bar-bg { height: 9px; border-radius: 999px; background: #edf2f7; overflow: hidden; } |
| .bar-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, #f97316, #2563eb); } |
| .diagnosis { border: 1px solid #cbd5e1; border-radius: 8px; padding: 15px; background: #fbfdff; } |
| .diagnosis h3 { margin: 0 0 10px; font-size: 18px; } |
| .diag-row { display: grid; grid-template-columns: 150px 1fr; padding: 7px 0; border-bottom: 1px solid #e8edf4; color: #475569; } |
| .diag-row b { color: var(--ink); } |
| .diagnosis p { margin: 12px 0 0; color: #334155; } |
| .error-card { border: 1px solid #fecaca; background: #fef2f2; color: #991b1b; border-radius: 8px; padding: 14px; } |
| .error-card h3 { margin: 0 0 6px; } |
| .error-card pre { white-space: pre-wrap; color: #7f1d1d; } |
| .metric-card-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 12px; } |
| .metric-card { border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: 14px; } |
| .metric-card span { display: block; color: var(--muted); font-size: 13px; } |
| .metric-card b { display: block; margin: 6px 0; font-size: 26px; color: var(--ink); } |
| .metric-card em { color: #475569; font-style: normal; font-size: 12px; } |
| @media (max-width: 860px) { .aspect-grid, .attn-grid, .metric-card-grid { grid-template-columns: 1fr; } .hero h1 { font-size: 24px; } } |
| """ |
|
|
|
|
| def build_app() -> gr.Blocks: |
| with gr.Blocks(css=CSS, title="ACSA Clothing Sentiment") as demo: |
| gr.HTML( |
| "<div class='hero'><h1>Aspect-Level Sentiment Analysis for Clothing Reviews</h1>" |
| "<p>BERT + metadata fusion for SIZE, MATERIAL, QUALITY, APPEARANCE, STYLE, and VALUE.</p></div>" |
| ) |
| gr.HTML(_status_html()) |
|
|
| with gr.Tabs(): |
| with gr.Tab("Customer Demo"): |
| with gr.Row(equal_height=False): |
| with gr.Column(scale=5): |
| with gr.Group(elem_classes="input-panel"): |
| review = gr.Textbox( |
| label="Customer review", |
| lines=4, |
| value="The size runs really small, I ordered an XL but it fits like a Medium. The fabric feels soft and the print looks great.", |
| ) |
| features = gr.Textbox(label="Product features", value="100% Cotton, Slim Fit, Machine Wash Cold") |
| categories = gr.Textbox(label="Product categories", value="Clothing > Men > T-Shirts > Graphic Tees") |
| with gr.Group(elem_classes="input-panel"): |
| with gr.Row(): |
| price = gr.Number(label="Price", value=19.99) |
| avg_rating = gr.Number(label="Average rating", value=4.2) |
| rating_count = gr.Number(label="Rating count", value=312, precision=0) |
| run = gr.Button("Analyze Review", variant="primary", elem_classes=["analyze-button"]) |
| with gr.Column(scale=7, elem_classes="output-panel"): |
| pred = gr.HTML("<div class='result-placeholder'>Enter a review and click Analyze Review to generate aspect-level results.</div>") |
| diagnosis = gr.HTML("<div class='diagnosis-placeholder'>The business diagnosis and metadata contribution summary will appear here.</div>") |
| with gr.Row(): |
| attn = gr.HTML(label="Metadata attention") |
| table = gr.Dataframe( |
| headers=["Aspect", "Prediction", "Business Meaning", "Evidence"], |
| label="Aspect-level result table", |
| interactive=False, |
| ) |
| run.click(analyze_review, [review, features, categories, price, avg_rating, rating_count], [pred, attn, diagnosis, table]) |
|
|
| with gr.Tab("Research Dashboard"): |
| gr.Markdown("### Research Dashboard") |
| gr.Markdown("Metrics are loaded from the bundled 30K experiment reports. All values use the same held-out test split.") |
| refresh = gr.Button("Refresh Dashboard", variant="primary") |
| metric_cards = gr.HTML(_metric_cards()) |
|
|
| gr.Markdown("#### 1. Overall Sentiment Accuracy") |
| overall_accuracy_table = gr.Dataframe( |
| headers=["Model", "Macro-F1", "Accuracy"], |
| value=_overall_accuracy_rows(), |
| label="Overall: two baselines and the Proposed overall head", |
| interactive=False, |
| ) |
|
|
| gr.Markdown("#### 2. Aspect-Level Overall Comparison") |
| aspect_overall_table = gr.Dataframe( |
| headers=["Model", "Mean Macro-F1", "Mean Accuracy"], |
| value=_aspect_overall_rows(), |
| label="Text-only BERT ACSA versus metadata fusion", |
| interactive=False, |
| ) |
|
|
| gr.Markdown("#### 3. Six-Aspect Comparison") |
| aspect_table = gr.Dataframe( |
| headers=["Aspect", "Text-only F1", "Proposed F1", "F1 Delta", "Text-only Acc", "Proposed Acc", "Acc Delta"], |
| value=_aspect_rows(), |
| label="SIZE, MATERIAL, QUALITY, APPEARANCE, STYLE, and VALUE", |
| interactive=False, |
| ) |
|
|
| gr.Markdown("#### 4. Ablation Study") |
| ablation_table = gr.Dataframe( |
| headers=["Variant", "Mean Macro-F1", "Mean Accuracy"], |
| value=_ablation_rows(), |
| label="Contribution of text metadata, numeric metadata, and cross-attention", |
| interactive=False, |
| ) |
| refresh.click( |
| refresh_dashboard, |
| outputs=[metric_cards, overall_accuracy_table, aspect_overall_table, aspect_table, ablation_table], |
| ) |
| demo.load( |
| refresh_dashboard, |
| outputs=[metric_cards, overall_accuracy_table, aspect_overall_table, aspect_table, ablation_table], |
| ) |
| return demo |
|
|
|
|
| if __name__ == "__main__": |
| build_app().launch() |
|
|
|
|
|
|
|
|
|
|