S-4-G-4-R commited on
Commit
20b15f3
·
0 Parent(s):

Initial commit

Browse files
.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&nbsp;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> &ldquo;{phase}&rdquo;</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 &amp; 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> &ldquo;{quote}&rdquo;</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 &amp; 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
+ """