"""Keyword classification for community posts. Turns a title/snippet into a severity and a sentiment. This is deliberately crude -- its job is to rank and pre-filter evidence so the expensive reasoning layer sees the right eight posts, and to give a usable answer when no API key is configured. The LLM layer overrides these on the items it looks at. Severity ladder, worst first: brick -- device unusable, needs recovery/RMA crash -- hard failure in use: crashes, freezes, dropouts, no output regression -- worked before, broken now; feature removed annoyance -- cosmetic, minor, workaround exists info -- neutral chatter """ from __future__ import annotations import re # Ordered worst -> mildest. First matching tier wins. SEVERITY_PATTERNS: list[tuple[str, list[str]]] = [ ("brick", [ r"\bbrick(?:ed|ing|s)?\b", r"\bwon'?t (?:boot|power ?on|start ?up|turn on)\b", r"\bdead\b.{0,20}\bafter (?:the )?(?:update|upgrade|firmware)", r"\bunrecoverable\b", r"\bhard ?bricked\b", r"\brma\b", r"\breturn to (?:the )?(?:factory|manufacturer)\b", r"\bstuck (?:in|on) (?:boot|recovery|safe ?mode|update)\b", r"\bfailed (?:firmware )?(?:update|flash)\b.{0,30}\b(?:dead|unusable|brick)", r"\bno longer (?:boots|powers|starts)\b", ]), ("crash", [ r"\bcrash(?:es|ed|ing)?\b", r"\bfreez(?:e|es|ing|se)\b", r"\bhang(?:s|ing)\b", r"\bkernel panic\b", r"\bbsod\b", r"\bblue screen\b", r"\bno (?:audio|sound|video|signal|output|picture)\b", r"\bdrop ?outs?\b", r"\bdropp(?:ed|ing) (?:audio|video|frames|signal)\b", r"\blocks? up\b", r"\bunusable\b", r"\bwon'?t (?:launch|open|connect)\b", r"\bcuts? out\b", r"\bdisconnect(?:s|ing|ed)\b.{0,25}\brandom", r"\bblack ?screen\b", r"\bstops? (?:responding|working)\b", ]), ("regression", [ r"\brolled? back\b", r"\brollback\b", r"\brevert(?:ed|ing)?\b", r"\bdowngrad(?:e|ed|ing)\b", r"\bregression\b", r"\bbroke(?:n|s)?\b", r"\bstopped working (?:after|since)\b", r"\bworked (?:fine )?(?:before|until|on)\b.{0,30}\b(?:now|after)\b", r"\bremoved (?:the )?(?:feature|support)\b", r"\bno longer (?:works|supported|compatible)\b", r"\bincompatib(?:le|ility)\b", r"\bbreaking change\b", r"\bwarn(?:ing)?\b.{0,20}\bdo ?n'?t update\b", r"\bavoid (?:this )?update\b", r"\bhold ?off\b", r"\bwait (?:before|to) updat", ]), ("annoyance", [ r"\bglitch(?:es|y)?\b", r"\bcosmetic\b", r"\bminor (?:bug|issue)\b", r"\bworkaround\b", r"\bui (?:bug|issue|weird)\b", r"\bslow(?:er)?\b", r"\bannoying\b", r"\bquirk\b", ]), ] NEGATIVE_HINTS = [ r"\bissue(?:s)?\b", r"\bproblem(?:s)?\b", r"\bbug(?:s|gy)?\b", r"\bfail(?:s|ed|ure)\b", r"\berror(?:s)?\b", r"\bbroke", r"\bcrash", r"\bbrick", r"\bwarning\b", r"\bdo ?n'?t\b", r"\bavoid\b", r"\bbewar[e]\b", r"\bcaution\b", r"\bbad\b", r"\bworse\b", r"\bunstable\b", r"\bbuggy\b", r"\bhelp\b", r"\bstuck\b", ] POSITIVE_HINTS = [ r"\bfix(?:ed|es)\b", r"\bstable\b", r"\bsmooth\b", r"\bno issues?\b", r"\bworks? (?:great|fine|well|perfectly)\b", r"\bsolved\b", r"\bresolved\b", r"\brock ?solid\b", r"\bimprov(?:ed|ement)\b", r"\brecommend(?:ed)?\b", r"\bsafe to (?:update|upgrade)\b", r"\bran? (?:fine|clean)\b", ] # Anything phrased as a question. QUESTION_HINTS = [r"^how (?:do|to|can)", r"\?\s*$", r"^(?:any|is|are|does|should|can|has|have|did) "] # A narrower set: someone asking how to do a thing, or canvassing opinion, # rather than reporting that something broke. Only these get their sentiment # softened -- "My ATEM won't boot after the update, what do I do?" is a real # failure report that happens to end in a question mark, and downgrading it # would throw away exactly the signal we are looking for. HOWTO_HINTS = [ r"^how (?:do|to|can|would|should)\b", r"^what(?:'s| is| are)? the best\b", r"^(?:should|would) (?:i|we)\b", r"^(?:any|anyone) (?:advice|recommendations?|thoughts|experience)\b", r"^is it (?:safe|worth|ok|okay)\b", r"^(?:which|what) (?:version|firmware)\b", ] _SEV = [(name, [re.compile(p, re.I) for p in pats]) for name, pats in SEVERITY_PATTERNS] _NEG = [re.compile(p, re.I) for p in NEGATIVE_HINTS] _POS = [re.compile(p, re.I) for p in POSITIVE_HINTS] _Q = [re.compile(p, re.I) for p in QUESTION_HINTS] _HOWTO = [re.compile(p, re.I) for p in HOWTO_HINTS] def severity(text: str) -> tuple[str, list[str]]: """Worst matching severity tier and the terms that triggered it.""" blob = text or "" for name, patterns in _SEV: hits = [p.pattern for p in patterns if p.search(blob)] if hits: return name, hits return "info", [] def sentiment(text: str) -> str: blob = text or "" neg = sum(1 for p in _NEG if p.search(blob)) pos = sum(1 for p in _POS if p.search(blob)) if neg > pos: return "negative" if pos > neg: return "positive" return "neutral" if (neg or pos) else "unknown" def is_question(text: str) -> bool: blob = (text or "").strip() return any(p.search(blob) for p in _Q) def is_howto(text: str) -> bool: """Asking how to do something, or canvassing opinion -- as opposed to reporting a symptom.""" blob = (text or "").strip() return any(p.search(blob) for p in _HOWTO) def classify(title: str, snippet: str = "") -> tuple[str, str, list[str]]: """Return (severity, sentiment, matched_terms) for a post.""" blob = f"{title}\n{snippet}" sev, terms = severity(blob) sent = sentiment(blob) # A report of a brick, crash or regression IS negative, whether or not it # happens to use any of the sentiment words. Without this, a post like # "My ATEM won't boot after the update" scores severity=brick but # sentiment=unknown, and the scorer -- which only counts negatives -- # drops the single most serious piece of evidence it found. if sev in ("brick", "crash", "regression") and sent in ("unknown", "neutral"): sent = "negative" # "How do I fix the crash on startup?" is somebody asking for help, not a # report that this release crashes. Soften it so it does not count as # evidence against an update. Symptom reports that merely end in a # question mark keep their negative reading. if sent == "negative" and is_howto(title): sent = "neutral" return sev, sent, terms def relevance(text: str, item_terms: list[str], version: str | None) -> float: """0..1 score for 'is this post actually about our thing'. DuckDuckGo happily returns 3D-printer firmware threads for an ATEM query, so this filter matters more than it looks. """ blob = (text or "").lower() if not blob: return 0.0 terms = [t.lower() for t in item_terms if t and len(t) > 2] if not terms: return 0.0 hits = sum(1 for t in terms if t in blob) score = hits / max(len(terms), 1) # Exact version match is a strong signal. if version and version.lower() in blob: score = min(1.0, score + 0.35) return round(min(score, 1.0), 3)