File size: 17,136 Bytes
025be21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14e0359
025be21
 
 
ebe90cc
025be21
4d9ecfd
025be21
eb1ceb7
 
 
 
 
 
 
 
 
 
 
ebe90cc
eb1ceb7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14e0359
 
 
 
 
 
 
 
 
 
 
 
99c3989
025be21
 
e6f2d55
0d70800
 
 
 
 
 
51c9f7f
 
 
 
 
 
025be21
 
 
4d9ecfd
 
 
51c9f7f
 
4d9ecfd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72860f1
 
 
 
 
eb1ceb7
72860f1
4d9ecfd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99c3989
 
 
 
 
 
 
025be21
99c3989
 
025be21
99c3989
025be21
99c3989
 
 
 
 
025be21
 
99c3989
025be21
def50fa
025be21
 
 
 
eb1ceb7
 
 
 
 
 
 
 
 
 
 
 
025be21
eb1ceb7
ebe90cc
 
 
 
 
 
 
eb1ceb7
 
 
 
 
 
 
 
 
 
 
 
 
025be21
 
 
eb1ceb7
 
 
 
 
 
 
 
 
 
 
 
 
86b43f8
 
 
eb1ceb7
 
 
 
 
 
 
5601292
 
 
 
 
 
 
 
 
025be21
 
 
 
 
 
def50fa
 
 
 
0d70800
 
 
 
 
def50fa
 
0d70800
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
def50fa
5601292
eb1ceb7
5601292
 
 
 
 
 
 
eb1ceb7
4d9ecfd
5601292
 
 
 
 
 
 
0d70800
 
5601292
eb1ceb7
 
5601292
 
 
 
 
 
 
 
 
eb1ceb7
 
 
4d9ecfd
025be21
 
 
 
 
 
 
eb1ceb7
 
025be21
 
 
 
 
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
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
"""
Stage 6 β€” grounded generation with a local model (DeepSeek-R1-Distill-7B via Ollama).

WHY grounding is the whole point: a raw LLM answers from memory and will confidently
invent a wrong derivation. RAG constrains it to answer ONLY from retrieved chunks, cite
which chunk each claim came from, and refuse ("I don't know") when the material doesn't
cover the question. For a probability course that's the difference between a study aid and
a hallucination machine. We also gate on the reranker score: if the best retrieved chunk
is weak, we refuse rather than generate from irrelevant context.

DeepSeek-R1 emits its reasoning inside <think>...</think> then the answer. We split them:
the answer is the deliverable; the thinking is shown optionally (it's great for studying).
"""
from __future__ import annotations

import os
import re
import json
import urllib.request
import urllib.error

from src.store import search, search_multi  # imports torch/FlagEmbedding before qdrant (ordering matters)

_OLLAMA_URL = "http://localhost:11434/api/chat"

# Model registry: short name -> where/how to call it. "api" picks the wire format in _chat().
# WHY native Ollama /api/chat (not its OpenAI-compatible /v1): /v1 ignores num_ctx, and
# num_ctx=4096 is exactly what keeps the retrieval models + LLM co-resident on the 8GB GPU.
MODELS: dict[str, dict] = {
    # qwen stays the default so LOCAL behavior is byte-identical when no new env vars are set.
    "qwen": {"model": "qwen2.5:3b-instruct", "url": _OLLAMA_URL, "api": "ollama",
             "api_key_env": None},
    "glm-local": {"model": "glm4:9b", "url": _OLLAMA_URL, "api": "ollama",
                  "api_key_env": None},
    "glm-flash": {"model": os.environ.get("ZAI_MODEL", "glm-4.7-flash"),
                  "url": "https://api.z.ai/api/paas/v4/chat/completions", "api": "openai",
                  "api_key_env": "ZAI_API_KEY"},
}

# PROBRAG_MODEL is a legacy override (README documents PROBRAG_MODEL=deepseek-r1:7b): it swaps
# the model id behind the "qwen" name rather than adding an entry, so old invocations still work.
_legacy_model = os.environ.get("PROBRAG_MODEL")
if _legacy_model:
    MODELS["qwen"]["model"] = _legacy_model

# PROBRAG_MODELS = comma-separated names to ENABLE (default just qwen -> local unchanged).
ENABLED_MODELS = [
    n.strip() for n in os.environ.get("PROBRAG_MODELS", "qwen").split(",")
    if n.strip() in MODELS
] or ["qwen"]
DEFAULT_MODEL = os.environ.get("PROBRAG_DEFAULT_MODEL") or ENABLED_MODELS[0]
if DEFAULT_MODEL not in ENABLED_MODELS:
    DEFAULT_MODEL = ENABLED_MODELS[0]


def _resolve(model: str | None) -> dict:
    """Map a requested model name to its registry entry. None -> default. Invalid or
    not-enabled -> ValueError (server maps to 400)."""
    name = model or DEFAULT_MODEL
    if name not in ENABLED_MODELS:
        raise ValueError(f"unknown model {name!r}; enabled: {ENABLED_MODELS}")
    return MODELS[name]
MIN_RERANK_SCORE = 0.005           # low backstop only. The eval-derived 0.024 cleanly split the
                                   # GOLDEN phrasings, but real-world phrasings of answerable
                                   # questions can score lower and get wrongly refused. So we keep
                                   # the hard threshold minimal (catch ~zero-signal retrieval) and
                                   # let the GROUNDED PROMPT be the real guard β€” it refuses when the
                                   # retrieved context doesn't actually contain the answer.
# On 8GB VRAM the embed+rerank models (~2.2GB) and the 7B (~4.7GB) must coexist on the GPU.
# A smaller context shrinks the 7B's KV cache so everything fits on-GPU (fast generation)
# instead of spilling Ollama to CPU. 4K is plenty for top_k grounded passages.
NUM_CTX = 4096
CTX_TOKEN_BUDGET = 2600            # room for system + question + answer within NUM_CTX
MAX_PARENT_TOKENS = 1100           # truncate oversized parents (chunker max ~7600)
_CHARS_PER_TOK = 4

SYSTEM = (
    "You are a probability tutor. Answer the question using ONLY the numbered context "
    "passages β€” no outside facts. Be concise and plain: the shortest answer that is still "
    "correct, everyday words, no filler and no unnecessary jargon. Keep the essential terms "
    "the course tests (variance, moment generating function, Bernoulli trials) but gloss each "
    "in plain words the first time it appears. Textbook results often appear inside worked "
    "examples and in notation; treat these as answers (g(t) = moment generating function; "
    "V(X) or sigma^2 = variance; E(X) or mu = expected value; a 'first success' count is "
    "geometric; a Bernoulli-trials success count is binomial). A worked Example or derivation "
    "IS a valid answer: if a passage shows the steps (e.g. computes g(t), then g'(0) and "
    "g''(0), to get a mean or variance), walk through those steps and state the result. "
    "Extract the key formula and cite its tag, e.g. [S1]. Keep LaTeX as written ($...$, "
    "$$...$$). Only if the passages are unrelated to the question, reply exactly: \"I don't "
    "know based on the provided material.\""
)


# Only comparison / derive-from questions need splitting; everything else takes the
# single-query path untouched (keeps the 39 saturated single-hop cases byte-identical, and
# avoids paying an LLM call + NΓ— retrieval on queries one embedding already covers).
DECOMPOSE_GATE = re.compile(
    r"\bcompare\b|\bversus\b|\bvs\b|difference between|\bderive\b.*\b(from|via|using|through)\b", re.I)

DECOMPOSE_SYSTEM = (
    "Split a probability question into focused search queries, ONE per line, no numbering "
    "and no other text. Rules: (1) each query targets a SINGLE quantity of a SINGLE "
    "distribution so it can be searched on its own; (2) always name the distribution IN FULL "
    "β€” write 'the geometric distribution', never bare 'geometric' (which collides with the "
    "geometric mean); (3) if the question asks about two quantities (e.g. mean and variance) "
    "of two distributions, emit one query per (quantity, distribution) pair. Example β€” for "
    "'compare the mean and variance of the Poisson and geometric distributions':\n"
    "mean of the Poisson distribution\n"
    "variance of the Poisson distribution\n"
    "mean of the geometric distribution\n"
    "variance of the geometric distribution\n"
    "Output only the queries."
)


def decompose(question: str) -> list[str]:
    """Split a multi-part question into focused sub-queries via the local LLM, but ONLY when
    the gate matches (comparison / derive-from). Returns [] otherwise, or if the model
    returns nothing usable β€” the caller then falls back to plain single-query retrieval.
    Temp 0 so the split is stable enough for the eval harness to A/B against."""
    if not DECOMPOSE_GATE.search(question):
        return []
    try:
        # Prefer the local qwen entry so sub-query splitting doesn't burn hosted-API quota.
        # When qwen isn't enabled (the deployed Space has no Ollama), use the default hosted
        # entry instead β€” a decompose call is ~100 tokens, and losing decomposition would
        # silently degrade every comparison question (the 45/45 multi-hop wins).
        entry = MODELS["qwen"] if "qwen" in ENABLED_MODELS else _resolve(None)
        raw = _chat([{"role": "system", "content": DECOMPOSE_SYSTEM},
                     {"role": "user", "content": question}], entry)
    except Exception:
        return []  # fail open: a decompose hiccup falls back to single-query retrieval
    subs = []
    for line in raw.splitlines():
        s = re.sub(r"^\s*(?:[-*\d.)]+\s*)+", "", line).strip()  # strip bullets/numbering
        if s:
            subs.append(s)
    return subs[:4]  # up to 4: a "mean and variance of A and B" question is 4 (quantity, dist) pairs


def _retrieve_for(client, question: str, top_k: int) -> list[dict]:
    """Retrieve grounding hits: multi-query when the question decomposes, else single-query
    rerank. The one entry point so answer/stream/why all get decomposition for free."""
    subs = decompose(question)
    if subs:
        # Sub-query lanes only β€” the original comparison query as a lane just re-adds the
        # single-topic-biased hits decomposition exists to get past, and costs a rerank pass.
        return search_multi(client, subs, top_k=top_k)
    return search(client, question, top_k=top_k, mode="rerank")


def _truncate(text: str, max_tokens: int) -> str:
    cap = max_tokens * _CHARS_PER_TOK
    if len(text) <= cap:
        return text
    return text[:cap].rsplit("\n", 1)[0] + "\n... [passage truncated]"


def _build_context(hits: list[dict]) -> tuple[str, list[dict]]:
    """Pack passages under a token budget so the prompt fits NUM_CTX. Oversized parents
    are truncated; once the budget is hit we stop adding passages."""
    blocks, sources = [], []
    used = 0
    for i, h in enumerate(hits, 1):
        text = _truncate(h["parent_text"], MAX_PARENT_TOKENS)
        toks = len(text) // _CHARS_PER_TOK
        if used + toks > CTX_TOKEN_BUDGET and blocks:
            break  # budget exhausted; keep what we have
        used += toks
        tag = f"S{i}"
        loc = " / ".join(x for x in (h.get("chapter"), h.get("section")) if x) or h["source"]
        blocks.append(f"[{tag}] ({loc})\n{text}")
        sources.append({"tag": tag, "chunk_id": h["chunk_id"], "location": loc,
                        "page": h.get("page"),  # 1-indexed PDF page -> UI opens the viewer here
                        "score": h.get("rerank_score", h.get("score"))})
    return "\n\n".join(blocks), sources


def _headers(entry: dict) -> dict:
    h = {"Content-Type": "application/json"}
    key_env = entry.get("api_key_env")
    if key_env:
        key = os.environ.get(key_env)
        if not key:
            raise RuntimeError(f"model {entry['model']!r} needs env {key_env}, which is unset")
        h["Authorization"] = f"Bearer {key}"
    return h


def _post(entry: dict, payload: dict):
    req = urllib.request.Request(
        entry["url"], data=json.dumps(payload).encode(), headers=_headers(entry))
    try:
        return urllib.request.urlopen(req, timeout=600)
    except urllib.error.HTTPError as e:
        # Surface the provider's error body (e.g. Z.ai "model not found") β€” without this the
        # SSE stream just truncates silently and the failure is undiagnosable in production.
        body = e.read()[:300].decode("utf-8", "replace")
        raise RuntimeError(f"LLM API error {e.code} from {entry['model']}: {body}") from e


def _chat(messages: list[dict], entry: dict) -> str:
    """Non-streaming completion, dispatching on entry['api']."""
    if entry["api"] == "openai":
        payload = {"model": entry["model"], "messages": messages, "stream": False,
                   "temperature": 0.0}
        with _post(entry, payload) as r:
            return json.loads(r.read())["choices"][0]["message"]["content"]
    # ollama: native NDJSON /api/chat. num_ctx keeps the models co-resident on the 8GB GPU.
    payload = {"model": entry["model"], "messages": messages, "stream": False,
               "options": {"num_ctx": NUM_CTX, "temperature": 0.0}}
    with _post(entry, payload) as r:
        return json.loads(r.read())["message"]["content"]


def _chat_stream(messages: list[dict], entry: dict):
    """Yield content deltas, dispatching on entry['api']."""
    if entry["api"] == "openai":
        payload = {"model": entry["model"], "messages": messages, "stream": True,
                   "temperature": 0.0}
        with _post(entry, payload) as r:
            for raw in r:
                line = raw.decode("utf-8").strip()
                if not line.startswith("data:"):
                    continue
                data = line[len("data:"):].strip()
                if data == "[DONE]":
                    return
                # choices can be empty on trailing usage/keep-alive chunks β€” don't crash the stream
                choices = json.loads(data).get("choices") or [{}]
                delta = choices[0].get("delta", {}).get("content", "")
                if delta:
                    yield delta
        return
    # ollama: NDJSON stream.
    payload = {"model": entry["model"], "messages": messages, "stream": True,
               "options": {"num_ctx": NUM_CTX, "temperature": 0.0}}
    with _post(entry, payload) as r:
        for line in r:
            chunk = json.loads(line)
            piece = chunk.get("message", {}).get("content", "")
            if piece:
                yield piece
            if chunk.get("done"):
                return


def _split_think(text: str) -> tuple[str, str]:
    think = "\n".join(re.findall(r"<think>(.*?)</think>", text, flags=re.DOTALL)).strip()
    answer = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
    return answer, think


WHY_SYSTEM = (
    "You are a probability tutor giving a DEEPER explanation of a previous answer. Use ONLY "
    "the numbered context passages β€” no outside facts. The student already has the short "
    "answer and now wants the reasoning: explain the intuition, walk through the derivation "
    "step by step, define the symbols, and note where each step comes from. Use plain words "
    "and no unnecessary jargon β€” gloss each technical term in plain language as you use it. "
    "Same notation conventions as before (g(t) = mgf, V(X) = variance, etc.). Cite passages "
    "as [S1]. Keep LaTeX as written ($...$, $$...$$). If the context doesn't support a deeper "
    "explanation, say so plainly rather than inventing one."
)

ELI5_SYSTEM = (
    "You are a probability tutor explaining to a total beginner. Use ONLY the numbered "
    "context passages β€” no outside facts. Explain in the FEWEST words possible: simplest "
    "everyday language, short sentences, no jargon and no filler. Use one everyday analogy "
    "only if it truly helps. Keep the actual formula (in $...$) so the answer stays correct, "
    "but say what it means in plain words right after. Cite the passage tag, e.g. [S1]. If "
    "the context doesn't cover it, say so plainly."
)

# style -> (system prompt, user-instruction prefix). Keeps stream() one code path.
_STYLES = {
    "default": (SYSTEM, "Question:"),
    "deeper": (WHY_SYSTEM, "Explain in depth, with reasoning:"),
    "eli5": (ELI5_SYSTEM, "Explain as simply as possible, fewest words:"),
}


def stream(client, question: str, *, top_k: int = 6, show_thinking: bool = False,
           style: str = "default", model: str | None = None):
    """Streaming twin of answer()/why(). Yields event dicts: {"type": "sources", ...} first
    (so the UI can render citations before generation), then {"type": "token", "text": ...}
    deltas, then {"type": "done", ...} carrying the cleaned final answer β€” the UI replaces
    the streamed text with it.
    # ponytail: under PROBRAG_MODEL=deepseek-r1 the <think> block streams visibly and is
    # only stripped in the final event; filter deltas live if that ever matters (default
    # qwen2.5:3b never emits think tags)."""
    entry = _resolve(model)  # raises ValueError before retrieval on a bad name
    hits = _retrieve_for(client, question, top_k)
    best = hits[0].get("rerank_score", 0.0) if hits else 0.0
    if not hits or best < MIN_RERANK_SCORE:
        yield {"type": "done", "answer": "I don't know based on the provided material.",
               "sources": [], "refused": True, "best_score": best}
        return
    context, sources = _build_context(hits)
    yield {"type": "sources", "sources": sources, "best_score": best}
    system, prefix = _STYLES.get(style, _STYLES["default"])
    user = f"Context passages:\n\n{context}\n\n{prefix} {question}"
    parts: list[str] = []
    for piece in _chat_stream([{"role": "system", "content": system},
                               {"role": "user", "content": user}], entry):
        parts.append(piece)
        yield {"type": "token", "text": piece}
    ans, think = _split_think("".join(parts))
    done = {"type": "done", "answer": ans, "refused": False}
    if show_thinking:
        done["thinking"] = think
    yield done


def answer(client, question: str, *, top_k: int = 6, show_thinking: bool = False,
           model: str | None = None) -> dict:
    entry = _resolve(model)  # raises ValueError before retrieval on a bad name
    hits = _retrieve_for(client, question, top_k)
    best = hits[0].get("rerank_score", 0.0) if hits else 0.0
    if not hits or best < MIN_RERANK_SCORE:
        return {"answer": "I don't know based on the provided material.",
                "sources": [], "refused": True, "best_score": best}

    context, sources = _build_context(hits)
    user = f"Context passages:\n\n{context}\n\nQuestion: {question}"
    raw = _chat([{"role": "system", "content": SYSTEM},
                 {"role": "user", "content": user}], entry)
    ans, think = _split_think(raw)
    out = {"answer": ans, "sources": sources, "refused": False, "best_score": best}
    if show_thinking:
        out["thinking"] = think
    return out