173 lines
7.0 KiB
Python
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)
|