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],
    }