Files

173 lines
7.0 KiB
Python

"""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)