Spaces:
Sleeping
Sleeping
| """ | |
| src/models/sentiment_model.py | |
| Fix #6 — Replaces binary SST-2 with: | |
| • 5-class star-rating sentiment (nlptown/bert-base-multilingual-uncased-sentiment) | |
| • Aspect-level sentiment breakdown (keyword-extracted aspects + per-sentence scoring) | |
| Also covers Fix #4 (multilingual) for reviews. | |
| """ | |
| import re | |
| import logging | |
| from collections import defaultdict | |
| from transformers import pipeline | |
| logger = logging.getLogger(__name__) | |
| SENTIMENT_MODEL = "nlptown/bert-base-multilingual-uncased-sentiment" | |
| # Common e-commerce product aspects and their keyword triggers | |
| ASPECT_KEYWORDS = { | |
| "Battery": ["battery", "charge", "charging", "power", "mah", "backup"], | |
| "Display": ["display", "screen", "resolution", "brightness", "colour", "color", "oled", "amoled", "lcd"], | |
| "Camera": ["camera", "photo", "picture", "video", "megapixel", "mp", "selfie", "lens"], | |
| "Performance": ["performance", "speed", "fast", "slow", "lag", "processor", "chip", "ram", "snapdragon", "apple a"], | |
| "Build Quality": ["build", "quality", "material", "plastic", "metal", "glass", "premium", "cheap", "durable", "fragile"], | |
| "Price / Value": ["price", "value", "worth", "expensive", "cheap", "affordable", "overpriced", "budget", "cost"], | |
| "Delivery": ["delivery", "shipping", "packaging", "arrived", "damage", "box"], | |
| "Software": ["software", "ui", "ux", "android", "ios", "update", "bloatware", "interface", "app"], | |
| "Sound": ["sound", "speaker", "audio", "volume", "bass", "microphone", "earphone"], | |
| "Size / Weight": ["size", "weight", "heavy", "light", "compact", "bulky", "portable"], | |
| } | |
| STAR_MAP = { | |
| "1 star": 1, | |
| "2 stars": 2, | |
| "3 stars": 3, | |
| "4 stars": 4, | |
| "5 stars": 5, | |
| } | |
| def _star_to_sentiment(star: int) -> str: | |
| if star >= 4: | |
| return "Positive" | |
| if star == 3: | |
| return "Neutral" | |
| return "Negative" | |
| class SentimentModel: | |
| """ | |
| Analyses product review text and returns: | |
| - Overall star distribution (1–5) | |
| - Overall sentiment summary | |
| - Per-aspect sentiment breakdown | |
| """ | |
| def __init__(self): | |
| self._pipe = None | |
| def _get_pipe(self): | |
| if self._pipe is None: | |
| logger.info("Loading 5-class sentiment model (%s)…", SENTIMENT_MODEL) | |
| self._pipe = pipeline( | |
| "text-classification", | |
| model=SENTIMENT_MODEL, | |
| tokenizer=SENTIMENT_MODEL, | |
| top_k=None, # return all classes | |
| truncation=True, | |
| max_length=512, | |
| ) | |
| return self._pipe | |
| # ── Sentence splitting ──────────────────────────────────────────────────── | |
| def _split_sentences(text: str) -> list[str]: | |
| sentences = re.split(r"(?<=[.!?])\s+", text.strip()) | |
| # Keep only sentences with >= 5 words (filter noise) | |
| return [s for s in sentences if len(s.split()) >= 5] | |
| # ── Aspect detection ────────────────────────────────────────────────────── | |
| def _detect_aspects(sentence: str) -> list[str]: | |
| low = sentence.lower() | |
| return [aspect for aspect, keywords in ASPECT_KEYWORDS.items() | |
| if any(kw in low for kw in keywords)] | |
| # ── Score a single piece of text ───────────────────────────────────────── | |
| def _score_text(self, text: str) -> dict: | |
| """Returns {star_label: score} dict.""" | |
| pipe = self._get_pipe() | |
| try: | |
| results = pipe(text[:512])[0] # list of {label, score} | |
| return {r["label"]: r["score"] for r in results} | |
| except Exception as e: | |
| logger.warning("Sentiment scoring failed: %s", e) | |
| return {"3 stars": 1.0} | |
| def _best_star(scores: dict) -> int: | |
| return STAR_MAP.get(max(scores, key=scores.get), 3) | |
| # ── Public interface ────────────────────────────────────────────────────── | |
| def analyze(self, context: str, question: str = "") -> dict: | |
| """ | |
| Analyse context text for sentiment. | |
| Returns overall summary + per-aspect breakdown. | |
| """ | |
| sentences = self._split_sentences(context) | |
| if not sentences: | |
| return {"summary": "Not enough review text to analyse.", "aspects": {}} | |
| # ── Overall distribution ────────────────────────────────────────────── | |
| star_counts = defaultdict(int) | |
| star_scores = defaultdict(float) | |
| aspect_data = defaultdict(lambda: {"positive": 0, "neutral": 0, "negative": 0, "sentences": []}) | |
| for sent in sentences[:100]: # cap at 100 sentences for latency | |
| scores = self._score_text(sent) | |
| star = self._best_star(scores) | |
| sentiment = _star_to_sentiment(star) | |
| star_counts[star] += 1 | |
| star_scores[star] += scores.get(f"{star} stars" if star > 1 else "1 star", 0) | |
| for aspect in self._detect_aspects(sent): | |
| aspect_data[aspect][sentiment.lower()] += 1 | |
| aspect_data[aspect]["sentences"].append({ | |
| "text": sent[:120], | |
| "sentiment": sentiment, | |
| "stars": star, | |
| }) | |
| total = max(sum(star_counts.values()), 1) | |
| avg_stars = sum(k * v for k, v in star_counts.items()) / total | |
| # ── Build aspect summary ────────────────────────────────────────────── | |
| aspect_summary = {} | |
| for aspect, data in aspect_data.items(): | |
| pos = data["positive"] | |
| neu = data["neutral"] | |
| neg = data["negative"] | |
| total_asp = pos + neu + neg or 1 | |
| dominant = max(("Positive", pos), ("Neutral", neu), ("Negative", neg), key=lambda x: x[1])[0] | |
| aspect_summary[aspect] = { | |
| "dominant_sentiment": dominant, | |
| "positive_pct": round(100 * pos / total_asp), | |
| "neutral_pct": round(100 * neu / total_asp), | |
| "negative_pct": round(100 * neg / total_asp), | |
| "review_count": total_asp, | |
| "sample_sentences": data["sentences"][:3], | |
| } | |
| # ── Distribution percentages ────────────────────────────────────────── | |
| distribution = {f"{i} star{'s' if i > 1 else ''}": round(100 * star_counts.get(i, 0) / total) | |
| for i in range(5, 0, -1)} | |
| overall_sentiment = ( | |
| "Positive" if avg_stars >= 3.5 | |
| else "Negative" if avg_stars < 2.5 | |
| else "Mixed" | |
| ) | |
| return { | |
| "summary": f"{overall_sentiment} ({avg_stars:.1f}/5 avg across {total} sentences)", | |
| "average_stars": round(avg_stars, 2), | |
| "overall_sentiment": overall_sentiment, | |
| "star_distribution": distribution, | |
| "sentences_analysed": total, | |
| "aspects": aspect_summary, | |
| } | |