File size: 6,144 Bytes
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
"""
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

from ui.constants import SONIC_QUOTES
from ui.gpu import ON_ZEROGPU, 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)]


# --- 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:
        holder["error"] = f"Couldn't fetch this paper: {type(e).__name__}: {e}"


def _vectorize_blocking(pdf_path: str, holder: dict):
    try:
        vectorize_on_gpu(pdf_path)
    except Exception as e:
        holder["error"] = f"Couldn't read this paper: {type(e).__name__}: {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:
        yield fail(f"Couldn't prepare this paper for chat: {type(e).__name__}: {e}")
        return

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