File size: 7,262 Bytes
f0a602b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fe0365d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f0a602b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fe0365d
 
 
 
 
 
f0a602b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
"""The answering agent: intent routing -> retrieval -> grounded, cited generation.

Pipeline per user message:
1. Classify intent (greeting / logistics / research-or-person) with cheap heuristics.
2. Retrieve passages (+ graph expansion) from the knowledge base.
3. Grounding guard: if nothing in the corpus is relevant, decline rather than
   hallucinate.
4. Otherwise build a numbered, source-attributed context and stream an answer
   that cites with [n]; a clickable Sources list is always appended by us so the
   links are correct even if the model forgets to cite.
"""

from __future__ import annotations

import re
from typing import Iterator, List, Tuple

import config
import llm
from retriever import retrieve, RetrievalResult

# --------------------------------------------------------------------------- #
# Intent classification (cheap, no LLM call)
# --------------------------------------------------------------------------- #
_GREETING_RE = re.compile(r"^\s*(hi|hey|hello|yo|sup|good (morning|afternoon|evening)|thanks|thank you)\b", re.IGNORECASE)
_LOGISTICS_RE = re.compile(
    r"\b(phd|ph\.d|student|apply|application|admission|join|position|opening|"
    r"intern|internship|recruit|advisor|advise|supervis|collaborat|partner|"
    r"contact|email|reach|hire|hiring|work with|opportunit)\w*",
    re.IGNORECASE,
)


def classify_intent(message: str) -> str:
    text = (message or "").strip()
    if _GREETING_RE.search(text) and len(text) < 40:
        return "greeting"
    if _LOGISTICS_RE.search(text):
        return "logistics"
    return "research"


def wants_to_connect(message: str) -> bool:
    """Heuristic: does this message look like a prospective student/collaborator?"""
    return classify_intent(message) == "logistics"


def _content_to_text(content) -> str:
    """Coerce a Gradio message 'content' to plain text.

    Gradio 6 may hand history back with content as a string, a list of rich-text
    parts (e.g. [{'text': '...', 'type': 'text'}]), or a dict — but the LLM
    backends expect a plain string. Normalize all shapes here.
    """
    if content is None:
        return ""
    if isinstance(content, str):
        return content
    if isinstance(content, dict):
        return content.get("text", "")
    if isinstance(content, list):
        parts = []
        for p in content:
            if isinstance(p, str):
                parts.append(p)
            elif isinstance(p, dict) and p.get("text"):
                parts.append(p["text"])
        return " ".join(parts).strip()
    return str(content)


# --------------------------------------------------------------------------- #
# Prompt construction
# --------------------------------------------------------------------------- #
def _persona_system() -> str:
    base_rules = (
        "Rules:\n"
        "- Use ONLY the numbered context provided to state facts about the research.\n"
        "- Cite sources inline as [n] using the numbers in the context.\n"
        "- If the context does not contain the answer, say you don't have that "
        "information rather than guessing.\n"
        "- Be concise, professional, and engaging. Prefer 2-5 short paragraphs or "
        "bullet points.\n"
        "- Do not invent publication titles, links, dates, or numbers."
    )
    if config.PERSONA == "first_person":
        return (
            f"You are an AI assistant speaking as {config.PROFESSOR_NAME} "
            f"({config.PROFESSOR_TITLE}) on the lab website. Stay in character in "
            f"the first person, but you are an automated assistant, not the real "
            f"person. {base_rules}"
        )
    return (
        f"You are the AI research assistant for {config.LAB_NAME}. You help "
        f"prospective students, collaborators, and the public understand the work of "
        f"{config.PROFESSOR_NAME} ({config.PROFESSOR_TITLE}). Refer to him in the "
        f"third person as 'Prof. Zhao'. {base_rules}"
    )


def _build_context(result: RetrievalResult) -> Tuple[str, List[dict]]:
    """Turn hits into numbered context text + a deduped source list for rendering."""
    sources: List[dict] = []
    doc_to_num: dict = {}
    blocks: List[str] = []
    for hit in result.hits:
        doc = hit.chunk["doc_id"]
        if doc not in doc_to_num:
            doc_to_num[doc] = len(sources) + 1
            sources.append(
                {
                    "n": doc_to_num[doc],
                    "title": hit.chunk["title"],
                    "url": hit.chunk["url"],
                    "source": hit.chunk["source"],
                }
            )
        n = doc_to_num[doc]
        tag = " (related work, via paper graph)" if hit.via_graph else ""
        blocks.append(f"[{n}]{tag} {hit.chunk['title']}\n{hit.chunk['text']}")
    return "\n\n".join(blocks), sources


def _render_sources(sources: List[dict]) -> str:
    if not sources:
        return ""
    lines = ["\n\n---", "**Sources**"]
    for s in sources:
        # only publications get external links worth surfacing distinctly
        lines.append(f"{s['n']}. [{s['title']}]({s['url']})")
    return "\n".join(lines)


_GROUNDING_GUARD = (
    "I don't have that in my knowledge base, so I'd rather not guess. "
    "I can speak to Prof. Zhao's research on **graph neural networks**, "
    "**spatio-temporal / geospatial ML**, **retrieval-augmented and agentic LLMs**, "
    "**AI for science**, and **trustworthy & efficient AI**. Try asking about one of "
    "those — for example, *“What is Spatial-RAG?”* or *“Summarize the lab's work on "
    "graph RAG.”*\n\nIf you'd like to reach the lab directly, tell me you're a "
    "prospective student or collaborator and I can take your details."
)


# --------------------------------------------------------------------------- #
# Public: streaming answer
# --------------------------------------------------------------------------- #
def stream_answer(message: str, history: List[dict] | None = None) -> Iterator[str]:
    """Yield the cumulative answer text (suitable for Gradio streaming)."""
    history = history or []
    intent = classify_intent(message)
    result = retrieve(message)

    # Grounding guard: refuse to fabricate when the corpus is irrelevant.
    if not result.is_grounded and intent != "greeting":
        yield _GROUNDING_GUARD
        return

    context, sources = _build_context(result)
    tier = "deep" if intent == "research" else llm.route(message)

    system = _persona_system()
    convo = [
        {"role": m["role"], "content": _content_to_text(m.get("content"))}
        for m in history
        if m.get("role") in ("user", "assistant")
    ]
    convo = [m for m in convo if m["content"]][-6:]  # drop empties, keep last 6
    user_turn = (
        f"Numbered context:\n{context}\n\n"
        f"Question: {message}\n\n"
        f"Answer using only the context above and cite with [n]."
    )
    messages = [{"role": "system", "content": system}, *convo, {"role": "user", "content": user_turn}]

    body = ""
    for piece in llm.stream(messages, tier=tier):
        body += piece
        yield body

    # Always append correct, clickable sources.
    tail = _render_sources(sources)
    if tail:
        yield body + tail