Commit ·
20b15f3
0
Parent(s):
Initial commit
Browse files- .env.example +15 -0
- .gitignore +27 -0
- README.md +180 -0
- app/__init__.py +0 -0
- app/core/__init__.py +0 -0
- app/core/logging_config.py +71 -0
- app/main.py +39 -0
- app/modules/__init__.py +0 -0
- app/modules/intent/__init__.py +0 -0
- app/modules/intent/graph.py +308 -0
- app/modules/intent/router.py +77 -0
- app/modules/intent/schemas.py +25 -0
- app/modules/search/__init__.py +0 -0
- app/modules/search/graph.py +524 -0
- app/modules/search/jobs.py +54 -0
- app/modules/search/providers/__init__.py +0 -0
- app/modules/search/providers/arxiv.py +99 -0
- app/modules/search/providers/models.py +16 -0
- app/modules/search/providers/openalex.py +397 -0
- app/modules/search/providers/semantic_scholar.py +309 -0
- app/modules/search/reranking.py +159 -0
- app/modules/search/router.py +16 -0
- app/modules/search/schemas.py +10 -0
- chatbot_core/Qa.py +171 -0
- chatbot_core/vectorizeer.py +216 -0
- nova_app.py +544 -0
- requirements.txt +51 -0
- ui/__init__.py +6 -0
- ui/agents.py +71 -0
- ui/chat_engine.py +148 -0
- ui/constants.py +23 -0
- ui/gpu.py +54 -0
- ui/intent_text.py +33 -0
- ui/papers.py +89 -0
- ui/paths.py +38 -0
- ui/search_progress.py +41 -0
- ui/sonic.py +128 -0
- ui/theme.py +267 -0
.env.example
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copy to .env and fill in. NOVA loads this at import via ui/paths.py.
|
| 2 |
+
# On Hugging Face Spaces / any host, set these as secrets instead — there's no
|
| 3 |
+
# .env in the image.
|
| 4 |
+
|
| 5 |
+
# --- required ---
|
| 6 |
+
GROQ_API_KEY=
|
| 7 |
+
SECOND_GROQ_API_KEY= # both graphs read this one; Qa.get_llm() does too
|
| 8 |
+
TAVILY_API_KEY= # langchain-tavily, used to ground the INTENT graph
|
| 9 |
+
|
| 10 |
+
# --- optional: raise rate limits / unlock more sources ---
|
| 11 |
+
SEMANTIC_SCHOLAR_API_KEY=
|
| 12 |
+
OPENALEX_API_KEY=
|
| 13 |
+
OPENALEX_CONTENT_API_KEY=
|
| 14 |
+
OPENALEX_MAILTO=
|
| 15 |
+
UNPAYWALL_EMAIL=
|
.gitignore
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- secrets (NEVER commit) ---
|
| 2 |
+
.env
|
| 3 |
+
.env.*
|
| 4 |
+
!.env.example
|
| 5 |
+
|
| 6 |
+
# --- virtualenvs ---
|
| 7 |
+
NOVA_venv/
|
| 8 |
+
venv/
|
| 9 |
+
.venv/
|
| 10 |
+
|
| 11 |
+
# --- runtime caches (ephemeral, regenerated at runtime) ---
|
| 12 |
+
vectorstores/
|
| 13 |
+
downloads/
|
| 14 |
+
logs/
|
| 15 |
+
.gradio/
|
| 16 |
+
|
| 17 |
+
# --- generated at import by ui/sonic.py ---
|
| 18 |
+
assets/sonic.svg
|
| 19 |
+
assets/user.svg
|
| 20 |
+
|
| 21 |
+
# --- python cruft ---
|
| 22 |
+
__pycache__/
|
| 23 |
+
*.pyc
|
| 24 |
+
*.pyo
|
| 25 |
+
|
| 26 |
+
# --- misc ---
|
| 27 |
+
.DS_Store
|
README.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: NOVA
|
| 3 |
+
emoji: ✨
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 6.20.0
|
| 8 |
+
app_file: nova_app.py
|
| 9 |
+
python_version: "3.12"
|
| 10 |
+
pinned: false
|
| 11 |
+
short_description: Frame a research idea, find the papers, chat with them.
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
# NOVA — Research, guided by SONIC
|
| 15 |
+
|
| 16 |
+
A Gradio app that stitches two projects together, unchanged:
|
| 17 |
+
|
| 18 |
+
- **`app/`** — the research pipeline: an INTENT graph that frames your messy idea into
|
| 19 |
+
Problem / Objective / Additional Context, then a SEARCH + CLUSTER graph that fetches
|
| 20 |
+
from arXiv + Semantic Scholar + OpenAlex, reranks with SPECTER, and clusters by approach.
|
| 21 |
+
- **`chatbot_core/`** — single-PDF Q&A: `vectorizeer.py` chunks a paper by section and
|
| 22 |
+
embeds it, `Qa.py` answers over it with page citations.
|
| 23 |
+
|
| 24 |
+
NOVA is the product. SONIC is the assistant persona that talks you through it.
|
| 25 |
+
|
| 26 |
+
```
|
| 27 |
+
your raw idea
|
| 28 |
+
-> INTENT agent frames it
|
| 29 |
+
-> you review/edit the framing
|
| 30 |
+
-> SEARCH agent fetches + reranks + clusters
|
| 31 |
+
-> paper cards, grouped by approach
|
| 32 |
+
-> "Chat it out" on any card -> PDF fetched, vectorized, Q&A with citations
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
## Layout
|
| 36 |
+
|
| 37 |
+
| Path | What it is |
|
| 38 |
+
| --- | --- |
|
| 39 |
+
| `nova_app.py` | The whole UI: stage machine, event wiring, entrypoint |
|
| 40 |
+
| `ui/` | Presentation + glue — theme, SONIC, paper/PDF helpers, model loading |
|
| 41 |
+
| `app/`, `chatbot_core/` | Backend. Imported and driven, **never modified** |
|
| 42 |
+
|
| 43 |
+
`ui/paths.py` must be imported first — it does the `sys.path` + `chdir` + `.env` wiring
|
| 44 |
+
that makes `import app...` and `from vectorizeer import ...` resolve.
|
| 45 |
+
|
| 46 |
+
## Run it
|
| 47 |
+
|
| 48 |
+
```bash
|
| 49 |
+
cd NOVA
|
| 50 |
+
python -m venv .venv && source .venv/bin/activate
|
| 51 |
+
|
| 52 |
+
# CPU-only box: install the CPU torch wheel FIRST, or pip resolves the CUDA build
|
| 53 |
+
# and drags in ~2.5 GB of nvidia-*/cuda-* wheels you'll never execute. NOVA pins
|
| 54 |
+
# device="cpu" and never asks for a GPU.
|
| 55 |
+
pip install torch --index-url https://download.pytorch.org/whl/cpu
|
| 56 |
+
|
| 57 |
+
pip install -r requirements.txt
|
| 58 |
+
cp .env.example .env # then fill it in
|
| 59 |
+
python nova_app.py # http://localhost:7860
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
Verified end-to-end on **Python 3.14** with torch 2.13.0+cpu. Anything ≥3.10 works.
|
| 63 |
+
Doing the CPU-torch line first pulls in **zero** nvidia packages and lands at a 2.0 GB
|
| 64 |
+
venv; skip it and you get roughly double that, for a GPU the app never touches.
|
| 65 |
+
|
| 66 |
+
### Keys
|
| 67 |
+
|
| 68 |
+
`GROQ_API_KEY`, `SECOND_GROQ_API_KEY` and `TAVILY_API_KEY` are required — both graphs
|
| 69 |
+
read them at *import* time, so a missing key fails the boot splash, not the first search.
|
| 70 |
+
The rest in `.env.example` are optional and only raise rate limits.
|
| 71 |
+
|
| 72 |
+
## First boot is slow, once
|
| 73 |
+
|
| 74 |
+
NOVA loads three models totalling ~2 GB:
|
| 75 |
+
|
| 76 |
+
| Model | Loaded by | Size |
|
| 77 |
+
| --- | --- | --- |
|
| 78 |
+
| `allenai-specter` | the search graph, at import | 440 MB |
|
| 79 |
+
| `BAAI/bge-base-en-v1.5` | the chatbot's embeddings | 438 MB |
|
| 80 |
+
| `BAAI/bge-reranker-base` | the chatbot's cross-encoder | 1.1 GB |
|
| 81 |
+
|
| 82 |
+
They download from HuggingFace once and cache in `~/.cache/huggingface`. Set `HF_TOKEN`
|
| 83 |
+
to avoid the anonymous rate limit on that first pull.
|
| 84 |
+
|
| 85 |
+
Only the search graph loads **synchronously** behind the splash — the two chatbot models
|
| 86 |
+
warm on a background thread, since you can't click "Chat it out" until you've framed an
|
| 87 |
+
intent and run a search anyway. If the thread hasn't finished by then, the click simply
|
| 88 |
+
blocks on the same lock rather than loading twice.
|
| 89 |
+
|
| 90 |
+
## Deploying to Hugging Face Spaces
|
| 91 |
+
|
| 92 |
+
The YAML front-matter at the top of this file *is* the Space config — `app_file` points
|
| 93 |
+
at `nova_app.py`, so nothing needs renaming. The free CPU tier (16 GB RAM) is the target.
|
| 94 |
+
|
| 95 |
+
**1. Create the Space.** [huggingface.co/new-space](https://huggingface.co/new-space) →
|
| 96 |
+
SDK **Gradio**, hardware **CPU basic (free)**. Don't initialize it with anything.
|
| 97 |
+
|
| 98 |
+
**2. Push this folder as the Space's repo root.** `nova_app.py` must land at the top
|
| 99 |
+
level, not inside a `NOVA/` subfolder:
|
| 100 |
+
|
| 101 |
+
```bash
|
| 102 |
+
cd NOVA
|
| 103 |
+
git init && git branch -M main
|
| 104 |
+
git remote add space https://huggingface.co/spaces/<your-username>/NOVA
|
| 105 |
+
git add -A && git commit -m "NOVA on Gradio"
|
| 106 |
+
git push --force space main
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
Push over HTTPS and use an [access token](https://huggingface.co/settings/tokens) with
|
| 110 |
+
**write** scope as the password — your account password won't work.
|
| 111 |
+
|
| 112 |
+
**3. Add the keys** under *Settings → Variables and secrets*, as **Secrets** (not public
|
| 113 |
+
variables): `GROQ_API_KEY`, `SECOND_GROQ_API_KEY`, `TAVILY_API_KEY`. Spaces injects them
|
| 114 |
+
as env vars, which is exactly what the code reads — `load_dotenv()` finding no `.env` is
|
| 115 |
+
fine and expected. Add `HF_TOKEN` too, to lift the anonymous rate limit on the first
|
| 116 |
+
model pull.
|
| 117 |
+
|
| 118 |
+
Miss a key and the Space still boots, then shows a named error on the splash telling you
|
| 119 |
+
which one — it won't hang.
|
| 120 |
+
|
| 121 |
+
**4. First boot takes a few minutes** while ~2 GB of weights download. The server binds
|
| 122 |
+
the port *before* loading anything (models load on first visit, via `demo.load`), so the
|
| 123 |
+
Space goes green early and the splash does the waiting.
|
| 124 |
+
|
| 125 |
+
### Hardware: ZeroGPU or CPU basic
|
| 126 |
+
|
| 127 |
+
This repo is configured for **ZeroGPU**, and runs unmodified on **CPU basic** too — it
|
| 128 |
+
detects which it's on via `SPACES_ZERO_GPU` and adapts. Note HF only lets free accounts
|
| 129 |
+
pick the free tier *at Space creation*; you can't downgrade into it later.
|
| 130 |
+
|
| 131 |
+
ZeroGPU imposes two hard rules, and both shape the code:
|
| 132 |
+
|
| 133 |
+
1. **torch must be 2.11.0 / 2.10.0 / 2.9.1 / 2.8.0**, plain CUDA build — the `+cpu` local
|
| 134 |
+
version is rejected. Hence the exact pin in `requirements.txt`.
|
| 135 |
+
2. **At least one `@spaces.GPU` function must exist at import**, or the Space dies with
|
| 136 |
+
*"No @spaces.GPU function detected during startup"*.
|
| 137 |
+
|
| 138 |
+
A GPU exists **only inside** an `@spaces.GPU` call. Everything outliving that window must
|
| 139 |
+
be CPU-resident, which is why `get_embeddings`, `get_retriever` and the SPECTER model now
|
| 140 |
+
take an explicit `device` (defaulting to `cpu`) instead of auto-detecting. Auto-detect is
|
| 141 |
+
the trap: torch reports a GPU at import on ZeroGPU, so a model would load onto `cuda` and
|
| 142 |
+
then fail on first use out in a LangGraph node.
|
| 143 |
+
|
| 144 |
+
So exactly one thing runs on the GPU — the bulk chunk embedding in
|
| 145 |
+
[ui/gpu.py](ui/gpu.py), the slowest step in the app. It builds and **persists** the
|
| 146 |
+
vectorstore, then the caller re-opens it on CPU; what crosses the GPU boundary is the
|
| 147 |
+
file on disk, never a cuda-resident object.
|
| 148 |
+
|
| 149 |
+
### Verified
|
| 150 |
+
|
| 151 |
+
- `sdk_version: 6.20.0` — the code needs Gradio 6 (`launch()` takes `theme`/`css`/`js`;
|
| 152 |
+
`Chatbot` dropped `type=`), and 6.20.0 is what Spaces currently serves.
|
| 153 |
+
- `python_version: "3.12"` — the full dependency set resolves cleanly there with
|
| 154 |
+
`torch==2.11.0`.
|
| 155 |
+
- Off ZeroGPU, `@spaces.GPU` is a transparent no-op, so local runs and CPU hardware are
|
| 156 |
+
unaffected — the CPU path keeps the threaded progress animation, and only the ZeroGPU
|
| 157 |
+
path calls the GPU inline (it must run on the caller's thread, not one we spawn).
|
| 158 |
+
|
| 159 |
+
### Why not Streamlit Community Cloud
|
| 160 |
+
|
| 161 |
+
That's what this rewrite escapes. It caps at roughly 1 GB of RAM, so ~2 GB of weights got
|
| 162 |
+
the container OOM-killed mid-load; it restarted, re-entered boot, and sat on the splash
|
| 163 |
+
forever. Any host needs **≥ 4 GB RAM**.
|
| 164 |
+
|
| 165 |
+
## Notes for the next person
|
| 166 |
+
|
| 167 |
+
- **State.** Streamlit re-ran the script top-to-bottom and branched on
|
| 168 |
+
`session_state.stage`. Gradio builds the component graph once, so a stage is a
|
| 169 |
+
`gr.Column` and every handler returns the `visible` flag for all seven. Same state
|
| 170 |
+
machine, declared instead of re-derived.
|
| 171 |
+
- **The card grid** is inside `@gr.render` because each card's button needs a real
|
| 172 |
+
handler closing over its paper key. It redraws off `clusters_state.change` — a
|
| 173 |
+
`gr.State` reassigned to a fresh list, since `gr.render` can't see a dict mutated
|
| 174 |
+
in place.
|
| 175 |
+
- **Models are process-global**, not per-session, behind a double-checked lock in
|
| 176 |
+
`ui/agents.py`. Gradio serves from a thread pool, so without the lock two
|
| 177 |
+
simultaneous first-visitors would each kick off a 2 GB load.
|
| 178 |
+
- **The two graphs share `SECOND_GROQ_API_KEY`** (see `app/modules/search/graph.py`) —
|
| 179 |
+
`GROQ_API_KEY` is read but only `second_api_key` is actually passed to both `ChatGroq`
|
| 180 |
+
instances. Pre-existing upstream behaviour, left alone. Set both keys.
|
app/__init__.py
ADDED
|
File without changes
|
app/core/__init__.py
ADDED
|
File without changes
|
app/core/logging_config.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Per-run logging.
|
| 3 |
+
|
| 4 |
+
Every user-submitted research query gets one identifier — the `thread_id`
|
| 5 |
+
handed back by POST /intent/start — that flows through both graphs as
|
| 6 |
+
`run_id`: the intent graph's ResearchIntentState carries it from the start,
|
| 7 |
+
and app/modules/search/jobs.py threads the same value into the search
|
| 8 |
+
graph's ResearchSearchState when it kicks off the background job. Every
|
| 9 |
+
node in both graphs, plus the API providers and reranker they call, logs
|
| 10 |
+
through `get_node_logger(run_id, ...)`, so logs/{run_id}.log ends up with
|
| 11 |
+
the complete story for that one input: every node's output, paper counts
|
| 12 |
+
per source, and any failure with its exception, in call order.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import logging
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
LOG_DIR = Path(__file__).resolve().parent.parent.parent / "logs"
|
| 19 |
+
LOG_DIR.mkdir(exist_ok=True)
|
| 20 |
+
|
| 21 |
+
_FORMATTER = logging.Formatter(
|
| 22 |
+
fmt="%(asctime)s | %(levelname)-7s | %(name)s | %(message)s",
|
| 23 |
+
datefmt="%Y-%m-%d %H:%M:%S",
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
_run_loggers: dict[str, logging.Logger] = {}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _safe_filename(run_id: str) -> str:
|
| 30 |
+
return "".join(c if c.isalnum() or c in "-_" else "_" for c in run_id)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _get_run_logger(run_id: str) -> logging.Logger:
|
| 34 |
+
"""
|
| 35 |
+
One logger per run_id, writing to logs/{run_id}.log plus the console.
|
| 36 |
+
propagate=False so records don't also bubble up to the root logger's
|
| 37 |
+
own console handler and print twice.
|
| 38 |
+
"""
|
| 39 |
+
if run_id in _run_loggers:
|
| 40 |
+
return _run_loggers[run_id]
|
| 41 |
+
|
| 42 |
+
logger = logging.getLogger(f"run.{run_id}")
|
| 43 |
+
logger.setLevel(logging.INFO)
|
| 44 |
+
logger.propagate = False
|
| 45 |
+
|
| 46 |
+
# `logging.getLogger` returns the SAME object process-wide, so guard against
|
| 47 |
+
# re-attaching handlers if this logger was already set up — otherwise a host
|
| 48 |
+
# that re-executes the module (e.g. Streamlit re-running the script, which can
|
| 49 |
+
# bypass the _run_loggers cache) stacks duplicate handlers and every line gets
|
| 50 |
+
# written to the file N times.
|
| 51 |
+
if not logger.handlers:
|
| 52 |
+
file_handler = logging.FileHandler(LOG_DIR / f"{_safe_filename(run_id)}.log", encoding="utf-8")
|
| 53 |
+
file_handler.setFormatter(_FORMATTER)
|
| 54 |
+
logger.addHandler(file_handler)
|
| 55 |
+
|
| 56 |
+
console_handler = logging.StreamHandler()
|
| 57 |
+
console_handler.setFormatter(_FORMATTER)
|
| 58 |
+
logger.addHandler(console_handler)
|
| 59 |
+
|
| 60 |
+
_run_loggers[run_id] = logger
|
| 61 |
+
return logger
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def get_node_logger(run_id: str, node_name: str) -> logging.Logger:
|
| 65 |
+
"""
|
| 66 |
+
Logger for one node/step within one run, e.g.
|
| 67 |
+
get_node_logger(run_id, "intent.problem") or
|
| 68 |
+
get_node_logger(run_id, "search.arxiv"). Records go to
|
| 69 |
+
logs/{run_id}.log (and the console) via the shared run-level logger.
|
| 70 |
+
"""
|
| 71 |
+
return _get_run_logger(run_id).getChild(node_name)
|
app/main.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
|
| 3 |
+
from fastapi import FastAPI
|
| 4 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
+
|
| 6 |
+
from app.modules.intent.router import router as intent_router
|
| 7 |
+
from app.modules.search.router import router as search_router
|
| 8 |
+
|
| 9 |
+
# Root config for anything logged outside a specific run (server
|
| 10 |
+
# startup/shutdown, module-level fallback loggers used when a provider
|
| 11 |
+
# function is called standalone rather than from a graph node). Per-request
|
| 12 |
+
# logs go through app.core.logging_config.get_node_logger instead, which
|
| 13 |
+
# writes its own logs/{run_id}.log and does not propagate up to this root
|
| 14 |
+
# handler — so this does not duplicate those.
|
| 15 |
+
logging.basicConfig(
|
| 16 |
+
level=logging.INFO,
|
| 17 |
+
format="%(asctime)s | %(levelname)-7s | %(name)s | %(message)s",
|
| 18 |
+
datefmt="%Y-%m-%d %H:%M:%S",
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
app = FastAPI(title="Research Assistant Pipeline API")
|
| 22 |
+
|
| 23 |
+
# Dev-friendly CORS. Lock this down to your actual frontend origin(s) before
|
| 24 |
+
# deploying anywhere real.
|
| 25 |
+
app.add_middleware(
|
| 26 |
+
CORSMiddleware,
|
| 27 |
+
allow_origins=["*"],
|
| 28 |
+
allow_methods=["*"],
|
| 29 |
+
allow_headers=["*"],
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@app.get("/health")
|
| 34 |
+
def health():
|
| 35 |
+
return {"status": "ok"}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
app.include_router(intent_router)
|
| 39 |
+
app.include_router(search_router)
|
app/modules/__init__.py
ADDED
|
File without changes
|
app/modules/intent/__init__.py
ADDED
|
File without changes
|
app/modules/intent/graph.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import TypedDict
|
| 2 |
+
import os
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
from langchain_groq import ChatGroq
|
| 5 |
+
from langchain_tavily import TavilySearch
|
| 6 |
+
from langgraph.checkpoint.memory import MemorySaver
|
| 7 |
+
from langgraph.graph import END, START, StateGraph
|
| 8 |
+
from langgraph.types import Command, interrupt
|
| 9 |
+
|
| 10 |
+
from app.core.logging_config import get_node_logger
|
| 11 |
+
|
| 12 |
+
load_dotenv()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# State definition
|
| 16 |
+
class ResearchIntentState(TypedDict):
|
| 17 |
+
run_id: str
|
| 18 |
+
user_query: str
|
| 19 |
+
problem: str
|
| 20 |
+
objective: str
|
| 21 |
+
additional_context: str
|
| 22 |
+
aggregated_intent: str
|
| 23 |
+
search_results: str
|
| 24 |
+
polished_research_intent: str
|
| 25 |
+
human_verified_intent: str
|
| 26 |
+
|
| 27 |
+
second_api_key = os.getenv("SECOND_GROQ_API_KEY")
|
| 28 |
+
|
| 29 |
+
# LLM + tools — instantiated once at import time, reused across requests
|
| 30 |
+
llm = ChatGroq(model="openai/gpt-oss-120b",api_key =second_api_key)
|
| 31 |
+
search_tool = TavilySearch(max_results=5)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# Nodes
|
| 35 |
+
def problem_framing(state: ResearchIntentState) -> ResearchIntentState:
|
| 36 |
+
logger = get_node_logger(state["run_id"], "intent.problem")
|
| 37 |
+
prompt = f""" You are extracting the PROBLEM STATEMENT from a researcher's raw, informal research idea.
|
| 38 |
+
|
| 39 |
+
Your only job: describe what is happening — the gap, limitation, failure mode, or open issue in the current state of the field — as understood from the query.
|
| 40 |
+
|
| 41 |
+
STRICT RULES:
|
| 42 |
+
- Do NOT propose what should be done about it. That is not your job.
|
| 43 |
+
- Do NOT suggest methods, techniques, or solutions, even in passing.
|
| 44 |
+
- Do NOT include constraints, preferences, or scope limits (time range, baselines, excluded approaches). That belongs elsewhere.
|
| 45 |
+
- Write 2-4 sentences, in formal academic register, third person.
|
| 46 |
+
- If the query does not clearly state a problem, infer the most reasonable underlying problem implied by the researcher's framing — do not leave it empty, but do not invent specifics not implied by the query.
|
| 47 |
+
|
| 48 |
+
Output only the problem statement text. No labels, no preamble.
|
| 49 |
+
|
| 50 |
+
User's raw query:
|
| 51 |
+
{state['user_query']}
|
| 52 |
+
"""
|
| 53 |
+
logger.info("Framing problem statement from user query (%d chars)", len(state["user_query"]))
|
| 54 |
+
try:
|
| 55 |
+
problem = llm.invoke(prompt).content
|
| 56 |
+
except Exception:
|
| 57 |
+
logger.exception("LLM call failed while framing the problem statement")
|
| 58 |
+
raise
|
| 59 |
+
logger.info("Problem statement produced (%d chars): %s", len(problem), problem[:200].replace("\n", " "))
|
| 60 |
+
return {"problem": problem}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def objective_framing(state: ResearchIntentState) -> ResearchIntentState:
|
| 64 |
+
logger = get_node_logger(state["run_id"], "intent.objective")
|
| 65 |
+
prompt = f"""You are extracting the RESEARCH OBJECTIVE from a researcher's raw, informal research idea.
|
| 66 |
+
|
| 67 |
+
Your only job: describe what the researcher wants to find out, achieve, or resolve — the goal of the research, not the method to get there.
|
| 68 |
+
|
| 69 |
+
STRICT RULES:
|
| 70 |
+
- Do NOT describe the current problem/gap in the field. That belongs elsewhere.
|
| 71 |
+
- Do NOT propose a specific method, technique, model, or solution as the objective. Phrase the objective in terms of outcome ("identify", "evaluate", "compare", "reduce", "understand"), never in terms of a specific technique to be applied.
|
| 72 |
+
- Wrong: "Use contrastive learning to reduce hallucinations."
|
| 73 |
+
- Right: "Identify techniques that reduce hallucinations while preserving answer quality."
|
| 74 |
+
- Do NOT include constraints, preferred methods, or scope limits. That belongs elsewhere.
|
| 75 |
+
- Write 1-3 sentences, in formal academic register, third person.
|
| 76 |
+
|
| 77 |
+
Output only the objective text. No labels, no preamble.
|
| 78 |
+
|
| 79 |
+
User's raw query:
|
| 80 |
+
{state['user_query']}
|
| 81 |
+
"""
|
| 82 |
+
logger.info("Framing research objective from user query")
|
| 83 |
+
try:
|
| 84 |
+
objective = llm.invoke(prompt).content
|
| 85 |
+
except Exception:
|
| 86 |
+
logger.exception("LLM call failed while framing the research objective")
|
| 87 |
+
raise
|
| 88 |
+
logger.info("Objective produced (%d chars): %s", len(objective), objective[:200].replace("\n", " "))
|
| 89 |
+
return {"objective": objective}
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def additional_context_framing(state: ResearchIntentState) -> ResearchIntentState:
|
| 93 |
+
logger = get_node_logger(state["run_id"], "intent.context")
|
| 94 |
+
prompt = f"""You are extracting ADDITIONAL CONTEXT from a researcher's raw, informal research idea.
|
| 95 |
+
|
| 96 |
+
Your only job: capture optional supporting details the researcher mentioned that are NOT the problem and NOT the objective — for example: preferred methods, known approaches they're already aware of, constraints, application domain, exclusions, time range, baseline models/papers to compare against.
|
| 97 |
+
|
| 98 |
+
STRICT RULES:
|
| 99 |
+
- Do NOT restate the problem or the objective in different words.
|
| 100 |
+
- Do NOT invent context that isn't implied by the query. It is normal and acceptable for this field to be sparse.
|
| 101 |
+
- If the query genuinely contains no such details, output exactly: "None specified."
|
| 102 |
+
- If details exist, write them as a short list or 1-3 sentences, in formal academic register.
|
| 103 |
+
|
| 104 |
+
Output only the additional context text (or "None specified."). No labels, no preamble.
|
| 105 |
+
|
| 106 |
+
User's raw query:
|
| 107 |
+
{state['user_query']}"""
|
| 108 |
+
logger.info("Extracting additional context from user query")
|
| 109 |
+
try:
|
| 110 |
+
additional_context = llm.invoke(prompt).content
|
| 111 |
+
except Exception:
|
| 112 |
+
logger.exception("LLM call failed while extracting additional context")
|
| 113 |
+
raise
|
| 114 |
+
logger.info("Additional context produced (%d chars): %s", len(additional_context), additional_context[:200].replace("\n", " "))
|
| 115 |
+
return {"additional_context": additional_context}
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def aggregator(state: ResearchIntentState) -> ResearchIntentState:
|
| 119 |
+
logger = get_node_logger(state["run_id"], "intent.aggregator")
|
| 120 |
+
prompt = f"""You are producing the FINAL RESEARCH INTENT document by harmonizing three independently-drafted sections into one coherent whole.
|
| 121 |
+
|
| 122 |
+
You will receive:
|
| 123 |
+
- PROBLEM: what is currently happening / the gap
|
| 124 |
+
- OBJECTIVE: what the researcher wants to find out or achieve
|
| 125 |
+
- ADDITIONAL_CONTEXT: optional supporting details (may be "None specified.")
|
| 126 |
+
|
| 127 |
+
Your job is to make these three sections read as if written together, by:
|
| 128 |
+
- Using consistent terminology across all three (e.g., if PROBLEM says "vision-language models" and OBJECTIVE says "VLMs," pick one term and use it consistently)
|
| 129 |
+
- Smoothing awkward phrasing or redundancy between sections
|
| 130 |
+
- Ensuring pronouns and references are unambiguous across sections (e.g., "this issue" in OBJECTIVE should clearly refer back to something named in PROBLEM)
|
| 131 |
+
- Adjusting tone/register so all three sections sound like one voice
|
| 132 |
+
|
| 133 |
+
STRICT RULES — DO NOT:
|
| 134 |
+
- Do NOT move content between sections. If OBJECTIVE contains a method, do not move it to ADDITIONAL_CONTEXT — leave the section boundary as-is, just smooth the language.
|
| 135 |
+
- Do NOT add new claims, facts, or specifics that were not present in the original three sections.
|
| 136 |
+
- Do NOT remove information for brevity. Every substantive point from all three sections must remain.
|
| 137 |
+
- Do NOT collapse the three sections into a single paragraph. Keep them clearly separated under their own headers.
|
| 138 |
+
- If ADDITIONAL_CONTEXT is "None specified.", keep it exactly as "None specified." in the output — do not fabricate content for it.
|
| 139 |
+
|
| 140 |
+
Output in exactly this format:
|
| 141 |
+
|
| 142 |
+
Problem:
|
| 143 |
+
<harmonized problem text>
|
| 144 |
+
|
| 145 |
+
Objective:
|
| 146 |
+
<harmonized objective text>
|
| 147 |
+
|
| 148 |
+
Additional Context:
|
| 149 |
+
<harmonized additional context text, or "None specified.">
|
| 150 |
+
|
| 151 |
+
Do not include any preamble, explanation, or text outside this format.
|
| 152 |
+
|
| 153 |
+
Inputs:
|
| 154 |
+
PROBLEM:
|
| 155 |
+
{state['problem']}
|
| 156 |
+
|
| 157 |
+
OBJECTIVE:
|
| 158 |
+
{state['objective']}
|
| 159 |
+
|
| 160 |
+
ADDITIONAL_CONTEXT:
|
| 161 |
+
{state['additional_context']}"""
|
| 162 |
+
logger.info("Harmonizing problem/objective/context into one intent document")
|
| 163 |
+
try:
|
| 164 |
+
aggregator_intent = llm.invoke(prompt).content
|
| 165 |
+
except Exception:
|
| 166 |
+
logger.exception("LLM call failed while harmonizing the intent sections")
|
| 167 |
+
raise
|
| 168 |
+
logger.info("Aggregated intent produced (%d chars)", len(aggregator_intent))
|
| 169 |
+
return {"aggregated_intent": aggregator_intent}
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def web_polish_node(state: ResearchIntentState) -> ResearchIntentState:
|
| 173 |
+
logger = get_node_logger(state["run_id"], "intent.polish")
|
| 174 |
+
|
| 175 |
+
# Step 1: derive a concise search query from the aggregated intent
|
| 176 |
+
query_gen_prompt = f"""Given this Research Intent, generate ONE concise web search query
|
| 177 |
+
(under 15 words) to find current terminology, related work, or factual grounding
|
| 178 |
+
for the topic. Output only the query text, nothing else.
|
| 179 |
+
|
| 180 |
+
Research Intent:
|
| 181 |
+
{state['aggregated_intent']}
|
| 182 |
+
"""
|
| 183 |
+
try:
|
| 184 |
+
search_query = llm.invoke(query_gen_prompt).content
|
| 185 |
+
except Exception:
|
| 186 |
+
logger.exception("LLM call failed while generating the web search query")
|
| 187 |
+
raise
|
| 188 |
+
logger.info("Web search query: %s", search_query)
|
| 189 |
+
|
| 190 |
+
# Step 2: run the search
|
| 191 |
+
try:
|
| 192 |
+
raw_results = search_tool.invoke({"query": search_query})
|
| 193 |
+
except Exception:
|
| 194 |
+
logger.exception("Tavily web search failed")
|
| 195 |
+
raise
|
| 196 |
+
results = raw_results.get("results", [])
|
| 197 |
+
logger.info("Web search returned %d result(s)", len(results))
|
| 198 |
+
results_text = "\n\n".join(
|
| 199 |
+
f"Source: {r.get('title', 'N/A')}\n{r.get('content', '')}"
|
| 200 |
+
for r in results
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
# Step 3: polish the intent using search results, with strict boundaries
|
| 204 |
+
polish_prompt = f"""You are GROUNDING and POLISHING a Research Intent document using web search results.
|
| 205 |
+
|
| 206 |
+
You will receive the current Research Intent (Problem / Objective / Additional Context)
|
| 207 |
+
and a set of web search results related to its topic.
|
| 208 |
+
|
| 209 |
+
Your ONLY job:
|
| 210 |
+
- Correct or sharpen field-specific terminology if the current text uses vague or outdated terms
|
| 211 |
+
- Verify and correct named entities (model names, benchmark names, paper titles) if search results
|
| 212 |
+
show the current text has them wrong or imprecise
|
| 213 |
+
- Improve clarity and precision of wording
|
| 214 |
+
|
| 215 |
+
STRICT RULES — DO NOT:
|
| 216 |
+
- Do NOT move content between Problem / Objective / Additional Context sections.
|
| 217 |
+
- Do NOT change the meaning, scope, or intent of any section.
|
| 218 |
+
- Do NOT add citations, links, or references to sources in the output text.
|
| 219 |
+
- If the search results are irrelevant or add nothing useful, return the Research Intent unchanged.
|
| 220 |
+
- Preserve the exact three-section format with headers: Problem / Objective / Additional Context.
|
| 221 |
+
|
| 222 |
+
Output in exactly this format, nothing else:
|
| 223 |
+
|
| 224 |
+
Problem:
|
| 225 |
+
<text>
|
| 226 |
+
|
| 227 |
+
Objective:
|
| 228 |
+
<text>
|
| 229 |
+
|
| 230 |
+
Additional Context:
|
| 231 |
+
<text>
|
| 232 |
+
|
| 233 |
+
Current Research Intent:
|
| 234 |
+
{state['aggregated_intent']}
|
| 235 |
+
|
| 236 |
+
Web Search Results:
|
| 237 |
+
{results_text}
|
| 238 |
+
"""
|
| 239 |
+
try:
|
| 240 |
+
polished = llm.invoke(polish_prompt)
|
| 241 |
+
except Exception:
|
| 242 |
+
logger.exception("LLM call failed while polishing the research intent")
|
| 243 |
+
raise
|
| 244 |
+
logger.info("Polished research intent produced (%d chars)", len(polished.content))
|
| 245 |
+
|
| 246 |
+
return {
|
| 247 |
+
"search_results": results_text,
|
| 248 |
+
"polished_research_intent": polished.content,
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def human_review_node(state: ResearchIntentState) -> ResearchIntentState:
|
| 253 |
+
logger = get_node_logger(state["run_id"], "intent.human_review")
|
| 254 |
+
logger.info("Pausing for human review of the polished research intent")
|
| 255 |
+
human_response = interrupt({
|
| 256 |
+
"polished_research_intent": state["polished_research_intent"],
|
| 257 |
+
"instruction": "Review and edit this Research Intent.",
|
| 258 |
+
})
|
| 259 |
+
logger.info("Human-verified intent received (%d chars)", len(human_response))
|
| 260 |
+
return {"human_verified_intent": human_response}
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
# Graph building
|
| 264 |
+
builder = StateGraph(ResearchIntentState)
|
| 265 |
+
|
| 266 |
+
builder.add_node("problem", problem_framing)
|
| 267 |
+
builder.add_node("objective", objective_framing)
|
| 268 |
+
builder.add_node("context", additional_context_framing)
|
| 269 |
+
builder.add_node("human_review", human_review_node)
|
| 270 |
+
builder.add_node("aggregator", aggregator)
|
| 271 |
+
builder.add_node("polish", web_polish_node)
|
| 272 |
+
|
| 273 |
+
builder.add_edge(START, "problem")
|
| 274 |
+
builder.add_edge(START, "objective")
|
| 275 |
+
builder.add_edge(START, "context")
|
| 276 |
+
builder.add_edge("problem", "aggregator")
|
| 277 |
+
builder.add_edge("objective", "aggregator")
|
| 278 |
+
builder.add_edge("context", "aggregator")
|
| 279 |
+
builder.add_edge("aggregator", "polish")
|
| 280 |
+
builder.add_edge("polish", "human_review")
|
| 281 |
+
builder.add_edge("human_review", END)
|
| 282 |
+
|
| 283 |
+
# In-process checkpointer — keeps interrupted threads alive between the
|
| 284 |
+
# /intent/start and /intent/resume calls. Lives in this worker's memory only:
|
| 285 |
+
# fine for a single dev server, but state is lost on restart and isn't shared
|
| 286 |
+
# across multiple uvicorn workers. Swap for a SqliteSaver/PostgresSaver if
|
| 287 |
+
# that ever matters.
|
| 288 |
+
checkpointer = MemorySaver()
|
| 289 |
+
|
| 290 |
+
graph = builder.compile(checkpointer=checkpointer)
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
if __name__ == "__main__":
|
| 294 |
+
# Manual standalone test — only runs when you execute this file directly
|
| 295 |
+
# (`python -m app.modules.intent.graph`), never on import.
|
| 296 |
+
query = """Hi ... well I have been looking into the problem of the driving behaviour in the car following scenarios in this scenarios what needs to be done is I want to analyse the car following behavior for the context of fuel efficicency comparing the human driven vehicle which are not fuel efficient and the RL driven which are excellent in fuel efficiency the comparison is tedious because many variables change at once velocity acceleration relative distance from the vehicle ahead etc so just to compare fuel efficiency is tough. I want to find out the ways in which we can get a good comparing method for both the vehicles"""
|
| 297 |
+
|
| 298 |
+
config = {"configurable": {"thread_id": "test-thread-1"}}
|
| 299 |
+
result = graph.invoke({"user_query": query, "run_id": "standalone-test"}, config=config)
|
| 300 |
+
|
| 301 |
+
print("--- PAUSED FOR HUMAN REVIEW ---")
|
| 302 |
+
print(result["__interrupt__"])
|
| 303 |
+
|
| 304 |
+
edited_text = result["__interrupt__"][0].value["polished_research_intent"]
|
| 305 |
+
final_result = graph.invoke(Command(resume=edited_text), config=config)
|
| 306 |
+
|
| 307 |
+
print("\n--- FINAL RESULT ---")
|
| 308 |
+
print(final_result["human_verified_intent"])
|
app/modules/intent/router.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
| 4 |
+
from langgraph.types import Command
|
| 5 |
+
|
| 6 |
+
from app.core.logging_config import get_node_logger
|
| 7 |
+
from app.modules.intent.graph import graph as intent_graph
|
| 8 |
+
from app.modules.intent.schemas import (
|
| 9 |
+
IntentResumeRequest,
|
| 10 |
+
IntentResumeResponse,
|
| 11 |
+
IntentStartRequest,
|
| 12 |
+
IntentStartResponse,
|
| 13 |
+
)
|
| 14 |
+
from app.modules.search.jobs import create_job, run_search_job
|
| 15 |
+
|
| 16 |
+
router = APIRouter(prefix="/intent", tags=["intent"])
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.post("/start", response_model=IntentStartResponse)
|
| 20 |
+
def start_intent(payload: IntentStartRequest):
|
| 21 |
+
"""Runs the intent graph's parallel framing + polish nodes. The graph
|
| 22 |
+
hits human_review_node's interrupt() immediately, so this call always
|
| 23 |
+
returns the paused state — it never runs to completion on its own."""
|
| 24 |
+
thread_id = str(uuid.uuid4())
|
| 25 |
+
config = {"configurable": {"thread_id": thread_id}}
|
| 26 |
+
logger = get_node_logger(thread_id, "api.intent_start")
|
| 27 |
+
logger.info("New research query received (%d chars)", len(payload.user_query))
|
| 28 |
+
|
| 29 |
+
result = intent_graph.invoke({"user_query": payload.user_query, "run_id": thread_id}, config=config)
|
| 30 |
+
|
| 31 |
+
if "__interrupt__" not in result:
|
| 32 |
+
# Defensive: should never happen given the graph's fixed structure,
|
| 33 |
+
# but fail loudly rather than return a malformed response.
|
| 34 |
+
logger.error("Intent graph did not pause for review as expected")
|
| 35 |
+
raise HTTPException(status_code=500, detail="Intent graph did not pause for review as expected.")
|
| 36 |
+
|
| 37 |
+
interrupt_payload = result["__interrupt__"][0].value
|
| 38 |
+
logger.info("Intent framing complete — paused for human review")
|
| 39 |
+
|
| 40 |
+
return IntentStartResponse(
|
| 41 |
+
thread_id=thread_id,
|
| 42 |
+
polished_research_intent=interrupt_payload["polished_research_intent"],
|
| 43 |
+
instruction=interrupt_payload["instruction"],
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@router.post("/resume", response_model=IntentResumeResponse)
|
| 48 |
+
def resume_intent(payload: IntentResumeRequest, background_tasks: BackgroundTasks):
|
| 49 |
+
"""Resumes the paused intent graph with the human-edited text, then
|
| 50 |
+
immediately kicks off the search graph as a background job using the
|
| 51 |
+
resulting human_verified_intent — no separate /search/start call needed."""
|
| 52 |
+
config = {"configurable": {"thread_id": payload.thread_id}}
|
| 53 |
+
logger = get_node_logger(payload.thread_id, "api.intent_resume")
|
| 54 |
+
logger.info("Resuming intent graph with human-edited text")
|
| 55 |
+
|
| 56 |
+
result = intent_graph.invoke(Command(resume=payload.edited_intent), config=config)
|
| 57 |
+
|
| 58 |
+
if "human_verified_intent" not in result:
|
| 59 |
+
logger.error("No paused intent graph found for this thread_id, or it was already resumed")
|
| 60 |
+
raise HTTPException(
|
| 61 |
+
status_code=404,
|
| 62 |
+
detail="No paused intent graph found for this thread_id, or it has already been resumed.",
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
human_verified_intent = result["human_verified_intent"]
|
| 66 |
+
|
| 67 |
+
job_id = str(uuid.uuid4())
|
| 68 |
+
create_job(job_id, payload.thread_id)
|
| 69 |
+
logger.info("Human-verified intent finalized — starting search job %s", job_id)
|
| 70 |
+
background_tasks.add_task(run_search_job, job_id, human_verified_intent, payload.thread_id)
|
| 71 |
+
|
| 72 |
+
return IntentResumeResponse(
|
| 73 |
+
thread_id=payload.thread_id,
|
| 74 |
+
human_verified_intent=human_verified_intent,
|
| 75 |
+
job_id=job_id,
|
| 76 |
+
status="running",
|
| 77 |
+
)
|
app/modules/intent/schemas.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Literal
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class IntentStartRequest(BaseModel):
|
| 7 |
+
user_query: str
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class IntentStartResponse(BaseModel):
|
| 11 |
+
thread_id: str
|
| 12 |
+
polished_research_intent: str
|
| 13 |
+
instruction: str
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class IntentResumeRequest(BaseModel):
|
| 17 |
+
thread_id: str
|
| 18 |
+
edited_intent: str
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class IntentResumeResponse(BaseModel):
|
| 22 |
+
thread_id: str
|
| 23 |
+
human_verified_intent: str
|
| 24 |
+
job_id: str
|
| 25 |
+
status: Literal["running"]
|
app/modules/search/__init__.py
ADDED
|
File without changes
|
app/modules/search/graph.py
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langchain_groq import ChatGroq
|
| 2 |
+
from langgraph.graph import StateGraph, START, END
|
| 3 |
+
from typing import TypedDict, List, Dict, Literal, Optional
|
| 4 |
+
from pydantic import BaseModel, Field
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
from app.core.logging_config import get_node_logger
|
| 7 |
+
from app.modules.search.providers.arxiv import search_arxiv
|
| 8 |
+
from app.modules.search.providers.semantic_scholar import search_semantic_scholar
|
| 9 |
+
from app.modules.search.providers.openalex import search_openalex
|
| 10 |
+
from app.modules.search.reranking import rerank_by_relevance
|
| 11 |
+
import re
|
| 12 |
+
import unicodedata
|
| 13 |
+
import os
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
load_dotenv()
|
| 17 |
+
|
| 18 |
+
second_api_key = os.getenv("SECOND_GROQ_API_KEY")
|
| 19 |
+
api_key = os.getenv("GROQ_API_KEY")
|
| 20 |
+
# Setting up model
|
| 21 |
+
llm = ChatGroq(model = 'openai/gpt-oss-120b',api_key=second_api_key)
|
| 22 |
+
llm_2 = ChatGroq(model='openai/gpt-oss-120b',api_key=second_api_key)
|
| 23 |
+
|
| 24 |
+
# State of the Agent
|
| 25 |
+
class ResearchSearchState(TypedDict):
|
| 26 |
+
run_id : str
|
| 27 |
+
ResearchIntent : str
|
| 28 |
+
Open_Alex_paper: List[dict]
|
| 29 |
+
Semantic_Scholar_paper : List[dict]
|
| 30 |
+
arXiv_paper: List[dict]
|
| 31 |
+
aggregator : dict
|
| 32 |
+
reranked_papers : dict
|
| 33 |
+
clustered_papers : List[dict]
|
| 34 |
+
# Per-source status so the UI can surface WHY a source came back empty
|
| 35 |
+
# (e.g. Semantic Scholar rate-limited) instead of it looking silently blank.
|
| 36 |
+
# Distinct keys per source -> no concurrent-write conflict across the
|
| 37 |
+
# parallel fetch nodes.
|
| 38 |
+
arxiv_status : dict
|
| 39 |
+
semantic_scholar_status : dict
|
| 40 |
+
open_alex_status : dict
|
| 41 |
+
|
| 42 |
+
class ClusterItem(BaseModel):
|
| 43 |
+
cluster_id: int
|
| 44 |
+
label: str = Field(description="Short, specific approach name")
|
| 45 |
+
rationale: str = Field(description="2-3 sentence explanation of what unifies this cluster")
|
| 46 |
+
paper_ids: List[int]
|
| 47 |
+
|
| 48 |
+
class ClusteringOutput(BaseModel):
|
| 49 |
+
clusters: List[ClusterItem]
|
| 50 |
+
|
| 51 |
+
# Defining Nodes
|
| 52 |
+
def arXiv_papers(state: ResearchSearchState)-> ResearchSearchState:
|
| 53 |
+
logger = get_node_logger(state["run_id"], "search.arxiv")
|
| 54 |
+
prompt = f"""You are converting a Research Intent into a search query for the arXiv API.
|
| 55 |
+
|
| 56 |
+
arXiv search is PURELY LEXICAL — it does not understand natural language, intent,
|
| 57 |
+
or reasoning. It only matches literal keyword overlap using field-prefixed terms.
|
| 58 |
+
|
| 59 |
+
RULES FOR THE QUERY YOU PRODUCE:
|
| 60 |
+
- Use only these field prefixes: ti: (title), abs: (abstract), all: (all fields).
|
| 61 |
+
Default to all: unless a term is clearly a proper technical name that belongs in a title.
|
| 62 |
+
- Combine terms using ONLY uppercase AND, OR, ANDNOT. No other operators exist.
|
| 63 |
+
- Do NOT nest field prefixes inside parentheses (e.g. all:(ti:(x)) is invalid/ambiguous
|
| 64 |
+
on arXiv and must be avoided).
|
| 65 |
+
- Keep the structure FLAT: field:term AND field:term AND (term1 OR term2) is fine;
|
| 66 |
+
deeper nesting is not.
|
| 67 |
+
- Extract only concrete technical noun phrases from the Problem and Objective sections.
|
| 68 |
+
Do NOT use Additional Context — arXiv cannot reason about constraints, exclusions,
|
| 69 |
+
or preferences, only literal keyword matches.
|
| 70 |
+
- Multi-word technical phrases should be joined with + between words (arXiv API convention),
|
| 71 |
+
e.g. reinforcement+learning.
|
| 72 |
+
- Produce 4-6 core technical terms maximum. More terms narrow results without arXiv
|
| 73 |
+
being able to judge relevance the way a ranked search engine would.
|
| 74 |
+
- Output ONLY the final query string in arXiv API syntax. No explanation, no preamble.
|
| 75 |
+
|
| 76 |
+
Research Intent:
|
| 77 |
+
{state['ResearchIntent']}"""
|
| 78 |
+
try:
|
| 79 |
+
query = llm.invoke(prompt).content
|
| 80 |
+
except Exception:
|
| 81 |
+
logger.exception("LLM call failed while generating the arXiv query")
|
| 82 |
+
raise
|
| 83 |
+
query = query.strip().strip('"').strip("'")
|
| 84 |
+
logger.info("arXiv query: %s", query)
|
| 85 |
+
papers = search_arxiv(query=query, logger=logger)
|
| 86 |
+
logger.info("arXiv returned %d paper(s)", len(papers))
|
| 87 |
+
return {'arXiv_paper': papers, 'arxiv_status': {"state": "ok", "count": len(papers)}}
|
| 88 |
+
|
| 89 |
+
def semantic_scholar_papers(state : ResearchSearchState)-> ResearchSearchState:
|
| 90 |
+
logger = get_node_logger(state["run_id"], "search.semantic_scholar")
|
| 91 |
+
prompt = f"""You are converting a Research Intent into a search query for the Semantic Scholar Search API. This endpoint matches terms ONLY against each paper's title and abstract.
|
| 92 |
+
|
| 93 |
+
CRITICAL BEHAVIOR TO UNDERSTAND FIRST:
|
| 94 |
+
This is an EXACT MATCH system, not a semantic/fuzzy search. There is no "close enough."
|
| 95 |
+
Bare words or quoted phrases placed next to each other with no operator between them
|
| 96 |
+
are implicitly ANDed — ALL of them must appear, verbatim, in the same paper's title or
|
| 97 |
+
abstract. This means every additional required term you add narrows the result set
|
| 98 |
+
further and increases the risk of zero results. A SINGLE required phrase that doesn't
|
| 99 |
+
match the field's actual standard terminology (e.g. requiring "human-operated" when
|
| 100 |
+
the literature actually says "human-driven") can zero out the entire query, even if
|
| 101 |
+
every other term in it is correct.
|
| 102 |
+
|
| 103 |
+
SYNTAX AVAILABLE:
|
| 104 |
+
- Bare word or phrase with no operator: still REQUIRED (ANDed with everything else),
|
| 105 |
+
not optional. There is no implicit OR between adjacent terms.
|
| 106 |
+
- "exact phrase": double-quote a multi-word phrase that must appear exactly as
|
| 107 |
+
written, word-for-word. Only use this for terminology you are highly confident
|
| 108 |
+
is the field's standard phrasing.
|
| 109 |
+
- +term: explicitly required (behaves the same as a bare term — the + is for
|
| 110 |
+
clarity/emphasis, not a different matching rule).
|
| 111 |
+
- -term: term must NOT appear. Use only for genuine exclusions the researcher
|
| 112 |
+
explicitly stated.
|
| 113 |
+
- (a | b | c): matches ANY of the listed alternatives — use this to hedge against
|
| 114 |
+
terminology uncertainty for a single concept.
|
| 115 |
+
- Parentheses group | and + clauses; keep grouping to 1 level deep.
|
| 116 |
+
|
| 117 |
+
RULES FOR CONSTRUCTING THE QUERY:
|
| 118 |
+
- Identify at most 2 CORE concepts from Problem + Objective that are essential to
|
| 119 |
+
the research question, and require each with a quoted exact phrase (using + or
|
| 120 |
+
bare — same effect). Do not require more than 2 concepts — since all required
|
| 121 |
+
terms are ANDed, each additional one multiplies the risk of zero results.
|
| 122 |
+
- For ANY concept where you are not fully certain of the field's standard exact
|
| 123 |
+
wording (e.g. how a specific comparison, method, or entity type is typically
|
| 124 |
+
phrased in academic writing), do NOT lock it to a single quoted phrase. Instead,
|
| 125 |
+
express it as an OR group of 2-3 plausible standard phrasings, e.g.
|
| 126 |
+
("human-driven" | "human-operated" | "manually driven"). This is especially
|
| 127 |
+
important for adjective/description phrases (how something is characterized),
|
| 128 |
+
which vary more across papers than core technical nouns do.
|
| 129 |
+
- Use (a | b) groups only for genuine synonymous variants of ONE concept — do not
|
| 130 |
+
invent alternative concepts, methods, or approaches not present in the Research
|
| 131 |
+
Intent.
|
| 132 |
+
- Do NOT use - exclusions unless Additional Context explicitly states something
|
| 133 |
+
should be excluded.
|
| 134 |
+
- Keep the total query under ~15 words worth of terms. Favor 2 required core
|
| 135 |
+
concepts plus 1 hedged OR group over stacking many required exact phrases.
|
| 136 |
+
- Output ONLY the final query string. No explanation, no preamble.
|
| 137 |
+
|
| 138 |
+
Research Intent:
|
| 139 |
+
{state['ResearchIntent']}"""
|
| 140 |
+
try:
|
| 141 |
+
query = llm.invoke(prompt).content
|
| 142 |
+
except Exception:
|
| 143 |
+
logger.exception("LLM call failed while generating the Semantic Scholar query")
|
| 144 |
+
raise
|
| 145 |
+
query = query.strip().strip('"').strip("'")
|
| 146 |
+
logger.info("Semantic Scholar query: %s", query)
|
| 147 |
+
status: dict = {}
|
| 148 |
+
papers = search_semantic_scholar(query=query, logger=logger, status_out=status)
|
| 149 |
+
logger.info("Semantic Scholar returned %d paper(s)", len(papers))
|
| 150 |
+
return {'Semantic_Scholar_paper': papers, 'semantic_scholar_status': status}
|
| 151 |
+
|
| 152 |
+
def Open_alex_papers(state : ResearchSearchState)-> ResearchSearchState:
|
| 153 |
+
logger = get_node_logger(state["run_id"], "search.openalex")
|
| 154 |
+
prompt = f"""You are converting a Research Intent into a search query for the OpenAlex /works
|
| 155 |
+
search endpoint, which searches titles, abstracts, and available fulltext.
|
| 156 |
+
|
| 157 |
+
CRITICAL BEHAVIOR TO ACCOUNT FOR:
|
| 158 |
+
Words not separated by explicit boolean operators are treated as AND by default.
|
| 159 |
+
This means a long plain-keyword query risks ZERO results if any single term is too
|
| 160 |
+
narrow, because ALL terms must match. You must actively defend against this.
|
| 161 |
+
|
| 162 |
+
SYNTAX AVAILABLE:
|
| 163 |
+
- Uppercase AND, OR, NOT only (lowercase will not be recognized as operators).
|
| 164 |
+
- "exact phrase" for phrases that must appear together.
|
| 165 |
+
- Parentheses for grouping.
|
| 166 |
+
|
| 167 |
+
RULES FOR CONSTRUCTING THE QUERY:
|
| 168 |
+
- Identify 2-4 DISTINCT core concepts from Problem + Objective (not more — each
|
| 169 |
+
AND-ed concept multiplies the risk of zero results).
|
| 170 |
+
- For each core concept, group any synonym/near-synonym terms together with OR
|
| 171 |
+
inside parentheses, e.g. ("fuel efficiency" OR "fuel consumption").
|
| 172 |
+
- Join the distinct concept groups with AND.
|
| 173 |
+
- Use quotes around every multi-word phrase — unquoted multi-word phrases will be
|
| 174 |
+
split into individual AND-ed words, which is almost always too strict.
|
| 175 |
+
- Do NOT use NOT unless Additional Context explicitly names something to exclude.
|
| 176 |
+
- Do NOT add synonyms or terms not implied by the Research Intent, even to "help"
|
| 177 |
+
recall — only group terms that are genuinely equivalent phrasings of the same idea.
|
| 178 |
+
- Output ONLY the final query string. No explanation, no preamble.
|
| 179 |
+
|
| 180 |
+
Research Intent:
|
| 181 |
+
{state['ResearchIntent']}"""
|
| 182 |
+
try:
|
| 183 |
+
query = llm.invoke(prompt).content
|
| 184 |
+
except Exception:
|
| 185 |
+
logger.exception("LLM call failed while generating the OpenAlex query")
|
| 186 |
+
raise
|
| 187 |
+
query = query.strip().strip('"').strip("'")
|
| 188 |
+
logger.info("OpenAlex query: %s", query)
|
| 189 |
+
status: dict = {}
|
| 190 |
+
papers = search_openalex(query=query, logger=logger, status_out=status)
|
| 191 |
+
logger.info("OpenAlex returned %d paper(s)", len(papers))
|
| 192 |
+
return {'Open_Alex_paper': papers, 'open_alex_status': status}
|
| 193 |
+
|
| 194 |
+
def normalize_title(title: str) -> str:
|
| 195 |
+
"""
|
| 196 |
+
Produce a normalized string used ONLY for matching titles across sources.
|
| 197 |
+
Never stored on the paper record itself — purely a dedup key.
|
| 198 |
+
"""
|
| 199 |
+
if not title:
|
| 200 |
+
return ""
|
| 201 |
+
|
| 202 |
+
# Normalize unicode (curly quotes, accented chars) to a comparable ASCII-ish form
|
| 203 |
+
text = unicodedata.normalize("NFKD", title)
|
| 204 |
+
text = "".join(c for c in text if not unicodedata.combining(c))
|
| 205 |
+
|
| 206 |
+
text = text.lower()
|
| 207 |
+
text = re.sub(r"[^\w\s]", "", text) # strip punctuation (colons, hyphens, periods, etc.)
|
| 208 |
+
text = re.sub(r"\s+", " ", text) # collapse whitespace
|
| 209 |
+
return text.strip()
|
| 210 |
+
|
| 211 |
+
def merge_paper_entry(existing: dict, new: dict) -> dict:
|
| 212 |
+
"""
|
| 213 |
+
Merge a newly-encountered paper into an existing aggregated entry
|
| 214 |
+
for the same normalized title. No information is dropped —
|
| 215 |
+
source-varying fields (source, citation_count, url, pdf_url)
|
| 216 |
+
are kept per-source in dicts.
|
| 217 |
+
"""
|
| 218 |
+
new_source = new.get("source")
|
| 219 |
+
|
| 220 |
+
# --- title: keep the longer/more complete one ---
|
| 221 |
+
existing_title = existing.get("title") or ""
|
| 222 |
+
new_title = new.get("title") or ""
|
| 223 |
+
merged_title = new_title if len(new_title) > len(existing_title) else existing_title
|
| 224 |
+
|
| 225 |
+
# --- authors: union, order-preserving ---
|
| 226 |
+
merged_authors = list(existing.get("authors") or [])
|
| 227 |
+
for author in (new.get("authors") or []):
|
| 228 |
+
if author not in merged_authors:
|
| 229 |
+
merged_authors.append(author)
|
| 230 |
+
|
| 231 |
+
# --- abstract: keep the longer one ---
|
| 232 |
+
existing_abstract = existing.get("abstract")
|
| 233 |
+
new_abstract = new.get("abstract")
|
| 234 |
+
if existing_abstract and new_abstract:
|
| 235 |
+
merged_abstract = existing_abstract if len(existing_abstract) >= len(new_abstract) else new_abstract
|
| 236 |
+
else:
|
| 237 |
+
merged_abstract = existing_abstract or new_abstract
|
| 238 |
+
|
| 239 |
+
# --- year: first non-null wins (should agree across sources) ---
|
| 240 |
+
merged_year = existing.get("year") if existing.get("year") is not None else new.get("year")
|
| 241 |
+
|
| 242 |
+
# --- source-varying fields: dict keyed by source, nothing overwritten ---
|
| 243 |
+
merged_citation_count = dict(existing.get("citation_count") or {})
|
| 244 |
+
merged_citation_count[new_source] = new.get("citation_count")
|
| 245 |
+
|
| 246 |
+
merged_url = dict(existing.get("url") or {})
|
| 247 |
+
merged_url[new_source] = new.get("url")
|
| 248 |
+
|
| 249 |
+
merged_pdf_url = dict(existing.get("pdf_url") or {})
|
| 250 |
+
merged_pdf_url[new_source] = new.get("pdf_url")
|
| 251 |
+
|
| 252 |
+
merged_sources = list(existing.get("source") or [])
|
| 253 |
+
if new_source not in merged_sources:
|
| 254 |
+
merged_sources.append(new_source)
|
| 255 |
+
|
| 256 |
+
return {
|
| 257 |
+
"title": merged_title,
|
| 258 |
+
"authors": merged_authors or None,
|
| 259 |
+
"abstract": merged_abstract,
|
| 260 |
+
"year": merged_year,
|
| 261 |
+
"source": merged_sources,
|
| 262 |
+
"citation_count": merged_citation_count,
|
| 263 |
+
"url": merged_url,
|
| 264 |
+
"pdf_url": merged_pdf_url,
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
def aggregator_node(state: ResearchSearchState) -> ResearchSearchState:
|
| 268 |
+
logger = get_node_logger(state["run_id"], "search.aggregate")
|
| 269 |
+
arxiv_papers = state.get("arXiv_paper", []) or []
|
| 270 |
+
openalex_papers = state.get("Open_Alex_paper", []) or []
|
| 271 |
+
semantic_scholar_papers_list = state.get("Semantic_Scholar_paper", []) or []
|
| 272 |
+
all_papers = arxiv_papers + openalex_papers + semantic_scholar_papers_list
|
| 273 |
+
|
| 274 |
+
aggregated: dict[str, dict] = {}
|
| 275 |
+
|
| 276 |
+
for paper in all_papers:
|
| 277 |
+
key = normalize_title(paper.get("title"))
|
| 278 |
+
if not key:
|
| 279 |
+
continue
|
| 280 |
+
|
| 281 |
+
if key in aggregated:
|
| 282 |
+
aggregated[key] = merge_paper_entry(aggregated[key], paper)
|
| 283 |
+
else:
|
| 284 |
+
# first time seeing this title — wrap single-source fields
|
| 285 |
+
# into the same dict-keyed-by-source shape used by merges,
|
| 286 |
+
# so every entry has a UNIFORM shape whether it came from
|
| 287 |
+
# 1 source or all 3
|
| 288 |
+
source = paper.get("source")
|
| 289 |
+
aggregated[key] = {
|
| 290 |
+
"title": paper.get("title"),
|
| 291 |
+
"authors": paper.get("authors"),
|
| 292 |
+
"abstract": paper.get("abstract"),
|
| 293 |
+
"year": paper.get("year"),
|
| 294 |
+
"source": [source],
|
| 295 |
+
"citation_count": {source: paper.get("citation_count")},
|
| 296 |
+
"url": {source: paper.get("url")},
|
| 297 |
+
"pdf_url": {source: paper.get("pdf_url")},
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
logger.info(
|
| 301 |
+
"Aggregated %d raw records (arXiv=%d, OpenAlex=%d, SemanticScholar=%d) into %d unique papers after dedup",
|
| 302 |
+
len(all_papers), len(arxiv_papers), len(openalex_papers), len(semantic_scholar_papers_list), len(aggregated),
|
| 303 |
+
)
|
| 304 |
+
return {"aggregator": aggregated}
|
| 305 |
+
|
| 306 |
+
def reranker_node(state: ResearchSearchState) -> ResearchSearchState:
|
| 307 |
+
"""
|
| 308 |
+
Reranks the aggregated papers by semantic similarity to the Research Intent,
|
| 309 |
+
keeps the top 15, and returns the FULL records (not just title:abstract)
|
| 310 |
+
for those top 15 — in the same order the reranker ranked them.
|
| 311 |
+
"""
|
| 312 |
+
logger = get_node_logger(state["run_id"], "search.rerank")
|
| 313 |
+
aggregator = state["aggregator"]
|
| 314 |
+
|
| 315 |
+
# --- Build the {normalized_title: abstract} view the reranker expects ---
|
| 316 |
+
papers_for_rerank: dict[str, str] = {
|
| 317 |
+
normalized_title: record.get("abstract")
|
| 318 |
+
for normalized_title, record in aggregator.items()
|
| 319 |
+
}
|
| 320 |
+
logger.info("Reranking %d aggregated papers by semantic similarity to the intent", len(papers_for_rerank))
|
| 321 |
+
# --- Run the rerank ---
|
| 322 |
+
top_papers = rerank_by_relevance(
|
| 323 |
+
research_intent=state["ResearchIntent"],
|
| 324 |
+
papers=papers_for_rerank,
|
| 325 |
+
top_n=15,
|
| 326 |
+
logger=logger,
|
| 327 |
+
)
|
| 328 |
+
|
| 329 |
+
# --- Rebuild full records for only the titles that survived reranking,
|
| 330 |
+
# preserving the ranked order (top_papers is an ordered dict) ---
|
| 331 |
+
reranked_full: dict[str, dict] = {}
|
| 332 |
+
for normalized_title in top_papers:
|
| 333 |
+
record = aggregator.get(normalized_title)
|
| 334 |
+
if record is not None:
|
| 335 |
+
reranked_full[normalized_title] = record
|
| 336 |
+
|
| 337 |
+
dropped_count = len(aggregator) - len(reranked_full)
|
| 338 |
+
logger.info(
|
| 339 |
+
"Kept top %d of %d papers after semantic reranking (%d dropped)",
|
| 340 |
+
len(reranked_full), len(aggregator), dropped_count,
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
return {"reranked_papers": reranked_full}
|
| 344 |
+
|
| 345 |
+
def build_clustered_output(
|
| 346 |
+
clustering_result: ClusteringOutput,
|
| 347 |
+
id_to_title: dict[int, str],
|
| 348 |
+
aggregator: dict,
|
| 349 |
+
) -> list[dict]:
|
| 350 |
+
"""
|
| 351 |
+
Converts {cluster_id, label, rationale, paper_ids} clusters into:
|
| 352 |
+
[{"label": ..., "rationale": ..., "papers": {normalized_title: full_aggregator_record, ...}}, ...]
|
| 353 |
+
"""
|
| 354 |
+
output = []
|
| 355 |
+
|
| 356 |
+
for cluster in clustering_result.clusters:
|
| 357 |
+
papers_in_cluster = {}
|
| 358 |
+
|
| 359 |
+
for pid in cluster.paper_ids:
|
| 360 |
+
normalized_title = id_to_title.get(pid)
|
| 361 |
+
if normalized_title is None:
|
| 362 |
+
continue # LLM hallucinated an ID that was never sent — skip, don't crash
|
| 363 |
+
|
| 364 |
+
record = aggregator.get(normalized_title)
|
| 365 |
+
if record is None:
|
| 366 |
+
continue # defensive: shouldn't happen if id_to_title is built correctly
|
| 367 |
+
|
| 368 |
+
papers_in_cluster[normalized_title] = record
|
| 369 |
+
|
| 370 |
+
output.append({
|
| 371 |
+
"label": cluster.label,
|
| 372 |
+
"rationale": cluster.rationale,
|
| 373 |
+
"papers": papers_in_cluster,
|
| 374 |
+
})
|
| 375 |
+
|
| 376 |
+
return output
|
| 377 |
+
|
| 378 |
+
def prepare_papers_for_clustering(aggregator: dict) -> tuple[list[dict], dict[int, str]]:
|
| 379 |
+
"""
|
| 380 |
+
Convert the aggregator dict into the (id, title, abstract) form needed for the
|
| 381 |
+
clustering prompt, plus an id -> normalized_title map to reverse-lookup results.
|
| 382 |
+
|
| 383 |
+
Papers with no abstract are excluded from clustering input (title alone is too
|
| 384 |
+
weak a signal for approach-based grouping) but returned separately so they can
|
| 385 |
+
still be surfaced to the user as "not clustered" rather than silently dropped.
|
| 386 |
+
"""
|
| 387 |
+
clustering_input = []
|
| 388 |
+
id_to_title: dict[int, str] = {}
|
| 389 |
+
excluded_no_abstract: list[str] = []
|
| 390 |
+
|
| 391 |
+
paper_id = 0
|
| 392 |
+
for normalized_title, record in aggregator.items():
|
| 393 |
+
abstract = record.get("abstract")
|
| 394 |
+
|
| 395 |
+
if not abstract or not abstract.strip():
|
| 396 |
+
excluded_no_abstract.append(normalized_title)
|
| 397 |
+
continue
|
| 398 |
+
|
| 399 |
+
clustering_input.append({
|
| 400 |
+
"id": paper_id,
|
| 401 |
+
"title": record.get("title") or normalized_title,
|
| 402 |
+
"abstract": abstract,
|
| 403 |
+
})
|
| 404 |
+
id_to_title[paper_id] = normalized_title
|
| 405 |
+
paper_id += 1
|
| 406 |
+
|
| 407 |
+
return clustering_input, id_to_title
|
| 408 |
+
|
| 409 |
+
def clustering_node(state: ResearchSearchState) -> ResearchSearchState:
|
| 410 |
+
logger = get_node_logger(state["run_id"], "search.cluster")
|
| 411 |
+
clustering_input , id_to_title = prepare_papers_for_clustering(state['reranked_papers'])
|
| 412 |
+
logger.info("Clustering %d paper(s) with usable abstracts into approach groups", len(clustering_input))
|
| 413 |
+
|
| 414 |
+
prompt = f"""You are helping a researcher make sense of a set of retrieved papers by organizing
|
| 415 |
+
them into methodologically distinct APPROACH CLUSTERS.
|
| 416 |
+
|
| 417 |
+
You will receive:
|
| 418 |
+
1. A RESEARCH INTENT (Problem / Objective / Additional Context) — this defines what
|
| 419 |
+
the researcher is actually trying to figure out.
|
| 420 |
+
2. A numbered list of papers, each with a title and abstract.
|
| 421 |
+
|
| 422 |
+
YOUR GOAL:
|
| 423 |
+
All these papers were retrieved because they already share a common TOPIC with the
|
| 424 |
+
Research Intent — that overlap is not useful information on its own. Your job is to
|
| 425 |
+
find the more useful signal underneath: the DIFFERENT APPROACHES, METHODS, or
|
| 426 |
+
STRATEGIES these papers take toward addressing that shared topic. Think of each
|
| 427 |
+
cluster as answering the question: "If the researcher wanted to pursue THIS
|
| 428 |
+
direction, which papers would be their starting point?"
|
| 429 |
+
|
| 430 |
+
USE THE RESEARCH INTENT TO GUIDE WHAT COUNTS AS A MEANINGFUL DISTINCTION:
|
| 431 |
+
- The Objective tells you what decision or comparison the researcher actually cares
|
| 432 |
+
about — let it sharpen which methodological differences matter versus which are
|
| 433 |
+
noise. If the Objective is about comparing two paradigms, treat "which paradigm
|
| 434 |
+
does this paper represent" as a primary clustering axis, not an incidental detail.
|
| 435 |
+
- The Additional Context may reveal known approaches the researcher is already aware
|
| 436 |
+
of, or constraints (like avoiding full retraining) — clusters that map onto these
|
| 437 |
+
should be called out explicitly, since they connect directly to something the
|
| 438 |
+
researcher already flagged as relevant.
|
| 439 |
+
|
| 440 |
+
FOR EACH CLUSTER, PRODUCE:
|
| 441 |
+
- cluster_id: a sequential integer starting at 0, unique per cluster.
|
| 442 |
+
- label: a short, specific name for the actual approach (not a vague topic word) —
|
| 443 |
+
e.g. "Reinforcement-learning-based car-following control policies," not "RL papers."
|
| 444 |
+
- rationale: 2-3 sentences explaining WHAT unifies these papers methodologically,
|
| 445 |
+
and WHY this represents a distinct direction the researcher could pursue or
|
| 446 |
+
compare against, relative to the other clusters.
|
| 447 |
+
- paper_ids: the list of paper IDs belonging to this cluster.
|
| 448 |
+
|
| 449 |
+
SIZING THE CLUSTERS:
|
| 450 |
+
- Aim for 4-7 clusters when the paper count comfortably supports that many distinct
|
| 451 |
+
methodological groupings. This is a target, not a quota — with a small paper set,
|
| 452 |
+
fewer, larger clusters are correct; do not manufacture extra clusters just to hit
|
| 453 |
+
the range, and do not force a paper into a cluster it doesn't methodologically fit.
|
| 454 |
+
- If two clusters would end up nearly identical in meaning, merge them instead of
|
| 455 |
+
keeping both.
|
| 456 |
+
- If some papers do not fit coherently into any clear methodological group, place
|
| 457 |
+
them together in one final cluster labeled "Other / Mixed Approaches" rather than
|
| 458 |
+
forcing a false fit elsewhere. One honest catch-all beats several padded clusters.
|
| 459 |
+
|
| 460 |
+
HARD CONSTRAINTS:
|
| 461 |
+
- Every paper ID in the input must appear in exactly one cluster, including the
|
| 462 |
+
catch-all if used. None may be omitted or duplicated across clusters.
|
| 463 |
+
- Do not invent methods, results, or claims not supported by the given title/abstract
|
| 464 |
+
text — ground every rationale only in what's actually stated.
|
| 465 |
+
|
| 466 |
+
RESEARCH INTENT:
|
| 467 |
+
{state["ResearchIntent"]}
|
| 468 |
+
|
| 469 |
+
PAPERS:
|
| 470 |
+
{clustering_input}
|
| 471 |
+
"""
|
| 472 |
+
|
| 473 |
+
structured_llm = llm_2.with_structured_output(ClusteringOutput)
|
| 474 |
+
|
| 475 |
+
try:
|
| 476 |
+
clustering_result = structured_llm.invoke(prompt)
|
| 477 |
+
except Exception as e:
|
| 478 |
+
logger.exception("Structured LLM invocation failed during clustering: %s: %s", type(e).__name__, e)
|
| 479 |
+
return {"clustered_papers": []}
|
| 480 |
+
|
| 481 |
+
clustered = build_clustered_output(clustering_result, id_to_title, state["aggregator"])
|
| 482 |
+
total_clustered_papers = sum(len(c["papers"]) for c in clustered)
|
| 483 |
+
logger.info("Clustering produced %d cluster(s) covering %d paper(s)", len(clustered), total_clustered_papers)
|
| 484 |
+
|
| 485 |
+
return {"clustered_papers": clustered}
|
| 486 |
+
|
| 487 |
+
# Building The Graph
|
| 488 |
+
builder = StateGraph(ResearchSearchState)
|
| 489 |
+
|
| 490 |
+
builder.add_node('arxiv',arXiv_papers)
|
| 491 |
+
builder.add_node('semantic_scholar',semantic_scholar_papers)
|
| 492 |
+
builder.add_node('open_alex',Open_alex_papers)
|
| 493 |
+
builder.add_node('aggregation',aggregator_node)
|
| 494 |
+
builder.add_node('reranker', reranker_node)
|
| 495 |
+
builder.add_node('clustering',clustering_node)
|
| 496 |
+
|
| 497 |
+
builder.add_edge(START,'arxiv')
|
| 498 |
+
builder.add_edge(START,'semantic_scholar')
|
| 499 |
+
builder.add_edge(START,'open_alex')
|
| 500 |
+
builder.add_edge('arxiv','aggregation')
|
| 501 |
+
builder.add_edge('semantic_scholar','aggregation')
|
| 502 |
+
builder.add_edge('open_alex','aggregation')
|
| 503 |
+
builder.add_edge('aggregation','reranker')
|
| 504 |
+
builder.add_edge('reranker','clustering')
|
| 505 |
+
builder.add_edge('clustering',END)
|
| 506 |
+
|
| 507 |
+
graph = builder.compile()
|
| 508 |
+
|
| 509 |
+
if __name__ == "__main__":
|
| 510 |
+
# Manual standalone test — only runs when you execute this file directly
|
| 511 |
+
# (`python -m app.modules.search.graph`), never on import.
|
| 512 |
+
input_research_intent_5 = """Problem:
|
| 513 |
+
Large language models frequently produce fluent, confident-sounding answers that contain factual errors, and existing hallucination-detection methods disagree substantially with each other when applied to the same model outputs, making it unclear which detection approach should be trusted for deployment decisions.
|
| 514 |
+
|
| 515 |
+
Objective:
|
| 516 |
+
Identify hallucination-detection methods for large language models that show consistent, reliable performance across different model families and task types, rather than being effective only in the narrow setting they were originally evaluated on.
|
| 517 |
+
|
| 518 |
+
Additional Context:
|
| 519 |
+
- Focus on post-hoc detection methods applied to generated text, not training-time interventions like RLHF.
|
| 520 |
+
- Known approaches the researcher is aware of: self-consistency sampling, retrieval-based fact verification, and uncertainty/entropy-based methods.
|
| 521 |
+
- Interested primarily in open-domain question answering and summarization tasks, not code generation."""
|
| 522 |
+
|
| 523 |
+
result = graph.invoke({"ResearchIntent": input_research_intent_5, "run_id": "standalone-test"})
|
| 524 |
+
print(result["clustered_papers"])
|
app/modules/search/jobs.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
In-memory job tracking for the search graph.
|
| 3 |
+
|
| 4 |
+
The search graph (3 external APIs + a local SPECTER rerank pass + a
|
| 5 |
+
structured-output clustering call) can take tens of seconds, so it runs as a
|
| 6 |
+
FastAPI BackgroundTask rather than inline in a request. Progress is tracked
|
| 7 |
+
in this plain dict, which the client polls via GET /search/status/{job_id}.
|
| 8 |
+
|
| 9 |
+
Import note: `search_graph` is imported at module level (not inside the
|
| 10 |
+
function) so that importing this module — which happens once, at FastAPI
|
| 11 |
+
startup — is what triggers loading the SPECTER embedding model in
|
| 12 |
+
app/modules/search/reranking.py. That way the multi-second model load
|
| 13 |
+
happens once at boot, not on the first user's search request.
|
| 14 |
+
|
| 15 |
+
Limitation to be aware of: this dict lives in a single Python process's
|
| 16 |
+
memory. It resets on server restart and is NOT shared across multiple
|
| 17 |
+
uvicorn workers (`--workers N`). Fine for a single dev/demo server; swap for
|
| 18 |
+
Redis or a DB-backed job table if this ever needs to run with more than one
|
| 19 |
+
worker process.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from typing import Optional
|
| 23 |
+
|
| 24 |
+
from app.core.logging_config import get_node_logger
|
| 25 |
+
from app.modules.search.graph import graph as search_graph
|
| 26 |
+
|
| 27 |
+
JOBS: dict[str, dict] = {}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def create_job(job_id: str, run_id: str) -> None:
|
| 31 |
+
JOBS[job_id] = {"job_id": job_id, "status": "running", "clustered_papers": None, "error": None}
|
| 32 |
+
get_node_logger(run_id, "job").info("Search job %s created", job_id)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def get_job(job_id: str) -> Optional[dict]:
|
| 36 |
+
return JOBS.get(job_id)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def run_search_job(job_id: str, research_intent: str, run_id: str) -> None:
|
| 40 |
+
"""Runs the search graph synchronously. Called via BackgroundTasks,
|
| 41 |
+
which executes sync callables in a threadpool — so this does not block
|
| 42 |
+
the event loop for other requests while it runs."""
|
| 43 |
+
logger = get_node_logger(run_id, "job")
|
| 44 |
+
logger.info("Search job %s started", job_id)
|
| 45 |
+
try:
|
| 46 |
+
result = search_graph.invoke({"ResearchIntent": research_intent, "run_id": run_id})
|
| 47 |
+
clustered_papers = result["clustered_papers"]
|
| 48 |
+
JOBS[job_id]["status"] = "done"
|
| 49 |
+
JOBS[job_id]["clustered_papers"] = clustered_papers
|
| 50 |
+
logger.info("Search job %s completed: %d cluster(s) produced", job_id, len(clustered_papers or []))
|
| 51 |
+
except Exception as e:
|
| 52 |
+
JOBS[job_id]["status"] = "error"
|
| 53 |
+
JOBS[job_id]["error"] = f"{type(e).__name__}: {e}"
|
| 54 |
+
logger.exception("Search job %s failed", job_id)
|
app/modules/search/providers/__init__.py
ADDED
|
File without changes
|
app/modules/search/providers/arxiv.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import xml.etree.ElementTree as ET
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
|
| 5 |
+
import requests
|
| 6 |
+
|
| 7 |
+
from app.modules.search.providers.models import ResearchPaper
|
| 8 |
+
|
| 9 |
+
_fallback_logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def search_arxiv(query: str, max_results: int = 10, logger: Optional[logging.Logger] = None) -> List[dict]:
|
| 13 |
+
"""
|
| 14 |
+
Search papers from arXiv. Returns a list of validated paper dicts.
|
| 15 |
+
"""
|
| 16 |
+
log = logger or _fallback_logger
|
| 17 |
+
log.info("Searching arXiv for query: %s", query)
|
| 18 |
+
query = query.replace(" ", "+")
|
| 19 |
+
|
| 20 |
+
url = (
|
| 21 |
+
f"https://export.arxiv.org/api/query?"
|
| 22 |
+
f"search_query={query}"
|
| 23 |
+
f"&start=0"
|
| 24 |
+
f"&max_results={max_results}"
|
| 25 |
+
f"&sortBy=relevance"
|
| 26 |
+
f"&sortOrder=descending"
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
try:
|
| 30 |
+
response = requests.get(url, timeout=15)
|
| 31 |
+
except requests.RequestException as e:
|
| 32 |
+
log.warning("arXiv request failed: %s: %s", type(e).__name__, e)
|
| 33 |
+
return []
|
| 34 |
+
|
| 35 |
+
if response.status_code != 200:
|
| 36 |
+
log.warning("arXiv returned bad status: %s", response.status_code)
|
| 37 |
+
return []
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
root = ET.fromstring(response.content)
|
| 41 |
+
except ET.ParseError as e:
|
| 42 |
+
log.warning("arXiv response failed to parse as XML: %s", e)
|
| 43 |
+
return []
|
| 44 |
+
|
| 45 |
+
namespace = {"atom": "http://www.w3.org/2005/Atom"}
|
| 46 |
+
entries = root.findall("atom:entry", namespace)
|
| 47 |
+
|
| 48 |
+
papers = []
|
| 49 |
+
|
| 50 |
+
for entry in entries:
|
| 51 |
+
title_el = entry.find("atom:title", namespace)
|
| 52 |
+
if title_el is None or not title_el.text or not title_el.text.strip():
|
| 53 |
+
continue
|
| 54 |
+
title = title_el.text.strip()
|
| 55 |
+
|
| 56 |
+
summary_el = entry.find("atom:summary", namespace)
|
| 57 |
+
abstract = summary_el.text.strip() if summary_el is not None and summary_el.text else None
|
| 58 |
+
|
| 59 |
+
authors = []
|
| 60 |
+
for author_el in entry.findall("atom:author", namespace):
|
| 61 |
+
name_el = author_el.find("atom:name", namespace)
|
| 62 |
+
if name_el is not None and name_el.text:
|
| 63 |
+
authors.append(name_el.text.strip())
|
| 64 |
+
|
| 65 |
+
published_el = entry.find("atom:published", namespace)
|
| 66 |
+
year = None
|
| 67 |
+
if published_el is not None and published_el.text and len(published_el.text) >= 4:
|
| 68 |
+
try:
|
| 69 |
+
year = int(published_el.text[:4])
|
| 70 |
+
except ValueError:
|
| 71 |
+
year = None
|
| 72 |
+
|
| 73 |
+
pdf_link = None
|
| 74 |
+
for link in entry.findall("atom:link", namespace):
|
| 75 |
+
if link.attrib.get("title") == "pdf":
|
| 76 |
+
pdf_link = link.attrib.get("href")
|
| 77 |
+
break
|
| 78 |
+
|
| 79 |
+
id_el = entry.find("atom:id", namespace)
|
| 80 |
+
url_val = id_el.text.strip() if id_el is not None and id_el.text else None
|
| 81 |
+
|
| 82 |
+
try:
|
| 83 |
+
paper = ResearchPaper(
|
| 84 |
+
title=title,
|
| 85 |
+
authors=authors if authors else None,
|
| 86 |
+
abstract=abstract,
|
| 87 |
+
year=year,
|
| 88 |
+
citation_count=None, # arXiv never provides this
|
| 89 |
+
url=url_val,
|
| 90 |
+
pdf_url=pdf_link,
|
| 91 |
+
source="arXiv",
|
| 92 |
+
)
|
| 93 |
+
papers.append(paper.model_dump())
|
| 94 |
+
except Exception as e:
|
| 95 |
+
log.warning("Skipped malformed arXiv entry: %s", e)
|
| 96 |
+
continue # skip malformed entries rather than crash the whole batch
|
| 97 |
+
|
| 98 |
+
log.info("arXiv: %d paper(s) retrieved", len(papers))
|
| 99 |
+
return papers
|
app/modules/search/providers/models.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Literal, Optional
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class ResearchPaper(BaseModel):
|
| 7 |
+
title: str = Field(description="The title of the paper")
|
| 8 |
+
authors: Optional[List[str]] = Field(description="The list of the authors of the paper mentioned.", default=None)
|
| 9 |
+
abstract: Optional[str] = Field(description="The abstract or the summary of the mentioned research paper.", default=None)
|
| 10 |
+
year: Optional[int] = Field(description="The year in which the paper was published, leave if not mentioned", default=None)
|
| 11 |
+
citation_count: Optional[int] = Field(description="The citation count of the paper, leave if not mentioned", default=None)
|
| 12 |
+
url: Optional[str] = Field(description="The url of the research given on the source", default=None)
|
| 13 |
+
pdf_url: Optional[str] = Field(description="The pdf url of the research paper if mentioned", default=None)
|
| 14 |
+
source: Literal["arXiv", "SemanticScholar", "OpenAlex"] = Field(
|
| 15 |
+
description="The source from which it was extracted 'arXiv' , 'SemanticScholar' or 'OpenAlex'"
|
| 16 |
+
)
|
app/modules/search/providers/openalex.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import os
|
| 3 |
+
import re
|
| 4 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 5 |
+
from typing import List, Optional
|
| 6 |
+
|
| 7 |
+
import requests
|
| 8 |
+
|
| 9 |
+
from app.modules.search.providers.models import ResearchPaper
|
| 10 |
+
|
| 11 |
+
_fallback_logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
CONTACT_EMAIL = os.getenv("OPENALEX_MAILTO") or os.getenv("UNPAYWALL_EMAIL")
|
| 14 |
+
OPENALEX_API_KEY = os.getenv("OPENALEX_API_KEY")
|
| 15 |
+
OPENALEX_CONTENT_API_KEY = os.getenv("OPENALEX_CONTENT_API_KEY") or OPENALEX_API_KEY
|
| 16 |
+
|
| 17 |
+
_REQUEST_HEADERS = {
|
| 18 |
+
"User-Agent": "Mozilla/5.0 (compatible; research-pipeline/1.0; +mailto:contact@example.com)"
|
| 19 |
+
}
|
| 20 |
+
_PDF_MAGIC = b"%PDF"
|
| 21 |
+
_CITATION_PDF_URL_RE = re.compile(
|
| 22 |
+
r'<meta[^>]+name=["\']citation_pdf_url["\'][^>]+content=["\']([^"\']+)["\']'
|
| 23 |
+
r'|<meta[^>]+content=["\']([^"\']+)["\'][^>]+name=["\']citation_pdf_url["\']',
|
| 24 |
+
re.IGNORECASE,
|
| 25 |
+
)
|
| 26 |
+
_ARXIV_ID_RE = re.compile(r'arxiv\.org/(?:abs|pdf)/([^\s/?#]+)', re.IGNORECASE)
|
| 27 |
+
_ACL_ID_RE = re.compile(r'aclanthology\.org/([^\s/?#"\']+)', re.IGNORECASE)
|
| 28 |
+
_PMC_ID_RE = re.compile(r'(PMC\d+)', re.IGNORECASE)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def decode_abstract(inverted_index):
|
| 32 |
+
"""
|
| 33 |
+
Convert OpenAlex's abstract_inverted_index into a readable abstract.
|
| 34 |
+
Returns None if the index is missing or empty.
|
| 35 |
+
"""
|
| 36 |
+
if not inverted_index: # handles both None and empty dict {}
|
| 37 |
+
return None
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
max_position = max(
|
| 41 |
+
pos for positions in inverted_index.values() for pos in positions
|
| 42 |
+
)
|
| 43 |
+
except ValueError:
|
| 44 |
+
# inverted_index had keys but all position lists were empty
|
| 45 |
+
return None
|
| 46 |
+
|
| 47 |
+
words = [""] * (max_position + 1)
|
| 48 |
+
for word, positions in inverted_index.items():
|
| 49 |
+
for position in positions:
|
| 50 |
+
words[position] = word
|
| 51 |
+
|
| 52 |
+
result = " ".join(words).strip()
|
| 53 |
+
return result if result else None
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# --------------------------------------------------------------------------
|
| 57 |
+
# PDF resolution / verification helpers
|
| 58 |
+
#
|
| 59 |
+
# OpenAlex's primary_location.pdf_url is often null even when a free copy
|
| 60 |
+
# exists elsewhere (it's the version closest to the record, frequently the
|
| 61 |
+
# paywalled publisher copy). Even best_oa_location.pdf_url can legitimately
|
| 62 |
+
# be null while a landing page is known. So we walk every location OpenAlex
|
| 63 |
+
# knows about, try deterministic direct-PDF reconstruction for arXiv / ACL
|
| 64 |
+
# Anthology / PubMedCentral repos, fall back to scraping the citation_pdf_url
|
| 65 |
+
# meta tag, then DOI -> Unpaywall, and VERIFY every candidate by checking the
|
| 66 |
+
# real response body for the "%PDF" magic number rather than trusting a
|
| 67 |
+
# Content-Type header or a field name.
|
| 68 |
+
# --------------------------------------------------------------------------
|
| 69 |
+
|
| 70 |
+
def _is_verified_pdf(url: str, timeout: int = 8) -> bool:
|
| 71 |
+
if not url:
|
| 72 |
+
return False
|
| 73 |
+
try:
|
| 74 |
+
with requests.get(
|
| 75 |
+
url, headers=_REQUEST_HEADERS, stream=True, timeout=timeout, allow_redirects=True
|
| 76 |
+
) as resp:
|
| 77 |
+
if resp.status_code != 200:
|
| 78 |
+
return False
|
| 79 |
+
chunk = next(resp.iter_content(chunk_size=16), b"")
|
| 80 |
+
return chunk.startswith(_PDF_MAGIC)
|
| 81 |
+
except requests.RequestException:
|
| 82 |
+
return False
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _extract_citation_pdf_url(landing_page_url: str, timeout: int = 8) -> Optional[str]:
|
| 86 |
+
"""Scrape the citation_pdf_url meta tag off an HTML landing page."""
|
| 87 |
+
try:
|
| 88 |
+
resp = requests.get(landing_page_url, headers=_REQUEST_HEADERS, timeout=timeout)
|
| 89 |
+
if resp.status_code != 200:
|
| 90 |
+
return None
|
| 91 |
+
match = _CITATION_PDF_URL_RE.search(resp.text[:20000])
|
| 92 |
+
if not match:
|
| 93 |
+
return None
|
| 94 |
+
return match.group(1) or match.group(2)
|
| 95 |
+
except requests.RequestException:
|
| 96 |
+
return None
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _resolve_pmc_pdf(pmc_id: str, timeout: int = 8) -> Optional[str]:
|
| 100 |
+
"""Try Europe PMC's render endpoint for a PubMedCentral ID."""
|
| 101 |
+
clean_id = pmc_id if pmc_id.upper().startswith("PMC") else f"PMC{pmc_id}"
|
| 102 |
+
candidate = f"https://europepmc.org/articles/{clean_id}?pdf=render"
|
| 103 |
+
return candidate if _is_verified_pdf(candidate, timeout=timeout) else None
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _resolve_unpaywall_pdf(doi: str, email: str, timeout: int = 8) -> Optional[str]:
|
| 107 |
+
"""Last-resort fallback: ask Unpaywall for the best OA location's direct PDF."""
|
| 108 |
+
try:
|
| 109 |
+
resp = requests.get(
|
| 110 |
+
f"https://api.unpaywall.org/v2/{doi}", params={"email": email}, timeout=timeout
|
| 111 |
+
)
|
| 112 |
+
if resp.status_code != 200:
|
| 113 |
+
return None
|
| 114 |
+
best_location = (resp.json() or {}).get("best_oa_location") or {}
|
| 115 |
+
return best_location.get("url_for_pdf")
|
| 116 |
+
except (requests.RequestException, ValueError):
|
| 117 |
+
return None
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _scan_locations_for_repo_ids(locations: List[dict]) -> dict:
|
| 121 |
+
"""
|
| 122 |
+
Walk every location OpenAlex knows about for this work and pull out
|
| 123 |
+
arXiv / ACL Anthology / PubMedCentral identifiers wherever they show up,
|
| 124 |
+
so a deterministic direct-PDF link can be reconstructed even when
|
| 125 |
+
best_oa_location isn't the repo that actually has one.
|
| 126 |
+
"""
|
| 127 |
+
found = {}
|
| 128 |
+
for loc in locations:
|
| 129 |
+
landing = (loc or {}).get("landing_page_url") or ""
|
| 130 |
+
pdf = (loc or {}).get("pdf_url") or ""
|
| 131 |
+
haystack = f"{landing} {pdf}"
|
| 132 |
+
|
| 133 |
+
if "arxiv" not in found:
|
| 134 |
+
m = _ARXIV_ID_RE.search(haystack)
|
| 135 |
+
if m:
|
| 136 |
+
found["arxiv"] = m.group(1)
|
| 137 |
+
|
| 138 |
+
if "acl" not in found:
|
| 139 |
+
m = _ACL_ID_RE.search(haystack)
|
| 140 |
+
if m:
|
| 141 |
+
acl_id = m.group(1)
|
| 142 |
+
if acl_id.lower().endswith(".pdf"):
|
| 143 |
+
acl_id = acl_id[:-4]
|
| 144 |
+
found["acl"] = acl_id
|
| 145 |
+
|
| 146 |
+
if "pmc" not in found:
|
| 147 |
+
m = _PMC_ID_RE.search(haystack)
|
| 148 |
+
if m:
|
| 149 |
+
found["pmc"] = m.group(1)
|
| 150 |
+
|
| 151 |
+
return found
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def resolve_openalex_pdf(
|
| 155 |
+
paper: dict,
|
| 156 |
+
unpaywall_email: Optional[str] = None,
|
| 157 |
+
use_paid_content_api: bool = False,
|
| 158 |
+
openalex_content_api_key: Optional[str] = None,
|
| 159 |
+
log: Optional[logging.Logger] = None,
|
| 160 |
+
) -> Optional[str]:
|
| 161 |
+
"""Runs the full escalation ladder and returns a VERIFIED direct PDF url, or None."""
|
| 162 |
+
log = log or _fallback_logger
|
| 163 |
+
primary = paper.get("primary_location") or {}
|
| 164 |
+
best_oa = paper.get("best_oa_location") or {}
|
| 165 |
+
locations = paper.get("locations") or []
|
| 166 |
+
all_locations = [primary, best_oa] + locations
|
| 167 |
+
|
| 168 |
+
# 1. Any pdf_url OpenAlex already gave us directly, wherever it's hiding.
|
| 169 |
+
seen = set()
|
| 170 |
+
direct_candidates = []
|
| 171 |
+
for loc in all_locations:
|
| 172 |
+
u = (loc or {}).get("pdf_url")
|
| 173 |
+
if u and u not in seen:
|
| 174 |
+
seen.add(u)
|
| 175 |
+
direct_candidates.append(u)
|
| 176 |
+
|
| 177 |
+
for candidate in direct_candidates:
|
| 178 |
+
if _is_verified_pdf(candidate):
|
| 179 |
+
return candidate
|
| 180 |
+
|
| 181 |
+
# 2. Deterministic reconstruction from repo IDs found anywhere in locations.
|
| 182 |
+
repo_ids = _scan_locations_for_repo_ids(all_locations)
|
| 183 |
+
|
| 184 |
+
if repo_ids.get("arxiv"):
|
| 185 |
+
candidate = f"https://arxiv.org/pdf/{repo_ids['arxiv']}"
|
| 186 |
+
if _is_verified_pdf(candidate):
|
| 187 |
+
return candidate
|
| 188 |
+
|
| 189 |
+
if repo_ids.get("acl"):
|
| 190 |
+
candidate = f"https://aclanthology.org/{repo_ids['acl']}.pdf"
|
| 191 |
+
if _is_verified_pdf(candidate):
|
| 192 |
+
return candidate
|
| 193 |
+
|
| 194 |
+
if repo_ids.get("pmc"):
|
| 195 |
+
candidate = _resolve_pmc_pdf(repo_ids["pmc"])
|
| 196 |
+
if candidate:
|
| 197 |
+
return candidate
|
| 198 |
+
|
| 199 |
+
# 3. Scrape citation_pdf_url off whatever landing pages we have (capped
|
| 200 |
+
# at 3 to bound worst-case latency per paper).
|
| 201 |
+
seen = set()
|
| 202 |
+
landing_pages = []
|
| 203 |
+
for loc in [best_oa, primary] + locations:
|
| 204 |
+
lp = (loc or {}).get("landing_page_url")
|
| 205 |
+
if lp and lp not in seen:
|
| 206 |
+
seen.add(lp)
|
| 207 |
+
landing_pages.append(lp)
|
| 208 |
+
|
| 209 |
+
for page in landing_pages[:3]:
|
| 210 |
+
scraped = _extract_citation_pdf_url(page)
|
| 211 |
+
if scraped and _is_verified_pdf(scraped):
|
| 212 |
+
return scraped
|
| 213 |
+
|
| 214 |
+
# 4. DOI -> Unpaywall, only if we have a contact email to use.
|
| 215 |
+
doi = paper.get("doi")
|
| 216 |
+
if doi and unpaywall_email:
|
| 217 |
+
doi_clean = doi.replace("https://doi.org/", "").replace("http://doi.org/", "")
|
| 218 |
+
candidate = _resolve_unpaywall_pdf(doi_clean, unpaywall_email)
|
| 219 |
+
if candidate and _is_verified_pdf(candidate):
|
| 220 |
+
return candidate
|
| 221 |
+
|
| 222 |
+
# 5. Opt-in, PAID last resort: OpenAlex's own hosted content API.
|
| 223 |
+
has_content = paper.get("has_content") or {}
|
| 224 |
+
if use_paid_content_api and has_content.get("pdf") and openalex_content_api_key:
|
| 225 |
+
work_id = (paper.get("id") or "").rstrip("/").split("/")[-1]
|
| 226 |
+
if work_id:
|
| 227 |
+
candidate = (
|
| 228 |
+
f"https://content.openalex.org/works/{work_id}.pdf"
|
| 229 |
+
f"?api_key={openalex_content_api_key}"
|
| 230 |
+
)
|
| 231 |
+
if _is_verified_pdf(candidate):
|
| 232 |
+
return candidate
|
| 233 |
+
|
| 234 |
+
log.debug("No verified PDF resolved for OpenAlex work %s", paper.get("id"))
|
| 235 |
+
return None
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def resolve_openalex_pdf_fast(paper: dict) -> Optional[str]:
|
| 239 |
+
"""
|
| 240 |
+
HYBRID fast path: a cheap, ZERO-network best-effort PDF link.
|
| 241 |
+
|
| 242 |
+
Takes any direct pdf_url OpenAlex already handed us, or rebuilds a
|
| 243 |
+
deterministic repo URL (arXiv / ACL / PubMedCentral) from ids found in the
|
| 244 |
+
work's locations — WITHOUT downloading or verifying anything. The expensive
|
| 245 |
+
verify + landing-page scrape + Unpaywall ladder is deferred to 'Chat it out'
|
| 246 |
+
time (see resolve_openalex_pdf), so search stays fast.
|
| 247 |
+
"""
|
| 248 |
+
primary = paper.get("primary_location") or {}
|
| 249 |
+
best_oa = paper.get("best_oa_location") or {}
|
| 250 |
+
locations = paper.get("locations") or []
|
| 251 |
+
all_locations = [best_oa, primary] + locations
|
| 252 |
+
|
| 253 |
+
for loc in all_locations:
|
| 254 |
+
u = (loc or {}).get("pdf_url")
|
| 255 |
+
if u:
|
| 256 |
+
return u
|
| 257 |
+
|
| 258 |
+
repo_ids = _scan_locations_for_repo_ids(all_locations)
|
| 259 |
+
if repo_ids.get("arxiv"):
|
| 260 |
+
return f"https://arxiv.org/pdf/{repo_ids['arxiv']}"
|
| 261 |
+
if repo_ids.get("acl"):
|
| 262 |
+
return f"https://aclanthology.org/{repo_ids['acl']}.pdf"
|
| 263 |
+
if repo_ids.get("pmc"):
|
| 264 |
+
clean = repo_ids["pmc"] if str(repo_ids["pmc"]).upper().startswith("PMC") else f"PMC{repo_ids['pmc']}"
|
| 265 |
+
return f"https://europepmc.org/articles/{clean}?pdf=render"
|
| 266 |
+
return None
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def search_openalex(
|
| 270 |
+
query: str,
|
| 271 |
+
limit: int = 15,
|
| 272 |
+
sort="cited_by_count:desc",
|
| 273 |
+
logger: Optional[logging.Logger] = None,
|
| 274 |
+
max_candidates: int = 40,
|
| 275 |
+
use_paid_content_api: bool = False,
|
| 276 |
+
status_out: Optional[dict] = None,
|
| 277 |
+
fast: bool = True,
|
| 278 |
+
) -> List[dict]:
|
| 279 |
+
"""
|
| 280 |
+
Search research papers using OpenAlex. Returns a list of validated paper dicts.
|
| 281 |
+
sort: 'cited_by_count:desc' for most-cited, 'publication_date:desc' for latest,
|
| 282 |
+
or None for relevance (OpenAlex default).
|
| 283 |
+
|
| 284 |
+
Only papers for which a VERIFIED, direct PDF link could be resolved are
|
| 285 |
+
returned -- papers with no reachable PDF are filtered out entirely,
|
| 286 |
+
never returned with pdf_url=None.
|
| 287 |
+
|
| 288 |
+
max_candidates: how many raw results to pull from OpenAlex and PDF-check
|
| 289 |
+
before stopping at `limit` verified papers, since each candidate
|
| 290 |
+
can cost several extra network calls to resolve/verify.
|
| 291 |
+
use_paid_content_api: opt-in only -- lets the resolution ladder's last
|
| 292 |
+
step (OpenAlex's own hosted, metered content API) run as a final
|
| 293 |
+
fallback. Requires OPENALEX_CONTENT_API_KEY to be set. Off by
|
| 294 |
+
default since it costs a small amount per download.
|
| 295 |
+
"""
|
| 296 |
+
log = logger or _fallback_logger
|
| 297 |
+
url = "https://api.openalex.org/works"
|
| 298 |
+
params = {"search": query, "per_page": max_candidates}
|
| 299 |
+
if sort:
|
| 300 |
+
params["sort"] = sort
|
| 301 |
+
if CONTACT_EMAIL:
|
| 302 |
+
# OpenAlex's "polite pool" -- faster, more consistent response times.
|
| 303 |
+
params["mailto"] = CONTACT_EMAIL
|
| 304 |
+
|
| 305 |
+
log.info("Searching OpenAlex for: %r (sort=%s)", query, sort or "relevance")
|
| 306 |
+
|
| 307 |
+
try:
|
| 308 |
+
response = requests.get(url, params=params, timeout=15)
|
| 309 |
+
except requests.RequestException as e:
|
| 310 |
+
log.warning("OpenAlex request failed: %s: %s", type(e).__name__, e)
|
| 311 |
+
if status_out is not None:
|
| 312 |
+
status_out.update(state="error", detail=f"{type(e).__name__}: {e}")
|
| 313 |
+
return []
|
| 314 |
+
|
| 315 |
+
if response.status_code != 200:
|
| 316 |
+
log.warning("OpenAlex returned bad status: %s", response.status_code)
|
| 317 |
+
if status_out is not None:
|
| 318 |
+
state = "rate_limited" if response.status_code == 429 else "error"
|
| 319 |
+
status_out.update(state=state, http=response.status_code)
|
| 320 |
+
return []
|
| 321 |
+
|
| 322 |
+
try:
|
| 323 |
+
data = response.json()
|
| 324 |
+
except ValueError as e:
|
| 325 |
+
log.warning("OpenAlex response failed to parse as JSON: %s", e)
|
| 326 |
+
if status_out is not None:
|
| 327 |
+
status_out.update(state="error", detail="bad JSON")
|
| 328 |
+
return []
|
| 329 |
+
|
| 330 |
+
raw_papers = data.get("results", [])
|
| 331 |
+
|
| 332 |
+
# Build lightweight candidate metadata first (no network calls here).
|
| 333 |
+
candidates = []
|
| 334 |
+
for paper in raw_papers:
|
| 335 |
+
title = paper.get("display_name")
|
| 336 |
+
if not title or not title.strip():
|
| 337 |
+
continue
|
| 338 |
+
authors = []
|
| 339 |
+
for authorship in paper.get("authorships", []) or []:
|
| 340 |
+
author_obj = authorship.get("author") or {}
|
| 341 |
+
name = author_obj.get("display_name")
|
| 342 |
+
if name:
|
| 343 |
+
authors.append(name)
|
| 344 |
+
abstract = decode_abstract(paper.get("abstract_inverted_index"))
|
| 345 |
+
landing_url = (paper.get("primary_location") or {}).get("landing_page_url")
|
| 346 |
+
candidates.append((paper, title, authors, abstract, landing_url))
|
| 347 |
+
|
| 348 |
+
# Resolve the slow, network-bound PDF checks for all candidates CONCURRENTLY
|
| 349 |
+
# rather than one at a time. This is what turns an ~80s wait into a few
|
| 350 |
+
# seconds; the escalation ladder and the verified-PDF-only result are
|
| 351 |
+
# unchanged -- only the wall-clock changes.
|
| 352 |
+
if fast:
|
| 353 |
+
# HYBRID default: cheap zero-network best-effort links, keep ALL papers
|
| 354 |
+
# (deep verification is deferred to 'Chat it out'). No network here.
|
| 355 |
+
resolved_pdfs = [resolve_openalex_pdf_fast(paper) for (paper, *_rest) in candidates]
|
| 356 |
+
else:
|
| 357 |
+
# Deep path: full verify ladder in parallel, keep only verified-PDF papers.
|
| 358 |
+
def _resolve(item):
|
| 359 |
+
return resolve_openalex_pdf(
|
| 360 |
+
item[0],
|
| 361 |
+
unpaywall_email=CONTACT_EMAIL,
|
| 362 |
+
use_paid_content_api=use_paid_content_api,
|
| 363 |
+
openalex_content_api_key=OPENALEX_CONTENT_API_KEY,
|
| 364 |
+
log=log,
|
| 365 |
+
)
|
| 366 |
+
with ThreadPoolExecutor(max_workers=min(12, len(candidates))) as ex:
|
| 367 |
+
resolved_pdfs = list(ex.map(_resolve, candidates)) if candidates else []
|
| 368 |
+
|
| 369 |
+
# Assemble in original (citation-sorted) order, keeping the first `limit` papers.
|
| 370 |
+
papers = []
|
| 371 |
+
for (paper, title, authors, abstract, landing_url), pdf_url in zip(candidates, resolved_pdfs):
|
| 372 |
+
if len(papers) >= limit:
|
| 373 |
+
break
|
| 374 |
+
if not fast and not pdf_url:
|
| 375 |
+
# Deep mode only: drop papers with no verifiable PDF.
|
| 376 |
+
continue
|
| 377 |
+
try:
|
| 378 |
+
paper_obj = ResearchPaper(
|
| 379 |
+
title=title.strip(),
|
| 380 |
+
authors=authors if authors else None,
|
| 381 |
+
abstract=abstract,
|
| 382 |
+
year=paper.get("publication_year"),
|
| 383 |
+
citation_count=paper.get("cited_by_count"),
|
| 384 |
+
url=landing_url,
|
| 385 |
+
pdf_url=pdf_url,
|
| 386 |
+
source="OpenAlex",
|
| 387 |
+
)
|
| 388 |
+
papers.append(paper_obj.model_dump())
|
| 389 |
+
except Exception as e:
|
| 390 |
+
log.warning("Skipped malformed OpenAlex paper: %s", e)
|
| 391 |
+
continue
|
| 392 |
+
|
| 393 |
+
if status_out is not None:
|
| 394 |
+
status_out.update(state="ok", count=len(papers))
|
| 395 |
+
log.info("OpenAlex: %d paper(s) retrieved (%s) out of %d candidates",
|
| 396 |
+
len(papers), "fast/best-effort PDF" if fast else "verified PDF", len(raw_papers))
|
| 397 |
+
return papers
|
app/modules/search/providers/semantic_scholar.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import os
|
| 3 |
+
import re
|
| 4 |
+
import time
|
| 5 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 6 |
+
from typing import List, Optional
|
| 7 |
+
|
| 8 |
+
import requests
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
|
| 11 |
+
from app.modules.search.providers.models import ResearchPaper
|
| 12 |
+
|
| 13 |
+
load_dotenv()
|
| 14 |
+
|
| 15 |
+
_fallback_logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
_REQUEST_HEADERS = {
|
| 18 |
+
# Some publisher/repo servers block requests with no browser-like UA.
|
| 19 |
+
"User-Agent": "Mozilla/5.0 (compatible; research-pipeline/1.0; +mailto:contact@example.com)"
|
| 20 |
+
}
|
| 21 |
+
_PDF_MAGIC = b"%PDF"
|
| 22 |
+
_CITATION_PDF_URL_RE = re.compile(
|
| 23 |
+
r'<meta[^>]+name=["\']citation_pdf_url["\'][^>]+content=["\']([^"\']+)["\']'
|
| 24 |
+
r'|<meta[^>]+content=["\']([^"\']+)["\'][^>]+name=["\']citation_pdf_url["\']',
|
| 25 |
+
re.IGNORECASE,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# --------------------------------------------------------------------------
|
| 30 |
+
# PDF resolution / verification helpers
|
| 31 |
+
#
|
| 32 |
+
# Semantic Scholar's openAccessPdf.url is a best-effort pointer -- sometimes
|
| 33 |
+
# a raw PDF, sometimes a landing page that merely displays one. This runs
|
| 34 |
+
# each candidate through an escalation ladder and VERIFIES the result
|
| 35 |
+
# actually serves PDF bytes (checks the real response body's magic number,
|
| 36 |
+
# "%PDF", rather than trusting a Content-Type header or a field name)
|
| 37 |
+
# before ever handing it back.
|
| 38 |
+
# --------------------------------------------------------------------------
|
| 39 |
+
|
| 40 |
+
def _is_verified_pdf(url: str, timeout: int = 8) -> bool:
|
| 41 |
+
if not url:
|
| 42 |
+
return False
|
| 43 |
+
try:
|
| 44 |
+
with requests.get(
|
| 45 |
+
url, headers=_REQUEST_HEADERS, stream=True, timeout=timeout, allow_redirects=True
|
| 46 |
+
) as resp:
|
| 47 |
+
if resp.status_code != 200:
|
| 48 |
+
return False
|
| 49 |
+
chunk = next(resp.iter_content(chunk_size=16), b"")
|
| 50 |
+
return chunk.startswith(_PDF_MAGIC)
|
| 51 |
+
except requests.RequestException:
|
| 52 |
+
return False
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _extract_citation_pdf_url(landing_page_url: str, timeout: int = 8) -> Optional[str]:
|
| 56 |
+
"""Scrape the citation_pdf_url meta tag off an HTML landing page."""
|
| 57 |
+
try:
|
| 58 |
+
resp = requests.get(landing_page_url, headers=_REQUEST_HEADERS, timeout=timeout)
|
| 59 |
+
if resp.status_code != 200:
|
| 60 |
+
return None
|
| 61 |
+
match = _CITATION_PDF_URL_RE.search(resp.text[:20000])
|
| 62 |
+
if not match:
|
| 63 |
+
return None
|
| 64 |
+
return match.group(1) or match.group(2)
|
| 65 |
+
except requests.RequestException:
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _resolve_pmc_pdf(pmc_id: str, timeout: int = 8) -> Optional[str]:
|
| 70 |
+
"""Try Europe PMC's render endpoint for a PubMedCentral ID."""
|
| 71 |
+
clean_id = pmc_id if str(pmc_id).upper().startswith("PMC") else f"PMC{pmc_id}"
|
| 72 |
+
candidate = f"https://europepmc.org/articles/{clean_id}?pdf=render"
|
| 73 |
+
return candidate if _is_verified_pdf(candidate, timeout=timeout) else None
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _resolve_unpaywall_pdf(doi: str, email: str, timeout: int = 8) -> Optional[str]:
|
| 77 |
+
"""Last-resort fallback: ask Unpaywall for the best OA location's direct PDF."""
|
| 78 |
+
try:
|
| 79 |
+
resp = requests.get(
|
| 80 |
+
f"https://api.unpaywall.org/v2/{doi}", params={"email": email}, timeout=timeout
|
| 81 |
+
)
|
| 82 |
+
if resp.status_code != 200:
|
| 83 |
+
return None
|
| 84 |
+
best_location = (resp.json() or {}).get("best_oa_location") or {}
|
| 85 |
+
return best_location.get("url_for_pdf")
|
| 86 |
+
except (requests.RequestException, ValueError):
|
| 87 |
+
return None
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def resolve_pdf_url(
|
| 91 |
+
external_ids: dict,
|
| 92 |
+
fallback_url: Optional[str],
|
| 93 |
+
unpaywall_email: Optional[str] = None,
|
| 94 |
+
log: Optional[logging.Logger] = None,
|
| 95 |
+
) -> Optional[str]:
|
| 96 |
+
"""
|
| 97 |
+
Runs the full escalation ladder and returns a VERIFIED direct PDF url,
|
| 98 |
+
or None if nothing in the chain resolves to actual PDF bytes.
|
| 99 |
+
|
| 100 |
+
1. externalIds.ArXiv -> arxiv.org/pdf/{id} (deterministic)
|
| 101 |
+
2. externalIds.ACL -> aclanthology.org/{id}.pdf (deterministic)
|
| 102 |
+
3. externalIds.PubMedCentral -> Europe PMC render endpoint (verified)
|
| 103 |
+
4. openAccessPdf.url -> verified directly, or scraped for
|
| 104 |
+
the citation_pdf_url meta tag if
|
| 105 |
+
it turns out to be an HTML page
|
| 106 |
+
5. externalIds.DOI -> Unpaywall best_oa_location.url_for_pdf
|
| 107 |
+
(only if unpaywall_email is set)
|
| 108 |
+
"""
|
| 109 |
+
log = log or _fallback_logger
|
| 110 |
+
external_ids = external_ids or {}
|
| 111 |
+
|
| 112 |
+
arxiv_id = external_ids.get("ArXiv")
|
| 113 |
+
if arxiv_id:
|
| 114 |
+
candidate = f"https://arxiv.org/pdf/{arxiv_id}"
|
| 115 |
+
if _is_verified_pdf(candidate):
|
| 116 |
+
return candidate
|
| 117 |
+
|
| 118 |
+
acl_id = external_ids.get("ACL")
|
| 119 |
+
if acl_id:
|
| 120 |
+
candidate = f"https://aclanthology.org/{acl_id}.pdf"
|
| 121 |
+
if _is_verified_pdf(candidate):
|
| 122 |
+
return candidate
|
| 123 |
+
|
| 124 |
+
pmc_id = external_ids.get("PubMedCentral")
|
| 125 |
+
if pmc_id:
|
| 126 |
+
candidate = _resolve_pmc_pdf(pmc_id)
|
| 127 |
+
if candidate:
|
| 128 |
+
return candidate
|
| 129 |
+
|
| 130 |
+
if fallback_url:
|
| 131 |
+
if _is_verified_pdf(fallback_url):
|
| 132 |
+
return fallback_url
|
| 133 |
+
scraped = _extract_citation_pdf_url(fallback_url)
|
| 134 |
+
if scraped and _is_verified_pdf(scraped):
|
| 135 |
+
return scraped
|
| 136 |
+
|
| 137 |
+
doi = external_ids.get("DOI")
|
| 138 |
+
if doi and unpaywall_email:
|
| 139 |
+
candidate = _resolve_unpaywall_pdf(doi, unpaywall_email)
|
| 140 |
+
if candidate and _is_verified_pdf(candidate):
|
| 141 |
+
return candidate
|
| 142 |
+
|
| 143 |
+
log.debug("No verified PDF resolved for Semantic Scholar paper externalIds=%s", external_ids)
|
| 144 |
+
return None
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def resolve_pdf_url_fast(external_ids: dict, fallback_url: Optional[str]) -> Optional[str]:
|
| 148 |
+
"""
|
| 149 |
+
HYBRID fast path: a cheap, ZERO-network best-effort PDF link.
|
| 150 |
+
|
| 151 |
+
Builds the deterministic repo URL (arXiv / ACL / PubMedCentral) or takes
|
| 152 |
+
Semantic Scholar's own openAccessPdf link as-is, WITHOUT downloading or
|
| 153 |
+
verifying anything. The expensive verify + landing-page scrape + Unpaywall
|
| 154 |
+
ladder is deferred to 'Chat it out' time (see resolve_pdf_url), so search
|
| 155 |
+
stays fast while most links still point straight at a real PDF.
|
| 156 |
+
"""
|
| 157 |
+
external_ids = external_ids or {}
|
| 158 |
+
arxiv_id = external_ids.get("ArXiv")
|
| 159 |
+
if arxiv_id:
|
| 160 |
+
return f"https://arxiv.org/pdf/{arxiv_id}"
|
| 161 |
+
acl_id = external_ids.get("ACL")
|
| 162 |
+
if acl_id:
|
| 163 |
+
return f"https://aclanthology.org/{acl_id}.pdf"
|
| 164 |
+
pmc_id = external_ids.get("PubMedCentral")
|
| 165 |
+
if pmc_id:
|
| 166 |
+
clean = pmc_id if str(pmc_id).upper().startswith("PMC") else f"PMC{pmc_id}"
|
| 167 |
+
return f"https://europepmc.org/articles/{clean}?pdf=render"
|
| 168 |
+
return fallback_url or None
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def search_semantic_scholar(
|
| 172 |
+
query: str,
|
| 173 |
+
limit: int = 15,
|
| 174 |
+
sort="citationCount:desc",
|
| 175 |
+
logger: Optional[logging.Logger] = None,
|
| 176 |
+
max_candidates: int = 25,
|
| 177 |
+
status_out: Optional[dict] = None,
|
| 178 |
+
fast: bool = True,
|
| 179 |
+
) -> List[dict]:
|
| 180 |
+
"""
|
| 181 |
+
Search research papers using the Semantic Scholar Bulk Search API (supports sorting).
|
| 182 |
+
|
| 183 |
+
Only papers for which a VERIFIED, direct PDF link could be resolved are
|
| 184 |
+
returned -- papers with no reachable PDF are filtered out entirely,
|
| 185 |
+
never returned with pdf_url=None.
|
| 186 |
+
|
| 187 |
+
max_candidates: how many raw search results to PDF-check before
|
| 188 |
+
stopping at `limit` verified papers, since each candidate can
|
| 189 |
+
cost 1-2 extra network calls to resolve/verify.
|
| 190 |
+
status_out: optional dict the caller can pass to learn WHY this source
|
| 191 |
+
came back empty -- populated with {"state": "ok"|"rate_limited"|
|
| 192 |
+
"error", ...} so a rate-limit (HTTP 429) is surfaced instead of
|
| 193 |
+
silently looking like "no results".
|
| 194 |
+
"""
|
| 195 |
+
log = logger or _fallback_logger
|
| 196 |
+
url = "https://api.semanticscholar.org/graph/v1/paper/search"
|
| 197 |
+
api_key = os.getenv("SEMANTIC_SCHOLAR_API_KEY")
|
| 198 |
+
unpaywall_email = os.getenv("UNPAYWALL_EMAIL")
|
| 199 |
+
# Only send the header when we actually have a key -- an `x-api-key: None`
|
| 200 |
+
# header is meaningless and unauthenticated requests are throttled harder.
|
| 201 |
+
headers = {"x-api-key": api_key} if api_key else {}
|
| 202 |
+
log.info("Searching Semantic Scholar for: %r (sort=%s, keyed=%s)", query, sort or "default", bool(api_key))
|
| 203 |
+
|
| 204 |
+
params = {
|
| 205 |
+
"query": query,
|
| 206 |
+
"fields": ",".join([
|
| 207 |
+
"title", "abstract", "authors", "year",
|
| 208 |
+
"citationCount", "url", "openAccessPdf", "externalIds"
|
| 209 |
+
])
|
| 210 |
+
}
|
| 211 |
+
if sort:
|
| 212 |
+
params["sort"] = sort
|
| 213 |
+
|
| 214 |
+
# Semantic Scholar's free tier throttles aggressively (HTTP 429). Retry a
|
| 215 |
+
# few times with backoff before giving up, and surface the rate-limit
|
| 216 |
+
# explicitly via status_out so it never just goes silently blank.
|
| 217 |
+
response = None
|
| 218 |
+
for attempt in range(3):
|
| 219 |
+
try:
|
| 220 |
+
response = requests.get(url, headers=headers, params=params, timeout=15)
|
| 221 |
+
except requests.RequestException as e:
|
| 222 |
+
log.warning("Semantic Scholar request failed: %s: %s", type(e).__name__, e)
|
| 223 |
+
if status_out is not None:
|
| 224 |
+
status_out.update(state="error", detail=f"{type(e).__name__}: {e}")
|
| 225 |
+
return []
|
| 226 |
+
if response.status_code != 429:
|
| 227 |
+
break
|
| 228 |
+
wait = 2 * (attempt + 1)
|
| 229 |
+
log.warning("Semantic Scholar rate-limited (HTTP 429) — attempt %d/3, retrying in %ds", attempt + 1, wait)
|
| 230 |
+
time.sleep(wait)
|
| 231 |
+
|
| 232 |
+
if response.status_code == 429:
|
| 233 |
+
log.warning("Semantic Scholar STILL rate-limited (HTTP 429) after retries — 0 results from this source.")
|
| 234 |
+
if status_out is not None:
|
| 235 |
+
status_out.update(state="rate_limited", http=429)
|
| 236 |
+
return []
|
| 237 |
+
|
| 238 |
+
if response.status_code != 200:
|
| 239 |
+
log.warning("Semantic Scholar returned bad status %s: %s", response.status_code, response.text[:300])
|
| 240 |
+
if status_out is not None:
|
| 241 |
+
status_out.update(state="error", http=response.status_code)
|
| 242 |
+
return []
|
| 243 |
+
|
| 244 |
+
try:
|
| 245 |
+
data = response.json()
|
| 246 |
+
except ValueError as e:
|
| 247 |
+
log.warning("Semantic Scholar response failed to parse as JSON: %s (raw: %s)", e, response.text[:500])
|
| 248 |
+
if status_out is not None:
|
| 249 |
+
status_out.update(state="error", detail="bad JSON")
|
| 250 |
+
return []
|
| 251 |
+
|
| 252 |
+
total_available = data.get("total", "unknown")
|
| 253 |
+
log.info("Semantic Scholar reports %s total match(es)", total_available)
|
| 254 |
+
|
| 255 |
+
raw_papers = data.get("data", [])[:max_candidates]
|
| 256 |
+
|
| 257 |
+
# Build candidate metadata first (no network), then resolve every
|
| 258 |
+
# candidate's PDF CONCURRENTLY instead of one at a time.
|
| 259 |
+
candidates = []
|
| 260 |
+
for paper in raw_papers:
|
| 261 |
+
title = paper.get("title")
|
| 262 |
+
if not title or not title.strip():
|
| 263 |
+
continue
|
| 264 |
+
authors = [a.get("name") for a in (paper.get("authors") or []) if a.get("name")]
|
| 265 |
+
openaccess_url = (paper.get("openAccessPdf") or {}).get("url")
|
| 266 |
+
external_ids = paper.get("externalIds") or {}
|
| 267 |
+
candidates.append((paper, title, authors, openaccess_url, external_ids))
|
| 268 |
+
|
| 269 |
+
if fast:
|
| 270 |
+
# HYBRID default: cheap zero-network best-effort links, keep ALL papers
|
| 271 |
+
# (deep verification is deferred to 'Chat it out'). No thread pool needed
|
| 272 |
+
# since resolution does no network here.
|
| 273 |
+
resolved = [resolve_pdf_url_fast(ext, oa) for (_, _, _, oa, ext) in candidates]
|
| 274 |
+
else:
|
| 275 |
+
# Deep path: full verify ladder in parallel, keep only verified-PDF papers.
|
| 276 |
+
def _resolve(item):
|
| 277 |
+
_, _, _, openaccess_url, external_ids = item
|
| 278 |
+
return resolve_pdf_url(external_ids, openaccess_url, unpaywall_email=unpaywall_email, log=log)
|
| 279 |
+
with ThreadPoolExecutor(max_workers=min(12, len(candidates))) as ex:
|
| 280 |
+
resolved = list(ex.map(_resolve, candidates)) if candidates else []
|
| 281 |
+
|
| 282 |
+
papers = []
|
| 283 |
+
for (paper, title, authors, openaccess_url, external_ids), pdf_url in zip(candidates, resolved):
|
| 284 |
+
if len(papers) >= limit:
|
| 285 |
+
break
|
| 286 |
+
if not fast and not pdf_url:
|
| 287 |
+
# Deep mode only: drop papers with no verifiable PDF.
|
| 288 |
+
continue
|
| 289 |
+
try:
|
| 290 |
+
paper_obj = ResearchPaper(
|
| 291 |
+
title=title.strip(),
|
| 292 |
+
authors=authors if authors else None,
|
| 293 |
+
abstract=paper.get("abstract"),
|
| 294 |
+
year=paper.get("year"),
|
| 295 |
+
citation_count=paper.get("citationCount"),
|
| 296 |
+
url=paper.get("url"),
|
| 297 |
+
pdf_url=pdf_url,
|
| 298 |
+
source="SemanticScholar",
|
| 299 |
+
)
|
| 300 |
+
papers.append(paper_obj.model_dump())
|
| 301 |
+
except Exception as e:
|
| 302 |
+
log.warning("Skipped malformed Semantic Scholar paper: %s", e)
|
| 303 |
+
continue
|
| 304 |
+
|
| 305 |
+
if status_out is not None:
|
| 306 |
+
status_out.update(state="ok", count=len(papers))
|
| 307 |
+
log.info("Semantic Scholar: %d paper(s) retrieved (%s) out of %d candidates",
|
| 308 |
+
len(papers), "fast/best-effort PDF" if fast else "verified PDF", len(raw_papers))
|
| 309 |
+
return papers
|
app/modules/search/reranking.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
reranking.py
|
| 3 |
+
|
| 4 |
+
Reranks papers by semantic similarity to the Research Intent using SPECTER
|
| 5 |
+
(allenai-specter) — an embedding model trained specifically on academic
|
| 6 |
+
paper title/abstract pairs, rather than a generic sentence embedding model.
|
| 7 |
+
|
| 8 |
+
Install once:
|
| 9 |
+
pip install sentence-transformers
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import logging
|
| 13 |
+
from typing import Optional
|
| 14 |
+
|
| 15 |
+
from sentence_transformers import SentenceTransformer
|
| 16 |
+
import numpy as np
|
| 17 |
+
|
| 18 |
+
# Loaded once at module import time — NOT inside the function.
|
| 19 |
+
# Loading a transformer model from disk/HF hub takes a few seconds; if this
|
| 20 |
+
# were inside the function, every call would reload it, which is wasteful
|
| 21 |
+
# if this function gets called more than once in a session.
|
| 22 |
+
#
|
| 23 |
+
# device is pinned explicitly instead of left to SentenceTransformer's
|
| 24 |
+
# auto-detect. On HF ZeroGPU, torch reports a GPU at import time but only
|
| 25 |
+
# actually grants one inside an @spaces.GPU window — and this model is called
|
| 26 |
+
# from a LangGraph node, which is outside any such window. Auto-detect would
|
| 27 |
+
# therefore load it onto "cuda" and fail on first encode. Reranking ~30 abstracts
|
| 28 |
+
# is a couple of seconds on CPU.
|
| 29 |
+
_MODEL = SentenceTransformer("sentence-transformers/allenai-specter", device="cpu")
|
| 30 |
+
|
| 31 |
+
_fallback_logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def rerank_by_relevance(
|
| 35 |
+
research_intent: str,
|
| 36 |
+
papers: dict[str, str],
|
| 37 |
+
top_n: int = 10,
|
| 38 |
+
logger: Optional[logging.Logger] = None,
|
| 39 |
+
) -> dict[str, str]:
|
| 40 |
+
"""
|
| 41 |
+
Rerank papers by semantic similarity to the Research Intent.
|
| 42 |
+
|
| 43 |
+
Parameters
|
| 44 |
+
----------
|
| 45 |
+
research_intent : str
|
| 46 |
+
The full Research Intent text (Problem + Objective + Additional
|
| 47 |
+
Context, or however you've combined it) — used as the query vector.
|
| 48 |
+
papers : dict[str, str]
|
| 49 |
+
{normalized_title: abstract} — the aggregator's title/abstract pairs.
|
| 50 |
+
top_n : int
|
| 51 |
+
How many top-ranked papers to keep. Default 15.
|
| 52 |
+
logger : logging.Logger, optional
|
| 53 |
+
Node-scoped logger from the caller. Falls back to a module logger
|
| 54 |
+
when this function is used standalone.
|
| 55 |
+
|
| 56 |
+
Returns
|
| 57 |
+
-------
|
| 58 |
+
dict[str, str]
|
| 59 |
+
A NEW dict, same {normalized_title: abstract} shape, containing only
|
| 60 |
+
the top_n most relevant entries, ordered from most to least relevant.
|
| 61 |
+
Insertion order is preserved (Python 3.7+ dicts are ordered), so
|
| 62 |
+
iterating this dict gives you the ranking directly.
|
| 63 |
+
"""
|
| 64 |
+
log = logger or _fallback_logger
|
| 65 |
+
|
| 66 |
+
if not papers:
|
| 67 |
+
return {}
|
| 68 |
+
|
| 69 |
+
# --- Guard against empty/whitespace-only abstracts ---
|
| 70 |
+
# These can't be meaningfully embedded for relevance comparison. Rather
|
| 71 |
+
# than crash or silently mis-rank them, exclude them from ranking and
|
| 72 |
+
# log which ones were skipped so nothing disappears without a trace.
|
| 73 |
+
valid_titles = []
|
| 74 |
+
valid_abstracts = []
|
| 75 |
+
skipped_no_abstract = []
|
| 76 |
+
|
| 77 |
+
for normalized_title, abstract in papers.items():
|
| 78 |
+
if abstract and abstract.strip():
|
| 79 |
+
valid_titles.append(normalized_title)
|
| 80 |
+
valid_abstracts.append(abstract)
|
| 81 |
+
else:
|
| 82 |
+
skipped_no_abstract.append(normalized_title)
|
| 83 |
+
|
| 84 |
+
if skipped_no_abstract:
|
| 85 |
+
log.info("Skipped %d paper(s) with no abstract: %s", len(skipped_no_abstract), skipped_no_abstract)
|
| 86 |
+
|
| 87 |
+
if not valid_abstracts:
|
| 88 |
+
log.info("No papers had usable abstracts — returning empty result.")
|
| 89 |
+
return {}
|
| 90 |
+
|
| 91 |
+
# --- Embed the query (Research Intent) and all candidate abstracts ---
|
| 92 |
+
# normalize_embeddings=True means each vector has unit length, so a
|
| 93 |
+
# simple dot product between two vectors IS the cosine similarity —
|
| 94 |
+
# no separate cosine-similarity library call needed.
|
| 95 |
+
query_embedding = _MODEL.encode(
|
| 96 |
+
research_intent,
|
| 97 |
+
normalize_embeddings=True,
|
| 98 |
+
convert_to_numpy=True,
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
paper_embeddings = _MODEL.encode(
|
| 102 |
+
valid_abstracts,
|
| 103 |
+
normalize_embeddings=True,
|
| 104 |
+
convert_to_numpy=True,
|
| 105 |
+
batch_size=32,
|
| 106 |
+
show_progress_bar=False,
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
# --- Cosine similarity of every paper against the query, in one shot ---
|
| 110 |
+
similarities = paper_embeddings @ query_embedding # shape: (num_papers,)
|
| 111 |
+
|
| 112 |
+
# --- Sort by similarity, descending ---
|
| 113 |
+
ranked_indices = np.argsort(-similarities)
|
| 114 |
+
|
| 115 |
+
# --- Log the full ranking for visibility/debugging before truncating ---
|
| 116 |
+
ranking_lines = "\n".join(
|
| 117 |
+
f" {similarities[idx]:.4f} {valid_titles[idx]}" for idx in ranked_indices
|
| 118 |
+
)
|
| 119 |
+
log.info("Full relevance ranking (%d papers, title : similarity score):\n%s", len(valid_titles), ranking_lines)
|
| 120 |
+
|
| 121 |
+
top_indices = ranked_indices[:top_n]
|
| 122 |
+
|
| 123 |
+
dropped_count = len(valid_titles) - len(top_indices)
|
| 124 |
+
if dropped_count > 0:
|
| 125 |
+
log.info("Kept top %d, dropped %d lower-relevance paper(s).", len(top_indices), dropped_count)
|
| 126 |
+
|
| 127 |
+
# --- Build the result dict in ranked order ---
|
| 128 |
+
result = {
|
| 129 |
+
valid_titles[idx]: valid_abstracts[idx]
|
| 130 |
+
for idx in top_indices
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
return result
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
if __name__ == "__main__":
|
| 137 |
+
# Quick standalone test
|
| 138 |
+
test_intent = (
|
| 139 |
+
"Identify a robust methodology for comparing the fuel efficiency of "
|
| 140 |
+
"human-driven and reinforcement-learning-controlled vehicles in "
|
| 141 |
+
"car-following maneuvers, accounting for speed, acceleration, and headway."
|
| 142 |
+
)
|
| 143 |
+
test_papers = {
|
| 144 |
+
"ecofollower an environmentfriendly car following model": (
|
| 145 |
+
"This study introduces EcoFollower, a novel eco-car-following "
|
| 146 |
+
"model developed using reinforcement learning to optimize fuel "
|
| 147 |
+
"consumption in car-following scenarios."
|
| 148 |
+
),
|
| 149 |
+
"predicting fuel research octane number using spectra": (
|
| 150 |
+
"We show that an accurate statistical model for the Research "
|
| 151 |
+
"Octane Number of gasoline can be constructed using infrared "
|
| 152 |
+
"absorbance spectroscopy data."
|
| 153 |
+
),
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
reranked = rerank_by_relevance(test_intent, test_papers, top_n=15)
|
| 157 |
+
print("\nFinal reranked result:")
|
| 158 |
+
for title, abstract in reranked.items():
|
| 159 |
+
print(f"- {title}")
|
app/modules/search/router.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
|
| 3 |
+
from app.modules.search.jobs import get_job
|
| 4 |
+
from app.modules.search.schemas import SearchStatusResponse
|
| 5 |
+
|
| 6 |
+
router = APIRouter(prefix="/search", tags=["search"])
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@router.get("/status/{job_id}", response_model=SearchStatusResponse)
|
| 10 |
+
def search_status(job_id: str):
|
| 11 |
+
"""Poll this until status is 'done' (or 'error'). On 'done',
|
| 12 |
+
clustered_papers is the final payload the UI renders."""
|
| 13 |
+
job = get_job(job_id)
|
| 14 |
+
if job is None:
|
| 15 |
+
raise HTTPException(status_code=404, detail="Unknown job_id.")
|
| 16 |
+
return SearchStatusResponse(**job)
|
app/modules/search/schemas.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Literal, Optional
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class SearchStatusResponse(BaseModel):
|
| 7 |
+
job_id: str
|
| 8 |
+
status: Literal["running", "done", "error"]
|
| 9 |
+
clustered_papers: Optional[List[dict]] = None
|
| 10 |
+
error: Optional[str] = None
|
chatbot_core/Qa.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
qa.py
|
| 3 |
+
-----
|
| 4 |
+
Interactive Q&A over a single research paper. Handles vectorization
|
| 5 |
+
automatically -- just point it at a PDF:
|
| 6 |
+
|
| 7 |
+
python qa.py /path/to/paper.pdf
|
| 8 |
+
|
| 9 |
+
First run on a given PDF builds the vectorstore (may take a moment while the
|
| 10 |
+
embedding model loads); every run after that loads the cached vectorstore
|
| 11 |
+
instantly since it's keyed by the PDF's content hash.
|
| 12 |
+
|
| 13 |
+
Requires a Groq API key. Create a .env file in this same directory containing:
|
| 14 |
+
|
| 15 |
+
GROQ_API_KEY=your-key-here
|
| 16 |
+
|
| 17 |
+
Install once (on top of vectorize.py's requirements):
|
| 18 |
+
pip install --break-system-packages langchain langchain-community \
|
| 19 |
+
langchain-classic langchain-groq python-dotenv
|
| 20 |
+
|
| 21 |
+
Note: LangChain 1.0+ split ContextualCompressionRetriever and
|
| 22 |
+
CrossEncoderReranker out of the core `langchain` package into the new
|
| 23 |
+
`langchain-classic` package -- that's why langchain-classic is required above.
|
| 24 |
+
|
| 25 |
+
Swap LLM providers: replace get_llm() below with e.g. ChatOpenAI or
|
| 26 |
+
ChatAnthropic if you'd rather not use Groq.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
import os
|
| 30 |
+
import sys
|
| 31 |
+
|
| 32 |
+
from dotenv import load_dotenv
|
| 33 |
+
from langchain_groq import ChatGroq
|
| 34 |
+
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
| 35 |
+
from langchain_core.messages import HumanMessage, AIMessage
|
| 36 |
+
from langchain_community.cross_encoders import HuggingFaceCrossEncoder
|
| 37 |
+
from langchain_classic.retrievers.document_compressors import CrossEncoderReranker
|
| 38 |
+
from langchain_classic.retrievers.contextual_compression import ContextualCompressionRetriever
|
| 39 |
+
|
| 40 |
+
from vectorizeer import build_vectorstore
|
| 41 |
+
|
| 42 |
+
load_dotenv() # reads GROQ_API_KEY from a .env file in the current directory
|
| 43 |
+
|
| 44 |
+
QA_SYSTEM_PROMPT = """You are a research assistant helping someone deeply understand a specific paper.
|
| 45 |
+
Treat every question as a chance to teach, not just retrieve -- they want both
|
| 46 |
+
the facts and why those facts matter.
|
| 47 |
+
|
| 48 |
+
Ground every answer strictly in the excerpts below. Do not use outside knowledge,
|
| 49 |
+
and do not fill gaps with what a similar paper would typically say. If the
|
| 50 |
+
excerpts don't contain the answer, say so plainly rather than guessing.
|
| 51 |
+
|
| 52 |
+
When you answer:
|
| 53 |
+
- Be comprehensive: explain the relevant method, result, or claim fully rather
|
| 54 |
+
than a one-line summary. If the question touches a mechanism (an algorithm, a
|
| 55 |
+
fine-tuning task, an experimental setup), walk through how it actually works,
|
| 56 |
+
not just what it's called.
|
| 57 |
+
- Surface significance: don't just report what the paper found -- explain why
|
| 58 |
+
it matters. What problem does it solve, what breaks without it, how does it
|
| 59 |
+
compare to prior approaches, what does it enable going forward.
|
| 60 |
+
- Stay accessible: write for someone smart but not necessarily a specialist in
|
| 61 |
+
this exact subfield. Define acronyms and technical terms the first time you
|
| 62 |
+
use them, and prefer plain language wherever it loses no precision.
|
| 63 |
+
- Cite as you go: every claim should be traceable to a page number from the
|
| 64 |
+
tagged excerpts below. Weave citations naturally into the explanation rather
|
| 65 |
+
than listing them at the end.
|
| 66 |
+
- Stay focused: comprehensive isn't the same as padded. Cut anything that
|
| 67 |
+
doesn't help the reader actually understand the paper better.
|
| 68 |
+
|
| 69 |
+
EXCERPTS:
|
| 70 |
+
{context}
|
| 71 |
+
"""
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def get_llm():
|
| 75 |
+
api_key = os.environ.get("SECOND_GROQ_API_KEY")
|
| 76 |
+
if not api_key:
|
| 77 |
+
raise EnvironmentError(
|
| 78 |
+
"GROQ_API_KEY not found. Add it to a .env file in this directory "
|
| 79 |
+
"(GROQ_API_KEY=your-key-here) -- load_dotenv() picks it up automatically."
|
| 80 |
+
)
|
| 81 |
+
return ChatGroq(model="llama-3.3-70b-versatile", temperature=0)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def get_retriever(vectorstore, k: int = 15, top_n: int = 5, device: str = "cpu"):
|
| 85 |
+
"""Retrieve k candidates by similarity, then rerank down to the best top_n
|
| 86 |
+
with a small cross-encoder -- this is the single biggest accuracy lever
|
| 87 |
+
on top of the chunking itself.
|
| 88 |
+
|
| 89 |
+
device is pinned to "cpu" by default rather than left to auto-detect. The
|
| 90 |
+
retriever outlives any single call, so on HF ZeroGPU -- where a GPU exists
|
| 91 |
+
only inside an @spaces.GPU window -- a cuda-resident cross-encoder here would
|
| 92 |
+
fail the moment it's used. Reranking 15 short chunks is ~a second on CPU."""
|
| 93 |
+
base_retriever = vectorstore.as_retriever(search_kwargs={"k": k})
|
| 94 |
+
reranker_model = HuggingFaceCrossEncoder(
|
| 95 |
+
model_name="BAAI/bge-reranker-base",
|
| 96 |
+
model_kwargs={"device": device},
|
| 97 |
+
)
|
| 98 |
+
compressor = CrossEncoderReranker(model=reranker_model, top_n=top_n)
|
| 99 |
+
return ContextualCompressionRetriever(base_compressor=compressor, base_retriever=base_retriever)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def format_docs(docs) -> str:
|
| 103 |
+
parts = []
|
| 104 |
+
for d in docs:
|
| 105 |
+
tag = f"[Section: {d.metadata.get('section', '?')} | Page: {d.metadata.get('page', '?')}]"
|
| 106 |
+
parts.append(f"{tag}\n{d.page_content}")
|
| 107 |
+
return "\n\n---\n\n".join(parts)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def build_chain(llm):
|
| 111 |
+
prompt = ChatPromptTemplate.from_messages([
|
| 112 |
+
("system", QA_SYSTEM_PROMPT),
|
| 113 |
+
MessagesPlaceholder("chat_history"),
|
| 114 |
+
("human", "{question}"),
|
| 115 |
+
])
|
| 116 |
+
return prompt | llm
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def main():
|
| 120 |
+
if len(sys.argv) < 2:
|
| 121 |
+
print("Usage: python qa.py /path/to/paper.pdf")
|
| 122 |
+
sys.exit(1)
|
| 123 |
+
|
| 124 |
+
pdf_path = sys.argv[1]
|
| 125 |
+
if not os.path.isfile(pdf_path):
|
| 126 |
+
print(f"File not found: {pdf_path}")
|
| 127 |
+
sys.exit(1)
|
| 128 |
+
|
| 129 |
+
vectorstore = build_vectorstore(pdf_path)
|
| 130 |
+
retriever = get_retriever(vectorstore)
|
| 131 |
+
llm = get_llm()
|
| 132 |
+
chain = build_chain(llm)
|
| 133 |
+
|
| 134 |
+
chat_history = []
|
| 135 |
+
print("\nReady. Ask questions about the paper (type 'exit' to quit).\n")
|
| 136 |
+
|
| 137 |
+
while True:
|
| 138 |
+
try:
|
| 139 |
+
question = input("You: ").strip()
|
| 140 |
+
except (EOFError, KeyboardInterrupt):
|
| 141 |
+
break
|
| 142 |
+
if question.lower() in ("exit", "quit"):
|
| 143 |
+
break
|
| 144 |
+
if not question:
|
| 145 |
+
continue
|
| 146 |
+
|
| 147 |
+
docs = retriever.invoke(question)
|
| 148 |
+
context = format_docs(docs)
|
| 149 |
+
|
| 150 |
+
print("\nAssistant: ", end="", flush=True)
|
| 151 |
+
answer = ""
|
| 152 |
+
for chunk in chain.stream({
|
| 153 |
+
"question": question,
|
| 154 |
+
"chat_history": chat_history,
|
| 155 |
+
"context": context,
|
| 156 |
+
}):
|
| 157 |
+
token = chunk.content
|
| 158 |
+
if token:
|
| 159 |
+
print(token, end="", flush=True)
|
| 160 |
+
answer += token
|
| 161 |
+
print("\n")
|
| 162 |
+
|
| 163 |
+
sources = sorted(set(f"p.{d.metadata.get('page')}" for d in docs), key=lambda s: s)
|
| 164 |
+
print(f"[sources: {', '.join(sources)}]\n")
|
| 165 |
+
|
| 166 |
+
chat_history.append(HumanMessage(content=question))
|
| 167 |
+
chat_history.append(AIMessage(content=answer))
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
if __name__ == "__main__":
|
| 171 |
+
main()
|
chatbot_core/vectorizeer.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
vectorize.py
|
| 3 |
+
------------
|
| 4 |
+
Builds (or loads, if already built) a persisted vectorstore for a single research
|
| 5 |
+
paper PDF. Designed to be imported by qa.py, but can also be run standalone:
|
| 6 |
+
|
| 7 |
+
python vectorize.py /path/to/paper.pdf
|
| 8 |
+
|
| 9 |
+
Chunking strategy:
|
| 10 |
+
1. Split the paper by detected section headers ("1 Introduction", "3.2 Inference
|
| 11 |
+
with V-RAG", etc.) so each chunk is a real semantic unit, not an arbitrary
|
| 12 |
+
page cut.
|
| 13 |
+
2. Within each section, protect table-like blocks (lines dense with numbers) so
|
| 14 |
+
they stay intact as their own chunk instead of getting sliced by the
|
| 15 |
+
recursive splitter.
|
| 16 |
+
3. Any remaining oversized prose is split with RecursiveCharacterTextSplitter
|
| 17 |
+
(paragraph -> sentence -> word fallback separators).
|
| 18 |
+
4. If no section headers are detected at all (unusual paper formatting), falls
|
| 19 |
+
back to page-wise chunks with a small overlap so nothing is silently lost.
|
| 20 |
+
|
| 21 |
+
Install once:
|
| 22 |
+
pip install --break-system-packages pymupdf langchain langchain-core \
|
| 23 |
+
langchain-text-splitters langchain-chroma langchain-community \
|
| 24 |
+
sentence-transformers chromadb
|
| 25 |
+
|
| 26 |
+
Embedding model: BAAI/bge-base-en-v1.5 (local, ~440MB, runs fine on CPU or a
|
| 27 |
+
sliver of GPU -- no API key, no rate limits, no VRAM worries).
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
import os
|
| 31 |
+
import re
|
| 32 |
+
import sys
|
| 33 |
+
import hashlib
|
| 34 |
+
|
| 35 |
+
import fitz # PyMuPDF
|
| 36 |
+
from langchain_core.documents import Document
|
| 37 |
+
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
| 38 |
+
from langchain_chroma import Chroma
|
| 39 |
+
|
| 40 |
+
# Matches headers like "1 Introduction", "3.2 Inference with V-RAG", "6 Conclusion"
|
| 41 |
+
HEADER_PATTERN = re.compile(r'\n(\d{1,2}(?:\.\d{1,2})?\s+[A-Z][A-Za-z][^\n]{2,60})(?=\n)')
|
| 42 |
+
|
| 43 |
+
# A line "looks like a table row" if enough of its tokens are numeric.
|
| 44 |
+
NUMERIC_TOKEN = re.compile(r'^-?\d+\.\d+$|^\d+$')
|
| 45 |
+
PAGE_OVERLAP_CHARS = 200
|
| 46 |
+
MAX_CHUNK_CHARS = 900
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def get_pdf_hash(pdf_path: str) -> str:
|
| 50 |
+
"""Short content hash -> stable, unique persist directory per PDF."""
|
| 51 |
+
with open(pdf_path, "rb") as f:
|
| 52 |
+
return hashlib.md5(f.read()).hexdigest()[:12]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def extract_sections(pdf_path: str):
|
| 56 |
+
"""Return a list of {title, text, page} dicts, one per detected section.
|
| 57 |
+
|
| 58 |
+
Falls back to page-wise chunks (with overlap) if no section headers are
|
| 59 |
+
detected, so unusual paper formats still produce usable chunks.
|
| 60 |
+
"""
|
| 61 |
+
doc = fitz.open(pdf_path)
|
| 62 |
+
full_text = ""
|
| 63 |
+
page_map = [] # (char_start, char_end, page_number) per page
|
| 64 |
+
for i, page in enumerate(doc):
|
| 65 |
+
t = page.get_text()
|
| 66 |
+
page_map.append((len(full_text), len(full_text) + len(t), i + 1))
|
| 67 |
+
full_text += t
|
| 68 |
+
|
| 69 |
+
matches = list(HEADER_PATTERN.finditer(full_text))
|
| 70 |
+
sections = []
|
| 71 |
+
|
| 72 |
+
if matches:
|
| 73 |
+
# Preamble before the first header = title/authors/abstract
|
| 74 |
+
if matches[0].start() > 0:
|
| 75 |
+
preamble = full_text[:matches[0].start()].strip()
|
| 76 |
+
if preamble:
|
| 77 |
+
page_no = next(p for s, e, p in page_map if s <= 0 < e)
|
| 78 |
+
sections.append({"title": "Abstract / Preamble", "text": preamble, "page": page_no})
|
| 79 |
+
|
| 80 |
+
for idx, m in enumerate(matches):
|
| 81 |
+
start = m.start()
|
| 82 |
+
end = matches[idx + 1].start() if idx + 1 < len(matches) else len(full_text)
|
| 83 |
+
title = m.group(1).strip()
|
| 84 |
+
body = full_text[m.end():end].strip()
|
| 85 |
+
if body:
|
| 86 |
+
page_no = next(p for s, e, p in page_map if s <= start < e)
|
| 87 |
+
sections.append({"title": title, "text": body, "page": page_no})
|
| 88 |
+
else:
|
| 89 |
+
print("[vectorize] No section headers detected -- falling back to page-wise chunking.")
|
| 90 |
+
page_texts = [page.get_text() for page in doc]
|
| 91 |
+
for i, t in enumerate(page_texts):
|
| 92 |
+
prefix = page_texts[i - 1][-PAGE_OVERLAP_CHARS:] if i > 0 else ""
|
| 93 |
+
sections.append({"title": f"Page {i + 1}", "text": prefix + t, "page": i + 1})
|
| 94 |
+
|
| 95 |
+
return sections
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _looks_tabular(line: str) -> bool:
|
| 99 |
+
tokens = line.split()
|
| 100 |
+
if len(tokens) < 3:
|
| 101 |
+
return False
|
| 102 |
+
numeric = sum(1 for t in tokens if NUMERIC_TOKEN.match(t))
|
| 103 |
+
return (numeric / len(tokens)) >= 0.4
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _split_protecting_tables(text: str, splitter: RecursiveCharacterTextSplitter):
|
| 107 |
+
"""Group consecutive table-like lines into their own block; split the rest
|
| 108 |
+
normally. Returns a list of (is_table: bool, chunk_text: str)."""
|
| 109 |
+
lines = text.split("\n")
|
| 110 |
+
blocks = []
|
| 111 |
+
buf, buf_is_table = [], None
|
| 112 |
+
|
| 113 |
+
for line in lines:
|
| 114 |
+
is_table = _looks_tabular(line)
|
| 115 |
+
if buf_is_table is None:
|
| 116 |
+
buf_is_table = is_table
|
| 117 |
+
if is_table == buf_is_table:
|
| 118 |
+
buf.append(line)
|
| 119 |
+
else:
|
| 120 |
+
blocks.append((buf_is_table, "\n".join(buf)))
|
| 121 |
+
buf, buf_is_table = [line], is_table
|
| 122 |
+
if buf:
|
| 123 |
+
blocks.append((buf_is_table, "\n".join(buf)))
|
| 124 |
+
|
| 125 |
+
chunks = []
|
| 126 |
+
for is_table, block_text in blocks:
|
| 127 |
+
block_text = block_text.strip()
|
| 128 |
+
if not block_text:
|
| 129 |
+
continue
|
| 130 |
+
if is_table or len(block_text) <= MAX_CHUNK_CHARS:
|
| 131 |
+
chunks.append((is_table, block_text))
|
| 132 |
+
else:
|
| 133 |
+
for piece in splitter.split_text(block_text):
|
| 134 |
+
chunks.append((False, piece))
|
| 135 |
+
return chunks
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def get_embeddings(device: str = "cpu"):
|
| 139 |
+
"""Local, lightweight embedding model -- no API key, no rate limits.
|
| 140 |
+
|
| 141 |
+
device stays "cpu" by default (standalone runs behave exactly as before).
|
| 142 |
+
It's a parameter rather than a hardcoded string because on HF ZeroGPU only
|
| 143 |
+
code inside an @spaces.GPU window may touch "cuda" -- so the caller has to
|
| 144 |
+
say which side of that window it's on. Letting the model auto-detect would
|
| 145 |
+
put it on "cuda" at import and blow up on first use outside the window.
|
| 146 |
+
"""
|
| 147 |
+
from langchain_community.embeddings import HuggingFaceBgeEmbeddings
|
| 148 |
+
return HuggingFaceBgeEmbeddings(
|
| 149 |
+
model_name="BAAI/bge-base-en-v1.5",
|
| 150 |
+
model_kwargs={"device": device},
|
| 151 |
+
encode_kwargs={"normalize_embeddings": True},
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def build_vectorstore(pdf_path: str, persist_root: str = "./vectorstores", force_rebuild: bool = False,
|
| 156 |
+
device: str = "cpu"):
|
| 157 |
+
"""Build a new vectorstore for pdf_path, or load the existing one if it was
|
| 158 |
+
already built for this exact file.
|
| 159 |
+
|
| 160 |
+
device is forwarded to get_embeddings. Building on "cuda" and re-opening on
|
| 161 |
+
"cpu" is a supported pattern: the persisted vectors are identical either way,
|
| 162 |
+
so the expensive bulk encode can run on a GPU and the cheap query-time encode
|
| 163 |
+
on the CPU.
|
| 164 |
+
"""
|
| 165 |
+
pdf_hash = get_pdf_hash(pdf_path)
|
| 166 |
+
persist_dir = os.path.join(persist_root, pdf_hash)
|
| 167 |
+
embeddings = get_embeddings(device)
|
| 168 |
+
|
| 169 |
+
if os.path.isdir(persist_dir) and os.listdir(persist_dir) and not force_rebuild:
|
| 170 |
+
print(f"[vectorize] Existing vectorstore found at {persist_dir} -- loading it.")
|
| 171 |
+
return Chroma(persist_directory=persist_dir, embedding_function=embeddings)
|
| 172 |
+
|
| 173 |
+
print(f"[vectorize] Building vectorstore for: {pdf_path}")
|
| 174 |
+
sections = extract_sections(pdf_path)
|
| 175 |
+
splitter = RecursiveCharacterTextSplitter(
|
| 176 |
+
chunk_size=700, chunk_overlap=100,
|
| 177 |
+
separators=["\n\n", "\n", ". ", " "],
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
docs = []
|
| 181 |
+
for sec in sections:
|
| 182 |
+
for is_table, piece_text in _split_protecting_tables(sec["text"], splitter):
|
| 183 |
+
docs.append(Document(
|
| 184 |
+
page_content=piece_text,
|
| 185 |
+
metadata={
|
| 186 |
+
"section": sec["title"],
|
| 187 |
+
"page": sec["page"],
|
| 188 |
+
"type": "table" if is_table else "text",
|
| 189 |
+
"source": os.path.basename(pdf_path),
|
| 190 |
+
},
|
| 191 |
+
))
|
| 192 |
+
|
| 193 |
+
if not docs:
|
| 194 |
+
raise ValueError(
|
| 195 |
+
f"No extractable text found in {pdf_path}. This PDF may be scanned "
|
| 196 |
+
"(image-only) rather than a native text PDF -- it needs OCR first."
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
print(f"[vectorize] {len(docs)} chunks created across {len(sections)} sections.")
|
| 200 |
+
os.makedirs(persist_dir, exist_ok=True)
|
| 201 |
+
vectorstore = Chroma.from_documents(docs, embeddings, persist_directory=persist_dir)
|
| 202 |
+
print(f"[vectorize] Saved to {persist_dir}")
|
| 203 |
+
return vectorstore
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
if __name__ == "__main__":
|
| 207 |
+
if len(sys.argv) < 2:
|
| 208 |
+
print("Usage: python vectorize.py /path/to/paper.pdf")
|
| 209 |
+
sys.exit(1)
|
| 210 |
+
|
| 211 |
+
path = sys.argv[1]
|
| 212 |
+
if not os.path.isfile(path):
|
| 213 |
+
print(f"File not found: {path}")
|
| 214 |
+
sys.exit(1)
|
| 215 |
+
|
| 216 |
+
build_vectorstore(path)
|
nova_app.py
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
NOVA — Research, guided by SONIC
|
| 3 |
+
================================================================
|
| 4 |
+
A single Gradio app that stitches together the two projects, unchanged:
|
| 5 |
+
|
| 6 |
+
• app/ the research pipeline (structured_agent's `app/` package):
|
| 7 |
+
INTENT graph -> SEARCH + CLUSTER graph
|
| 8 |
+
• chatbot_core/ the single-PDF Q&A chatbot (Qa.py + vectorizeer.py)
|
| 9 |
+
|
| 10 |
+
NOVA is the product. SONIC is the assistant persona that talks you through it.
|
| 11 |
+
|
| 12 |
+
Flow:
|
| 13 |
+
USER RESEARCH IDEA
|
| 14 |
+
-> INTENT agent frames it (Problem / Objective / Additional Context)
|
| 15 |
+
-> you review/edit it
|
| 16 |
+
-> SEARCH agent fetches + reranks + clusters papers
|
| 17 |
+
-> results shown as clean thumbnails (title, authors, links)
|
| 18 |
+
-> "Chat it out" on any paper: its PDF is downloaded, vectorized by
|
| 19 |
+
vectorizeer.build_vectorstore, and you Q&A over it with Qa.py's chain.
|
| 20 |
+
|
| 21 |
+
This file is UI + wiring only. It does NOT change any agent or chatbot logic —
|
| 22 |
+
it imports their functions and drives them.
|
| 23 |
+
|
| 24 |
+
Where Streamlit re-executed one script top-to-bottom on every interaction, Gradio
|
| 25 |
+
builds a persistent component graph once and fires explicit handlers. So the
|
| 26 |
+
"stage" that Streamlit kept in session_state and branched on is here a set of
|
| 27 |
+
Columns whose `visible` flag every handler returns. Same state machine — declared
|
| 28 |
+
once instead of re-derived per rerun.
|
| 29 |
+
|
| 30 |
+
Run:
|
| 31 |
+
python nova_app.py # from inside the NOVA/ folder
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
from ui import paths # noqa: F401 — MUST be first: wires sys.path + chdir + .env
|
| 35 |
+
|
| 36 |
+
import shutil
|
| 37 |
+
import uuid
|
| 38 |
+
|
| 39 |
+
import gradio as gr
|
| 40 |
+
|
| 41 |
+
from ui.agents import load_agents, warm_chatbot_models_async
|
| 42 |
+
from ui.chat_engine import prepare_chat_stream
|
| 43 |
+
from ui.constants import CLUSTER_ACCENTS, SOURCE_COLORS
|
| 44 |
+
from ui.intent_text import join_intent_sections, split_intent_sections
|
| 45 |
+
from ui.papers import author_line, first_available
|
| 46 |
+
from ui.paths import DOWNLOADS_DIR, VECTORSTORES_DIR
|
| 47 |
+
from ui.search_progress import search_steps_html
|
| 48 |
+
from ui.sonic import SONIC_AVATAR, SONIC_DATA_URI, USER_AVATAR, sonic_says
|
| 49 |
+
from ui.theme import CSS, FORCE_DARK, NOVA_THEME
|
| 50 |
+
|
| 51 |
+
# ---------------------------------------------------------------------------
|
| 52 |
+
# 1. SESSION STATE
|
| 53 |
+
# ---------------------------------------------------------------------------
|
| 54 |
+
STAGE_NAMES = ("boot", "welcome", "refining", "review", "searching", "results", "chat")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def new_state() -> dict:
|
| 58 |
+
"""One of these per browser session. Gradio deep-copies it into each new
|
| 59 |
+
session, so the mutable members below are never shared across users."""
|
| 60 |
+
return {
|
| 61 |
+
"run_id": "",
|
| 62 |
+
"user_query": "",
|
| 63 |
+
"problem": "",
|
| 64 |
+
"objective": "",
|
| 65 |
+
"context": "",
|
| 66 |
+
"clusters": [],
|
| 67 |
+
"papers_by_key": {}, # normalized_title -> full record (flattened, for chat lookup)
|
| 68 |
+
"active_chat": None, # normalized_title of the paper being chatted, or None
|
| 69 |
+
"chats": {}, # normalized_title -> {retriever, chain, messages, title}
|
| 70 |
+
"source_status": {}, # {"Semantic Scholar": {"state": "rate_limited", ...}, ...}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def wipe_disk_cache():
|
| 75 |
+
"""Delete every cached vectorstore and downloaded PDF so a new search starts
|
| 76 |
+
from a clean slate — no old paper's chunks or PDFs can leak in."""
|
| 77 |
+
for folder in (VECTORSTORES_DIR, DOWNLOADS_DIR):
|
| 78 |
+
try:
|
| 79 |
+
if folder.exists():
|
| 80 |
+
shutil.rmtree(folder, ignore_errors=True)
|
| 81 |
+
folder.mkdir(exist_ok=True)
|
| 82 |
+
except Exception:
|
| 83 |
+
pass
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _stages(active: str):
|
| 87 |
+
"""Visibility updates for every stage Column, in STAGE_NAMES order."""
|
| 88 |
+
return tuple(gr.update(visible=(name == active)) for name in STAGE_NAMES)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# ---------------------------------------------------------------------------
|
| 92 |
+
# 2. STATIC MARKUP
|
| 93 |
+
# ---------------------------------------------------------------------------
|
| 94 |
+
HEADER_HTML = """
|
| 95 |
+
<div class="nova-brand">
|
| 96 |
+
<span class="nova-star">✦</span>
|
| 97 |
+
<span class="nova-mark">NOVA</span>
|
| 98 |
+
<span class="nova-sub">Research Assistant</span>
|
| 99 |
+
<span class="sonic-chip"><span class="sonic-dot"></span> SONIC online</span>
|
| 100 |
+
</div>
|
| 101 |
+
"""
|
| 102 |
+
|
| 103 |
+
HERO_FIGURE_HTML = (
|
| 104 |
+
f'<div class="hero-figure"><img src="{SONIC_DATA_URI}" alt="SONIC"/>'
|
| 105 |
+
f'<div class="hero-name">SONIC · your research buddy</div></div>'
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
HERO_SPEECH_HTML = """
|
| 109 |
+
<div class="hero-speech">
|
| 110 |
+
<div class="sonic-name">SONIC</div>
|
| 111 |
+
hey, wass up 👋<br>what's on your mind about research today?<br>
|
| 112 |
+
Dump the raw idea on me — the messier the better. I'll shape it into something sharp.
|
| 113 |
+
</div>
|
| 114 |
+
<div class="hero-answer-label">✍️ your answer</div>
|
| 115 |
+
"""
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def boot_html(phase: str, pct: int) -> str:
|
| 119 |
+
return (
|
| 120 |
+
f'<div class="vec-wrap">'
|
| 121 |
+
f' <div class="vec-figure"><img src="{SONIC_DATA_URI}" alt="SONIC"/></div>'
|
| 122 |
+
f' <div class="vec-quote"><span class="q">SONIC:</span> “{phase}”</div>'
|
| 123 |
+
f' <div class="vec-bar"><div class="vec-fill" style="width:{pct}%"></div></div>'
|
| 124 |
+
f'</div>'
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def source_status_html(status: dict) -> str:
|
| 129 |
+
"""Show per-source status so a rate-limited/failed source is never invisible."""
|
| 130 |
+
if not status:
|
| 131 |
+
return ""
|
| 132 |
+
chips = []
|
| 133 |
+
for name, s in status.items():
|
| 134 |
+
state = (s or {}).get("state")
|
| 135 |
+
if state == "ok":
|
| 136 |
+
chips.append(f'<span class="src-stat ok">{name} ✓ {s.get("count", 0)}</span>')
|
| 137 |
+
elif state == "rate_limited":
|
| 138 |
+
chips.append(f'<span class="src-stat warn">{name} ⚠ rate-limited (HTTP {s.get("http", 429)})</span>')
|
| 139 |
+
elif state == "error":
|
| 140 |
+
detail = s.get("detail") or f'HTTP {s.get("http", "?")}'
|
| 141 |
+
chips.append(f'<span class="src-stat err">{name} ✕ {detail}</span>')
|
| 142 |
+
else:
|
| 143 |
+
chips.append(f'<span class="src-stat muted">{name} —</span>')
|
| 144 |
+
return '<div class="src-stat-row">' + "".join(chips) + "</div>"
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def paper_card_html(norm_title: str, record: dict) -> str:
|
| 148 |
+
title = record.get("title") or norm_title.title()
|
| 149 |
+
badges = "".join(
|
| 150 |
+
f'<span class="src-badge" style="color:{SOURCE_COLORS.get(s, "#8b93a7")};'
|
| 151 |
+
f'border-color:{SOURCE_COLORS.get(s, "#8b93a7")}55;'
|
| 152 |
+
f'background:{SOURCE_COLORS.get(s, "#8b93a7")}18;">{s}</span>'
|
| 153 |
+
for s in (record.get("source") or [])
|
| 154 |
+
)
|
| 155 |
+
return (
|
| 156 |
+
f'<div class="paper-title">{title}</div>'
|
| 157 |
+
f'<div class="paper-meta">{author_line(record.get("authors"), record.get("year"))}<br>{badges}</div>'
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def card_links_html(page_url: str, pdf_url: str) -> str:
|
| 162 |
+
"""The 📄 Paper / ⬇ PDF pair. Plain anchors rather than gr.Button: they're
|
| 163 |
+
pure navigation, and a real <a> opens a new tab with no server round-trip."""
|
| 164 |
+
paper = (f'<a class="card-link" href="{page_url}" target="_blank" rel="noopener">📄 Paper</a>'
|
| 165 |
+
if page_url else '<span class="card-link dead">📄 Paper</span>')
|
| 166 |
+
pdf = (f'<a class="card-link" href="{pdf_url}" target="_blank" rel="noopener">⬇ PDF</a>'
|
| 167 |
+
if pdf_url else '<span class="card-link dead">⬇ PDF</span>')
|
| 168 |
+
return f'<div class="card-links">{paper}{pdf}</div>'
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
# ---------------------------------------------------------------------------
|
| 172 |
+
# 3. HANDLERS THAT TOUCH NO COMPONENTS
|
| 173 |
+
# ---------------------------------------------------------------------------
|
| 174 |
+
def open_chat_for(norm_title: str):
|
| 175 |
+
"""Build a per-card click handler. The card grid is generated in a loop, so
|
| 176 |
+
each button needs to close over its own paper key."""
|
| 177 |
+
def _open(st):
|
| 178 |
+
st["active_chat"] = norm_title
|
| 179 |
+
record = st["papers_by_key"].get(norm_title, {})
|
| 180 |
+
title = record.get("title") or norm_title.title()
|
| 181 |
+
head = (f'<div class="cluster-head"><div class="cluster-bar" style="background:#7c5cff;"></div>'
|
| 182 |
+
f'<div class="cluster-title">💬 {title}</div></div>')
|
| 183 |
+
cached = st["chats"].get(norm_title)
|
| 184 |
+
return (*_stages("chat"), head,
|
| 185 |
+
gr.update(value="", visible=not cached),
|
| 186 |
+
gr.update(value=(cached["messages"] if cached else []), visible=bool(cached)),
|
| 187 |
+
gr.update(visible=bool(cached)),
|
| 188 |
+
st)
|
| 189 |
+
return _open
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def prep_chat(st):
|
| 193 |
+
"""Download + vectorize this paper, animating SONIC's pep-quotes while the
|
| 194 |
+
real work runs on a worker thread. No-op if this chat is already built."""
|
| 195 |
+
key = st["active_chat"]
|
| 196 |
+
if not key or key in st["chats"]:
|
| 197 |
+
return
|
| 198 |
+
|
| 199 |
+
record = st["papers_by_key"].get(key, {})
|
| 200 |
+
session = error = None
|
| 201 |
+
for html, done, session, error in prepare_chat_stream(record):
|
| 202 |
+
if not done:
|
| 203 |
+
yield (gr.update(value=html, visible=True), gr.update(visible=False),
|
| 204 |
+
gr.update(visible=False), st)
|
| 205 |
+
|
| 206 |
+
if error or not session:
|
| 207 |
+
msg = error or "Couldn't prepare this paper for chat."
|
| 208 |
+
yield (gr.update(value=f'<div class="nova-error">{msg}</div>', visible=True),
|
| 209 |
+
gr.update(visible=False), gr.update(visible=False), st)
|
| 210 |
+
return
|
| 211 |
+
|
| 212 |
+
session.update({"messages": [], "title": record.get("title") or key.title()})
|
| 213 |
+
st["chats"][key] = session
|
| 214 |
+
yield (gr.update(value="", visible=False), gr.update(value=[], visible=True),
|
| 215 |
+
gr.update(visible=True), st)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
# ---------------------------------------------------------------------------
|
| 219 |
+
# 4. THE APP
|
| 220 |
+
# ---------------------------------------------------------------------------
|
| 221 |
+
# Gradio 6 moved theme/css/js off the Blocks constructor and onto launch().
|
| 222 |
+
with gr.Blocks(title="NOVA · Research Assistant", analytics_enabled=False) as demo:
|
| 223 |
+
state = gr.State(new_state())
|
| 224 |
+
# Mirrors state["clusters"]. gr.render can't watch a dict mutated in place,
|
| 225 |
+
# so the search handler reassigns this to a fresh list to trigger a redraw.
|
| 226 |
+
clusters_state = gr.State([])
|
| 227 |
+
|
| 228 |
+
with gr.Column(elem_id="nova-root"):
|
| 229 |
+
gr.HTML(HEADER_HTML)
|
| 230 |
+
|
| 231 |
+
# ---------------- BOOT ----------------
|
| 232 |
+
with gr.Column(visible=True) as boot_col:
|
| 233 |
+
boot_panel = gr.HTML(boot_html("Waking up SONIC — loading the research + reading models…", 8))
|
| 234 |
+
|
| 235 |
+
# ---------------- WELCOME ----------------
|
| 236 |
+
with gr.Column(visible=False) as welcome_col:
|
| 237 |
+
with gr.Row(equal_height=False):
|
| 238 |
+
with gr.Column(scale=9):
|
| 239 |
+
gr.HTML(HERO_FIGURE_HTML)
|
| 240 |
+
with gr.Column(scale=11):
|
| 241 |
+
gr.HTML(HERO_SPEECH_HTML)
|
| 242 |
+
query_box = gr.Textbox(
|
| 243 |
+
lines=6, max_lines=12, show_label=False, container=False,
|
| 244 |
+
placeholder="e.g. I want to compare fuel efficiency of human-driven vs RL-controlled "
|
| 245 |
+
"cars in car-following… comparing is hard because velocity, acceleration, "
|
| 246 |
+
"headway all change at once…",
|
| 247 |
+
)
|
| 248 |
+
go_btn = gr.Button("Let's go ✦", variant="primary")
|
| 249 |
+
|
| 250 |
+
# ---------------- REFINING ----------------
|
| 251 |
+
with gr.Column(visible=False) as refining_col:
|
| 252 |
+
gr.HTML(sonic_says("that seems great — lemme juss refine it ✨"))
|
| 253 |
+
refining_panel = gr.HTML()
|
| 254 |
+
|
| 255 |
+
# ---------------- REVIEW ----------------
|
| 256 |
+
with gr.Column(visible=False) as review_col:
|
| 257 |
+
gr.HTML(sonic_says("here's how I framed it. Tweak anything that's off, then I'll go hunting 🔍"))
|
| 258 |
+
gr.HTML('<div class="field-label">🧩 Problem</div>')
|
| 259 |
+
problem_box = gr.Textbox(lines=5, show_label=False, container=False)
|
| 260 |
+
gr.HTML('<div class="field-label">🎯 Objective</div>')
|
| 261 |
+
objective_box = gr.Textbox(lines=4, show_label=False, container=False)
|
| 262 |
+
gr.HTML('<div class="field-label">🗂️ Additional Context</div>')
|
| 263 |
+
context_box = gr.Textbox(lines=4, show_label=False, container=False)
|
| 264 |
+
with gr.Row():
|
| 265 |
+
find_btn = gr.Button("Find the papers 🔍", variant="primary", scale=2)
|
| 266 |
+
over_btn = gr.Button("Start over", scale=1)
|
| 267 |
+
gr.HTML("") # spacer: keeps the two buttons off full width
|
| 268 |
+
|
| 269 |
+
# ---------------- SEARCHING ----------------
|
| 270 |
+
with gr.Column(visible=False) as searching_col:
|
| 271 |
+
gr.HTML(sonic_says("on it — scouring arXiv, Semantic Scholar & OpenAlex, then reranking "
|
| 272 |
+
"and clustering by approach 🔎"))
|
| 273 |
+
search_panel = gr.HTML()
|
| 274 |
+
|
| 275 |
+
# ---------------- RESULTS ----------------
|
| 276 |
+
# Body is filled in by the @gr.render below, once every component it
|
| 277 |
+
# needs to drive (the chat stage) exists.
|
| 278 |
+
with gr.Column(visible=False) as results_col:
|
| 279 |
+
results_head = gr.HTML()
|
| 280 |
+
with gr.Row():
|
| 281 |
+
new_search_btn = gr.Button("🔄 New search", scale=1)
|
| 282 |
+
gr.HTML("") # spacer
|
| 283 |
+
|
| 284 |
+
# ---------------- CHAT ----------------
|
| 285 |
+
with gr.Column(visible=False) as chat_col:
|
| 286 |
+
with gr.Row():
|
| 287 |
+
back_btn = gr.Button("← Back to papers", scale=1)
|
| 288 |
+
gr.HTML("") # spacer
|
| 289 |
+
chat_title = gr.HTML()
|
| 290 |
+
chat_vec = gr.HTML()
|
| 291 |
+
# Gradio 6 speaks the {"role","content"} message format natively —
|
| 292 |
+
# no type="messages" to opt into it any more.
|
| 293 |
+
chatbot = gr.Chatbot(
|
| 294 |
+
height=520, show_label=False, visible=False,
|
| 295 |
+
elem_id="nova-chat", avatar_images=(USER_AVATAR, SONIC_AVATAR),
|
| 296 |
+
placeholder="ask me anything about this paper — I've read every page 📄",
|
| 297 |
+
)
|
| 298 |
+
with gr.Row(visible=False) as chat_input_row:
|
| 299 |
+
chat_input = gr.Textbox(show_label=False, container=False, scale=9,
|
| 300 |
+
placeholder="Ask about this paper…")
|
| 301 |
+
send_btn = gr.Button("Send", variant="primary", scale=1)
|
| 302 |
+
|
| 303 |
+
STAGE_COLS = [boot_col, welcome_col, refining_col, review_col, searching_col, results_col, chat_col]
|
| 304 |
+
|
| 305 |
+
# Re-enter the results Column now that the chat components exist, so each
|
| 306 |
+
# card's "Chat it out" button can wire straight into them.
|
| 307 |
+
with results_col:
|
| 308 |
+
@gr.render(inputs=[clusters_state, state], triggers=[clusters_state.change])
|
| 309 |
+
def draw_results(clusters, st):
|
| 310 |
+
"""Redrawn whenever a search completes. Streamlit rebuilt this grid on
|
| 311 |
+
every rerun for free; in Gradio the per-card buttons need real event
|
| 312 |
+
handlers, so the whole thing is (re)declared here."""
|
| 313 |
+
if not clusters:
|
| 314 |
+
return
|
| 315 |
+
for i, cluster in enumerate(clusters):
|
| 316 |
+
papers = cluster.get("papers") or {}
|
| 317 |
+
if not papers:
|
| 318 |
+
continue
|
| 319 |
+
accent = CLUSTER_ACCENTS[i % len(CLUSTER_ACCENTS)]
|
| 320 |
+
gr.HTML(
|
| 321 |
+
f'<div class="cluster-head">'
|
| 322 |
+
f' <div class="cluster-bar" style="background:{accent};"></div>'
|
| 323 |
+
f' <div class="cluster-title">{cluster.get("label", "Approach")}</div>'
|
| 324 |
+
f'</div>'
|
| 325 |
+
)
|
| 326 |
+
if cluster.get("rationale"):
|
| 327 |
+
gr.HTML(f'<div class="cluster-why">{cluster["rationale"]}</div>')
|
| 328 |
+
|
| 329 |
+
items = list(papers.items())
|
| 330 |
+
for row_start in range(0, len(items), 2):
|
| 331 |
+
with gr.Row(equal_height=True):
|
| 332 |
+
for norm_title, record in items[row_start:row_start + 2]:
|
| 333 |
+
with gr.Column(elem_classes=["paper-card"]):
|
| 334 |
+
gr.HTML(paper_card_html(norm_title, record))
|
| 335 |
+
page_url = first_available(record.get("url"))
|
| 336 |
+
pdf_url = first_available(record.get("pdf_url"))
|
| 337 |
+
gr.HTML(card_links_html(page_url, pdf_url))
|
| 338 |
+
# The real PDF is resolved/verified on click (the
|
| 339 |
+
# HYBRID deep step), so any link is enough to try.
|
| 340 |
+
chat_btn = gr.Button(
|
| 341 |
+
"💬 Chat it out", variant="primary", size="sm",
|
| 342 |
+
interactive=bool(page_url or pdf_url),
|
| 343 |
+
)
|
| 344 |
+
chat_btn.click(
|
| 345 |
+
open_chat_for(norm_title),
|
| 346 |
+
inputs=[state],
|
| 347 |
+
outputs=[*STAGE_COLS, chat_title, chat_vec, chatbot,
|
| 348 |
+
chat_input_row, state],
|
| 349 |
+
).then(
|
| 350 |
+
prep_chat,
|
| 351 |
+
inputs=[state],
|
| 352 |
+
outputs=[chat_vec, chatbot, chat_input_row, state],
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
# -----------------------------------------------------------------------
|
| 356 |
+
# 5. WIRING
|
| 357 |
+
# -----------------------------------------------------------------------
|
| 358 |
+
def do_boot():
|
| 359 |
+
"""Runs once per page load, behind the splash.
|
| 360 |
+
|
| 361 |
+
Only the agents load synchronously — the chatbot's ~1.5 GB of embedding +
|
| 362 |
+
cross-encoder weights warm on a background thread. The Streamlit build
|
| 363 |
+
blocked on both, which is why it sat on this splash forever on a small host.
|
| 364 |
+
"""
|
| 365 |
+
yield (*_stages("boot"), boot_html("Waking up SONIC — loading the research + reading models…", 35))
|
| 366 |
+
try:
|
| 367 |
+
load_agents()
|
| 368 |
+
except Exception as e:
|
| 369 |
+
yield (*_stages("boot"),
|
| 370 |
+
f'<div class="nova-error">SONIC couldn\'t wake up: {type(e).__name__}: {e}<br>'
|
| 371 |
+
f'Check that GROQ_API_KEY / SECOND_GROQ_API_KEY / TAVILY_API_KEY are set.</div>')
|
| 372 |
+
return
|
| 373 |
+
warm_chatbot_models_async()
|
| 374 |
+
yield (*_stages("welcome"), "")
|
| 375 |
+
|
| 376 |
+
demo.load(do_boot, outputs=[*STAGE_COLS, boot_panel])
|
| 377 |
+
|
| 378 |
+
def go(query, st):
|
| 379 |
+
"""WELCOME -> REFINING -> REVIEW. Runs the INTENT graph up to its
|
| 380 |
+
human-review interrupt, then hands the framed sections to the form."""
|
| 381 |
+
if not (query or "").strip():
|
| 382 |
+
gr.Warning("Give me something to work with first 🙂")
|
| 383 |
+
yield (*_stages("welcome"), gr.update(), gr.update(), gr.update(), st)
|
| 384 |
+
return
|
| 385 |
+
|
| 386 |
+
st["user_query"] = query.strip()
|
| 387 |
+
st["run_id"] = str(uuid.uuid4())
|
| 388 |
+
yield (*_stages("refining"), gr.update(), gr.update(), gr.update(), st)
|
| 389 |
+
|
| 390 |
+
intent_graph, _ = load_agents()
|
| 391 |
+
config = {"configurable": {"thread_id": st["run_id"]}}
|
| 392 |
+
try:
|
| 393 |
+
result = intent_graph.invoke({"user_query": st["user_query"], "run_id": st["run_id"]},
|
| 394 |
+
config=config)
|
| 395 |
+
payload = result["__interrupt__"][0].value
|
| 396 |
+
problem, objective, context = split_intent_sections(payload["polished_research_intent"])
|
| 397 |
+
except Exception as e:
|
| 398 |
+
gr.Warning(f"Something went sideways: {type(e).__name__}: {e}")
|
| 399 |
+
yield (*_stages("welcome"), gr.update(), gr.update(), gr.update(), st)
|
| 400 |
+
return
|
| 401 |
+
|
| 402 |
+
st["problem"], st["objective"], st["context"] = problem, objective, context
|
| 403 |
+
yield (*_stages("review"), problem, objective, context, st)
|
| 404 |
+
|
| 405 |
+
go_btn.click(go, inputs=[query_box, state],
|
| 406 |
+
outputs=[*STAGE_COLS, problem_box, objective_box, context_box, state])
|
| 407 |
+
|
| 408 |
+
def find(problem, objective, context, st):
|
| 409 |
+
"""REVIEW -> SEARCHING -> RESULTS. Resumes the INTENT graph past its
|
| 410 |
+
interrupt, then STREAMS the SEARCH + CLUSTER graph so the checklist ticks
|
| 411 |
+
each step off live instead of hanging on one spinner."""
|
| 412 |
+
from langgraph.types import Command
|
| 413 |
+
|
| 414 |
+
if not (problem or "").strip() or not (objective or "").strip():
|
| 415 |
+
gr.Warning("Problem and Objective can't be empty.")
|
| 416 |
+
yield (*_stages("review"), gr.update(), gr.update(), st, gr.update())
|
| 417 |
+
return
|
| 418 |
+
|
| 419 |
+
st["problem"], st["objective"], st["context"] = problem, objective, context
|
| 420 |
+
yield (*_stages("searching"), search_steps_html(set(), {}), gr.update(), st, gr.update())
|
| 421 |
+
|
| 422 |
+
intent_graph, search_graph = load_agents()
|
| 423 |
+
config = {"configurable": {"thread_id": st["run_id"]}}
|
| 424 |
+
edited_intent = join_intent_sections(problem, objective, context)
|
| 425 |
+
|
| 426 |
+
try:
|
| 427 |
+
resume_result = intent_graph.invoke(Command(resume=edited_intent), config=config)
|
| 428 |
+
human_verified_intent = resume_result["human_verified_intent"]
|
| 429 |
+
|
| 430 |
+
completed, counts, final_state = set(), {}, {}
|
| 431 |
+
source_field = {"arxiv": "arXiv_paper", "semantic_scholar": "Semantic_Scholar_paper",
|
| 432 |
+
"open_alex": "Open_Alex_paper"}
|
| 433 |
+
for update in search_graph.stream(
|
| 434 |
+
{"ResearchIntent": human_verified_intent, "run_id": st["run_id"]},
|
| 435 |
+
stream_mode="updates",
|
| 436 |
+
):
|
| 437 |
+
for node_name, delta in update.items():
|
| 438 |
+
completed.add(node_name)
|
| 439 |
+
if isinstance(delta, dict):
|
| 440 |
+
final_state.update(delta)
|
| 441 |
+
if node_name in source_field:
|
| 442 |
+
counts[node_name] = len(delta.get(source_field[node_name]) or [])
|
| 443 |
+
yield (*_stages("searching"), search_steps_html(completed, counts),
|
| 444 |
+
gr.update(), st, gr.update())
|
| 445 |
+
except Exception as e:
|
| 446 |
+
gr.Warning(f"Something went sideways: {type(e).__name__}: {e}")
|
| 447 |
+
yield (*_stages("review"), gr.update(), gr.update(), st, gr.update())
|
| 448 |
+
return
|
| 449 |
+
|
| 450 |
+
clusters = final_state.get("clustered_papers") or []
|
| 451 |
+
# flatten every paper into a lookup keyed by its normalized_title (the
|
| 452 |
+
# cluster dict key) so "Chat it out" can find the record anywhere.
|
| 453 |
+
papers_by_key = {}
|
| 454 |
+
for cluster in clusters:
|
| 455 |
+
for norm_title, record in (cluster.get("papers") or {}).items():
|
| 456 |
+
papers_by_key[norm_title] = record
|
| 457 |
+
|
| 458 |
+
st["clusters"] = clusters
|
| 459 |
+
st["papers_by_key"] = papers_by_key
|
| 460 |
+
st["source_status"] = {
|
| 461 |
+
"arXiv": final_state.get("arxiv_status") or {},
|
| 462 |
+
"Semantic Scholar": final_state.get("semantic_scholar_status") or {},
|
| 463 |
+
"OpenAlex": final_state.get("open_alex_status") or {},
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
total = sum(len(c.get("papers") or {}) for c in clusters)
|
| 467 |
+
if total == 0:
|
| 468 |
+
head = sonic_says("hmm, I couldn't pull solid matches for that one — see the source status below. "
|
| 469 |
+
"If a source is rate-limited, that's usually why. Try again in a bit, or "
|
| 470 |
+
"loosen the framing.")
|
| 471 |
+
else:
|
| 472 |
+
head = sonic_says(f"these are the best matches 🎯<br>{total} papers, grouped into "
|
| 473 |
+
f"{len(clusters)} approaches. Hit <b>Chat it out</b> on any paper to "
|
| 474 |
+
f"actually talk to it.")
|
| 475 |
+
head += source_status_html(st["source_status"])
|
| 476 |
+
|
| 477 |
+
yield (*_stages("results"), gr.update(), head, st, list(clusters))
|
| 478 |
+
|
| 479 |
+
find_btn.click(find, inputs=[problem_box, objective_box, context_box, state],
|
| 480 |
+
outputs=[*STAGE_COLS, search_panel, results_head, state, clusters_state])
|
| 481 |
+
|
| 482 |
+
def start_over():
|
| 483 |
+
"""Full memory refresh: clear this session's results + open chats, and wipe
|
| 484 |
+
the on-disk vectorstore/PDF caches too."""
|
| 485 |
+
wipe_disk_cache()
|
| 486 |
+
return (*_stages("welcome"), "", new_state(), [])
|
| 487 |
+
|
| 488 |
+
over_btn.click(start_over, outputs=[*STAGE_COLS, query_box, state, clusters_state])
|
| 489 |
+
new_search_btn.click(start_over, outputs=[*STAGE_COLS, query_box, state, clusters_state])
|
| 490 |
+
|
| 491 |
+
def answer(message, st):
|
| 492 |
+
"""Stream one grounded answer, then append the page citations."""
|
| 493 |
+
from langchain_core.messages import AIMessage, HumanMessage
|
| 494 |
+
from Qa import format_docs
|
| 495 |
+
|
| 496 |
+
message = (message or "").strip()
|
| 497 |
+
if not message or not st.get("active_chat"):
|
| 498 |
+
yield gr.update(), ""
|
| 499 |
+
return
|
| 500 |
+
|
| 501 |
+
session = st["chats"][st["active_chat"]]
|
| 502 |
+
session["messages"].append({"role": "user", "content": message})
|
| 503 |
+
yield [dict(m) for m in session["messages"]], ""
|
| 504 |
+
|
| 505 |
+
# LangChain chat history from prior turns (excludes the just-added question)
|
| 506 |
+
history = [HumanMessage(content=m["content"]) if m["role"] == "user"
|
| 507 |
+
else AIMessage(content=m["content"])
|
| 508 |
+
for m in session["messages"][:-1]]
|
| 509 |
+
|
| 510 |
+
try:
|
| 511 |
+
docs = session["retriever"].invoke(message)
|
| 512 |
+
context = format_docs(docs)
|
| 513 |
+
session["messages"].append({"role": "assistant", "content": ""})
|
| 514 |
+
for chunk in session["chain"].stream(
|
| 515 |
+
{"question": message, "chat_history": history, "context": context}
|
| 516 |
+
):
|
| 517 |
+
if chunk.content:
|
| 518 |
+
session["messages"][-1]["content"] += chunk.content
|
| 519 |
+
yield [dict(m) for m in session["messages"]], ""
|
| 520 |
+
pages = sorted({f"p.{d.metadata.get('page')}" for d in docs})
|
| 521 |
+
if pages:
|
| 522 |
+
session["messages"][-1]["content"] += "\n\n*sources: " + ", ".join(pages) + "*"
|
| 523 |
+
except Exception as e:
|
| 524 |
+
session["messages"].append(
|
| 525 |
+
{"role": "assistant",
|
| 526 |
+
"content": f"Sorry — I hit an error answering that: {type(e).__name__}: {e}"}
|
| 527 |
+
)
|
| 528 |
+
yield [dict(m) for m in session["messages"]], ""
|
| 529 |
+
|
| 530 |
+
for trigger in (chat_input.submit, send_btn.click):
|
| 531 |
+
trigger(answer, inputs=[chat_input, state], outputs=[chatbot, chat_input])
|
| 532 |
+
|
| 533 |
+
def back_to_papers(st):
|
| 534 |
+
st["active_chat"] = None
|
| 535 |
+
return (*_stages("results"), st)
|
| 536 |
+
|
| 537 |
+
back_btn.click(back_to_papers, inputs=[state], outputs=[*STAGE_COLS, state])
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
if __name__ == "__main__":
|
| 541 |
+
demo.queue(default_concurrency_limit=4).launch(
|
| 542 |
+
theme=NOVA_THEME, css=CSS, js=FORCE_DARK,
|
| 543 |
+
server_name="0.0.0.0", server_port=7860,
|
| 544 |
+
)
|
requirements.txt
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# NOVA = research pipeline (agent) + single-PDF Q&A (chatbot) + Gradio UI.
|
| 2 |
+
# One environment runs all three. Versions below match what both sub-projects
|
| 3 |
+
# were already pinned to, so nothing gets upgraded out from under them.
|
| 4 |
+
|
| 5 |
+
# --- ZeroGPU ---
|
| 6 |
+
# `spaces` provides the @spaces.GPU decorator. ZeroGPU refuses to start a Space
|
| 7 |
+
# that has none ("No @spaces.GPU function detected during startup"), and it only
|
| 8 |
+
# attaches a GPU for the duration of such a call. See ui/gpu.py for the one
|
| 9 |
+
# function that uses it. Off ZeroGPU the decorator is a transparent no-op, so
|
| 10 |
+
# this package is harmless locally and on CPU hardware.
|
| 11 |
+
spaces
|
| 12 |
+
|
| 13 |
+
# --- torch ---
|
| 14 |
+
# 2.11.0 exactly: ZeroGPU accepts only 2.11.0 / 2.10.0 / 2.9.1 / 2.8.0 and fails
|
| 15 |
+
# the build at config-check otherwise. 2.11.0 is the newest it allows.
|
| 16 |
+
#
|
| 17 |
+
# Plain (CUDA) build on purpose — ZeroGPU rejects the `+cpu` local version, since
|
| 18 |
+
# it needs a CUDA-capable torch to hand the GPU over. That means ~2.5 GB of
|
| 19 |
+
# nvidia-* wheels; unavoidable on this hardware.
|
| 20 |
+
#
|
| 21 |
+
# For CPU-only hardware (Spaces "CPU basic", or a local box), these lines are
|
| 22 |
+
# smaller and faster and still work — NOVA falls back to CPU automatically:
|
| 23 |
+
# --extra-index-url https://download.pytorch.org/whl/cpu
|
| 24 |
+
# torch==2.11.0+cpu ; sys_platform == "linux"
|
| 25 |
+
# torch ; sys_platform != "linux"
|
| 26 |
+
torch==2.11.0
|
| 27 |
+
|
| 28 |
+
# --- UI ---
|
| 29 |
+
# 6.0 is a hard floor, not caution: launch() takes theme/css/js (Blocks did in 5),
|
| 30 |
+
# and Chatbot speaks {"role","content"} natively (5 needed type="messages").
|
| 31 |
+
gradio>=6.0
|
| 32 |
+
|
| 33 |
+
# --- research agent (intent + search/cluster graphs) ---
|
| 34 |
+
langgraph
|
| 35 |
+
langchain-core
|
| 36 |
+
langchain-groq
|
| 37 |
+
langchain-tavily
|
| 38 |
+
sentence-transformers # SPECTER reranker in the search graph
|
| 39 |
+
python-dotenv
|
| 40 |
+
pydantic
|
| 41 |
+
requests
|
| 42 |
+
numpy
|
| 43 |
+
|
| 44 |
+
# --- chatbot (single-PDF Q&A) ---
|
| 45 |
+
langchain
|
| 46 |
+
langchain-community # HuggingFaceCrossEncoder, HuggingFaceBgeEmbeddings
|
| 47 |
+
langchain-classic # CrossEncoderReranker + ContextualCompressionRetriever
|
| 48 |
+
langchain-text-splitters
|
| 49 |
+
langchain-chroma
|
| 50 |
+
chromadb
|
| 51 |
+
pymupdf # `import fitz`
|
ui/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""NOVA's Gradio UI layer.
|
| 2 |
+
|
| 3 |
+
Everything in here is presentation + wiring. The two backend sub-projects --
|
| 4 |
+
`app/` (the research pipeline) and `chatbot_core/` (the single-PDF Q&A) -- are
|
| 5 |
+
imported and driven, never modified.
|
| 6 |
+
"""
|
ui/agents.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ui/agents.py
|
| 3 |
+
------------
|
| 4 |
+
Heavy model / graph loading, done once per server process and shared by every
|
| 5 |
+
browser session.
|
| 6 |
+
|
| 7 |
+
Streamlit gave this to us for free via @st.cache_resource. Gradio has no
|
| 8 |
+
equivalent, and it serves requests from a thread pool, so a naive "load if
|
| 9 |
+
None" would let two simultaneous first-visitors each start a ~2 GB model load.
|
| 10 |
+
Hence the explicit double-checked locking below: the lock is held across the
|
| 11 |
+
load, and a second caller blocks and then sees the finished object.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import threading
|
| 15 |
+
|
| 16 |
+
_agents_lock = threading.Lock()
|
| 17 |
+
_agents = None
|
| 18 |
+
|
| 19 |
+
_chatbot_lock = threading.Lock()
|
| 20 |
+
_chatbot_models = None
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def load_agents():
|
| 24 |
+
"""Import the compiled LangGraph agents. Importing the search graph also
|
| 25 |
+
loads the SPECTER reranker model at module import time (by design)."""
|
| 26 |
+
global _agents
|
| 27 |
+
if _agents is None:
|
| 28 |
+
with _agents_lock:
|
| 29 |
+
if _agents is None:
|
| 30 |
+
from app.modules.intent.graph import graph as intent_graph
|
| 31 |
+
from app.modules.search.graph import graph as search_graph
|
| 32 |
+
_agents = (intent_graph, search_graph)
|
| 33 |
+
return _agents
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def warm_chatbot_models():
|
| 37 |
+
"""Pre-load the chatbot's embedding + cross-encoder models so the first
|
| 38 |
+
'Chat it out' click doesn't pay the model-load cost. We instantiate the
|
| 39 |
+
exact models the chatbot uses (BAAI/bge-base-en-v1.5 + BAAI/bge-reranker-base),
|
| 40 |
+
warming the weights into the HF/torch cache.
|
| 41 |
+
|
| 42 |
+
Both are pinned to CPU: this runs at boot, outside any ZeroGPU window, and
|
| 43 |
+
its whole job is to pull weights down — the GPU copy is made later, inside
|
| 44 |
+
ui.gpu.vectorize_on_gpu, from the same warmed cache."""
|
| 45 |
+
global _chatbot_models
|
| 46 |
+
if _chatbot_models is None:
|
| 47 |
+
with _chatbot_lock:
|
| 48 |
+
if _chatbot_models is None:
|
| 49 |
+
from vectorizeer import get_embeddings
|
| 50 |
+
from langchain_community.cross_encoders import HuggingFaceCrossEncoder
|
| 51 |
+
embeddings = get_embeddings(device="cpu")
|
| 52 |
+
reranker = HuggingFaceCrossEncoder(
|
| 53 |
+
model_name="BAAI/bge-reranker-base",
|
| 54 |
+
model_kwargs={"device": "cpu"},
|
| 55 |
+
)
|
| 56 |
+
_chatbot_models = (embeddings, reranker)
|
| 57 |
+
return _chatbot_models
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def warm_chatbot_models_async():
|
| 61 |
+
"""Kick the chatbot warm-up onto a daemon thread.
|
| 62 |
+
|
| 63 |
+
The Streamlit app warmed BOTH model sets behind one blocking splash, which
|
| 64 |
+
meant nobody saw a usable page until ~2 GB of weights had downloaded. Only
|
| 65 |
+
the agents are needed to act on the very first click, so we block on those
|
| 66 |
+
and let the chatbot models finish in the background — they have until the
|
| 67 |
+
user has framed an intent, run a search, and picked a paper, which is far
|
| 68 |
+
longer than the load takes. ensure_chat_ready() calls warm_chatbot_models()
|
| 69 |
+
anyway, so if the thread hasn't finished it simply blocks on the same lock.
|
| 70 |
+
"""
|
| 71 |
+
threading.Thread(target=warm_chatbot_models, daemon=True).start()
|
ui/chat_engine.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ui/chat_engine.py
|
| 3 |
+
-----------------
|
| 4 |
+
Chat-prep: turning a paper record into a retriever + chain, in three phases, with
|
| 5 |
+
SONIC's progress bar and pep-quotes animating over the slow bits.
|
| 6 |
+
|
| 7 |
+
`prepare_chat_stream()` is the public entrypoint. It's a generator so the Gradio
|
| 8 |
+
handler can relay each frame straight to the page.
|
| 9 |
+
|
| 10 |
+
The phase split exists because of ZeroGPU. Each phase wants something different:
|
| 11 |
+
|
| 12 |
+
1. fetch — network-bound. No GPU. Threaded, so the bar animates.
|
| 13 |
+
2. vectorize — the expensive encode. THE one GPU step (see ui/gpu.py).
|
| 14 |
+
3. assemble — re-open the persisted store on CPU + build the chain. Fast.
|
| 15 |
+
|
| 16 |
+
Phase 2 is the awkward one: ZeroGPU dispatches to its GPU worker from the calling
|
| 17 |
+
thread, so it must NOT run on a thread we spawned ourselves — which is precisely
|
| 18 |
+
what the original single-threaded-worker design did. On ZeroGPU we therefore call
|
| 19 |
+
it inline and the bar holds still for the few seconds it takes. Off ZeroGPU
|
| 20 |
+
there's no such constraint and the encode is slow (tens of seconds on CPU), so we
|
| 21 |
+
keep the old threaded animation. Same UX where it matters, correctness where it
|
| 22 |
+
counts.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
import random
|
| 26 |
+
import threading
|
| 27 |
+
import time
|
| 28 |
+
|
| 29 |
+
from ui.constants import SONIC_QUOTES
|
| 30 |
+
from ui.gpu import ON_ZEROGPU, vectorize_on_gpu
|
| 31 |
+
from ui.papers import resolve_and_download_pdf
|
| 32 |
+
from ui.sonic import SONIC_DATA_URI
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def vec_loading_html(quote: str, phase: str, pct: int) -> str:
|
| 36 |
+
return (
|
| 37 |
+
f'<div class="vec-wrap">'
|
| 38 |
+
f' <div class="vec-figure"><img src="{SONIC_DATA_URI}" alt="SONIC"/></div>'
|
| 39 |
+
f' <div class="vec-quote"><span class="q">SONIC:</span> “{quote}”</div>'
|
| 40 |
+
f' <div class="vec-bar"><div class="vec-fill" style="width:{pct}%"></div></div>'
|
| 41 |
+
f' <div class="vec-phase">{phase}</div>'
|
| 42 |
+
f'</div>'
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _quote_at(quotes, start):
|
| 47 |
+
"""Rotate every ~4s, as the Streamlit build did."""
|
| 48 |
+
return quotes[int((time.time() - start) // 4) % len(quotes)]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# --- phase workers (touch no UI) -------------------------------------------
|
| 52 |
+
|
| 53 |
+
def _download_blocking(record: dict, holder: dict):
|
| 54 |
+
try:
|
| 55 |
+
pdf_path = resolve_and_download_pdf(record)
|
| 56 |
+
if not pdf_path:
|
| 57 |
+
holder["error"] = ("Couldn't find a readable open-access PDF for this paper — it may be "
|
| 58 |
+
"paywalled. Use the 📄 Paper button to read it on the source site.")
|
| 59 |
+
return
|
| 60 |
+
holder["pdf_path"] = pdf_path
|
| 61 |
+
except Exception as e:
|
| 62 |
+
holder["error"] = f"Couldn't fetch this paper: {type(e).__name__}: {e}"
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _vectorize_blocking(pdf_path: str, holder: dict):
|
| 66 |
+
try:
|
| 67 |
+
vectorize_on_gpu(pdf_path)
|
| 68 |
+
except Exception as e:
|
| 69 |
+
holder["error"] = f"Couldn't read this paper: {type(e).__name__}: {e}"
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _assemble_session(pdf_path: str) -> dict:
|
| 73 |
+
"""Re-open the vectorstore on CPU and build the retriever + chain.
|
| 74 |
+
|
| 75 |
+
Cheap: phase 2 already persisted the vectors, so build_vectorstore
|
| 76 |
+
short-circuits to a plain load. Everything here is CPU-resident by design —
|
| 77 |
+
it outlives the GPU window (see ui/gpu.py).
|
| 78 |
+
"""
|
| 79 |
+
from vectorizeer import build_vectorstore
|
| 80 |
+
from Qa import build_chain, get_llm, get_retriever
|
| 81 |
+
vs = build_vectorstore(pdf_path)
|
| 82 |
+
return {"retriever": get_retriever(vs), "chain": build_chain(get_llm())}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# --- the stream -------------------------------------------------------------
|
| 86 |
+
|
| 87 |
+
def prepare_chat_stream(record: dict):
|
| 88 |
+
"""Yield (html, done, session, error) frames while the paper is fetched and
|
| 89 |
+
vectorized. Every frame before the last has done=False; the final frame
|
| 90 |
+
carries either a session or an error."""
|
| 91 |
+
quotes = random.sample(SONIC_QUOTES, len(SONIC_QUOTES))
|
| 92 |
+
start = time.time()
|
| 93 |
+
holder: dict = {}
|
| 94 |
+
|
| 95 |
+
def fail(msg):
|
| 96 |
+
return vec_loading_html(_quote_at(quotes, start), "…", 100), True, None, msg
|
| 97 |
+
|
| 98 |
+
# 1. FETCH — threaded so the bar moves while the network does its thing.
|
| 99 |
+
worker = threading.Thread(target=_download_blocking, args=(record, holder), daemon=True)
|
| 100 |
+
worker.start()
|
| 101 |
+
pct = 6
|
| 102 |
+
# Emit one frame up front: a cached PDF can finish before the first is_alive()
|
| 103 |
+
# check, and without this the panel would sit blank until the next phase.
|
| 104 |
+
yield vec_loading_html(_quote_at(quotes, start), "Fetching the PDF…", pct), False, None, None
|
| 105 |
+
while worker.is_alive():
|
| 106 |
+
pct = min(pct + 2, 44)
|
| 107 |
+
yield vec_loading_html(_quote_at(quotes, start), "Fetching the PDF…", pct), False, None, None
|
| 108 |
+
time.sleep(0.35)
|
| 109 |
+
worker.join()
|
| 110 |
+
if holder.get("error"):
|
| 111 |
+
yield fail(holder["error"])
|
| 112 |
+
return
|
| 113 |
+
pdf_path = holder["pdf_path"]
|
| 114 |
+
|
| 115 |
+
# 2. VECTORIZE — the GPU step.
|
| 116 |
+
if ON_ZEROGPU:
|
| 117 |
+
# Must run on this thread: ZeroGPU hands the GPU to the caller, and a
|
| 118 |
+
# thread we spawned isn't one. It's only a few seconds on a GPU, so the
|
| 119 |
+
# bar simply holds rather than animating.
|
| 120 |
+
yield vec_loading_html(_quote_at(quotes, start), "Reading & vectorizing every page…", 55), False, None, None
|
| 121 |
+
_vectorize_blocking(pdf_path, holder)
|
| 122 |
+
if holder.get("error"):
|
| 123 |
+
yield fail(holder["error"])
|
| 124 |
+
return
|
| 125 |
+
else:
|
| 126 |
+
# No such constraint on CPU — and here the encode is genuinely slow, so
|
| 127 |
+
# the animation earns its keep.
|
| 128 |
+
worker = threading.Thread(target=_vectorize_blocking, args=(pdf_path, holder), daemon=True)
|
| 129 |
+
worker.start()
|
| 130 |
+
while worker.is_alive():
|
| 131 |
+
pct = min(pct + 2, 88)
|
| 132 |
+
yield vec_loading_html(_quote_at(quotes, start),
|
| 133 |
+
"Reading & vectorizing every page…", pct), False, None, None
|
| 134 |
+
time.sleep(0.35)
|
| 135 |
+
worker.join()
|
| 136 |
+
if holder.get("error"):
|
| 137 |
+
yield fail(holder["error"])
|
| 138 |
+
return
|
| 139 |
+
|
| 140 |
+
# 3. ASSEMBLE — CPU, fast.
|
| 141 |
+
yield vec_loading_html(_quote_at(quotes, start), "Almost there…", 94), False, None, None
|
| 142 |
+
try:
|
| 143 |
+
session = _assemble_session(pdf_path)
|
| 144 |
+
except Exception as e:
|
| 145 |
+
yield fail(f"Couldn't prepare this paper for chat: {type(e).__name__}: {e}")
|
| 146 |
+
return
|
| 147 |
+
|
| 148 |
+
yield vec_loading_html(quotes[0], "Ready.", 100), True, session, None
|
ui/constants.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ui/constants.py
|
| 3 |
+
---------------
|
| 4 |
+
Static data shared across views: cluster accent colors, per-source badge
|
| 5 |
+
colors, and SONIC's motivational one-liners shown while a paper is being
|
| 6 |
+
vectorized.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
# Decorative, index-based — purely visual.
|
| 10 |
+
CLUSTER_ACCENTS = ["#7c5cff", "#22d3ee", "#f59e0b", "#ec4899", "#34d399", "#60a5fa", "#a78bfa", "#f472b6"]
|
| 11 |
+
SOURCE_COLORS = {"arXiv": "#e05263", "SemanticScholar": "#4c8bf5", "OpenAlex": "#2fa572"}
|
| 12 |
+
|
| 13 |
+
# SONIC's motivational one-liners, shown while a paper is being vectorized.
|
| 14 |
+
SONIC_QUOTES = [
|
| 15 |
+
"Great research isn't found — it's framed. You already did the hard part.",
|
| 16 |
+
"Every paper you read is a shortcut someone left for you. Let's decode this one.",
|
| 17 |
+
"Reading fast is fine. Understanding deeply is the flex. Hang tight — I'm doing the deep part.",
|
| 18 |
+
"The best researchers ask better questions, not more of them. Get yours ready.",
|
| 19 |
+
"Curiosity is a muscle. You're clearly training it. Almost there…",
|
| 20 |
+
"One good paper can change the whole direction. Could be this one.",
|
| 21 |
+
"I'm turning every page into something you can just… ask. Two seconds.",
|
| 22 |
+
"Knowledge compounds. This is you, quietly getting sharper.",
|
| 23 |
+
]
|
ui/gpu.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ui/gpu.py
|
| 3 |
+
---------
|
| 4 |
+
ZeroGPU wiring — the only place in NOVA that touches a GPU.
|
| 5 |
+
|
| 6 |
+
HF's ZeroGPU hardware hands a Space a GPU *only* for the duration of a call to an
|
| 7 |
+
@spaces.GPU-decorated function, and refuses to boot at all if it can't find one
|
| 8 |
+
at import time ("No @spaces.GPU function detected during startup"). That single
|
| 9 |
+
constraint drives the whole design here.
|
| 10 |
+
|
| 11 |
+
NOVA is CPU-first and stays that way. Exactly one operation runs on the GPU: the
|
| 12 |
+
bulk embedding of a paper's chunks during vectorizing, which is by far the
|
| 13 |
+
slowest thing in the app (tens of seconds on CPU, a few on GPU). Everything else
|
| 14 |
+
— SPECTER reranking, query embedding, the cross-encoder — is pinned to CPU *on
|
| 15 |
+
purpose*, because those run outside any GPU window and a cuda-resident model
|
| 16 |
+
there would fail on first use. That's why the three backend call sites now take
|
| 17 |
+
an explicit `device` instead of auto-detecting.
|
| 18 |
+
|
| 19 |
+
Off ZeroGPU (local, or Spaces "CPU basic") @spaces.GPU is a transparent
|
| 20 |
+
passthrough and ON_ZEROGPU is False, so this module quietly degrades to plain
|
| 21 |
+
CPU work and nothing else in the app changes.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
import os
|
| 25 |
+
|
| 26 |
+
import spaces
|
| 27 |
+
|
| 28 |
+
# Set by the ZeroGPU runtime; `spaces.config` reads the same variable.
|
| 29 |
+
ON_ZEROGPU = os.getenv("SPACES_ZERO_GPU", "").lower() in ("1", "t", "true")
|
| 30 |
+
|
| 31 |
+
# The device to use *inside* a GPU window. Outside one, always "cpu".
|
| 32 |
+
GPU_DEVICE = "cuda" if ON_ZEROGPU else "cpu"
|
| 33 |
+
|
| 34 |
+
# Generous but bounded. The window has to cover PDF text extraction and chunking
|
| 35 |
+
# (CPU work that unavoidably happens inside build_vectorstore) plus the encode
|
| 36 |
+
# itself. A long paper on a cold cache is the worst case.
|
| 37 |
+
_VECTORIZE_SECONDS = 120
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@spaces.GPU(duration=_VECTORIZE_SECONDS)
|
| 41 |
+
def vectorize_on_gpu(pdf_path: str) -> None:
|
| 42 |
+
"""Build and persist this paper's vectorstore with the embedder on GPU.
|
| 43 |
+
|
| 44 |
+
Returns None deliberately. ZeroGPU runs this in its own GPU worker, so a
|
| 45 |
+
Chroma handle created here would carry a cuda-resident embedding model back
|
| 46 |
+
to a caller that no longer holds the GPU — useless at best, a crash at worst.
|
| 47 |
+
What crosses the boundary is the *persisted vectorstore on disk*, which is
|
| 48 |
+
device-independent.
|
| 49 |
+
|
| 50 |
+
The caller then re-opens it on CPU, which costs nothing: build_vectorstore
|
| 51 |
+
short-circuits to a plain load as soon as the persist dir exists.
|
| 52 |
+
"""
|
| 53 |
+
from vectorizeer import build_vectorstore
|
| 54 |
+
build_vectorstore(pdf_path, device=GPU_DEVICE)
|
ui/intent_text.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ui/intent_text.py
|
| 3 |
+
-----------------
|
| 4 |
+
INTENT text <-> 3-field form conversion.
|
| 5 |
+
|
| 6 |
+
The backend hands us ONE string with three headed sections. We split it into
|
| 7 |
+
three editable fields and MUST rebuild the exact same "Problem: / Objective:
|
| 8 |
+
/ Additional Context:" shape before sending it into the search graph -- its
|
| 9 |
+
prompts assume that literal structure.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import re
|
| 13 |
+
|
| 14 |
+
_SECTION_PATTERN = re.compile(
|
| 15 |
+
r"Problem:\s*(.*?)\n\s*Objective:\s*(.*?)\n\s*Additional Context:\s*(.*)",
|
| 16 |
+
re.S,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def split_intent_sections(text: str):
|
| 21 |
+
m = _SECTION_PATTERN.search(text or "")
|
| 22 |
+
if m:
|
| 23 |
+
return m.group(1).strip(), m.group(2).strip(), m.group(3).strip()
|
| 24 |
+
return (text or "").strip(), "", ""
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def join_intent_sections(problem: str, objective: str, additional_context: str) -> str:
|
| 28 |
+
ac = additional_context.strip() or "None specified."
|
| 29 |
+
return (
|
| 30 |
+
f"Problem:\n{problem.strip()}\n\n"
|
| 31 |
+
f"Objective:\n{objective.strip()}\n\n"
|
| 32 |
+
f"Additional Context:\n{ac}"
|
| 33 |
+
)
|
ui/papers.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ui/papers.py
|
| 3 |
+
------------
|
| 4 |
+
Paper record + PDF helpers: pulling a display value out of a per-source
|
| 5 |
+
dict, formatting an author line, and robustly resolving + downloading a real
|
| 6 |
+
PDF for a paper at "Chat it out" time.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import hashlib
|
| 10 |
+
|
| 11 |
+
import requests
|
| 12 |
+
|
| 13 |
+
from ui.paths import DOWNLOADS_DIR, PDF_UA
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def first_available(d):
|
| 17 |
+
"""First non-empty value in a {source: value} dict (record fields are
|
| 18 |
+
dict-keyed by source after aggregation)."""
|
| 19 |
+
if not isinstance(d, dict):
|
| 20 |
+
return d or None
|
| 21 |
+
for v in d.values():
|
| 22 |
+
if v:
|
| 23 |
+
return v
|
| 24 |
+
return None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def author_line(authors, year):
|
| 28 |
+
authors = authors or []
|
| 29 |
+
if authors:
|
| 30 |
+
shown = ", ".join(authors[:4]) + (" et al." if len(authors) > 4 else "")
|
| 31 |
+
else:
|
| 32 |
+
shown = "Unknown authors"
|
| 33 |
+
return f"{shown} · {year or 'n.d.'}"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
_PDF_MAGIC = b"%PDF"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _download_if_pdf(url: str) -> "str | None":
|
| 40 |
+
"""Download url, but only keep it if the bytes are a real PDF (starts with
|
| 41 |
+
%PDF) — a best-effort link that's actually an HTML landing page returns None
|
| 42 |
+
so we can fall back to deep resolution. Cached by URL hash."""
|
| 43 |
+
if not url:
|
| 44 |
+
return None
|
| 45 |
+
key = hashlib.md5(url.encode("utf-8")).hexdigest()[:16]
|
| 46 |
+
dest = DOWNLOADS_DIR / f"{key}.pdf"
|
| 47 |
+
if dest.is_file() and dest.stat().st_size > 0:
|
| 48 |
+
return str(dest)
|
| 49 |
+
try:
|
| 50 |
+
resp = requests.get(url, headers=PDF_UA, timeout=45, stream=True, allow_redirects=True)
|
| 51 |
+
if resp.status_code != 200:
|
| 52 |
+
return None
|
| 53 |
+
it = resp.iter_content(chunk_size=32768)
|
| 54 |
+
first = next(it, b"")
|
| 55 |
+
if not first.startswith(_PDF_MAGIC):
|
| 56 |
+
return None # HTML landing page or something else, not a PDF
|
| 57 |
+
with open(dest, "wb") as f:
|
| 58 |
+
f.write(first)
|
| 59 |
+
for chunk in it:
|
| 60 |
+
if chunk:
|
| 61 |
+
f.write(chunk)
|
| 62 |
+
return str(dest) if dest.stat().st_size > 0 else None
|
| 63 |
+
except requests.RequestException:
|
| 64 |
+
return None
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def resolve_and_download_pdf(record: dict) -> "str | None":
|
| 68 |
+
"""Robustly obtain a readable PDF for a paper at 'Chat it out' time (the
|
| 69 |
+
HYBRID deep step). Search only stored a cheap best-effort link; here we:
|
| 70 |
+
1) try each best-effort pdf link directly (keep it only if it's a real PDF);
|
| 71 |
+
2) if those are landing pages / dead, scrape the citation_pdf_url meta tag
|
| 72 |
+
off them and off the paper's source page(s), then download + verify.
|
| 73 |
+
Returns a local path, or None if nothing yields real PDF bytes."""
|
| 74 |
+
from app.modules.search.providers.semantic_scholar import _extract_citation_pdf_url
|
| 75 |
+
|
| 76 |
+
pdf_candidates = [v for v in (record.get("pdf_url") or {}).values() if v]
|
| 77 |
+
page_candidates = [v for v in (record.get("url") or {}).values() if v]
|
| 78 |
+
|
| 79 |
+
for cand in pdf_candidates: # 1) direct best-effort PDFs
|
| 80 |
+
path = _download_if_pdf(cand)
|
| 81 |
+
if path:
|
| 82 |
+
return path
|
| 83 |
+
for page in pdf_candidates + page_candidates: # 2) deep: scrape landing pages
|
| 84 |
+
scraped = _extract_citation_pdf_url(page)
|
| 85 |
+
if scraped:
|
| 86 |
+
path = _download_if_pdf(scraped)
|
| 87 |
+
if path:
|
| 88 |
+
return path
|
| 89 |
+
return None
|
ui/paths.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ui/paths.py
|
| 3 |
+
-----------
|
| 4 |
+
Filesystem + import-path wiring for NOVA.
|
| 5 |
+
|
| 6 |
+
This module MUST be the first `ui` import in nova_app.py. It does the sys.path
|
| 7 |
+
+ chdir + .env side effects that make `import app...` (the agent package) and
|
| 8 |
+
`from vectorizeer import ...` / `from Qa import ...` (the chatbot) resolve
|
| 9 |
+
later -- so it has to run before any other `ui` module that needs BASE,
|
| 10 |
+
DOWNLOADS_DIR, VECTORSTORES_DIR, or PDF_UA. Once nova_app.py imports this
|
| 11 |
+
first, every other module gets the same path setup for free via Python's module
|
| 12 |
+
cache (re-importing `ui.paths` elsewhere just returns the already-initialized
|
| 13 |
+
module, it doesn't re-run this file).
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import os
|
| 17 |
+
import sys
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
# ---------------------------------------------------------------------------
|
| 21 |
+
# PATHS — make both sub-projects importable without touching their code
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
BASE = Path(__file__).resolve().parent.parent
|
| 24 |
+
os.chdir(BASE) # ./vectorstores, ./downloads resolve under NOVA/
|
| 25 |
+
sys.path.insert(0, str(BASE)) # `import app...` (the agent package)
|
| 26 |
+
sys.path.insert(0, str(BASE / "chatbot_core")) # `import Qa`, `from vectorizeer import ...`
|
| 27 |
+
|
| 28 |
+
from dotenv import load_dotenv
|
| 29 |
+
load_dotenv(BASE / ".env")
|
| 30 |
+
|
| 31 |
+
DOWNLOADS_DIR = BASE / "downloads"
|
| 32 |
+
DOWNLOADS_DIR.mkdir(exist_ok=True)
|
| 33 |
+
VECTORSTORES_DIR = BASE / "vectorstores"
|
| 34 |
+
|
| 35 |
+
ASSETS_DIR = BASE / "assets"
|
| 36 |
+
ASSETS_DIR.mkdir(exist_ok=True)
|
| 37 |
+
|
| 38 |
+
PDF_UA = {"User-Agent": "Mozilla/5.0 (compatible; NOVA-research/1.0)"}
|
ui/search_progress.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ui/search_progress.py
|
| 3 |
+
---------------------
|
| 4 |
+
Live checklist renderer for the SEARCHING stage: turns the set of completed
|
| 5 |
+
LangGraph node names + per-source paper counts into the step-by-step HTML
|
| 6 |
+
checklist markup.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
_SEARCH_STEPS = [
|
| 10 |
+
("arxiv", "Searching arXiv"),
|
| 11 |
+
("semantic_scholar", "Searching Semantic Scholar"),
|
| 12 |
+
("open_alex", "Searching OpenAlex"),
|
| 13 |
+
("aggregation", "Merging & deduplicating"),
|
| 14 |
+
("reranker", "Re-ranking by relevance (SPECTER)"),
|
| 15 |
+
("clustering", "Clustering by approach"),
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def search_steps_html(completed: set, counts: dict) -> str:
|
| 20 |
+
sources_done = all(s in completed for s in ("arxiv", "semantic_scholar", "open_alex"))
|
| 21 |
+
|
| 22 |
+
def state_of(key):
|
| 23 |
+
if key in completed:
|
| 24 |
+
return "done"
|
| 25 |
+
if key in ("arxiv", "semantic_scholar", "open_alex"):
|
| 26 |
+
return "active"
|
| 27 |
+
if key == "aggregation":
|
| 28 |
+
return "active" if sources_done else "pending"
|
| 29 |
+
if key == "reranker":
|
| 30 |
+
return "active" if "aggregation" in completed else "pending"
|
| 31 |
+
if key == "clustering":
|
| 32 |
+
return "active" if "reranker" in completed else "pending"
|
| 33 |
+
return "pending"
|
| 34 |
+
|
| 35 |
+
rows = []
|
| 36 |
+
for key, label in _SEARCH_STEPS:
|
| 37 |
+
s = state_of(key)
|
| 38 |
+
ic = "✓" if s == "done" else ("•" if s == "pending" else "")
|
| 39 |
+
cnt = f'<span class="step-count">{counts[key]} papers</span>' if key in counts else ""
|
| 40 |
+
rows.append(f'<div class="step-row {s}"><div class="step-ic">{ic}</div><div>{label}</div>{cnt}</div>')
|
| 41 |
+
return '<div class="search-wrap">' + "".join(rows) + "</div>"
|
ui/sonic.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ui/sonic.py
|
| 3 |
+
-----------
|
| 4 |
+
SONIC — the assistant caricature, drawn as an inline SVG (no external asset to
|
| 5 |
+
fetch, so it renders inside Gradio's HTML blocks with no network round-trip).
|
| 6 |
+
A friendly bespectacled researcher: brown quiff, black glasses, big smile,
|
| 7 |
+
white shirt + navy striped tie, suspenders. Used big on the welcome screen and
|
| 8 |
+
small as the chat avatar.
|
| 9 |
+
|
| 10 |
+
Two exports, for two different consumers:
|
| 11 |
+
SONIC_DATA_URI -> for <img src="..."> inside gr.HTML markup
|
| 12 |
+
SONIC_AVATAR -> a real file on disk, because gr.Chatbot's avatar_images
|
| 13 |
+
takes a path/URL and will not accept a data: URI
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import base64
|
| 17 |
+
|
| 18 |
+
from ui.paths import ASSETS_DIR
|
| 19 |
+
|
| 20 |
+
SONIC_SVG = """
|
| 21 |
+
<svg viewBox="0 0 240 280" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="SONIC the research assistant">
|
| 22 |
+
<defs>
|
| 23 |
+
<linearGradient id="s-card" x1="0" y1="0" x2="0" y2="1">
|
| 24 |
+
<stop offset="0" stop-color="#2b2168"/><stop offset="1" stop-color="#141a2e"/>
|
| 25 |
+
</linearGradient>
|
| 26 |
+
<linearGradient id="s-skin" x1="0" y1="0" x2="0" y2="1">
|
| 27 |
+
<stop offset="0" stop-color="#f8d0a8"/><stop offset="1" stop-color="#e7ad7f"/>
|
| 28 |
+
</linearGradient>
|
| 29 |
+
<linearGradient id="s-hair" x1="0" y1="0" x2="0" y2="1">
|
| 30 |
+
<stop offset="0" stop-color="#7d5636"/><stop offset="1" stop-color="#4e3421"/>
|
| 31 |
+
</linearGradient>
|
| 32 |
+
<linearGradient id="s-tie" x1="0" y1="0" x2="0" y2="1">
|
| 33 |
+
<stop offset="0" stop-color="#3a4f8a"/><stop offset="1" stop-color="#26325c"/>
|
| 34 |
+
</linearGradient>
|
| 35 |
+
<clipPath id="s-clip"><rect x="8" y="8" width="224" height="264" rx="34"/></clipPath>
|
| 36 |
+
<clipPath id="s-tieclip"><path d="M115 232 L125 232 L131 279 L120 289 L109 279 Z"/></clipPath>
|
| 37 |
+
</defs>
|
| 38 |
+
<rect x="8" y="8" width="224" height="264" rx="34" fill="url(#s-card)"/>
|
| 39 |
+
<g clip-path="url(#s-clip)">
|
| 40 |
+
<!-- shirt -->
|
| 41 |
+
<path d="M18 280 C22 226 64 202 120 202 C176 202 218 226 222 280 Z" fill="#eef2f8"/>
|
| 42 |
+
<!-- suspenders -->
|
| 43 |
+
<path d="M80 205 L96 205 L112 280 L96 280 Z" fill="#b45540"/>
|
| 44 |
+
<path d="M160 205 L144 205 L128 280 L144 280 Z" fill="#b45540"/>
|
| 45 |
+
<!-- collar -->
|
| 46 |
+
<path d="M104 200 L120 217 L98 226 Z" fill="#ffffff"/>
|
| 47 |
+
<path d="M136 200 L120 217 L142 226 Z" fill="#ffffff"/>
|
| 48 |
+
<!-- tie -->
|
| 49 |
+
<path d="M120 214 L110 223 L120 234 L130 223 Z" fill="url(#s-tie)"/>
|
| 50 |
+
<path d="M115 232 L125 232 L131 279 L120 289 L109 279 Z" fill="url(#s-tie)"/>
|
| 51 |
+
<g clip-path="url(#s-tieclip)" stroke="#5a70b4" stroke-width="4" opacity="0.8">
|
| 52 |
+
<line x1="104" y1="250" x2="136" y2="218"/>
|
| 53 |
+
<line x1="104" y1="264" x2="140" y2="228"/>
|
| 54 |
+
<line x1="108" y1="280" x2="140" y2="248"/>
|
| 55 |
+
</g>
|
| 56 |
+
<!-- neck -->
|
| 57 |
+
<rect x="105" y="156" width="30" height="48" rx="14" fill="#e6ad7f"/>
|
| 58 |
+
<!-- ears -->
|
| 59 |
+
<circle cx="66" cy="118" r="11" fill="url(#s-skin)"/>
|
| 60 |
+
<circle cx="174" cy="118" r="11" fill="url(#s-skin)"/>
|
| 61 |
+
<!-- head -->
|
| 62 |
+
<ellipse cx="120" cy="112" rx="56" ry="62" fill="url(#s-skin)"/>
|
| 63 |
+
<!-- cheeks -->
|
| 64 |
+
<ellipse cx="84" cy="140" rx="10" ry="6" fill="#ef9f8f" opacity="0.35"/>
|
| 65 |
+
<ellipse cx="156" cy="140" rx="10" ry="6" fill="#ef9f8f" opacity="0.35"/>
|
| 66 |
+
<!-- hair -->
|
| 67 |
+
<path d="M66 110 C62 54 92 38 120 38 C150 38 178 54 173 110
|
| 68 |
+
C167 92 155 84 140 82 C151 74 154 62 154 62
|
| 69 |
+
C141 76 129 79 120 79 C110 79 101 74 94 64
|
| 70 |
+
C96 74 99 82 99 82 C85 84 73 92 66 110 Z" fill="url(#s-hair)"/>
|
| 71 |
+
<!-- eyebrows -->
|
| 72 |
+
<path d="M83 97 Q97 90 110 97" stroke="#5c3f28" stroke-width="4" fill="none" stroke-linecap="round"/>
|
| 73 |
+
<path d="M130 97 Q143 90 157 97" stroke="#5c3f28" stroke-width="4" fill="none" stroke-linecap="round"/>
|
| 74 |
+
<!-- eyes -->
|
| 75 |
+
<ellipse cx="101" cy="117" rx="9" ry="10" fill="#ffffff"/>
|
| 76 |
+
<ellipse cx="139" cy="117" rx="9" ry="10" fill="#ffffff"/>
|
| 77 |
+
<circle cx="103" cy="118" r="5" fill="#2a2438"/>
|
| 78 |
+
<circle cx="137" cy="118" r="5" fill="#2a2438"/>
|
| 79 |
+
<circle cx="105" cy="116" r="1.6" fill="#ffffff"/>
|
| 80 |
+
<circle cx="139" cy="116" r="1.6" fill="#ffffff"/>
|
| 81 |
+
<!-- nose -->
|
| 82 |
+
<path d="M115 145 Q120 149 125 145" stroke="#d99b6f" stroke-width="3" fill="none" stroke-linecap="round"/>
|
| 83 |
+
<!-- smile -->
|
| 84 |
+
<path d="M97 156 Q120 186 143 156 Q120 172 97 156 Z" fill="#6d3630"/>
|
| 85 |
+
<path d="M101 157 Q120 170 139 157 Q120 165 101 157 Z" fill="#ffffff"/>
|
| 86 |
+
<!-- glasses -->
|
| 87 |
+
<g fill="none" stroke="#171b28" stroke-width="5">
|
| 88 |
+
<rect x="82" y="101" width="38" height="32" rx="13" fill="rgba(255,255,255,0.10)"/>
|
| 89 |
+
<rect x="120" y="101" width="38" height="32" rx="13" fill="rgba(255,255,255,0.10)"/>
|
| 90 |
+
<line x1="118" y1="115" x2="122" y2="115"/>
|
| 91 |
+
<line x1="82" y1="110" x2="67" y2="113"/>
|
| 92 |
+
<line x1="158" y1="110" x2="173" y2="113"/>
|
| 93 |
+
</g>
|
| 94 |
+
</g>
|
| 95 |
+
</svg>
|
| 96 |
+
"""
|
| 97 |
+
|
| 98 |
+
SONIC_DATA_URI = "data:image/svg+xml;base64," + base64.b64encode(SONIC_SVG.encode("utf-8")).decode("ascii")
|
| 99 |
+
|
| 100 |
+
# gr.Chatbot(avatar_images=...) resolves paths/URLs only, so materialize the SVG.
|
| 101 |
+
SONIC_AVATAR = str(ASSETS_DIR / "sonic.svg")
|
| 102 |
+
(ASSETS_DIR / "sonic.svg").write_text(SONIC_SVG, encoding="utf-8")
|
| 103 |
+
|
| 104 |
+
USER_SVG = """
|
| 105 |
+
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="You">
|
| 106 |
+
<defs>
|
| 107 |
+
<linearGradient id="u-bg" x1="0" y1="0" x2="1" y2="1">
|
| 108 |
+
<stop offset="0" stop-color="#1c2540"/><stop offset="1" stop-color="#0f1526"/>
|
| 109 |
+
</linearGradient>
|
| 110 |
+
</defs>
|
| 111 |
+
<rect width="64" height="64" rx="18" fill="url(#u-bg)"/>
|
| 112 |
+
<circle cx="32" cy="25" r="10" fill="#7c5cff" opacity="0.9"/>
|
| 113 |
+
<path d="M12 56 C14 42 22 37 32 37 C42 37 50 42 52 56 Z" fill="#22d3ee" opacity="0.75"/>
|
| 114 |
+
</svg>
|
| 115 |
+
"""
|
| 116 |
+
USER_AVATAR = str(ASSETS_DIR / "user.svg")
|
| 117 |
+
(ASSETS_DIR / "user.svg").write_text(USER_SVG, encoding="utf-8")
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def sonic_says(message: str) -> str:
|
| 121 |
+
"""SONIC's speech bubble. Returns markup for a gr.HTML block (the Streamlit
|
| 122 |
+
original wrote straight to the page; here the caller decides where it goes)."""
|
| 123 |
+
return (
|
| 124 |
+
f'<div class="sonic-row">'
|
| 125 |
+
f' <div class="sonic-avatar"><img src="{SONIC_DATA_URI}" alt="SONIC"/></div>'
|
| 126 |
+
f' <div class="sonic-bubble"><div class="sonic-name">SONIC</div>{message}</div>'
|
| 127 |
+
f'</div>'
|
| 128 |
+
)
|
ui/theme.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ui/theme.py
|
| 3 |
+
-----------
|
| 4 |
+
NOVA's look: a Gradio theme object for the component primitives, plus the CSS
|
| 5 |
+
that carries the actual identity.
|
| 6 |
+
|
| 7 |
+
Ported from the Streamlit build's utils/styles.py — same palette, same brand
|
| 8 |
+
header, same speech bubbles, same cluster/paper cards, same live checklist.
|
| 9 |
+
Gradio hands us a real DOM instead of Streamlit's opaque `data-testid` tree, so
|
| 10 |
+
everything here hooks `elem_classes`/`elem_id` we set ourselves rather than
|
| 11 |
+
selectors that break on the next framework release.
|
| 12 |
+
|
| 13 |
+
FORCE_DARK is a js hook, not CSS: Gradio decides light/dark from a URL param
|
| 14 |
+
before our stylesheet loads, and the palette below only exists in dark.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import gradio as gr
|
| 18 |
+
|
| 19 |
+
NOVA_THEME = gr.themes.Base(
|
| 20 |
+
primary_hue=gr.themes.Color(
|
| 21 |
+
c50="#f2efff", c100="#e5dfff", c200="#cabfff", c300="#b09fff",
|
| 22 |
+
c400="#957fff", c500="#7c5cff", c600="#6344e6", c700="#4a2fbf",
|
| 23 |
+
c800="#331f99", c900="#1f1273", c950="#0f0847",
|
| 24 |
+
),
|
| 25 |
+
secondary_hue=gr.themes.Color(
|
| 26 |
+
c50="#ecfeff", c100="#cffafe", c200="#a5f3fc", c300="#67e8f9",
|
| 27 |
+
c400="#22d3ee", c500="#06b6d4", c600="#0891b2", c700="#0e7490",
|
| 28 |
+
c800="#155e75", c900="#164e63", c950="#083344",
|
| 29 |
+
),
|
| 30 |
+
neutral_hue=gr.themes.Color(
|
| 31 |
+
c50="#f2f4fb", c100="#e6e9f2", c200="#cbd0e0", c300="#8b93a7",
|
| 32 |
+
c400="#5b6377", c500="#3a4157", c600="#252c40", c700="#111828",
|
| 33 |
+
c800="#0d1322", c900="#080b16", c950="#05070f",
|
| 34 |
+
),
|
| 35 |
+
font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"],
|
| 36 |
+
font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "monospace"],
|
| 37 |
+
).set(
|
| 38 |
+
body_background_fill="#080b16",
|
| 39 |
+
body_background_fill_dark="#080b16",
|
| 40 |
+
block_background_fill="transparent",
|
| 41 |
+
block_background_fill_dark="transparent",
|
| 42 |
+
block_border_width="0px",
|
| 43 |
+
block_shadow="none",
|
| 44 |
+
panel_background_fill="transparent",
|
| 45 |
+
panel_background_fill_dark="transparent",
|
| 46 |
+
border_color_primary="rgba(255,255,255,0.08)",
|
| 47 |
+
border_color_primary_dark="rgba(255,255,255,0.08)",
|
| 48 |
+
input_background_fill="rgba(8,11,22,0.7)",
|
| 49 |
+
input_background_fill_dark="rgba(8,11,22,0.7)",
|
| 50 |
+
input_border_color="rgba(255,255,255,0.08)",
|
| 51 |
+
input_border_color_dark="rgba(255,255,255,0.08)",
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
# Gradio reads ?__theme= before our CSS lands; the palette is dark-only, so pin it.
|
| 55 |
+
FORCE_DARK = """
|
| 56 |
+
function() {
|
| 57 |
+
const url = new URL(window.location);
|
| 58 |
+
if (url.searchParams.get('__theme') !== 'dark') {
|
| 59 |
+
url.searchParams.set('__theme', 'dark');
|
| 60 |
+
window.location.replace(url.href);
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
"""
|
| 64 |
+
|
| 65 |
+
CSS = """
|
| 66 |
+
:root {
|
| 67 |
+
--nova-bg: #080b16;
|
| 68 |
+
--nova-card: #111828;
|
| 69 |
+
--nova-card-2: #0d1322;
|
| 70 |
+
--nova-border: rgba(255,255,255,0.08);
|
| 71 |
+
--nova-text: #e6e9f2;
|
| 72 |
+
--nova-muted: #8b93a7;
|
| 73 |
+
--nova-accent: #7c5cff;
|
| 74 |
+
--nova-accent-2: #22d3ee;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
/* ---- page shell: cosmic gradient + a slow drifting aurora ---- */
|
| 78 |
+
gradio-app, .gradio-container {
|
| 79 |
+
background:
|
| 80 |
+
radial-gradient(1200px 600px at 12% -10%, rgba(124,92,255,0.18), transparent 55%),
|
| 81 |
+
radial-gradient(1000px 500px at 100% 0%, rgba(34,211,238,0.12), transparent 50%),
|
| 82 |
+
var(--nova-bg) !important;
|
| 83 |
+
max-width: 100% !important;
|
| 84 |
+
color: var(--nova-text);
|
| 85 |
+
}
|
| 86 |
+
.gradio-container { padding-top: 1.6rem !important; }
|
| 87 |
+
|
| 88 |
+
/* starfield + aurora live behind everything and never eat clicks */
|
| 89 |
+
gradio-app::before {
|
| 90 |
+
content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 0;
|
| 91 |
+
background-image:
|
| 92 |
+
radial-gradient(1.4px 1.4px at 12% 18%, rgba(255,255,255,.55), transparent),
|
| 93 |
+
radial-gradient(1.2px 1.2px at 72% 12%, rgba(255,255,255,.42), transparent),
|
| 94 |
+
radial-gradient(1.6px 1.6px at 38% 62%, rgba(255,255,255,.35), transparent),
|
| 95 |
+
radial-gradient(1.1px 1.1px at 88% 74%, rgba(255,255,255,.40), transparent),
|
| 96 |
+
radial-gradient(1.3px 1.3px at 24% 88%, rgba(255,255,255,.30), transparent),
|
| 97 |
+
radial-gradient(1.2px 1.2px at 58% 34%, rgba(255,255,255,.28), transparent);
|
| 98 |
+
animation: twinkle 7s ease-in-out infinite alternate;
|
| 99 |
+
}
|
| 100 |
+
@keyframes twinkle { from { opacity: .35; } to { opacity: .9; } }
|
| 101 |
+
|
| 102 |
+
#nova-root { position: relative; z-index: 1; max-width: 1180px; margin: 0 auto; }
|
| 103 |
+
|
| 104 |
+
/* hide Gradio's own chrome */
|
| 105 |
+
footer, .built-with, .show-api { display: none !important; }
|
| 106 |
+
|
| 107 |
+
/* ---- NOVA brand header ---- */
|
| 108 |
+
.nova-brand { display:flex; align-items:center; gap:14px; margin-bottom: 2px; }
|
| 109 |
+
.nova-mark {
|
| 110 |
+
font-size: 2.1rem; font-weight: 800; letter-spacing: .06em;
|
| 111 |
+
background: linear-gradient(120deg, #b8a7ff, #7c5cff 45%, #22d3ee);
|
| 112 |
+
background-size: 200% auto;
|
| 113 |
+
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
|
| 114 |
+
animation: shimmer 6s linear infinite;
|
| 115 |
+
}
|
| 116 |
+
@keyframes shimmer { to { background-position: 200% center; } }
|
| 117 |
+
.nova-star { font-size: 1.7rem; filter: drop-shadow(0 0 8px rgba(124,92,255,.7)); animation: pulse-star 3s ease-in-out infinite; }
|
| 118 |
+
@keyframes pulse-star { 50% { filter: drop-shadow(0 0 16px rgba(124,92,255,1)); transform: scale(1.08); } }
|
| 119 |
+
.nova-sub { color: var(--nova-muted); font-size: .82rem; letter-spacing:.14em; text-transform: uppercase; }
|
| 120 |
+
.sonic-chip {
|
| 121 |
+
margin-left:auto; display:flex; align-items:center; gap:8px;
|
| 122 |
+
background: rgba(124,92,255,0.1); border:1px solid rgba(124,92,255,0.35);
|
| 123 |
+
color:#cdbcff; padding:6px 14px; border-radius:999px; font-size:.78rem; font-weight:600;
|
| 124 |
+
}
|
| 125 |
+
.sonic-dot { width:8px; height:8px; border-radius:50%; background:#22d3ee; box-shadow:0 0 8px #22d3ee; animation: blink 2s ease-in-out infinite; }
|
| 126 |
+
@keyframes blink { 50% { opacity:.35; } }
|
| 127 |
+
|
| 128 |
+
/* ---- SONIC speech bubble ---- */
|
| 129 |
+
.sonic-row { display:flex; gap:14px; align-items:flex-start; margin: 20px 0 8px; }
|
| 130 |
+
.sonic-avatar {
|
| 131 |
+
flex:0 0 48px; width:48px; height:48px; border-radius:14px; overflow:hidden;
|
| 132 |
+
box-shadow: 0 0 22px rgba(124,92,255,.45);
|
| 133 |
+
}
|
| 134 |
+
.sonic-avatar img { width:100%; height:100%; object-fit:cover; display:block; }
|
| 135 |
+
.sonic-bubble {
|
| 136 |
+
background: var(--nova-card); border:1px solid var(--nova-border);
|
| 137 |
+
border-radius: 4px 16px 16px 16px; padding: 14px 18px; color: var(--nova-text);
|
| 138 |
+
font-size: 1.02rem; line-height:1.5; max-width: 760px;
|
| 139 |
+
animation: bubble-in .32s cubic-bezier(.2,.9,.3,1.2);
|
| 140 |
+
}
|
| 141 |
+
@keyframes bubble-in { from { opacity:0; transform: translateY(6px) scale(.98); } }
|
| 142 |
+
.sonic-name { color:#9d8cff; font-weight:700; font-size:.72rem; letter-spacing:.1em; text-transform:uppercase; margin-bottom:4px; }
|
| 143 |
+
|
| 144 |
+
/* ---- cluster section header ---- */
|
| 145 |
+
.cluster-head { display:flex; align-items:center; gap:12px; margin: 26px 0 6px; }
|
| 146 |
+
.cluster-bar { width:5px; height:30px; border-radius:3px; }
|
| 147 |
+
.cluster-title { font-size:1.22rem; font-weight:700; color:#f2f4fb; }
|
| 148 |
+
.cluster-why { color:var(--nova-muted); font-size:.88rem; line-height:1.5; margin: 0 0 6px 17px; max-width: 900px; }
|
| 149 |
+
|
| 150 |
+
/* ---- paper card ---- */
|
| 151 |
+
.paper-card {
|
| 152 |
+
background: linear-gradient(180deg, var(--nova-card), var(--nova-card-2));
|
| 153 |
+
border: 1px solid var(--nova-border) !important; border-radius: 16px !important;
|
| 154 |
+
padding: 16px !important;
|
| 155 |
+
transition: transform .15s ease, border-color .15s ease, box-shadow .15s ease;
|
| 156 |
+
}
|
| 157 |
+
.paper-card:hover {
|
| 158 |
+
transform: translateY(-3px); border-color: rgba(124,92,255,0.5) !important;
|
| 159 |
+
box-shadow: 0 12px 30px rgba(0,0,0,0.4);
|
| 160 |
+
}
|
| 161 |
+
.paper-title { font-size: 1.02rem; font-weight: 700; color:#f2f4fb; line-height:1.35; margin-bottom:6px; }
|
| 162 |
+
.paper-meta { color: var(--nova-muted); font-size: .82rem; margin-bottom: 10px; }
|
| 163 |
+
.src-badge { display:inline-block; padding:2px 9px; border-radius:999px; font-size:.68rem; font-weight:700;
|
| 164 |
+
margin-right:6px; border:1px solid transparent; }
|
| 165 |
+
|
| 166 |
+
/* ---- buttons ---- */
|
| 167 |
+
#nova-root button.secondary, #nova-root a.button {
|
| 168 |
+
border-radius: 10px !important; font-weight: 600 !important; font-size: .84rem !important;
|
| 169 |
+
border: 1px solid var(--nova-border) !important; background: rgba(255,255,255,0.04) !important;
|
| 170 |
+
color: #dfe3ee !important; transition: all .14s ease;
|
| 171 |
+
}
|
| 172 |
+
#nova-root button.secondary:hover, #nova-root a.button:hover {
|
| 173 |
+
border-color: rgba(124,92,255,0.6) !important; background: rgba(124,92,255,0.12) !important; color:#fff !important;
|
| 174 |
+
}
|
| 175 |
+
#nova-root button.primary {
|
| 176 |
+
background: linear-gradient(120deg, #7c5cff, #22d3ee) !important; border: none !important;
|
| 177 |
+
color: #0a0e1a !important; font-weight: 800 !important; border-radius: 10px !important;
|
| 178 |
+
box-shadow: 0 6px 18px rgba(124,92,255,.4) !important; transition: filter .14s ease, transform .14s ease;
|
| 179 |
+
}
|
| 180 |
+
#nova-root button.primary:hover { filter: brightness(1.08); transform: translateY(-1px); }
|
| 181 |
+
#nova-root button:disabled { opacity:.4 !important; cursor: not-allowed !important; }
|
| 182 |
+
|
| 183 |
+
/* link-buttons rendered as anchors inside cards */
|
| 184 |
+
.card-link {
|
| 185 |
+
display:inline-flex; align-items:center; justify-content:center; gap:6px;
|
| 186 |
+
padding:7px 12px; border-radius:10px; font-weight:600; font-size:.8rem; text-decoration:none !important;
|
| 187 |
+
border:1px solid var(--nova-border); background:rgba(255,255,255,.04); color:#dfe3ee !important;
|
| 188 |
+
transition: all .14s ease; flex:1; text-align:center;
|
| 189 |
+
}
|
| 190 |
+
.card-link:hover { border-color:rgba(124,92,255,.6); background:rgba(124,92,255,.12); color:#fff !important; }
|
| 191 |
+
.card-link.dead { opacity:.35; pointer-events:none; }
|
| 192 |
+
.card-links { display:flex; gap:8px; margin-top:2px; }
|
| 193 |
+
|
| 194 |
+
/* ---- inputs ---- */
|
| 195 |
+
#nova-root textarea, #nova-root input[type="text"] {
|
| 196 |
+
background: rgba(8,11,22,0.7) !important; border:1px solid var(--nova-border) !important;
|
| 197 |
+
color: var(--nova-text) !important; border-radius: 12px !important; font-size:.95rem !important;
|
| 198 |
+
}
|
| 199 |
+
#nova-root textarea:focus, #nova-root input[type="text"]:focus {
|
| 200 |
+
border-color: rgba(124,92,255,.6) !important; box-shadow: 0 0 0 3px rgba(124,92,255,.15) !important;
|
| 201 |
+
}
|
| 202 |
+
.field-label { font-weight:700; color:#cbd0e0; margin: 8px 0 2px; font-size:.9rem; }
|
| 203 |
+
|
| 204 |
+
/* ---- welcome hero ---- */
|
| 205 |
+
.hero-figure { display:flex; flex-direction:column; align-items:center; justify-content:center; }
|
| 206 |
+
.hero-figure img { height:52vh; max-height:540px; width:auto; filter: drop-shadow(0 20px 40px rgba(124,92,231,.35)); animation: float 6s ease-in-out infinite; }
|
| 207 |
+
@keyframes float { 50% { transform: translateY(-12px); } }
|
| 208 |
+
.hero-name { margin-top:14px; font-weight:800; letter-spacing:.12em; color:#c9c3ff; font-size:.9rem; }
|
| 209 |
+
.hero-speech {
|
| 210 |
+
background: var(--nova-card); border:1px solid var(--nova-border);
|
| 211 |
+
border-radius: 18px 18px 18px 4px; padding: 20px 22px; color: var(--nova-text);
|
| 212 |
+
font-size: 1.18rem; line-height:1.55; position:relative; margin-bottom: 18px;
|
| 213 |
+
}
|
| 214 |
+
.hero-speech .sonic-name { color:#9d8cff; font-weight:700; font-size:.72rem; letter-spacing:.12em; text-transform:uppercase; margin-bottom:8px; }
|
| 215 |
+
.hero-answer-label { color:var(--nova-muted); font-size:.82rem; text-transform:uppercase; letter-spacing:.1em; margin: 6px 0 8px; }
|
| 216 |
+
|
| 217 |
+
/* ---- live search checklist ---- */
|
| 218 |
+
.search-wrap { max-width: 620px; margin: 6px auto 0; }
|
| 219 |
+
.step-row { display:flex; align-items:center; gap:14px; padding:11px 4px; font-size:1rem; color:var(--nova-muted); border-bottom:1px solid rgba(255,255,255,.05); }
|
| 220 |
+
.step-ic { width:26px; height:26px; border-radius:50%; flex:0 0 26px; display:flex; align-items:center; justify-content:center; font-size:.8rem; border:2px solid rgba(255,255,255,.14); }
|
| 221 |
+
.step-row.done { color:#dfe3ee; }
|
| 222 |
+
.step-row.done .step-ic { background:linear-gradient(120deg,#7c5cff,#22d3ee); border-color:transparent; color:#0a0e1a; font-weight:800; }
|
| 223 |
+
.step-row.active { color:#fff; }
|
| 224 |
+
.step-row.active .step-ic { border-color:#7c5cff; border-top-color:transparent; animation: spin .8s linear infinite; }
|
| 225 |
+
.step-count { margin-left:auto; font-size:.8rem; color:var(--nova-muted); }
|
| 226 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 227 |
+
|
| 228 |
+
/* ---- boot splash + vectorize loading ---- */
|
| 229 |
+
.vec-wrap { max-width: 640px; margin: 10px auto; text-align:center; }
|
| 230 |
+
.vec-figure img { height:150px; width:auto; filter: drop-shadow(0 10px 26px rgba(124,92,231,.4)); animation: float 6s ease-in-out infinite; }
|
| 231 |
+
.vec-quote { color:#cbd0e6; font-size:1.12rem; line-height:1.55; font-style:italic; margin:18px auto 6px; max-width:520px; min-height:3em; }
|
| 232 |
+
.vec-quote .q { color:#9d8cff; font-weight:700; }
|
| 233 |
+
.vec-phase { color:var(--nova-muted); font-size:.85rem; margin-top:10px; }
|
| 234 |
+
.vec-bar { height:6px; border-radius:999px; background:rgba(255,255,255,.07); overflow:hidden; margin:16px auto 0; max-width:520px; }
|
| 235 |
+
.vec-fill { height:100%; border-radius:999px; background:linear-gradient(90deg,#7c5cff,#22d3ee); transition: width .35s ease; }
|
| 236 |
+
|
| 237 |
+
/* ---- per-source status chips ---- */
|
| 238 |
+
.src-stat-row { display:flex; flex-wrap:wrap; gap:8px; margin: 2px 0 18px; }
|
| 239 |
+
.src-stat { font-size:.74rem; font-weight:600; padding:4px 11px; border-radius:999px; border:1px solid transparent; }
|
| 240 |
+
.src-stat.ok { color:#5eead4; background:rgba(45,212,191,.10); border-color:rgba(45,212,191,.35); }
|
| 241 |
+
.src-stat.warn { color:#fbbf24; background:rgba(251,191,36,.12); border-color:rgba(251,191,36,.45); }
|
| 242 |
+
.src-stat.err { color:#f87171; background:rgba(248,113,113,.12); border-color:rgba(248,113,113,.45); }
|
| 243 |
+
.src-stat.muted { color:#8b93a7; background:rgba(255,255,255,.04); border-color:var(--nova-border); }
|
| 244 |
+
|
| 245 |
+
/* ---- chat ---- */
|
| 246 |
+
#nova-chat { background: transparent !important; border: none !important; }
|
| 247 |
+
#nova-chat .message-wrap { gap: 14px !important; }
|
| 248 |
+
#nova-chat .message {
|
| 249 |
+
border-radius: 4px 16px 16px 16px !important; border:1px solid var(--nova-border) !important;
|
| 250 |
+
background: var(--nova-card) !important; color: var(--nova-text) !important; line-height:1.55 !important;
|
| 251 |
+
}
|
| 252 |
+
#nova-chat .message.user {
|
| 253 |
+
border-radius: 16px 4px 16px 16px !important;
|
| 254 |
+
background: rgba(124,92,231,.12) !important; border-color: rgba(124,92,231,.3) !important;
|
| 255 |
+
}
|
| 256 |
+
#nova-chat .avatar-container img { border-radius: 12px !important; box-shadow: 0 0 14px rgba(124,92,255,.35); }
|
| 257 |
+
|
| 258 |
+
.nova-error {
|
| 259 |
+
border:1px solid rgba(251,191,36,.45); background:rgba(251,191,36,.10); color:#fbbf24;
|
| 260 |
+
border-radius:12px; padding:12px 16px; margin:10px 0; font-size:.9rem;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
@media (max-width: 860px) {
|
| 264 |
+
.hero-figure img { height:32vh; }
|
| 265 |
+
.card-links { flex-wrap: wrap; }
|
| 266 |
+
}
|
| 267 |
+
"""
|