File size: 8,077 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
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
---
title: NOVA
emoji: 
colorFrom: purple
colorTo: indigo
sdk: gradio
sdk_version: 6.20.0
app_file: nova_app.py
python_version: "3.12"
pinned: false
short_description: Frame a research idea, find the papers, chat with them.
---

# NOVA — Research, guided by SONIC

A Gradio app that stitches two projects together, unchanged:

- **`app/`** — the research pipeline: an INTENT graph that frames your messy idea into
  Problem / Objective / Additional Context, then a SEARCH + CLUSTER graph that fetches
  from arXiv + Semantic Scholar + OpenAlex, reranks with SPECTER, and clusters by approach.
- **`chatbot_core/`** — single-PDF Q&A: `vectorizeer.py` chunks a paper by section and
  embeds it, `Qa.py` answers over it with page citations.

NOVA is the product. SONIC is the assistant persona that talks you through it.

```
your raw idea
   -> INTENT agent frames it
   -> you review/edit the framing
   -> SEARCH agent fetches + reranks + clusters
   -> paper cards, grouped by approach
   -> "Chat it out" on any card -> PDF fetched, vectorized, Q&A with citations
```

## Layout

| Path | What it is |
| --- | --- |
| `nova_app.py` | The whole UI: stage machine, event wiring, entrypoint |
| `ui/` | Presentation + glue — theme, SONIC, paper/PDF helpers, model loading |
| `app/`, `chatbot_core/` | Backend. Imported and driven, **never modified** |

`ui/paths.py` must be imported first — it does the `sys.path` + `chdir` + `.env` wiring
that makes `import app...` and `from vectorizeer import ...` resolve.

## Run it

```bash
cd NOVA
python -m venv .venv && source .venv/bin/activate

# CPU-only box: install the CPU torch wheel FIRST, or pip resolves the CUDA build
# and drags in ~2.5 GB of nvidia-*/cuda-* wheels you'll never execute. NOVA pins
# device="cpu" and never asks for a GPU.
pip install torch --index-url https://download.pytorch.org/whl/cpu

pip install -r requirements.txt
cp .env.example .env      # then fill it in
python nova_app.py        # http://localhost:7860
```

Verified end-to-end on **Python 3.14** with torch 2.13.0+cpu. Anything ≥3.10 works.
Doing the CPU-torch line first pulls in **zero** nvidia packages and lands at a 2.0 GB
venv; skip it and you get roughly double that, for a GPU the app never touches.

### Keys

`GROQ_API_KEY`, `SECOND_GROQ_API_KEY` and `TAVILY_API_KEY` are required — both graphs
read them at *import* time, so a missing key fails the boot splash, not the first search.
The rest in `.env.example` are optional and only raise rate limits.

## First boot is slow, once

NOVA loads three models totalling ~2 GB:

| Model | Loaded by | Size |
| --- | --- | --- |
| `allenai-specter` | the search graph, at import | 440 MB |
| `BAAI/bge-base-en-v1.5` | the chatbot's embeddings | 438 MB |
| `BAAI/bge-reranker-base` | the chatbot's cross-encoder | 1.1 GB |

They download from HuggingFace once and cache in `~/.cache/huggingface`. Set `HF_TOKEN`
to avoid the anonymous rate limit on that first pull.

Only the search graph loads **synchronously** behind the splash — the two chatbot models
warm on a background thread, since you can't click "Chat it out" until you've framed an
intent and run a search anyway. If the thread hasn't finished by then, the click simply
blocks on the same lock rather than loading twice.

## Deploying to Hugging Face Spaces

The YAML front-matter at the top of this file *is* the Space config — `app_file` points
at `nova_app.py`, so nothing needs renaming. The free CPU tier (16 GB RAM) is the target.

**1. Create the Space.** [huggingface.co/new-space](https://huggingface.co/new-space) →
SDK **Gradio**, hardware **CPU basic (free)**. Don't initialize it with anything.

**2. Push this folder as the Space's repo root.** `nova_app.py` must land at the top
level, not inside a `NOVA/` subfolder:

```bash
cd NOVA
git init && git branch -M main
git remote add space https://huggingface.co/spaces/<your-username>/NOVA
git add -A && git commit -m "NOVA on Gradio"
git push --force space main
```

Push over HTTPS and use an [access token](https://huggingface.co/settings/tokens) with
**write** scope as the password — your account password won't work.

**3. Add the keys** under *Settings → Variables and secrets*, as **Secrets** (not public
variables): `GROQ_API_KEY`, `SECOND_GROQ_API_KEY`, `TAVILY_API_KEY`. Spaces injects them
as env vars, which is exactly what the code reads — `load_dotenv()` finding no `.env` is
fine and expected. Add `HF_TOKEN` too, to lift the anonymous rate limit on the first
model pull.

Miss a key and the Space still boots, then shows a named error on the splash telling you
which one — it won't hang.

**4. First boot takes a few minutes** while ~2 GB of weights download. The server binds
the port *before* loading anything (models load on first visit, via `demo.load`), so the
Space goes green early and the splash does the waiting.

### Hardware: ZeroGPU or CPU basic

This repo is configured for **ZeroGPU**, and runs unmodified on **CPU basic** too — it
detects which it's on via `SPACES_ZERO_GPU` and adapts. Note HF only lets free accounts
pick the free tier *at Space creation*; you can't downgrade into it later.

ZeroGPU imposes two hard rules, and both shape the code:

1. **torch must be 2.11.0 / 2.10.0 / 2.9.1 / 2.8.0**, plain CUDA build — the `+cpu` local
   version is rejected. Hence the exact pin in `requirements.txt`.
2. **At least one `@spaces.GPU` function must exist at import**, or the Space dies with
   *"No @spaces.GPU function detected during startup"*.

A GPU exists **only inside** an `@spaces.GPU` call. Everything outliving that window must
be CPU-resident, which is why `get_embeddings`, `get_retriever` and the SPECTER model now
take an explicit `device` (defaulting to `cpu`) instead of auto-detecting. Auto-detect is
the trap: torch reports a GPU at import on ZeroGPU, so a model would load onto `cuda` and
then fail on first use out in a LangGraph node.

So exactly one thing runs on the GPU — the bulk chunk embedding in
[ui/gpu.py](ui/gpu.py), the slowest step in the app. It builds and **persists** the
vectorstore, then the caller re-opens it on CPU; what crosses the GPU boundary is the
file on disk, never a cuda-resident object.

### Verified

- `sdk_version: 6.20.0` — the code needs Gradio 6 (`launch()` takes `theme`/`css`/`js`;
  `Chatbot` dropped `type=`), and 6.20.0 is what Spaces currently serves.
- `python_version: "3.12"` — the full dependency set resolves cleanly there with
  `torch==2.11.0`.
- Off ZeroGPU, `@spaces.GPU` is a transparent no-op, so local runs and CPU hardware are
  unaffected — the CPU path keeps the threaded progress animation, and only the ZeroGPU
  path calls the GPU inline (it must run on the caller's thread, not one we spawn).

### Why not Streamlit Community Cloud

That's what this rewrite escapes. It caps at roughly 1 GB of RAM, so ~2 GB of weights got
the container OOM-killed mid-load; it restarted, re-entered boot, and sat on the splash
forever. Any host needs **≥ 4 GB RAM**.

## Notes for the next person

- **State.** Streamlit re-ran the script top-to-bottom and branched on
  `session_state.stage`. Gradio builds the component graph once, so a stage is a
  `gr.Column` and every handler returns the `visible` flag for all seven. Same state
  machine, declared instead of re-derived.
- **The card grid** is inside `@gr.render` because each card's button needs a real
  handler closing over its paper key. It redraws off `clusters_state.change` — a
  `gr.State` reassigned to a fresh list, since `gr.render` can't see a dict mutated
  in place.
- **Models are process-global**, not per-session, behind a double-checked lock in
  `ui/agents.py`. Gradio serves from a thread pool, so without the lock two
  simultaneous first-visitors would each kick off a 2 GB load.
- **The two graphs share `SECOND_GROQ_API_KEY`** (see `app/modules/search/graph.py`) —
  `GROQ_API_KEY` is read but only `second_api_key` is actually passed to both `ChatGroq`
  instances. Pre-existing upstream behaviour, left alone. Set both keys.