| """ |
| 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" |
|
|
| |
| 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, |
| truncation=True, |
| max_length=512, |
| ) |
| return self._pipe |
|
|
| |
| @staticmethod |
| def _split_sentences(text: str) -> list[str]: |
| sentences = re.split(r"(?<=[.!?])\s+", text.strip()) |
| |
| return [s for s in sentences if len(s.split()) >= 5] |
|
|
| |
| @staticmethod |
| 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)] |
|
|
| |
| def _score_text(self, text: str) -> dict: |
| """Returns {star_label: score} dict.""" |
| pipe = self._get_pipe() |
| try: |
| results = pipe(text[:512])[0] |
| 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} |
|
|
| @staticmethod |
| def _best_star(scores: dict) -> int: |
| return STAR_MAP.get(max(scores, key=scores.get), 3) |
|
|
| |
| 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": {}} |
|
|
| |
| star_counts = defaultdict(int) |
| star_scores = defaultdict(float) |
| aspect_data = defaultdict(lambda: {"positive": 0, "neutral": 0, "negative": 0, "sentences": []}) |
|
|
| for sent in sentences[:100]: |
| 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 |
|
|
| |
| 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 = {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, |
| } |
|
|