MSG commited on
Commit
83e828a
·
1 Parent(s): e7fd66f

Feat/enhance feature teacher (#6)

Browse files

* lesson plan enhancement

* models agent

* education pptx

* test education wip

* wip memory gen

* wip fix model running

.cursor/plans/lesson_slides_search_rag_832c4077.plan.md ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Lesson slides search RAG
3
+ overview: Add source-mode dropdown (None / Web search / RAG), a search-workflow dropdown (Two-step vs Auto search) for web mode, optional URL/file inputs and session picker, and wire discover/ingest/retrieve into slide outline generation.
4
+ todos:
5
+ - id: extend-models
6
+ content: Add source_mode, search_workflow, urls, files, session_id, doc_ids to EducationPptxInput
7
+ status: completed
8
+ - id: runner-sources
9
+ content: "Extend run_education_pptx: discover or ingest (web/rag) → retrieve → pass context to _generate_outline"
10
+ status: completed
11
+ - id: prompts-context
12
+ content: Update education_outline_user/system to inject retrieved source excerpts
13
+ status: completed
14
+ - id: gradio-ui
15
+ content: Add source mode + search workflow dropdowns, Discover button (two-step), URL/file/session/doc controls, visibility toggles, status output
16
+ status: completed
17
+ - id: tests-docs
18
+ content: Add agent tests for source modes and search workflows; update USAGE.md
19
+ status: completed
20
+ isProject: false
21
+ ---
22
+
23
+ # Lesson Slides — Web Search + RAG Integration
24
+
25
+ ## Goal
26
+
27
+ Extend the **Lesson slides** tab so teachers can ground slide outlines on external sources.
28
+
29
+ ### Source mode dropdown
30
+
31
+ | Mode | Behavior |
32
+ |------|----------|
33
+ | **None** | Current flow — local model only (no ingest/retrieve) |
34
+ | **Web search** | Discover and ingest web sources for the lesson topic, then retrieve chunks and draft outline |
35
+ | **RAG** | Use **existing ResearchMind session** and/or **URLs/files entered on this tab** — ingest if needed, retrieve, then draft outline |
36
+
37
+ ### Search workflow dropdown (Web search only)
38
+
39
+ Mirrors ResearchMind [`INGEST_MODES`](apps/gradio-space/src/gradio_space/tabs/research_mind.py):
40
+
41
+ | Workflow | Label in UI | Behavior |
42
+ |----------|-------------|----------|
43
+ | **Two-step** | `Two-step search (suggest & confirm)` | **Step 1:** **Discover sources** runs `run_researchmind_discover` → verified URLs in checkbox list. **Step 2:** User selects URLs → **Generate lesson slides** ingests selection, retrieves, builds outline. |
44
+ | **Auto search** | `Auto search & ingest` | **Generate lesson slides** alone runs `run_researchmind_ingest(auto_search=True)` → retrieve → outline in one click. |
45
+
46
+ All modes still produce the same outputs (preview, `.pptx`, `.docx`, `.html`, trace).
47
+
48
+ ## Architecture
49
+
50
+ ```mermaid
51
+ flowchart TB
52
+ subgraph ui [education_pptx.py]
53
+ Topic[Lesson topic]
54
+ SourceMode[Source mode dropdown]
55
+ SearchFlow[Search workflow dropdown]
56
+ Discover[Discover sources button]
57
+ URLChoices[Suggested URLs checkbox]
58
+ URLs[URLs textarea]
59
+ Files[PDF/DOCX upload]
60
+ Session[Session dropdown]
61
+ Docs[Doc checkbox group]
62
+ Gen[Generate lesson slides]
63
+ end
64
+
65
+ subgraph runner [AgentRunner]
66
+ DiscoverFn[run_researchmind_discover]
67
+ Ingest[run_researchmind_ingest]
68
+ Retrieve[retrieve + format_context_block]
69
+ Outline[_generate_outline with context]
70
+ Export[create_pptx / docx / html]
71
+ end
72
+
73
+ SourceMode --> SearchFlow
74
+ Discover --> DiscoverFn
75
+ DiscoverFn --> URLChoices
76
+ Gen --> Ingest
77
+ URLChoices --> Ingest
78
+ URLs --> Ingest
79
+ Files --> Ingest
80
+ Session --> Retrieve
81
+ Docs --> Retrieve
82
+ Topic --> DiscoverFn
83
+ Topic --> Ingest
84
+ Topic --> Retrieve
85
+ Ingest --> Retrieve
86
+ Retrieve --> Outline
87
+ Outline --> Export
88
+ ```
89
+
90
+ Reuse existing ResearchMind primitives — no new search/RAG library code required:
91
+
92
+ - [`run_researchmind_discover`](libs/agent/src/agent/runner.py) — web search → verified URL list (two-step step 1)
93
+ - [`run_researchmind_ingest`](libs/agent/src/agent/runner.py) — ingest URLs/files; `auto_search=True` for auto workflow
94
+ - [`retrieve`](libs/researchmind/src/researchmind/retrieve.py) + [`format_context_block`](libs/researchmind/src/researchmind/citations.py) — context for LLM
95
+ - [`research_helpers.py`](apps/gradio-space/src/gradio_space/research_helpers.py) — session/doc dropdown helpers
96
+
97
+ ## 1. UI changes — [`education_pptx.py`](apps/gradio-space/src/gradio_space/tabs/education_pptx.py)
98
+
99
+ Add a **Research sources** block between the topic row and **Generate lesson slides**:
100
+
101
+ ```python
102
+ SOURCE_MODES = [
103
+ ("None (model only)", "none"),
104
+ ("Web search", "web"),
105
+ ("RAG (indexed sources)", "rag"),
106
+ ]
107
+
108
+ SEARCH_WORKFLOWS = [
109
+ ("Two-step search (suggest & confirm)", "two_step"),
110
+ ("Auto search & ingest", "auto"),
111
+ ]
112
+ ```
113
+
114
+ **Controls:**
115
+
116
+ | Control | When visible | Purpose |
117
+ |---------|--------------|---------|
118
+ | `source_mode` | always | None / Web search / RAG |
119
+ | `search_workflow` | `source_mode == web` | Two-step vs Auto search |
120
+ | `discover_btn` | `source_mode == web` and `search_workflow == two_step` | Runs discover only |
121
+ | `url_choices` | `source_mode == web` and `search_workflow == two_step` | CheckboxGroup of suggested URLs |
122
+ | `urls_text` | `source_mode` is `web` or `rag` | Manual URLs (merged with selected checkboxes on generate) |
123
+ | `upload_files` | `source_mode` is `web` or `rag` | PDF/DOCX upload |
124
+ | `session_dd` | `source_mode == rag` | Existing ResearchMind session |
125
+ | `doc_dd` | `source_mode == rag` | Limit retrieve to specific docs |
126
+ | `source_status` | always (below generate) | Discover / ingest / retrieve summary |
127
+
128
+ **Visibility rules** (`source_mode.change` + `search_workflow.change`):
129
+
130
+ - **None:** hide all source controls except `source_status`
131
+ - **Web + Two-step:** show `search_workflow`, `discover_btn`, `url_choices`, `urls_text`, `upload_files`; hide session/doc
132
+ - **Web + Auto:** show `search_workflow`, `urls_text`, `upload_files`; hide `discover_btn`, `url_choices`, session/doc
133
+ - **RAG:** show `urls_text`, `upload_files`, `session_dd`, `doc_dd`; hide `search_workflow`, discover, url_choices
134
+
135
+ **Handlers:**
136
+
137
+ 1. **`discover_lesson_sources(topic, session_id)`** — mirrors ResearchMind `discover_sources` with `ingest_mode="suggest"`:
138
+ - `run_researchmind_discover(topic, auto_search=False, ...)`
139
+ - Updates `url_choices`, `source_status`, `session_dd` (lesson session id)
140
+ 2. **`generate_lesson_slides(...)`** — extended signature; behavior by mode:
141
+ - **none:** current path
142
+ - **web + two_step:** ingest merged URLs (selected checkboxes + pasted lines + files), `auto_search=False`; error if nothing selected and no files
143
+ - **web + auto:** ingest with `auto_search=True` (+ optional pasted URLs/files)
144
+ - **rag:** ingest pasted URLs/files if any; use session + doc scope; error if no indexed docs after ingest
145
+
146
+ **Allowed paths:** extend `gradio_allowed_paths()` to include ResearchMind data dir (mirror [`research_mind.py`](apps/gradio-space/src/gradio_space/tabs/research_mind.py)), merged in [`app.py`](apps/gradio-space/src/gradio_space/app.py) if not already.
147
+
148
+ ## 2. Agent model — [`models.py`](libs/agent/src/agent/models.py)
149
+
150
+ Extend `EducationPptxInput`:
151
+
152
+ ```python
153
+ class EducationPptxInput(BaseModel):
154
+ topic: str
155
+ grade: str
156
+ slide_count: int = Field(ge=3, le=8)
157
+ source_mode: Literal["none", "web", "rag"] = "none"
158
+ search_workflow: Literal["two_step", "auto"] = "two_step" # only used when source_mode == "web"
159
+ urls: list[str] = Field(default_factory=list)
160
+ files: list[Path] = Field(default_factory=list)
161
+ session_id: str | None = None
162
+ doc_ids: list[str] = Field(default_factory=list)
163
+ ```
164
+
165
+ Add `source_summary: str` on `AgentResult` for UI status line.
166
+
167
+ ## 3. Runner orchestration — [`runner.py`](libs/agent/src/agent/runner.py)
168
+
169
+ Extend `run_education_pptx()`:
170
+
171
+ **Phase A — Gather sources (when `source_mode != "none"`):**
172
+
173
+ - **`web` + `auto`:** `run_researchmind_ingest(topic, urls, files, auto_search=True, session_id=...)`
174
+ - **`web` + `two_step`:** `run_researchmind_ingest(topic, urls, files, auto_search=False, ...)` — caller must pass URLs from discover selection + manual paste; runner does not auto-discover on generate
175
+ - **`rag`:** if URLs/files provided → `run_researchmind_ingest(..., auto_search=False)`. Resolve `session_id` via `_ensure_session()`. Validate session has docs (or ingest succeeded) before retrieve
176
+
177
+ Optional: add `discover_lesson_urls()` thin wrapper on runner that only calls `run_researchmind_discover` (used by Discover button, not by generate in two-step mode).
178
+
179
+ **Phase B — Retrieve context:**
180
+
181
+ ```python
182
+ chunks = retrieve(req.topic, store, session_id=..., doc_ids=...)
183
+ context, citations = format_context_block(chunks)
184
+ ```
185
+
186
+ Log retrieve + citation count in `TraceRecorder`.
187
+
188
+ **Phase C — Outline with context:**
189
+
190
+ Pass `context` into `_generate_outline()` when non-empty.
191
+
192
+ **Graceful fallback:** if discover/ingest finds nothing or retrieve returns 0 chunks, log trace note and continue with model-only outline plus UI warning in `source_status`.
193
+
194
+ ## 4. Prompt changes — [`prompts.py`](libs/agent/src/agent/prompts.py)
195
+
196
+ Add optional context block to user prompt:
197
+
198
+ ```python
199
+ def education_outline_user(req: EducationPptxInput, *, source_context: str = "") -> str:
200
+ ...
201
+ if source_context.strip():
202
+ base += (
203
+ "\n\nUse the following retrieved source excerpts as factual grounding. "
204
+ "Prefer these over general knowledge when they apply. "
205
+ "Do not invent citations in the JSON output.\n\n"
206
+ f"{source_context}\n"
207
+ )
208
+ return base + "\nReturn JSON only."
209
+ ```
210
+
211
+ Update `education_outline_system()` rules for age-appropriate bullets consistent with sources when context is present.
212
+
213
+ ## 5. Helper extraction (minimal)
214
+
215
+ Add thin helpers in [`research_helpers.py`](apps/gradio-space/src/gradio_space/research_helpers.py):
216
+
217
+ - `parse_urls_text(text: str) -> list[str]`
218
+ - `merge_lesson_urls(pasted: str, selected: list[str]) -> list[str]` — dedupe like ResearchMind `ingest_selected`
219
+
220
+ Reuse existing `list_session_choices`, `refresh_doc_choices`, `format_ingest_status`, `memory_summary`.
221
+
222
+ ## 6. Tests
223
+
224
+ Add [`libs/agent/tests/test_education_sources.py`](libs/agent/tests/test_education_sources.py):
225
+
226
+ - `none` — skips ingest/retrieve
227
+ - `web` + `auto` — ingest with `auto_search=True`
228
+ - `web` + `two_step` — ingest with `auto_search=False`, URLs from caller only
229
+ - `rag` — session + doc_ids, no auto-search
230
+ - Context injected into outline prompt when chunks exist
231
+ - Two-step generate with empty URL selection returns clear error (no silent auto-search)
232
+
233
+ Follow patterns in [`test_research_runner.py`](libs/agent/tests/test_research_runner.py).
234
+
235
+ ## 7. Docs
236
+
237
+ Update [`USAGE.md`](USAGE.md) Lesson slides section:
238
+
239
+ - Three source modes
240
+ - **Web search:** Two-step (Discover → select → Generate) vs Auto (Generate only)
241
+ - Network required for discover/auto web search
242
+ - RAG combines ResearchMind sessions with pasted URLs/uploads
243
+
244
+ ## Key files to change
245
+
246
+ | File | Change |
247
+ |------|--------|
248
+ | [`education_pptx.py`](apps/gradio-space/src/gradio_space/tabs/education_pptx.py) | Source mode + search workflow UI, Discover handler, generate wiring |
249
+ | [`runner.py`](libs/agent/src/agent/runner.py) | Branch on `search_workflow`; ingest → retrieve → grounded outline |
250
+ | [`models.py`](libs/agent/src/agent/models.py) | `search_workflow` + source fields on `EducationPptxInput` |
251
+ | [`prompts.py`](libs/agent/src/agent/prompts.py) | Context-aware user prompt |
252
+ | [`research_helpers.py`](apps/gradio-space/src/gradio_space/research_helpers.py) | URL parse/merge helpers |
253
+ | [`app.py`](apps/gradio-space/src/gradio_space/app.py) | Merge ResearchMind allowed paths if needed |
254
+ | [`USAGE.md`](USAGE.md) | User-facing docs |
255
+
256
+ ## UX summary
257
+
258
+ ```
259
+ [Lesson topic] [Grade] [Content slides]
260
+
261
+ [Source mode ▼] None | Web search | RAG
262
+
263
+ (when Web search)
264
+ [Search workflow ▼] Two-step search | Auto search & ingest
265
+
266
+ (when Web + Two-step)
267
+ [Discover sources] → populates checkbox list
268
+ [☑ Suggested URLs]
269
+
270
+ (when Web or RAG)
271
+ [URLs textarea]
272
+ [Upload PDF/DOCX]
273
+
274
+ (when RAG)
275
+ [Session ▼] [Documents ☑]
276
+
277
+ [Generate lesson slides]
278
+ Source status: "Found 5 URLs" / "Ingested 2 sources; 4 passages used"
279
+ [Slide preview | Outline] ...
280
+ ```
281
+
282
+ **Two-step** gives teachers control over which sources feed the lesson (same UX as ResearchMind Suggest mode). **Auto search** optimizes for speed when confirmation is not needed.
USAGE.md CHANGED
@@ -2,7 +2,7 @@
2
 
3
  How to run the **Lesson Agent** Gradio app locally, test it in Docker, and deploy to a Hugging Face Space for the [Build Small Hackathon](https://huggingface.co/build-small-hackathon).
4
 
5
- The primary UI is the **Lesson slides** tab (topic → local model outline → downloadable `.pptx`). The **Chat (debug)** tab tests the underlying model.
6
 
7
  ## Prerequisites
8
 
@@ -51,6 +51,27 @@ Open [http://localhost:7860](http://localhost:7860).
51
 
52
  The model loads on the **first Generate** (Lesson slides) or chat message. Agent traces are written to `outputs/traces/`. After code changes, restart the process to pick up updates.
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  ### 5. Upload agent trace (Sharing is Caring badge)
55
 
56
  ```bash
 
2
 
3
  How to run the **Lesson Agent** Gradio app locally, test it in Docker, and deploy to a Hugging Face Space for the [Build Small Hackathon](https://huggingface.co/build-small-hackathon).
4
 
5
+ The primary UI is the **Lesson slides** tab (topic → local model outline → downloadable `.pptx`). Use **ResearchMind** for corpus Q&A, or ground lessons directly from the Lesson tab. The **Chat (debug)** tab tests the underlying model.
6
 
7
  ## Prerequisites
8
 
 
51
 
52
  The model loads on the **first Generate** (Lesson slides) or chat message. Agent traces are written to `outputs/traces/`. After code changes, restart the process to pick up updates.
53
 
54
+ ### Lesson slides — research sources
55
+
56
+ The **Lesson slides** tab can ground outlines on external sources before building the deck:
57
+
58
+ | Source mode | What it does |
59
+ | ----------- | ------------ |
60
+ | **None (model only)** | Default — outline from the local model only |
61
+ | **Web search** | Search the web for the lesson topic, ingest pages, retrieve passages, then draft slides |
62
+ | **RAG (indexed sources)** | Use a **ResearchMind session** and/or URLs/files you provide on this tab |
63
+
64
+ When **Web search** is selected, choose a **search workflow**:
65
+
66
+ | Workflow | Steps |
67
+ | -------- | ----- |
68
+ | **Two-step search (suggest & confirm)** | Click **Discover sources** → select URLs → **Generate lesson slides** |
69
+ | **Auto search & ingest** | Click **Generate lesson slides** only — search, ingest, and outline in one step |
70
+
71
+ **RAG** mode accepts an optional ResearchMind session, document checkboxes (scope), pasted URLs, and PDF/DOCX uploads. Indexed content is retrieved and passed to the outline step.
72
+
73
+ Web discover/auto search requires network access. MemRAG data is stored under `RESEARCHMIND_DATA_DIR` (default `outputs/researchmind`).
74
+
75
  ### 5. Upload agent trace (Sharing is Caring badge)
76
 
77
  ```bash
apps/gradio-space/src/gradio_space/research_helpers.py CHANGED
@@ -121,6 +121,15 @@ def memory_summary(session_id: str) -> str:
121
  return "\n".join(lines)
122
 
123
 
 
 
 
 
 
 
 
 
 
124
  def rag_scope_hint(session_id: str, doc_ids: list[str] | None) -> str:
125
  if doc_ids:
126
  return f"RAG scope: **{len(doc_ids)}** selected document(s)."
 
121
  return "\n".join(lines)
122
 
123
 
124
+ def parse_urls_text(text: str) -> list[str]:
125
+ return [line.strip() for line in (text or "").splitlines() if line.strip()]
126
+
127
+
128
+ def merge_lesson_urls(pasted: str, selected: list[str] | None) -> list[str]:
129
+ direct = parse_urls_text(pasted)
130
+ return list(dict.fromkeys([*direct, *(selected or [])]))
131
+
132
+
133
  def rag_scope_hint(session_id: str, doc_ids: list[str] | None) -> str:
134
  if doc_ids:
135
  return f"RAG scope: **{len(doc_ids)}** selected document(s)."
apps/gradio-space/src/gradio_space/tabs/education_pptx.py CHANGED
@@ -5,7 +5,37 @@ import gradio as gr
5
  from agent.runner import AgentRunner
6
  from agent.tools.pptx import get_outputs_dir
7
  from gradio_space.model_loading import ensure_model_loaded, get_active_model_key, model_status
 
 
 
 
 
 
8
  from inference.factory import get_backend
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  def _error_html(message: str) -> str:
11
  safe = (
@@ -19,19 +49,106 @@ def _error_html(message: str) -> str:
19
  )
20
 
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  def generate_lesson_slides(
23
  topic: str,
24
  grade: str,
25
  slide_count: int,
26
- ) -> tuple[str, str, list[str], str | None, str | None, str | None, str, str]:
 
 
 
 
 
 
 
27
  model_key = get_active_model_key()
28
  load_error = ensure_model_loaded(model_key)
29
  if load_error:
30
- return load_error, _error_html(load_error), [], None, None, None, load_error, load_error
31
 
32
  if not topic.strip():
33
  message = "Please enter a lesson topic."
34
- return message, _error_html(message), [], None, None, None, message, message
 
 
 
 
 
35
 
36
  try:
37
  runner = AgentRunner()
@@ -41,10 +158,16 @@ def generate_lesson_slides(
41
  slide_count=int(slide_count),
42
  model_key=model_key,
43
  backend=get_backend(model_key),
 
 
 
 
 
 
44
  )
45
  except Exception as exc: # noqa: BLE001 — show agent errors in UI
46
  message = f"Agent error: {exc}"
47
- return message, _error_html(message), [], None, None, None, message, message
48
 
49
  gallery = [str(Path(p).resolve()) for p in result.preview_images]
50
  trace_summary = (
@@ -52,6 +175,7 @@ def generate_lesson_slides(
52
  f"model `{result.trace.model}`\n\n"
53
  f"Trace saved: `{result.trace_path}`"
54
  )
 
55
  return (
56
  result.markdown_preview,
57
  result.html_preview,
@@ -61,6 +185,7 @@ def generate_lesson_slides(
61
  str(Path(result.html_export_path).resolve()),
62
  trace_summary,
63
  result.trace.to_json(),
 
64
  )
65
 
66
 
@@ -72,7 +197,7 @@ def build_education_pptx_tab() -> None:
72
  ### Lesson slide builder
73
 
74
  Enter a topic and grade level. A **local small model** drafts the outline;
75
- the agent then builds a downloadable PowerPoint — no cloud LLM API.
76
  """
77
  )
78
  gr.Markdown(model_status(model_key))
@@ -95,7 +220,55 @@ the agent then builds a downloadable PowerPoint — no cloud LLM API.
95
  label="Content slides",
96
  )
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  generate_btn = gr.Button("Generate lesson slides", variant="primary")
 
99
 
100
  with gr.Tabs():
101
  with gr.Tab("Slide preview"):
@@ -139,9 +312,56 @@ then choose **Open with → Google Docs**. You can also upload the `.html` file
139
  with gr.Accordion("Trace summary", open=False):
140
  trace_summary = gr.Markdown()
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  generate_btn.click(
143
  fn=generate_lesson_slides,
144
- inputs=[topic, grade, slide_count],
 
 
 
 
 
 
 
 
 
 
 
145
  outputs=[
146
  outline_preview,
147
  slide_preview,
@@ -151,6 +371,7 @@ then choose **Open with → Google Docs**. You can also upload the `.html` file
151
  html_file,
152
  trace_summary,
153
  trace_box,
 
154
  ],
155
  )
156
 
@@ -158,4 +379,7 @@ then choose **Open with → Google Docs**. You can also upload the `.html` file
158
  def gradio_allowed_paths() -> list[str]:
159
  """Paths Gradio must be allowed to read for previews and downloads."""
160
  root = get_outputs_dir().resolve()
161
- return [str(root)]
 
 
 
 
5
  from agent.runner import AgentRunner
6
  from agent.tools.pptx import get_outputs_dir
7
  from gradio_space.model_loading import ensure_model_loaded, get_active_model_key, model_status
8
+ from gradio_space.research_helpers import (
9
+ list_session_choices,
10
+ merge_lesson_urls,
11
+ refresh_doc_choices,
12
+ refresh_sessions,
13
+ )
14
  from inference.factory import get_backend
15
+ from researchmind.config import get_config
16
+
17
+ SOURCE_MODES = [
18
+ ("None (model only)", "none"),
19
+ ("Web search", "web"),
20
+ ("RAG (indexed sources)", "rag"),
21
+ ]
22
+
23
+ SEARCH_WORKFLOWS = [
24
+ ("Two-step search (suggest & confirm)", "two_step"),
25
+ ("Auto search & ingest", "auto"),
26
+ ]
27
+
28
+ _SOURCE_LABEL_TO_VALUE = {label: value for label, value in SOURCE_MODES}
29
+ _WORKFLOW_LABEL_TO_VALUE = {label: value for label, value in SEARCH_WORKFLOWS}
30
+
31
+
32
+ def _source_mode_value(label: str) -> str:
33
+ return _SOURCE_LABEL_TO_VALUE.get(label, "none")
34
+
35
+
36
+ def _search_workflow_value(label: str) -> str:
37
+ return _WORKFLOW_LABEL_TO_VALUE.get(label, "two_step")
38
+
39
 
40
  def _error_html(message: str) -> str:
41
  safe = (
 
49
  )
50
 
51
 
52
+ def _empty_outputs(message: str) -> tuple:
53
+ return (
54
+ message,
55
+ _error_html(message),
56
+ [],
57
+ None,
58
+ None,
59
+ None,
60
+ message,
61
+ message,
62
+ message,
63
+ )
64
+
65
+
66
+ def update_source_visibility(source_mode_label: str, search_workflow_label: str):
67
+ mode = _source_mode_value(source_mode_label)
68
+ workflow = _search_workflow_value(search_workflow_label)
69
+ is_web = mode == "web"
70
+ is_rag = mode == "rag"
71
+ is_sources = is_web or is_rag
72
+ is_two_step = is_web and workflow == "two_step"
73
+ return (
74
+ gr.update(visible=is_web),
75
+ gr.update(visible=is_two_step),
76
+ gr.update(visible=is_two_step),
77
+ gr.update(visible=is_sources),
78
+ gr.update(visible=is_sources),
79
+ gr.update(visible=is_rag),
80
+ gr.update(visible=is_rag),
81
+ )
82
+
83
+
84
+ def discover_lesson_sources(
85
+ topic: str,
86
+ session_id: str,
87
+ ) -> tuple[str, object, object]:
88
+ model_key = get_active_model_key()
89
+ load_error = ensure_model_loaded(model_key)
90
+ if load_error:
91
+ return load_error, gr.update(choices=[], value=[]), refresh_sessions(session_id)
92
+
93
+ if not topic.strip():
94
+ msg = "Enter a lesson topic to discover sources."
95
+ return msg, gr.update(choices=[], value=[]), refresh_sessions(session_id)
96
+
97
+ try:
98
+ runner = AgentRunner()
99
+ discover = runner.run_researchmind_discover(
100
+ topic=topic,
101
+ auto_search=False,
102
+ session_id=session_id or None,
103
+ model_key=model_key,
104
+ backend=get_backend(model_key),
105
+ )
106
+ choices = discover.suggested_urls
107
+ if not choices:
108
+ summary = (
109
+ "No verified URLs found. Try a more specific topic, paste URLs manually, "
110
+ "or switch to **Auto search & ingest**."
111
+ )
112
+ else:
113
+ summary = (
114
+ f"Found **{len(choices)}** verified URL(s). Select sources, then click "
115
+ "**Generate lesson slides**."
116
+ )
117
+ return (
118
+ summary,
119
+ gr.update(choices=choices, value=choices),
120
+ refresh_sessions(discover.session_id),
121
+ )
122
+ except Exception as exc: # noqa: BLE001
123
+ msg = f"Discover error: {exc}"
124
+ return msg, gr.update(choices=[], value=[]), refresh_sessions(session_id)
125
+
126
+
127
  def generate_lesson_slides(
128
  topic: str,
129
  grade: str,
130
  slide_count: int,
131
+ source_mode_label: str,
132
+ search_workflow_label: str,
133
+ urls_text: str,
134
+ selected_urls: list[str],
135
+ upload_files: list[str] | None,
136
+ session_id: str,
137
+ doc_ids: list[str] | None,
138
+ ) -> tuple[str, str, list[str], str | None, str | None, str | None, str, str, str]:
139
  model_key = get_active_model_key()
140
  load_error = ensure_model_loaded(model_key)
141
  if load_error:
142
+ return _empty_outputs(load_error)
143
 
144
  if not topic.strip():
145
  message = "Please enter a lesson topic."
146
+ return _empty_outputs(message)
147
+
148
+ source_mode = _source_mode_value(source_mode_label)
149
+ search_workflow = _search_workflow_value(search_workflow_label)
150
+ merged_urls = merge_lesson_urls(urls_text, selected_urls)
151
+ files = [Path(p) for p in (upload_files or [])]
152
 
153
  try:
154
  runner = AgentRunner()
 
158
  slide_count=int(slide_count),
159
  model_key=model_key,
160
  backend=get_backend(model_key),
161
+ source_mode=source_mode, # type: ignore[arg-type]
162
+ search_workflow=search_workflow, # type: ignore[arg-type]
163
+ urls=merged_urls,
164
+ files=files,
165
+ session_id=session_id or None,
166
+ doc_ids=doc_ids or [],
167
  )
168
  except Exception as exc: # noqa: BLE001 — show agent errors in UI
169
  message = f"Agent error: {exc}"
170
+ return _empty_outputs(message)
171
 
172
  gallery = [str(Path(p).resolve()) for p in result.preview_images]
173
  trace_summary = (
 
175
  f"model `{result.trace.model}`\n\n"
176
  f"Trace saved: `{result.trace_path}`"
177
  )
178
+ source_status = result.source_summary or "_No external sources used (model only)._"
179
  return (
180
  result.markdown_preview,
181
  result.html_preview,
 
185
  str(Path(result.html_export_path).resolve()),
186
  trace_summary,
187
  result.trace.to_json(),
188
+ source_status,
189
  )
190
 
191
 
 
197
  ### Lesson slide builder
198
 
199
  Enter a topic and grade level. A **local small model** drafts the outline;
200
+ optionally ground it with **web search** or **RAG** from indexed sources.
201
  """
202
  )
203
  gr.Markdown(model_status(model_key))
 
220
  label="Content slides",
221
  )
222
 
223
+ gr.Markdown("#### Research sources (optional)")
224
+ with gr.Row():
225
+ source_mode = gr.Dropdown(
226
+ label="Source mode",
227
+ choices=[m[0] for m in SOURCE_MODES],
228
+ value=SOURCE_MODES[0][0],
229
+ )
230
+ search_workflow = gr.Dropdown(
231
+ label="Search workflow",
232
+ choices=[m[0] for m in SEARCH_WORKFLOWS],
233
+ value=SEARCH_WORKFLOWS[0][0],
234
+ visible=False,
235
+ )
236
+
237
+ with gr.Row():
238
+ discover_btn = gr.Button("Discover sources", variant="secondary", visible=False)
239
+ session_dd = gr.Dropdown(
240
+ label="ResearchMind session",
241
+ choices=list_session_choices(),
242
+ value="",
243
+ visible=False,
244
+ )
245
+
246
+ url_choices = gr.CheckboxGroup(
247
+ label="Suggested URLs to use",
248
+ choices=[],
249
+ visible=False,
250
+ )
251
+ urls_text = gr.Textbox(
252
+ label="URLs (one per line, optional)",
253
+ lines=3,
254
+ placeholder="https://en.wikipedia.org/wiki/...",
255
+ visible=False,
256
+ )
257
+ upload_files = gr.File(
258
+ label="Upload PDF or DOCX",
259
+ file_count="multiple",
260
+ file_types=[".pdf", ".docx"],
261
+ visible=False,
262
+ )
263
+ doc_dd = gr.CheckboxGroup(
264
+ label="Documents in session (RAG scope)",
265
+ choices=[],
266
+ value=[],
267
+ visible=False,
268
+ )
269
+
270
  generate_btn = gr.Button("Generate lesson slides", variant="primary")
271
+ source_status = gr.Markdown(value="_No sources gathered yet._")
272
 
273
  with gr.Tabs():
274
  with gr.Tab("Slide preview"):
 
312
  with gr.Accordion("Trace summary", open=False):
313
  trace_summary = gr.Markdown()
314
 
315
+ source_controls = [
316
+ search_workflow,
317
+ discover_btn,
318
+ url_choices,
319
+ urls_text,
320
+ upload_files,
321
+ session_dd,
322
+ doc_dd,
323
+ ]
324
+
325
+ def _refresh_visibility(mode_label: str, workflow_label: str):
326
+ return update_source_visibility(mode_label, workflow_label)
327
+
328
+ source_mode.change(
329
+ fn=_refresh_visibility,
330
+ inputs=[source_mode, search_workflow],
331
+ outputs=source_controls,
332
+ )
333
+ search_workflow.change(
334
+ fn=_refresh_visibility,
335
+ inputs=[source_mode, search_workflow],
336
+ outputs=source_controls,
337
+ )
338
+
339
+ session_dd.change(
340
+ fn=refresh_doc_choices,
341
+ inputs=[session_dd, doc_dd],
342
+ outputs=[doc_dd],
343
+ )
344
+
345
+ discover_btn.click(
346
+ fn=discover_lesson_sources,
347
+ inputs=[topic, session_dd],
348
+ outputs=[source_status, url_choices, session_dd],
349
+ )
350
+
351
  generate_btn.click(
352
  fn=generate_lesson_slides,
353
+ inputs=[
354
+ topic,
355
+ grade,
356
+ slide_count,
357
+ source_mode,
358
+ search_workflow,
359
+ urls_text,
360
+ url_choices,
361
+ upload_files,
362
+ session_dd,
363
+ doc_dd,
364
+ ],
365
  outputs=[
366
  outline_preview,
367
  slide_preview,
 
371
  html_file,
372
  trace_summary,
373
  trace_box,
374
+ source_status,
375
  ],
376
  )
377
 
 
379
  def gradio_allowed_paths() -> list[str]:
380
  """Paths Gradio must be allowed to read for previews and downloads."""
381
  root = get_outputs_dir().resolve()
382
+ cfg = get_config()
383
+ rm_root = cfg.data_dir.resolve()
384
+ rm_root.mkdir(parents=True, exist_ok=True)
385
+ return [str(root), str(rm_root)]
libs/agent/src/agent/models.py CHANGED
@@ -1,5 +1,8 @@
1
  from __future__ import annotations
2
 
 
 
 
3
  from pydantic import BaseModel, Field
4
 
5
 
@@ -18,6 +21,12 @@ class EducationPptxInput(BaseModel):
18
  topic: str
19
  grade: str
20
  slide_count: int = Field(ge=3, le=8)
 
 
 
 
 
 
21
 
22
 
23
  class Citation(BaseModel):
@@ -57,6 +66,7 @@ class ResearchIngestResult(BaseModel):
57
  session_id: str
58
  ingested: list[str]
59
  skipped: list[str]
 
60
  failures: list[IngestFailure] = Field(default_factory=list)
61
  doc_count: int
62
  chunk_count: int
 
1
  from __future__ import annotations
2
 
3
+ from pathlib import Path
4
+ from typing import Literal
5
+
6
  from pydantic import BaseModel, Field
7
 
8
 
 
21
  topic: str
22
  grade: str
23
  slide_count: int = Field(ge=3, le=8)
24
+ source_mode: Literal["none", "web", "rag"] = "none"
25
+ search_workflow: Literal["two_step", "auto"] = "two_step"
26
+ urls: list[str] = Field(default_factory=list)
27
+ files: list[Path] = Field(default_factory=list)
28
+ session_id: str | None = None
29
+ doc_ids: list[str] = Field(default_factory=list)
30
 
31
 
32
  class Citation(BaseModel):
 
66
  session_id: str
67
  ingested: list[str]
68
  skipped: list[str]
69
+ doc_ids: list[str] = Field(default_factory=list)
70
  failures: list[IngestFailure] = Field(default_factory=list)
71
  doc_count: int
72
  chunk_count: int
libs/agent/src/agent/prompts.py CHANGED
@@ -27,17 +27,25 @@ JSON schema:
27
  Rules:
28
  - Use exactly the requested number of content slides (title slide is added separately by the tool).
29
  - Bullets should be short, age-appropriate, and factual.
 
30
  - speaker_note is optional but helpful for each slide.
31
  """
32
 
33
 
34
- def education_outline_user(req: EducationPptxInput) -> str:
35
- return (
36
  f"Topic: {req.topic}\n"
37
  f"Grade level: {req.grade}\n"
38
  f"Number of content slides: {req.slide_count}\n"
39
- "Return JSON only."
40
  )
 
 
 
 
 
 
 
 
41
 
42
 
43
  def education_outline_repair(
 
27
  Rules:
28
  - Use exactly the requested number of content slides (title slide is added separately by the tool).
29
  - Bullets should be short, age-appropriate, and factual.
30
+ - When source excerpts are provided, prefer them over general knowledge and keep bullets consistent with those sources.
31
  - speaker_note is optional but helpful for each slide.
32
  """
33
 
34
 
35
+ def education_outline_user(req: EducationPptxInput, *, source_context: str = "") -> str:
36
+ base = (
37
  f"Topic: {req.topic}\n"
38
  f"Grade level: {req.grade}\n"
39
  f"Number of content slides: {req.slide_count}\n"
 
40
  )
41
+ if source_context.strip():
42
+ base += (
43
+ "\nUse the following retrieved source excerpts as factual grounding. "
44
+ "Prefer these over general knowledge when they apply. "
45
+ "Do not invent citations in the JSON output.\n\n"
46
+ f"{source_context}\n"
47
+ )
48
+ return base + "\nReturn JSON only."
49
 
50
 
51
  def education_outline_repair(
libs/agent/src/agent/runner.py CHANGED
@@ -4,11 +4,13 @@ import json
4
  import re
5
  from dataclasses import dataclass
6
  from pathlib import Path
7
- from typing import Any
8
 
9
  from inference.base import InferenceBackend
 
10
  from researchmind.extract import extract_docx
11
  from researchmind.ingest import IngestPipeline
 
12
 
13
  from agent.models import (
14
  Citation,
@@ -47,6 +49,7 @@ class AgentResult:
47
  trace: TraceRecorder
48
  trace_path: str
49
  outline: SlideOutline
 
50
 
51
 
52
  class AgentRunner:
@@ -66,18 +69,42 @@ class AgentRunner:
66
  slide_count: int,
67
  model_key: str,
68
  backend: InferenceBackend,
 
 
 
 
 
 
69
  ) -> AgentResult:
70
  skill = self._skills.get(EDUCATION_PPTX_SKILL)
71
- req = EducationPptxInput(topic=topic.strip(), grade=grade, slide_count=slide_count)
 
 
 
 
 
 
 
 
 
 
72
 
73
  trace = TraceRecorder(
74
  skill=skill.name,
75
  model=model_key,
76
- user_input=req.model_dump(),
77
  )
78
 
79
  backend.load()
80
- outline = self._generate_outline(skill, req, backend, trace)
 
 
 
 
 
 
 
 
81
  tool = self._tools.get("create_pptx")
82
  pptx_path = tool.handler(outline, run_id=trace.run_id)
83
  trace.log_tool(
@@ -118,7 +145,102 @@ class AgentRunner:
118
  trace=trace,
119
  trace_path=str(trace_path),
120
  outline=outline,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  )
 
 
122
 
123
  def _generate_outline(
124
  self,
@@ -126,9 +248,11 @@ class AgentRunner:
126
  req: EducationPptxInput,
127
  backend: InferenceBackend,
128
  trace: TraceRecorder,
 
 
129
  ) -> SlideOutline:
130
  system = education_outline_system(skill.body)
131
- user = education_outline_user(req)
132
  messages = [
133
  {"role": "system", "content": system},
134
  {"role": "user", "content": user},
@@ -229,16 +353,51 @@ class AgentRunner:
229
  @staticmethod
230
  def _extract_json(text: str) -> dict[str, Any]:
231
  cleaned = text.strip()
232
- fence = re.search(r"```(?:json)?\s*(\{.*\})\s*```", cleaned, re.DOTALL)
233
  if fence:
234
- cleaned = fence.group(1)
235
- else:
236
- start = cleaned.find("{")
237
- end = cleaned.rfind("}")
238
- if start >= 0 and end > start:
239
- cleaned = cleaned[start : end + 1]
 
 
 
 
 
 
 
240
  return json.loads(cleaned)
241
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  def _research_skill(self) -> Any:
243
  return self._skills.get(RESEARCH_MIND_SKILL)
244
 
@@ -252,6 +411,56 @@ class AgentRunner:
252
  return session_id
253
  return store.create_session(topic=topic).id
254
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  def run_researchmind_discover(
256
  self,
257
  *,
@@ -339,6 +548,8 @@ class AgentRunner:
339
 
340
  ingested: list[str] = []
341
  skipped: list[str] = []
 
 
342
  failures: list[IngestFailure] = []
343
 
344
  scrape_web = self._tools.get("scrape_web")
@@ -360,6 +571,9 @@ class AgentRunner:
360
  failures.append(IngestFailure(url=url, reason=msg, stage="scrape"))
361
  continue
362
  doc_id, is_new = extract_index.handler(doc, session_id=sid)
 
 
 
363
  trace.log_tool("scrape_web", {"url": url}, doc.title)
364
  trace.log_tool(
365
  "extract_and_index",
@@ -389,6 +603,9 @@ class AgentRunner:
389
  text=text,
390
  )
391
  doc_id, is_new = extract_index.handler(doc, session_id=sid)
 
 
 
392
  trace.log_tool("extract_and_index", {"file": str(path)}, f"{doc_id} new={is_new}")
393
  label = path.name
394
  (ingested if is_new else skipped).append(label)
@@ -411,6 +628,7 @@ class AgentRunner:
411
  session_id=sid,
412
  ingested=ingested,
413
  skipped=skipped,
 
414
  failures=failures,
415
  doc_count=doc_count,
416
  chunk_count=chunk_count,
 
4
  import re
5
  from dataclasses import dataclass
6
  from pathlib import Path
7
+ from typing import Any, Literal
8
 
9
  from inference.base import InferenceBackend
10
+ from researchmind.citations import format_context_block
11
  from researchmind.extract import extract_docx
12
  from researchmind.ingest import IngestPipeline
13
+ from researchmind.retrieve import retrieve
14
 
15
  from agent.models import (
16
  Citation,
 
49
  trace: TraceRecorder
50
  trace_path: str
51
  outline: SlideOutline
52
+ source_summary: str = ""
53
 
54
 
55
  class AgentRunner:
 
69
  slide_count: int,
70
  model_key: str,
71
  backend: InferenceBackend,
72
+ source_mode: Literal["none", "web", "rag"] = "none",
73
+ search_workflow: Literal["two_step", "auto"] = "two_step",
74
+ urls: list[str] | None = None,
75
+ files: list[Path] | None = None,
76
+ session_id: str | None = None,
77
+ doc_ids: list[str] | None = None,
78
  ) -> AgentResult:
79
  skill = self._skills.get(EDUCATION_PPTX_SKILL)
80
+ req = EducationPptxInput(
81
+ topic=topic.strip(),
82
+ grade=grade,
83
+ slide_count=slide_count,
84
+ source_mode=source_mode,
85
+ search_workflow=search_workflow,
86
+ urls=urls or [],
87
+ files=files or [],
88
+ session_id=session_id or None,
89
+ doc_ids=doc_ids or [],
90
+ )
91
 
92
  trace = TraceRecorder(
93
  skill=skill.name,
94
  model=model_key,
95
+ user_input=req.model_dump(mode="json"),
96
  )
97
 
98
  backend.load()
99
+ source_context, source_summary, active_session = self._gather_lesson_source_context(
100
+ req, backend, model_key, trace
101
+ )
102
+ if active_session:
103
+ req = req.model_copy(update={"session_id": active_session})
104
+
105
+ outline = self._generate_outline(
106
+ skill, req, backend, trace, source_context=source_context
107
+ )
108
  tool = self._tools.get("create_pptx")
109
  pptx_path = tool.handler(outline, run_id=trace.run_id)
110
  trace.log_tool(
 
145
  trace=trace,
146
  trace_path=str(trace_path),
147
  outline=outline,
148
+ source_summary=source_summary,
149
+ )
150
+
151
+ def _gather_lesson_source_context(
152
+ self,
153
+ req: EducationPptxInput,
154
+ backend: InferenceBackend,
155
+ model_key: str,
156
+ trace: TraceRecorder,
157
+ ) -> tuple[str, str, str | None]:
158
+ if req.source_mode == "none":
159
+ return "", "", None
160
+
161
+ pipeline = IngestPipeline()
162
+ store = pipeline.store
163
+ session_id = req.session_id
164
+ ingest_summary = ""
165
+ ingest: ResearchIngestResult | None = None
166
+
167
+ if req.source_mode == "web":
168
+ if req.search_workflow == "two_step" and not req.urls and not req.files:
169
+ raise ValueError(
170
+ "Two-step web search requires selected URLs, pasted URLs, or uploaded files. "
171
+ "Click **Discover sources** first, then select sources before generating."
172
+ )
173
+ auto_search = req.search_workflow == "auto"
174
+ ingest = self.run_researchmind_ingest(
175
+ topic=req.topic,
176
+ urls=req.urls,
177
+ files=req.files,
178
+ auto_search=auto_search,
179
+ session_id=session_id,
180
+ model_key=model_key,
181
+ backend=backend,
182
+ )
183
+ session_id = ingest.session_id
184
+ ingest_summary = ingest.message
185
+ trace.log_note(ingest.message, phase="lesson_ingest", session_id=session_id)
186
+ elif req.source_mode == "rag":
187
+ session_id = self._ensure_session(store, session_id, topic=req.topic)
188
+ if req.urls or req.files:
189
+ ingest = self.run_researchmind_ingest(
190
+ topic=req.topic,
191
+ urls=req.urls,
192
+ files=req.files,
193
+ auto_search=False,
194
+ session_id=session_id,
195
+ model_key=model_key,
196
+ backend=backend,
197
+ )
198
+ session_id = ingest.session_id
199
+ ingest_summary = ingest.message
200
+ trace.log_note(ingest.message, phase="lesson_ingest", session_id=session_id)
201
+
202
+ doc_count = len(store.list_documents(session_id=session_id))
203
+ resolved = self._lesson_doc_ids(store, session_id, req, ingest)
204
+ if doc_count == 0 and not resolved:
205
+ raise ValueError(
206
+ "RAG mode requires indexed sources. Select a ResearchMind session with "
207
+ "documents, or paste URLs / upload files on this tab."
208
+ )
209
+
210
+ scope_session, scope_docs = self._lesson_retrieve_scope(
211
+ store, session_id, req, ingest
212
+ )
213
+ chunks = retrieve(
214
+ req.topic,
215
+ store,
216
+ session_id=scope_session,
217
+ doc_ids=scope_docs,
218
+ )
219
+ if not chunks:
220
+ warning = (
221
+ "No passages retrieved from indexed sources; outline uses model knowledge only."
222
+ )
223
+ trace.log_note(warning, session_id=session_id, doc_ids=req.doc_ids)
224
+ summary = ingest_summary or warning
225
+ if ingest_summary:
226
+ summary = f"{ingest_summary}\n\n_{warning}_"
227
+ return "", summary, session_id
228
+
229
+ context, citations = format_context_block(chunks)
230
+ trace.log_note(
231
+ f"Retrieved {len(chunks)} passage(s) from {len(citations)} source(s)",
232
+ phase="lesson_retrieve",
233
+ passage_count=len(chunks),
234
+ citation_count=len(citations),
235
+ session_id=session_id,
236
+ doc_ids=req.doc_ids,
237
+ )
238
+ retrieve_line = (
239
+ f"Retrieved **{len(chunks)}** passage(s) from **{len(citations)}** source(s) "
240
+ f"for outline grounding."
241
  )
242
+ summary = f"{ingest_summary}\n\n{retrieve_line}".strip() if ingest_summary else retrieve_line
243
+ return context, summary, session_id
244
 
245
  def _generate_outline(
246
  self,
 
248
  req: EducationPptxInput,
249
  backend: InferenceBackend,
250
  trace: TraceRecorder,
251
+ *,
252
+ source_context: str = "",
253
  ) -> SlideOutline:
254
  system = education_outline_system(skill.body)
255
+ user = education_outline_user(req, source_context=source_context)
256
  messages = [
257
  {"role": "system", "content": system},
258
  {"role": "user", "content": user},
 
353
  @staticmethod
354
  def _extract_json(text: str) -> dict[str, Any]:
355
  cleaned = text.strip()
356
+ fence = re.search(r"```(?:json)?\s*(.*?)\s*```", cleaned, re.DOTALL | re.IGNORECASE)
357
  if fence:
358
+ cleaned = fence.group(1).strip()
359
+
360
+ start = cleaned.find("{")
361
+ if start < 0:
362
+ return json.loads(cleaned)
363
+
364
+ end = AgentRunner._matching_brace_end(cleaned, start)
365
+ if end is not None:
366
+ return json.loads(cleaned[start : end + 1])
367
+
368
+ fallback_end = cleaned.rfind("}")
369
+ if fallback_end > start:
370
+ return json.loads(cleaned[start : fallback_end + 1])
371
  return json.loads(cleaned)
372
 
373
+ @staticmethod
374
+ def _matching_brace_end(text: str, start: int) -> int | None:
375
+ """Return index of the closing brace that matches ``start`` (must be ``{``)."""
376
+ if start >= len(text) or text[start] != "{":
377
+ return None
378
+ depth = 0
379
+ in_string = False
380
+ escape = False
381
+ for index in range(start, len(text)):
382
+ char = text[index]
383
+ if in_string:
384
+ if escape:
385
+ escape = False
386
+ elif char == "\\":
387
+ escape = True
388
+ elif char == '"':
389
+ in_string = False
390
+ continue
391
+ if char == '"':
392
+ in_string = True
393
+ elif char == "{":
394
+ depth += 1
395
+ elif char == "}":
396
+ depth -= 1
397
+ if depth == 0:
398
+ return index
399
+ return None
400
+
401
  def _research_skill(self) -> Any:
402
  return self._skills.get(RESEARCH_MIND_SKILL)
403
 
 
411
  return session_id
412
  return store.create_session(topic=topic).id
413
 
414
+ @staticmethod
415
+ def _lesson_doc_ids(
416
+ store: Any,
417
+ session_id: str | None,
418
+ req: EducationPptxInput,
419
+ ingest: ResearchIngestResult | None,
420
+ ) -> list[str]:
421
+ if req.doc_ids:
422
+ return list(req.doc_ids)
423
+
424
+ resolved: list[str] = []
425
+ seen: set[str] = set()
426
+
427
+ def add(doc_id: str | None) -> None:
428
+ if doc_id and doc_id not in seen:
429
+ seen.add(doc_id)
430
+ resolved.append(doc_id)
431
+
432
+ if ingest:
433
+ for doc_id in ingest.doc_ids:
434
+ add(doc_id)
435
+
436
+ for doc in store.list_documents(session_id=session_id):
437
+ add(doc.id)
438
+
439
+ if ingest and not resolved:
440
+ from researchmind.url_validate import validate_url
441
+
442
+ for label in (*ingest.ingested, *ingest.skipped):
443
+ ok, _, normalized = validate_url(label, check_reachable=False)
444
+ if ok:
445
+ add(store.find_document_id_by_uri(normalized))
446
+ add(store.find_document_id_by_uri(label))
447
+
448
+ return resolved
449
+
450
+ @staticmethod
451
+ def _lesson_retrieve_scope(
452
+ store: Any,
453
+ session_id: str | None,
454
+ req: EducationPptxInput,
455
+ ingest: ResearchIngestResult | None,
456
+ ) -> tuple[str | None, list[str] | None]:
457
+ doc_ids = AgentRunner._lesson_doc_ids(store, session_id, req, ingest)
458
+ if doc_ids:
459
+ return None, doc_ids
460
+ if session_id:
461
+ return session_id, None
462
+ return None, None
463
+
464
  def run_researchmind_discover(
465
  self,
466
  *,
 
548
 
549
  ingested: list[str] = []
550
  skipped: list[str] = []
551
+ doc_ids: list[str] = []
552
+ seen_doc_ids: set[str] = set()
553
  failures: list[IngestFailure] = []
554
 
555
  scrape_web = self._tools.get("scrape_web")
 
571
  failures.append(IngestFailure(url=url, reason=msg, stage="scrape"))
572
  continue
573
  doc_id, is_new = extract_index.handler(doc, session_id=sid)
574
+ if doc_id not in seen_doc_ids:
575
+ seen_doc_ids.add(doc_id)
576
+ doc_ids.append(doc_id)
577
  trace.log_tool("scrape_web", {"url": url}, doc.title)
578
  trace.log_tool(
579
  "extract_and_index",
 
603
  text=text,
604
  )
605
  doc_id, is_new = extract_index.handler(doc, session_id=sid)
606
+ if doc_id not in seen_doc_ids:
607
+ seen_doc_ids.add(doc_id)
608
+ doc_ids.append(doc_id)
609
  trace.log_tool("extract_and_index", {"file": str(path)}, f"{doc_id} new={is_new}")
610
  label = path.name
611
  (ingested if is_new else skipped).append(label)
 
628
  session_id=sid,
629
  ingested=ingested,
630
  skipped=skipped,
631
+ doc_ids=doc_ids,
632
  failures=failures,
633
  doc_count=doc_count,
634
  chunk_count=chunk_count,
libs/agent/tests/test_education_sources.py ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import numpy as np
5
+ import pytest
6
+
7
+ from agent.models import EducationPptxInput, ResearchIngestResult
8
+ from agent.prompts import education_outline_user
9
+ from agent.runner import AgentRunner
10
+ from researchmind.config import ResearchMindConfig
11
+ from researchmind.extract import ExtractedDocument
12
+
13
+
14
+ def _outline_json(slide_count: int = 3) -> str:
15
+ slides = [
16
+ {
17
+ "title": f"Slide {i}",
18
+ "bullets": ["Key point"],
19
+ "speaker_note": "Note",
20
+ }
21
+ for i in range(1, slide_count + 1)
22
+ ]
23
+ return json.dumps({"title": "Test Lesson", "slides": slides})
24
+
25
+
26
+ class OutlineBackend:
27
+ def load(self) -> None:
28
+ return None
29
+
30
+ def chat(self, messages, *, max_tokens=2048, temperature=0.3):
31
+ return _outline_json(3)
32
+
33
+ def generate(self, prompt, *, max_tokens=512, temperature=0.7):
34
+ return self.chat([{"role": "user", "content": prompt}], max_tokens=max_tokens)
35
+
36
+
37
+ @pytest.fixture
38
+ def research_env(tmp_path, monkeypatch):
39
+ cfg = ResearchMindConfig(
40
+ data_dir=tmp_path / "rm",
41
+ embed_model="test",
42
+ auto_search=False,
43
+ top_k=2,
44
+ max_context_chunks=8,
45
+ chunk_size=50,
46
+ chunk_overlap=10,
47
+ )
48
+ monkeypatch.setenv("RESEARCHMIND_DATA_DIR", str(cfg.data_dir))
49
+ monkeypatch.setenv("AGENT_OUTPUTS_DIR", str(tmp_path / "outputs"))
50
+
51
+ def fake_embed(texts, *, model_name):
52
+ vecs = [np.array([1.0, 0.0, 0.0], dtype=np.float32) for _ in texts]
53
+ return np.stack(vecs) if vecs else np.zeros((0, 3), dtype=np.float32)
54
+
55
+ monkeypatch.setattr("researchmind.ingest.embed_texts", fake_embed)
56
+ monkeypatch.setattr("researchmind.retrieve.embed_texts", fake_embed)
57
+
58
+ def fake_scrape(url: str):
59
+ return ExtractedDocument(
60
+ source_type="web",
61
+ uri=url,
62
+ title="Example",
63
+ text="Photosynthesis converts light to energy in plants.",
64
+ )
65
+
66
+ monkeypatch.setattr("agent.tools.research_tools.fetch_and_extract", fake_scrape)
67
+
68
+ def fake_search(topic, *, n=5, check_reachable=True):
69
+ return [f"https://example.com/{topic.replace(' ', '-')}"]
70
+
71
+ monkeypatch.setattr("agent.tools.research_tools.search_urls", fake_search)
72
+
73
+ def fake_validate(url, *, check_reachable=True):
74
+ normalized = url if url.startswith("http") else f"https://{url}"
75
+ return True, "ok", normalized
76
+
77
+ monkeypatch.setattr("researchmind.url_validate.validate_url", fake_validate)
78
+ return cfg
79
+
80
+
81
+ def test_education_outline_user_includes_source_context():
82
+ req = EducationPptxInput(topic="Photosynthesis", grade="6", slide_count=3)
83
+ user = education_outline_user(req, source_context="[1] Plants use chlorophyll.")
84
+ assert "retrieved source excerpts" in user
85
+ assert "chlorophyll" in user
86
+
87
+
88
+ def test_none_mode_skips_source_summary(research_env):
89
+ runner = AgentRunner()
90
+ result = runner.run_education_pptx(
91
+ topic="Photosynthesis",
92
+ grade="6",
93
+ slide_count=3,
94
+ model_key="test",
95
+ backend=OutlineBackend(),
96
+ source_mode="none",
97
+ )
98
+ assert result.outline.title == "Test Lesson"
99
+ assert result.source_summary == ""
100
+
101
+
102
+ def test_web_auto_calls_ingest_with_auto_search(research_env, monkeypatch):
103
+ calls: list[dict] = []
104
+
105
+ def fake_ingest(self, **kwargs):
106
+ calls.append(kwargs)
107
+ return ResearchIngestResult(
108
+ session_id="sess-auto",
109
+ ingested=["https://example.com/photosynthesis"],
110
+ skipped=[],
111
+ failures=[],
112
+ doc_count=1,
113
+ chunk_count=1,
114
+ trace_path="/tmp/trace.json",
115
+ message="Ingested 1 source(s)",
116
+ )
117
+
118
+ monkeypatch.setattr(AgentRunner, "run_researchmind_ingest", fake_ingest)
119
+
120
+ runner = AgentRunner()
121
+ result = runner.run_education_pptx(
122
+ topic="Photosynthesis",
123
+ grade="6",
124
+ slide_count=3,
125
+ model_key="test",
126
+ backend=OutlineBackend(),
127
+ source_mode="web",
128
+ search_workflow="auto",
129
+ )
130
+ assert len(calls) == 1
131
+ assert calls[0]["auto_search"] is True
132
+ assert "Ingested 1 source(s)" in result.source_summary
133
+
134
+
135
+ def test_web_two_step_requires_urls(research_env):
136
+ runner = AgentRunner()
137
+ with pytest.raises(ValueError, match="Two-step web search requires"):
138
+ runner.run_education_pptx(
139
+ topic="Photosynthesis",
140
+ grade="6",
141
+ slide_count=3,
142
+ model_key="test",
143
+ backend=OutlineBackend(),
144
+ source_mode="web",
145
+ search_workflow="two_step",
146
+ urls=[],
147
+ files=[],
148
+ )
149
+
150
+
151
+ def test_web_two_step_ingests_without_auto_search(research_env, monkeypatch):
152
+ calls: list[dict] = []
153
+
154
+ def fake_ingest(self, **kwargs):
155
+ calls.append(kwargs)
156
+ return ResearchIngestResult(
157
+ session_id="sess-two",
158
+ ingested=["https://example.com/a"],
159
+ skipped=[],
160
+ failures=[],
161
+ doc_count=1,
162
+ chunk_count=1,
163
+ trace_path="/tmp/trace.json",
164
+ message="Ingested 1 source(s)",
165
+ )
166
+
167
+ monkeypatch.setattr(AgentRunner, "run_researchmind_ingest", fake_ingest)
168
+
169
+ runner = AgentRunner()
170
+ runner.run_education_pptx(
171
+ topic="Photosynthesis",
172
+ grade="6",
173
+ slide_count=3,
174
+ model_key="test",
175
+ backend=OutlineBackend(),
176
+ source_mode="web",
177
+ search_workflow="two_step",
178
+ urls=["https://example.com/a"],
179
+ )
180
+ assert calls[0]["auto_search"] is False
181
+
182
+
183
+ def test_rag_requires_indexed_sources(research_env):
184
+ runner = AgentRunner()
185
+ with pytest.raises(ValueError, match="RAG mode requires indexed sources"):
186
+ runner.run_education_pptx(
187
+ topic="Photosynthesis",
188
+ grade="6",
189
+ slide_count=3,
190
+ model_key="test",
191
+ backend=OutlineBackend(),
192
+ source_mode="rag",
193
+ session_id="",
194
+ urls=[],
195
+ files=[],
196
+ )
197
+
198
+
199
+ def test_web_two_step_uses_duplicate_doc_ids(research_env):
200
+ runner = AgentRunner()
201
+ first = runner.run_researchmind_ingest(
202
+ topic="Photosynthesis",
203
+ urls=["https://example.com/a"],
204
+ files=[],
205
+ auto_search=False,
206
+ session_id=None,
207
+ model_key="test",
208
+ backend=OutlineBackend(),
209
+ )
210
+ assert first.doc_ids
211
+
212
+ new_session = runner.run_researchmind_discover(
213
+ topic="Photosynthesis",
214
+ auto_search=False,
215
+ session_id=None,
216
+ model_key="test",
217
+ backend=OutlineBackend(),
218
+ ).session_id
219
+
220
+ second = runner.run_researchmind_ingest(
221
+ topic="Photosynthesis",
222
+ urls=["https://example.com/a"],
223
+ files=[],
224
+ auto_search=False,
225
+ session_id=new_session,
226
+ model_key="test",
227
+ backend=OutlineBackend(),
228
+ )
229
+ assert second.ingested == []
230
+ assert len(second.skipped) == 1
231
+ assert second.doc_ids == first.doc_ids
232
+
233
+ result = runner.run_education_pptx(
234
+ topic="Photosynthesis",
235
+ grade="6",
236
+ slide_count=3,
237
+ model_key="test",
238
+ backend=OutlineBackend(),
239
+ source_mode="web",
240
+ search_workflow="two_step",
241
+ urls=["https://example.com/a"],
242
+ session_id=new_session,
243
+ )
244
+ assert "Retrieved" in result.source_summary
245
+ assert result.source_summary.count("model knowledge only") == 0
246
+
247
+
248
+ def test_rag_uses_session_without_auto_search(research_env, monkeypatch):
249
+ ingest = AgentRunner().run_researchmind_ingest(
250
+ topic="Photosynthesis",
251
+ urls=["https://example.com/a"],
252
+ files=[],
253
+ auto_search=False,
254
+ session_id=None,
255
+ model_key="test",
256
+ backend=OutlineBackend(),
257
+ )
258
+
259
+ calls: list[dict] = []
260
+
261
+ def fake_ingest(self, **kwargs):
262
+ calls.append(kwargs)
263
+ return ingest
264
+
265
+ monkeypatch.setattr(AgentRunner, "run_researchmind_ingest", fake_ingest)
266
+
267
+ runner = AgentRunner()
268
+ result = runner.run_education_pptx(
269
+ topic="Photosynthesis",
270
+ grade="6",
271
+ slide_count=3,
272
+ model_key="test",
273
+ backend=OutlineBackend(),
274
+ source_mode="rag",
275
+ session_id=ingest.session_id,
276
+ )
277
+ assert calls == []
278
+ assert "Retrieved" in result.source_summary
libs/agent/tests/test_runner.py CHANGED
@@ -38,6 +38,22 @@ def test_extract_json_from_fenced_block():
38
  assert data["title"] == "T"
39
 
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  def test_create_pptx_writes_file(tmp_path, monkeypatch):
42
  monkeypatch.setenv("AGENT_OUTPUTS_DIR", str(tmp_path))
43
  outline = SlideOutline(
 
38
  assert data["title"] == "T"
39
 
40
 
41
+ def test_extract_json_ignores_trailing_text():
42
+ raw = (
43
+ '{"title": "AI Agents", "slides": [{"title": "Intro", "bullets": ["a"]}]}\n'
44
+ "Here is a short explanation of the lesson outline."
45
+ )
46
+ data = AgentRunner._extract_json(raw)
47
+ assert data["title"] == "AI Agents"
48
+
49
+
50
+ def test_extract_json_ignores_duplicate_object():
51
+ first = '{"title": "First", "slides": [{"title": "A", "bullets": ["a"]}]}'
52
+ second = '{"title": "Second", "slides": [{"title": "B", "bullets": ["b"]}]}'
53
+ data = AgentRunner._extract_json(f"{first}\n{second}")
54
+ assert data["title"] == "First"
55
+
56
+
57
  def test_create_pptx_writes_file(tmp_path, monkeypatch):
58
  monkeypatch.setenv("AGENT_OUTPUTS_DIR", str(tmp_path))
59
  outline = SlideOutline(
libs/researchmind/src/researchmind/store.py CHANGED
@@ -165,6 +165,23 @@ class MemRAGStore:
165
  ).fetchone()
166
  return row["id"] if row else None
167
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  def add_document(
169
  self,
170
  *,
 
165
  ).fetchone()
166
  return row["id"] if row else None
167
 
168
+ def find_document_id_by_uri(self, uri: str) -> str | None:
169
+ from researchmind.url_validate import normalize_url
170
+
171
+ candidates = [uri.strip()]
172
+ normalized = normalize_url(uri)
173
+ if normalized and normalized not in candidates:
174
+ candidates.append(normalized)
175
+ with self._conn() as conn:
176
+ for candidate in candidates:
177
+ row = conn.execute(
178
+ "SELECT id FROM documents WHERE uri = ?",
179
+ (candidate,),
180
+ ).fetchone()
181
+ if row:
182
+ return str(row["id"])
183
+ return None
184
+
185
  def add_document(
186
  self,
187
  *,