File size: 12,453 Bytes
2f25a40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3f31583
 
 
2f25a40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3f31583
 
 
 
2f25a40
 
3f31583
2f25a40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
"""ResearchPath Streamlit app.

Two tabs:
  1. Reading Path  β€” prerequisite-chain planner (the unique agentic feature)
  2. Ask           β€” grounded Q&A over the indexed RL corpus
"""
from __future__ import annotations

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

import streamlit as st

from researchpath.corpus import CANONICAL_RL_PAPERS
from researchpath.planning import plan_reading_path

ROOT = Path(__file__).resolve().parent
INDEX_PATH = ROOT / "data" / "index.faiss"

_TAG_TO_ID = {p.tag.lower(): p.arxiv_id for p in CANONICAL_RL_PAPERS}
_ID_TO_PAPER = {p.arxiv_id: p for p in CANONICAL_RL_PAPERS}
_ERA_ORDER = ["value-based", "policy-gradient", "actor-critic", "model-based", "rlhf"]

st.set_page_config(
    page_title="ResearchPath",
    page_icon="πŸ—ΊοΈ",
    layout="wide",
    initial_sidebar_state="collapsed",
)

# ── minimal style ──────────────────────────────────────────────────────────
st.markdown(
    """
    <style>
    .step-card {
        background: #1e2330;
        border-left: 4px solid #4f8ef7;
        border-radius: 6px;
        padding: 14px 18px;
        margin-bottom: 12px;
    }
    .step-card h4 { margin: 0 0 4px 0; color: #e0e6f0; }
    .step-card .concepts { color: #a0aec0; font-size: 0.85rem; }
    .step-card .why { color: #cbd5e0; font-size: 0.9rem; margin-top: 6px; }
    .tag-chip {
        display: inline-block;
        background: #2d3748;
        color: #90cdf4;
        border-radius: 12px;
        padding: 2px 10px;
        font-size: 0.78rem;
        margin-right: 4px;
    }
    </style>
    """,
    unsafe_allow_html=True,
)

# ── header ─────────────────────────────────────────────────────────────────
st.title("πŸ—ΊοΈ ResearchPath")
st.caption(
    "An agentic research-onboarding companion for Reinforcement Learning. "
    "Give it a target paper and your background; it builds a personalized, "
    "dependency-ordered reading plan."
)

tab_plan, tab_ask = st.tabs(["πŸ“š Reading Path", "πŸ’¬ Ask a Question"])


# ── helpers ────────────────────────────────────────────────────────────────

def _resolve(token: str) -> str | None:
    token = token.strip()
    if token in _ID_TO_PAPER:
        return token
    if token.lower() in _TAG_TO_ID:
        return _TAG_TO_ID[token.lower()]
    return None


def _paper_label(arxiv_id: str) -> str:
    p = _ID_TO_PAPER[arxiv_id]
    return f"{p.tag} ({p.year})"


@st.cache_resource(show_spinner="Loading index and embedder…")
def _load_retrieval():
    """Load FAISS index + embedder once per session."""
    from researchpath.embeddings import Embedder
    from researchpath.index import load_index
    from researchpath.retrieval import HybridRetriever

    if not INDEX_PATH.exists():
        return None, None, None
    index, chunks = load_index(INDEX_PATH)
    embedder = Embedder()
    retriever = HybridRetriever(index, chunks, embedder)
    return index, chunks, retriever


# ═══════════════════════════════════════════════════════════════════════════
# TAB 1 β€” Reading Path
# ═══════════════════════════════════════════════════════════════════════════
with tab_plan:
    st.subheader("Build your personalized reading path")
    st.markdown(
        "Select a **target paper** you want to understand, then tell us what you **already know**. "
        "ResearchPath traces the prerequisite chain and gives you a topologically-sorted reading list "
        "with *why each paper is next*."
    )

    # Group papers by era for a nicer selectbox
    era_groups: dict[str, list] = {era: [] for era in _ERA_ORDER}
    for p in CANONICAL_RL_PAPERS:
        era_groups[p.era].append(p)

    paper_options = []
    for era in _ERA_ORDER:
        for p in era_groups[era]:
            paper_options.append(p.arxiv_id)

    def _format_option(arxiv_id: str) -> str:
        p = _ID_TO_PAPER[arxiv_id]
        return f"{p.tag}  β€”  {p.title[:60]}{'…' if len(p.title) > 60 else ''}  ({p.year})"

    col_target, col_known = st.columns([1, 1])

    with col_target:
        target_id = st.selectbox(
            "Target paper",
            options=paper_options,
            format_func=_format_option,
            index=paper_options.index("1710.02298"),  # default: Rainbow
        )

    with col_known:
        known_options = [aid for aid in paper_options if aid != target_id]
        known_ids_raw = st.multiselect(
            "Papers you already know (optional)",
            options=known_options,
            format_func=_format_option,
            default=[],
        )

    if st.button("Generate reading path", type="primary", use_container_width=True):
        plan = plan_reading_path(target_id, known_ids=set(known_ids_raw))
        target_paper = _ID_TO_PAPER[target_id]

        if not plan.steps:
            st.success(f"You're already ready to read **{target_paper.tag}** directly!")
        else:
            st.markdown(
                f"**{len(plan.steps)} paper(s)** to read before (and including) "
                f"**{target_paper.tag}**, in order:"
            )
            for i, step in enumerate(plan.steps, 1):
                p = step.paper
                is_target = p.arxiv_id == target_id
                border_color = "#f6c90e" if is_target else "#4f8ef7"
                label = "🎯 TARGET" if is_target else f"Step {i}"
                concepts_html = "".join(
                    f'<span class="tag-chip">{c}</span>' for c in step.concepts
                )
                bridges_text = (
                    "β†’ " + ", ".join(_paper_label(b) for b in step.bridges_to)
                    if step.bridges_to
                    else ""
                )
                st.markdown(
                    f"""
                    <div class="step-card" style="border-left-color:{border_color}">
                        <h4>{label} &nbsp; [{p.arxiv_id}] &nbsp; <b>{p.tag}</b> &nbsp; <span style="color:#718096;font-weight:normal">({p.year})</span></h4>
                        <div style="color:#a0aec0;font-size:0.9rem">{p.title}</div>
                        {f'<div class="concepts" style="margin-top:8px">{concepts_html}</div>' if concepts_html else ""}
                        {f'<div class="why">{step.why}</div>' if step.why else ""}
                        {f'<div style="color:#718096;font-size:0.82rem;margin-top:6px">{bridges_text}</div>' if bridges_text else ""}
                    </div>
                    """,
                    unsafe_allow_html=True,
                )

    with st.expander("Browse all canonical RL papers"):
        for era in _ERA_ORDER:
            st.markdown(f"**{era.replace('-', ' ').title()}**")
            for p in era_groups[era]:
                st.markdown(
                    f"&nbsp;&nbsp;&nbsp;`{p.arxiv_id}` &nbsp; **{p.tag}** ({p.year}) β€” {p.title}"
                )


# ═══════════════════════════════════════════════════════════════════════════
# TAB 2 β€” Ask a Question
# ═══════════════════════════════════════════════════════════════════════════
with tab_ask:
    st.subheader("Ask anything about the RL corpus")
    st.markdown(
        "Every answer is grounded in the indexed corpus with **`[source_id, p<page>]` citations**. "
        "Corpus: 17 RL papers + Sutton & Barto textbook + RLHF Book + CS224R notes + 5 tutorials "
        "(5,531 chunks total). No hallucination β€” if the corpus doesn't contain an answer, the model says so."
    )

    index_ready = INDEX_PATH.exists()
    if not index_ready:
        st.warning(
            "No FAISS index found at `data/index.faiss`. "
            "Run `uv run python scripts/build_index.py` first."
        )

    with st.form("ask_form"):
        question = st.text_area(
            "Your question",
            placeholder="What is the main idea behind Proximal Policy Optimization?",
            height=80,
        )
        col_k, col_mode, _ = st.columns([1, 2, 3])
        with col_k:
            k = st.number_input("Top-k chunks", min_value=1, max_value=20, value=5)
        with col_mode:
            retrieval_mode = st.selectbox(
                "Retrieval mode",
                ["Hybrid (BM25 + FAISS)", "Dense (FAISS only)"],
            )
        submitted = st.form_submit_button("Ask", type="primary", use_container_width=True)

    import os
    _gemini_key = os.environ.get("GEMINI_API_KEY", "")
    if not _gemini_key:
        st.info(
            "**GEMINI_API_KEY not set** β€” the Ask tab needs a Gemini API key to generate answers. "
            "Set it in your `.env` file (local) or as a Space secret (HF Spaces). "
            "The Reading Path tab above works entirely offline with no API key."
        )

    if submitted and question.strip() and index_ready:
        if not _gemini_key:
            st.error("Cannot generate an answer: GEMINI_API_KEY is not set.")
        else:
            _, _, retriever = _load_retrieval()
            if retriever is None:
                st.error("Failed to load index.")
            else:
                with st.spinner("Retrieving and generating…"):
                    from researchpath.rag import answer as rag_answer
                    from researchpath.index import load_index, search
                    from researchpath.embeddings import Embedder

                    if retrieval_mode.startswith("Hybrid"):
                        hits = retriever.search(question.strip(), k=int(k))
                    else:
                        index, chunks = load_index(INDEX_PATH)
                        embedder = Embedder()
                        hits = search(index, chunks, embedder, question.strip(), k=int(k))

                    result = rag_answer(question.strip(), hits)

                st.markdown("### Answer")
                st.markdown(result.answer)

                st.markdown("---")
                col_meta1, col_meta2, col_meta3 = st.columns(3)
                col_meta1.metric("Chunks retrieved", len(hits))
                col_meta2.metric("Tokens in", result.llm.input_tokens or "β€”")
                col_meta3.metric("Tokens out", result.llm.output_tokens or "β€”")

                with st.expander(f"πŸ“„ Retrieved chunks ({len(hits)})"):
                    for i, h in enumerate(hits, 1):
                        _type_badge = {
                            "paper": "πŸ“„", "textbook": "πŸ“š",
                            "course": "πŸŽ“", "tutorial": "🌐",
                        }.get(h.source_type, "πŸ“„")
                        st.markdown(
                            f"**#{i}** &nbsp; `[{h.arxiv_id}, p{h.page}]` &nbsp; "
                            f"{_type_badge} *{h.source_type}* &nbsp; "
                            f"score={h.score:.3f}"
                        )
                        st.text(h.text[:400] + ("…" if len(h.text) > 400 else ""))
                        st.divider()

    # Example questions
    with st.expander("Example questions to try"):
        examples = [
            "What are the six components Rainbow combines?",
            "How does PPO avoid the large policy update problem in TRPO?",
            "What is the key insight behind Generalized Advantage Estimation?",
            "How does DDPG handle continuous action spaces?",
            "What is the DPO loss function and why does it not need a reward model?",
            "How does A3C's asynchronous training replace experience replay?",
        ]
        for ex in examples:
            st.markdown(f"- *{ex}*")