"""
NOVA — Research, guided by SONIC
================================================================
A single Gradio app that stitches together the two projects, unchanged:
• app/ the research pipeline (structured_agent's `app/` package):
INTENT graph -> SEARCH + CLUSTER graph
• chatbot_core/ the single-PDF Q&A chatbot (Qa.py + vectorizeer.py)
NOVA is the product. SONIC is the assistant persona that talks you through it.
Flow:
USER RESEARCH IDEA
-> INTENT agent frames it (Problem / Objective / Additional Context)
-> you review/edit it
-> SEARCH agent fetches + reranks + clusters papers
-> results shown as clean thumbnails (title, authors, links)
-> "Chat it out" on any paper: its PDF is downloaded, vectorized by
vectorizeer.build_vectorstore, and you Q&A over it with Qa.py's chain.
This file is UI + wiring only. It does NOT change any agent or chatbot logic —
it imports their functions and drives them.
Where Streamlit re-executed one script top-to-bottom on every interaction, Gradio
builds a persistent component graph once and fires explicit handlers. So the
"stage" that Streamlit kept in session_state and branched on is here a set of
Columns whose `visible` flag every handler returns. Same state machine — declared
once instead of re-derived per rerun.
Run:
python nova_app.py # from inside the NOVA/ folder
"""
from ui import paths # noqa: F401 — MUST be first: wires sys.path + chdir + .env
import shutil
import uuid
import gradio as gr
from ui.agents import load_agents, warm_chatbot_models_async
from ui.chat_engine import prepare_chat_stream
from ui.constants import CLUSTER_ACCENTS, SOURCE_COLORS
from ui.intent_text import join_intent_sections, split_intent_sections
from ui.papers import author_line, first_available
from ui.paths import DOWNLOADS_DIR, VECTORSTORES_DIR
from ui.search_progress import search_steps_html
from ui.sonic import SONIC_AVATAR, SONIC_DATA_URI, USER_AVATAR, sonic_says
from ui.theme import CSS, FORCE_DARK, NOVA_THEME
# ---------------------------------------------------------------------------
# 1. SESSION STATE
# ---------------------------------------------------------------------------
STAGE_NAMES = ("boot", "welcome", "refining", "review", "searching", "results", "chat")
def new_state() -> dict:
"""One of these per browser session. Gradio deep-copies it into each new
session, so the mutable members below are never shared across users."""
return {
"run_id": "",
"user_query": "",
"problem": "",
"objective": "",
"context": "",
"clusters": [],
"papers_by_key": {}, # normalized_title -> full record (flattened, for chat lookup)
"active_chat": None, # normalized_title of the paper being chatted, or None
"chats": {}, # normalized_title -> {retriever, chain, messages, title}
"source_status": {}, # {"Semantic Scholar": {"state": "rate_limited", ...}, ...}
}
def wipe_disk_cache():
"""Delete every cached vectorstore and downloaded PDF so a new search starts
from a clean slate — no old paper's chunks or PDFs can leak in."""
for folder in (VECTORSTORES_DIR, DOWNLOADS_DIR):
try:
if folder.exists():
shutil.rmtree(folder, ignore_errors=True)
folder.mkdir(exist_ok=True)
except Exception:
pass
def _stages(active: str):
"""Visibility updates for every stage Column, in STAGE_NAMES order."""
return tuple(gr.update(visible=(name == active)) for name in STAGE_NAMES)
# ---------------------------------------------------------------------------
# 2. STATIC MARKUP
# ---------------------------------------------------------------------------
HEADER_HTML = """
✦
NOVA
Research Assistant
SONIC online
"""
HERO_FIGURE_HTML = (
f''
)
HERO_SPEECH_HTML = """
SONIC
hey, wass up 👋
what's on your mind about research today?
Dump the raw idea on me — the messier the better. I'll shape it into something sharp.
✍️ your answer
"""
def boot_html(phase: str, pct: int) -> str:
return (
f''
f'
'
f'
SONIC: “{phase}”
'
f'
'
f'
'
)
def source_status_html(status: dict) -> str:
"""Show per-source status so a rate-limited/failed source is never invisible."""
if not status:
return ""
chips = []
for name, s in status.items():
state = (s or {}).get("state")
if state == "ok":
chips.append(f'{name} ✓ {s.get("count", 0)}')
elif state == "rate_limited":
chips.append(f'{name} ⚠ rate-limited (HTTP {s.get("http", 429)})')
elif state == "error":
detail = s.get("detail") or f'HTTP {s.get("http", "?")}'
chips.append(f'{name} ✕ {detail}')
else:
chips.append(f'{name} —')
return '' + "".join(chips) + "
"
def paper_card_html(norm_title: str, record: dict) -> str:
title = record.get("title") or norm_title.title()
badges = "".join(
f'{s}'
for s in (record.get("source") or [])
)
return (
f'{title}
'
f'{author_line(record.get("authors"), record.get("year"))}
{badges}
'
)
def card_links_html(page_url: str, pdf_url: str) -> str:
"""The 📄 Paper / ⬇ PDF pair. Plain anchors rather than gr.Button: they're
pure navigation, and a real opens a new tab with no server round-trip."""
paper = (f'📄 Paper'
if page_url else '📄 Paper')
pdf = (f'⬇ PDF'
if pdf_url else '⬇ PDF')
return f'{paper}{pdf}
'
# ---------------------------------------------------------------------------
# 3. HANDLERS THAT TOUCH NO COMPONENTS
# ---------------------------------------------------------------------------
def open_chat_for(norm_title: str):
"""Build a per-card click handler. The card grid is generated in a loop, so
each button needs to close over its own paper key."""
def _open(st):
st["active_chat"] = norm_title
record = st["papers_by_key"].get(norm_title, {})
title = record.get("title") or norm_title.title()
head = (f'')
cached = st["chats"].get(norm_title)
return (*_stages("chat"), head,
gr.update(value="", visible=not cached),
gr.update(value=(cached["messages"] if cached else []), visible=bool(cached)),
gr.update(visible=bool(cached)),
st)
return _open
def prep_chat(st):
"""Download + vectorize this paper, animating SONIC's pep-quotes while the
real work runs on a worker thread. No-op if this chat is already built."""
key = st["active_chat"]
if not key or key in st["chats"]:
return
record = st["papers_by_key"].get(key, {})
session = error = None
for html, done, session, error in prepare_chat_stream(record):
if not done:
yield (gr.update(value=html, visible=True), gr.update(visible=False),
gr.update(visible=False), st)
if error or not session:
msg = error or "Couldn't prepare this paper for chat."
yield (gr.update(value=f'{msg}
', visible=True),
gr.update(visible=False), gr.update(visible=False), st)
return
session.update({"messages": [], "title": record.get("title") or key.title()})
st["chats"][key] = session
yield (gr.update(value="", visible=False), gr.update(value=[], visible=True),
gr.update(visible=True), st)
# ---------------------------------------------------------------------------
# 4. THE APP
# ---------------------------------------------------------------------------
# Gradio 6 moved theme/css/js off the Blocks constructor and onto launch().
with gr.Blocks(title="NOVA · Research Assistant", analytics_enabled=False) as demo:
state = gr.State(new_state())
# Mirrors state["clusters"]. gr.render can't watch a dict mutated in place,
# so the search handler reassigns this to a fresh list to trigger a redraw.
clusters_state = gr.State([])
with gr.Column(elem_id="nova-root"):
gr.HTML(HEADER_HTML)
# ---------------- BOOT ----------------
with gr.Column(visible=True) as boot_col:
boot_panel = gr.HTML(boot_html("Waking up SONIC — loading the research + reading models…", 8))
# ---------------- WELCOME ----------------
with gr.Column(visible=False) as welcome_col:
with gr.Row(equal_height=False):
with gr.Column(scale=9):
gr.HTML(HERO_FIGURE_HTML)
with gr.Column(scale=11):
gr.HTML(HERO_SPEECH_HTML)
query_box = gr.Textbox(
lines=6, max_lines=12, show_label=False, container=False,
placeholder="e.g. I want to compare fuel efficiency of human-driven vs RL-controlled "
"cars in car-following… comparing is hard because velocity, acceleration, "
"headway all change at once…",
)
go_btn = gr.Button("Let's go ✦", variant="primary")
# ---------------- REFINING ----------------
with gr.Column(visible=False) as refining_col:
gr.HTML(sonic_says("that seems great — lemme juss refine it ✨"))
refining_panel = gr.HTML()
# ---------------- REVIEW ----------------
with gr.Column(visible=False) as review_col:
gr.HTML(sonic_says("here's how I framed it. Tweak anything that's off, then I'll go hunting 🔍"))
gr.HTML('🧩 Problem
')
problem_box = gr.Textbox(lines=5, show_label=False, container=False)
gr.HTML('🎯 Objective
')
objective_box = gr.Textbox(lines=4, show_label=False, container=False)
gr.HTML('🗂️ Additional Context
')
context_box = gr.Textbox(lines=4, show_label=False, container=False)
with gr.Row():
find_btn = gr.Button("Find the papers 🔍", variant="primary", scale=2)
over_btn = gr.Button("Start over", scale=1)
gr.HTML("") # spacer: keeps the two buttons off full width
# ---------------- SEARCHING ----------------
with gr.Column(visible=False) as searching_col:
gr.HTML(sonic_says("on it — scouring arXiv, Semantic Scholar & OpenAlex, then reranking "
"and clustering by approach 🔎"))
search_panel = gr.HTML()
# ---------------- RESULTS ----------------
# Body is filled in by the @gr.render below, once every component it
# needs to drive (the chat stage) exists.
with gr.Column(visible=False) as results_col:
results_head = gr.HTML()
with gr.Row():
new_search_btn = gr.Button("🔄 New search", scale=1)
gr.HTML("") # spacer
# ---------------- CHAT ----------------
with gr.Column(visible=False) as chat_col:
with gr.Row():
back_btn = gr.Button("← Back to papers", scale=1)
gr.HTML("") # spacer
chat_title = gr.HTML()
chat_vec = gr.HTML()
# Gradio 6 speaks the {"role","content"} message format natively —
# no type="messages" to opt into it any more.
chatbot = gr.Chatbot(
height=520, show_label=False, visible=False,
elem_id="nova-chat", avatar_images=(USER_AVATAR, SONIC_AVATAR),
placeholder="ask me anything about this paper — I've read every page 📄",
)
with gr.Row(visible=False) as chat_input_row:
chat_input = gr.Textbox(show_label=False, container=False, scale=9,
placeholder="Ask about this paper…")
send_btn = gr.Button("Send", variant="primary", scale=1)
STAGE_COLS = [boot_col, welcome_col, refining_col, review_col, searching_col, results_col, chat_col]
# Re-enter the results Column now that the chat components exist, so each
# card's "Chat it out" button can wire straight into them.
with results_col:
@gr.render(inputs=[clusters_state, state], triggers=[clusters_state.change])
def draw_results(clusters, st):
"""Redrawn whenever a search completes. Streamlit rebuilt this grid on
every rerun for free; in Gradio the per-card buttons need real event
handlers, so the whole thing is (re)declared here."""
if not clusters:
return
for i, cluster in enumerate(clusters):
papers = cluster.get("papers") or {}
if not papers:
continue
accent = CLUSTER_ACCENTS[i % len(CLUSTER_ACCENTS)]
gr.HTML(
f''
f'
'
f'
{cluster.get("label", "Approach")}
'
f'
'
)
if cluster.get("rationale"):
gr.HTML(f'{cluster["rationale"]}
')
items = list(papers.items())
for row_start in range(0, len(items), 2):
with gr.Row(equal_height=True):
for norm_title, record in items[row_start:row_start + 2]:
with gr.Column(elem_classes=["paper-card"]):
gr.HTML(paper_card_html(norm_title, record))
page_url = first_available(record.get("url"))
pdf_url = first_available(record.get("pdf_url"))
gr.HTML(card_links_html(page_url, pdf_url))
# The real PDF is resolved/verified on click (the
# HYBRID deep step), so any link is enough to try.
chat_btn = gr.Button(
"💬 Chat it out", variant="primary", size="sm",
interactive=bool(page_url or pdf_url),
)
chat_btn.click(
open_chat_for(norm_title),
inputs=[state],
outputs=[*STAGE_COLS, chat_title, chat_vec, chatbot,
chat_input_row, state],
).then(
prep_chat,
inputs=[state],
outputs=[chat_vec, chatbot, chat_input_row, state],
)
# -----------------------------------------------------------------------
# 5. WIRING
# -----------------------------------------------------------------------
def do_boot():
"""Runs once per page load, behind the splash.
Only the agents load synchronously — the chatbot's ~1.5 GB of embedding +
cross-encoder weights warm on a background thread. The Streamlit build
blocked on both, which is why it sat on this splash forever on a small host.
"""
yield (*_stages("boot"), boot_html("Waking up SONIC — loading the research + reading models…", 35))
try:
load_agents()
except Exception as e:
yield (*_stages("boot"),
f'SONIC couldn\'t wake up: {type(e).__name__}: {e}
'
f'Check that GROQ_API_KEY / SECOND_GROQ_API_KEY / TAVILY_API_KEY are set.
')
warm_chatbot_models_async()
return
yield (*_stages("welcome"), "")
demo.load(do_boot, outputs=[*STAGE_COLS, boot_panel])
def go(query, st):
"""WELCOME -> REFINING -> REVIEW. Runs the INTENT graph up to its
human-review interrupt, then hands the framed sections to the form."""
if not (query or "").strip():
gr.Warning("Give me something to work with first 🙂")
yield (*_stages("welcome"), gr.update(), gr.update(), gr.update(), st)
return
st["user_query"] = query.strip()
st["run_id"] = str(uuid.uuid4())
yield (*_stages("refining"), gr.update(), gr.update(), gr.update(), st)
intent_graph, _ = load_agents()
config = {"configurable": {"thread_id": st["run_id"]}}
try:
result = intent_graph.invoke({"user_query": st["user_query"], "run_id": st["run_id"]},
config=config)
payload = result["__interrupt__"][0].value
problem, objective, context = split_intent_sections(payload["polished_research_intent"])
except Exception as e:
gr.Warning(f"Something went sideways: {type(e).__name__}: {e}")
yield (*_stages("welcome"), gr.update(), gr.update(), gr.update(), st)
return
st["problem"], st["objective"], st["context"] = problem, objective, context
yield (*_stages("review"), problem, objective, context, st)
go_btn.click(go, inputs=[query_box, state],
outputs=[*STAGE_COLS, problem_box, objective_box, context_box, state])
def find(problem, objective, context, st):
"""REVIEW -> SEARCHING -> RESULTS. Resumes the INTENT graph past its
interrupt, then STREAMS the SEARCH + CLUSTER graph so the checklist ticks
each step off live instead of hanging on one spinner."""
from langgraph.types import Command
if not (problem or "").strip() or not (objective or "").strip():
gr.Warning("Problem and Objective can't be empty.")
yield (*_stages("review"), gr.update(), gr.update(), st, gr.update())
return
st["problem"], st["objective"], st["context"] = problem, objective, context
yield (*_stages("searching"), search_steps_html(set(), {}), gr.update(), st, gr.update())
intent_graph, search_graph = load_agents()
config = {"configurable": {"thread_id": st["run_id"]}}
edited_intent = join_intent_sections(problem, objective, context)
try:
resume_result = intent_graph.invoke(Command(resume=edited_intent), config=config)
human_verified_intent = resume_result["human_verified_intent"]
completed, counts, final_state = set(), {}, {}
source_field = {"arxiv": "arXiv_paper", "semantic_scholar": "Semantic_Scholar_paper",
"open_alex": "Open_Alex_paper"}
for update in search_graph.stream(
{"ResearchIntent": human_verified_intent, "run_id": st["run_id"]},
stream_mode="updates",
):
for node_name, delta in update.items():
completed.add(node_name)
if isinstance(delta, dict):
final_state.update(delta)
if node_name in source_field:
counts[node_name] = len(delta.get(source_field[node_name]) or [])
yield (*_stages("searching"), search_steps_html(completed, counts),
gr.update(), st, gr.update())
except Exception as e:
gr.Warning(f"Something went sideways: {type(e).__name__}: {e}")
yield (*_stages("review"), gr.update(), gr.update(), st, gr.update())
return
clusters = final_state.get("clustered_papers") or []
# flatten every paper into a lookup keyed by its normalized_title (the
# cluster dict key) so "Chat it out" can find the record anywhere.
papers_by_key = {}
for cluster in clusters:
for norm_title, record in (cluster.get("papers") or {}).items():
papers_by_key[norm_title] = record
st["clusters"] = clusters
st["papers_by_key"] = papers_by_key
st["source_status"] = {
"arXiv": final_state.get("arxiv_status") or {},
"Semantic Scholar": final_state.get("semantic_scholar_status") or {},
"OpenAlex": final_state.get("open_alex_status") or {},
}
total = sum(len(c.get("papers") or {}) for c in clusters)
if total == 0:
head = sonic_says("hmm, I couldn't pull solid matches for that one — see the source status below. "
"If a source is rate-limited, that's usually why. Try again in a bit, or "
"loosen the framing.")
else:
head = sonic_says(f"these are the best matches 🎯
{total} papers, grouped into "
f"{len(clusters)} approaches. Hit Chat it out on any paper to "
f"actually talk to it.")
head += source_status_html(st["source_status"])
yield (*_stages("results"), gr.update(), head, st, list(clusters))
find_btn.click(find, inputs=[problem_box, objective_box, context_box, state],
outputs=[*STAGE_COLS, search_panel, results_head, state, clusters_state])
def start_over():
"""Full memory refresh: clear this session's results + open chats, and wipe
the on-disk vectorstore/PDF caches too."""
wipe_disk_cache()
return (*_stages("welcome"), "", new_state(), [])
over_btn.click(start_over, outputs=[*STAGE_COLS, query_box, state, clusters_state])
new_search_btn.click(start_over, outputs=[*STAGE_COLS, query_box, state, clusters_state])
def answer(message, st):
"""Stream one grounded answer, then append the page citations."""
from langchain_core.messages import AIMessage, HumanMessage
from Qa import format_docs
message = (message or "").strip()
if not message or not st.get("active_chat"):
yield gr.update(), ""
return
session = st["chats"][st["active_chat"]]
session["messages"].append({"role": "user", "content": message})
yield [dict(m) for m in session["messages"]], ""
# LangChain chat history from prior turns (excludes the just-added question)
history = [HumanMessage(content=m["content"]) if m["role"] == "user"
else AIMessage(content=m["content"])
for m in session["messages"][:-1]]
try:
docs = session["retriever"].invoke(message)
context = format_docs(docs)
session["messages"].append({"role": "assistant", "content": ""})
for chunk in session["chain"].stream(
{"question": message, "chat_history": history, "context": context}
):
if chunk.content:
session["messages"][-1]["content"] += chunk.content
yield [dict(m) for m in session["messages"]], ""
pages = sorted({f"p.{d.metadata.get('page')}" for d in docs})
if pages:
session["messages"][-1]["content"] += "\n\n*sources: " + ", ".join(pages) + "*"
except Exception as e:
session["messages"].append(
{"role": "assistant",
"content": f"Sorry — I hit an error answering that: {type(e).__name__}: {e}"}
)
yield [dict(m) for m in session["messages"]], ""
for trigger in (chat_input.submit, send_btn.click):
trigger(answer, inputs=[chat_input, state], outputs=[chatbot, chat_input])
def back_to_papers(st):
st["active_chat"] = None
return (*_stages("results"), st)
back_btn.click(back_to_papers, inputs=[state], outputs=[*STAGE_COLS, state])
if __name__ == "__main__":
demo.queue(default_concurrency_limit=4).launch(
theme=NOVA_THEME, css=CSS, js=FORCE_DARK,
server_name="0.0.0.0", server_port=7860,
)