File size: 7,794 Bytes
20b15f3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fc5dd1c
20b15f3
 
fc5dd1c
20b15f3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fc5dd1c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20b15f3
 
 
 
 
 
 
 
 
 
 
fc5dd1c
 
20b15f3
 
 
fc5dd1c
 
 
 
20b15f3
 
 
fc5dd1c
 
 
20b15f3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fc5dd1c
 
20b15f3
 
 
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
"""
ui/chat_engine.py
-----------------
Chat-prep: turning a paper record into a retriever + chain, in three phases, with
SONIC's progress bar and pep-quotes animating over the slow bits.

`prepare_chat_stream()` is the public entrypoint. It's a generator so the Gradio
handler can relay each frame straight to the page.

The phase split exists because of ZeroGPU. Each phase wants something different:

  1. fetch      — network-bound. No GPU. Threaded, so the bar animates.
  2. vectorize  — the expensive encode. THE one GPU step (see ui/gpu.py).
  3. assemble   — re-open the persisted store on CPU + build the chain. Fast.

Phase 2 is the awkward one: ZeroGPU dispatches to its GPU worker from the calling
thread, so it must NOT run on a thread we spawned ourselves — which is precisely
what the original single-threaded-worker design did. On ZeroGPU we therefore call
it inline and the bar holds still for the few seconds it takes. Off ZeroGPU
there's no such constraint and the encode is slow (tens of seconds on CPU), so we
keep the old threaded animation. Same UX where it matters, correctness where it
counts.
"""

import random
import threading
import time
import traceback

from ui.constants import SONIC_QUOTES
from ui.gpu import ON_ZEROGPU, cuda_state, vectorize_on_gpu
from ui.papers import resolve_and_download_pdf
from ui.sonic import SONIC_DATA_URI


def vec_loading_html(quote: str, phase: str, pct: int) -> str:
    return (
        f'<div class="vec-wrap">'
        f'  <div class="vec-figure"><img src="{SONIC_DATA_URI}" alt="SONIC"/></div>'
        f'  <div class="vec-quote"><span class="q">SONIC:</span> &ldquo;{quote}&rdquo;</div>'
        f'  <div class="vec-bar"><div class="vec-fill" style="width:{pct}%"></div></div>'
        f'  <div class="vec-phase">{phase}</div>'
        f'</div>'
    )


def _quote_at(quotes, start):
    """Rotate every ~4s, as the Streamlit build did."""
    return quotes[int((time.time() - start) // 4) % len(quotes)]


# --- error reporting --------------------------------------------------------

def describe_exception(e: BaseException) -> str:
    """Flatten a whole exception chain into one readable line.

    `f"{type(e).__name__}: {e}"` is lossy the moment anything wraps anything,
    and that is precisely what cost us a debugging session: ZeroGPU marshals a
    worker-side failure back to the caller as a `gradio.exceptions.Error` whose
    payload is just the *name* of the original class. The naive format rendered
    that as the useless string

        Couldn't read this paper: Error: 'RuntimeError'

    — a wrapper around a wrapper, with the real traceback discarded. Walking
    __cause__/__context__ keeps the causal chain, and the callers below print
    the traceback too, since only that names the failing frame.
    """
    parts: list[str] = []
    seen: set[int] = set()
    current: BaseException | None = e
    while current is not None and id(current) not in seen:
        seen.add(id(current))
        text = str(current).strip()
        parts.append(f"{type(current).__name__}: {text}" if text else type(current).__name__)
        current = current.__cause__ or current.__context__
    return "  <-  ".join(parts)


# --- phase workers (touch no UI) -------------------------------------------

def _download_blocking(record: dict, holder: dict):
    try:
        pdf_path = resolve_and_download_pdf(record)
        if not pdf_path:
            holder["error"] = ("Couldn't find a readable open-access PDF for this paper — it may be "
                               "paywalled. Use the 📄 Paper button to read it on the source site.")
            return
        holder["pdf_path"] = pdf_path
    except Exception as e:
        traceback.print_exc()
        holder["error"] = f"Couldn't fetch this paper: {describe_exception(e)}"


def _vectorize_blocking(pdf_path: str, holder: dict):
    # The decisive log line. If CUDA is already initialised *here*, in the
    # parent, the ZeroGPU fork about to happen is already doomed — see the
    # CUDA-VIRGINITY RULE in ui/gpu.py.
    print(cuda_state("pre-gpu-call"), flush=True)
    try:
        vectorize_on_gpu(pdf_path)
    except Exception as e:
        traceback.print_exc()
        print(cuda_state("post-gpu-failure"), flush=True)
        holder["error"] = f"Couldn't read this paper: {describe_exception(e)}"


def _assemble_session(pdf_path: str) -> dict:
    """Re-open the vectorstore on CPU and build the retriever + chain.

    Cheap: phase 2 already persisted the vectors, so build_vectorstore
    short-circuits to a plain load. Everything here is CPU-resident by design —
    it outlives the GPU window (see ui/gpu.py).
    """
    from vectorizeer import build_vectorstore
    from Qa import build_chain, get_llm, get_retriever
    vs = build_vectorstore(pdf_path)
    return {"retriever": get_retriever(vs), "chain": build_chain(get_llm())}


# --- the stream -------------------------------------------------------------

def prepare_chat_stream(record: dict):
    """Yield (html, done, session, error) frames while the paper is fetched and
    vectorized. Every frame before the last has done=False; the final frame
    carries either a session or an error."""
    quotes = random.sample(SONIC_QUOTES, len(SONIC_QUOTES))
    start = time.time()
    holder: dict = {}

    def fail(msg):
        return vec_loading_html(_quote_at(quotes, start), "…", 100), True, None, msg

    # 1. FETCH — threaded so the bar moves while the network does its thing.
    worker = threading.Thread(target=_download_blocking, args=(record, holder), daemon=True)
    worker.start()
    pct = 6
    # Emit one frame up front: a cached PDF can finish before the first is_alive()
    # check, and without this the panel would sit blank until the next phase.
    yield vec_loading_html(_quote_at(quotes, start), "Fetching the PDF…", pct), False, None, None
    while worker.is_alive():
        pct = min(pct + 2, 44)
        yield vec_loading_html(_quote_at(quotes, start), "Fetching the PDF…", pct), False, None, None
        time.sleep(0.35)
    worker.join()
    if holder.get("error"):
        yield fail(holder["error"])
        return
    pdf_path = holder["pdf_path"]

    # 2. VECTORIZE — the GPU step.
    if ON_ZEROGPU:
        # Must run on this thread: ZeroGPU hands the GPU to the caller, and a
        # thread we spawned isn't one. It's only a few seconds on a GPU, so the
        # bar simply holds rather than animating.
        yield vec_loading_html(_quote_at(quotes, start), "Reading & vectorizing every page…", 55), False, None, None
        _vectorize_blocking(pdf_path, holder)
        if holder.get("error"):
            yield fail(holder["error"])
            return
    else:
        # No such constraint on CPU — and here the encode is genuinely slow, so
        # the animation earns its keep.
        worker = threading.Thread(target=_vectorize_blocking, args=(pdf_path, holder), daemon=True)
        worker.start()
        while worker.is_alive():
            pct = min(pct + 2, 88)
            yield vec_loading_html(_quote_at(quotes, start),
                                   "Reading & vectorizing every page…", pct), False, None, None
            time.sleep(0.35)
        worker.join()
        if holder.get("error"):
            yield fail(holder["error"])
            return

    # 3. ASSEMBLE — CPU, fast.
    yield vec_loading_html(_quote_at(quotes, start), "Almost there…", 94), False, None, None
    try:
        session = _assemble_session(pdf_path)
    except Exception as e:
        traceback.print_exc()
        yield fail(f"Couldn't prepare this paper for chat: {describe_exception(e)}")
        return

    yield vec_loading_html(quotes[0], "Ready.", 100), True, session, None