Spaces:
Running
Running
File size: 6,819 Bytes
c05106c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 | """Answers questions from the site's own words. No model, no inference.
Everything returned here is a direct quote from published page content plus the
link it came from. If the site does not say it, this says so rather than
producing something plausible — a school site cannot afford invented answers
about pricing, dates or who is responsible for a child.
"""
import re
import mesh
STOPWORDS = {
"a", "an", "the", "is", "are", "was", "were", "do", "does", "did", "i", "you",
"my", "our", "we", "can", "could", "would", "should", "please", "tell", "me",
"about", "for", "of", "to", "in", "on", "at", "and", "or", "it", "this", "that",
"there", "here", "what", "whats", "when", "where", "who", "whos", "how", "why",
"which", "have", "has", "any", "get", "find", "know", "want", "need", "am",
"be", "with", "from", "if", "so", "up", "out", "will", "much", "many", "some",
}
# Question shape → how to phrase the reply. Order matters: first match wins.
INTENTS = [
("who", re.compile(r"\bwho(?:'s|s\b|\s+is|\s+are|\s+teaches|\s+do)?\b", re.I),
"Here is what the site says about who is involved:"),
("when", re.compile(r"\bwhen\b|\bwhat time\b|\bdates?\b|\bschedule\b|\bcalendar\b|\bbreak\b|\bholiday\b", re.I),
"Here are the dates and times listed:"),
("cost", re.compile(r"\bcost|price|pricing|how much|fee|fees|pay|payment|free\b", re.I),
"Here is the pricing information listed:"),
("where", re.compile(r"\bwhere\b|\baddress\b|\blocation\b|\bdirections?\b", re.I),
"Here is the location information listed:"),
("contact", re.compile(r"\bcontact\b|\bphone\b|\bemail\b|\bcall\b|\breach\b", re.I),
"Here are the contact details listed:"),
("menu", re.compile(r"\bmenu\b|\blunch\b|\bbreakfast\b|\bmeal\b|\bcafeteria\b", re.I),
"Here is what the menu information says:"),
]
DEFAULT_LEAD = "Here is what the site says:"
DATE_RE = re.compile(
r"\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+\d{1,2}"
r"|\b\d{1,2}/\d{1,2}(?:/\d{2,4})?"
r"|\b(?:mon|tues|wednes|thurs|fri|satur|sun)day\b"
r"|\b\d{1,2}:\d{2}\s*(?:a\.?m\.?|p\.?m\.?)?", re.I)
MONEY_RE = re.compile(r"\$\s?\d[\d,]*(?:\.\d{2})?")
PHONE_RE = re.compile(r"\b\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b")
EMAIL_RE = re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.]+\b")
INTENT_PATTERNS = {"when": DATE_RE, "cost": MONEY_RE, "contact": PHONE_RE}
def keywords(question):
words = re.findall(r"[\w']+", (question or "").lower())
kept = [w for w in words if w not in STOPWORDS and len(w) > 2]
return kept or words
def intent_of(question):
for name, pattern, lead in INTENTS:
if pattern.search(question or ""):
return name, lead
return "what", DEFAULT_LEAD
DANGLING = re.compile(r"\b(?:the|a|an|on|at|to|of|for|in|and|or|with|by|from|our|your)$", re.I)
def clean(text):
"""Editors write literal ** in this content; visitors should not see it."""
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text or "")
text = text.replace("**", "").replace("__", "")
return re.sub(r"\s+", " ", text).strip()
def sentences(text):
parts = re.split(r"(?<=[.!?])\s+|\s*\n+\s*|\s{2,}", text or "")
return [clean(s).strip(" \t·-–—") for s in parts if len(clean(s)) > 2]
def score_sentence(s, terms, intent):
low = s.lower()
score = sum(3 for t in terms if t in low)
pattern = INTENT_PATTERNS.get(intent)
if pattern and pattern.search(s):
score += 4
if intent == "contact" and (EMAIL_RE.search(s) or PHONE_RE.search(s)):
score += 3
# A bare heading like "Superintendent" repeats the question instead of
# answering it. Reward sentences that actually carry information.
if len(s) < 20:
score -= 6
elif len(s) >= 45:
score += 2
# "For school-specific contacts, visit the" — a fragment cut mid-thought
# reads as broken. Prefer sentences that finish.
if DANGLING.search(s.rstrip(" .,:;")):
score -= 5
score -= abs(len(s) - 130) / 400 # prefer a readable length
return score
def best_sentence(text, terms, intent):
"""The one sentence that most answers the question, not the whole block."""
best, best_score = "", -999
for s in sentences(text):
score = score_sentence(s, terms, intent)
if score > best_score:
best, best_score = s, score
return best, best_score
def ask(question, limit=3):
"""Answer from published content only. Returns quotes plus where to read more."""
terms = keywords(question)
intent, lead = intent_of(question)
if not terms:
return {"ok": False, "intent": intent,
"answer": "Ask me about something on the site — lunch menus, "
"calendars, staff, or a school office.",
"sources": []}
hits = mesh.public_search(" ".join(terms))
if not hits:
return {"ok": False, "intent": intent,
"answer": f"I could not find anything on the site about "
f"“{question.strip()}”. Try different words, or use the "
f"site menu to browse.",
"sources": []}
# Collapse to the strongest passage per page.
pages = {}
for h in hits:
aid = h["article_id"]
quote, quality = best_sentence(h["text"], terms, intent)
if not quote:
continue
# A headline names the topic; the body answers the question.
if h["type"] == "headline":
quality -= 4
page = pages.get(aid)
if page is None or quality > page["quality"]:
pages[aid] = {"article_id": aid, "title": h["article"], "quote": quote,
"quality": quality, "coord": h["coord"],
"terms": page["terms"] if page else set()}
pages[aid]["terms"] |= {t for t in terms if t in (h["text"] or "").lower()}
# Rank pages by how much of the question they actually cover, not by any
# single passage — a page mentioning every term is the likelier answer.
ranked = sorted(pages.values(),
key=lambda p: (len(p["terms"]), p["quality"]), reverse=True)
top = ranked[:limit]
if not top:
return {"ok": False, "intent": intent,
"answer": "I found related pages but no clear sentence to quote. "
"Try opening one of them.",
"sources": []}
return {
"ok": True,
"intent": intent,
"lead": lead,
"answer": top[0]["quote"],
"source": {"title": top[0]["title"], "article_id": top[0]["article_id"]},
"sources": [{"title": t["title"], "article_id": t["article_id"], "quote": t["quote"]}
for t in top],
}
|