Studio: fit Deep Research synthesis evidence to loaded context, add opt-in web grounding

Size the synthesis evidence budget to the loaded model context so the prompt is not
silently truncated on small contexts. When the evidence overflowed the window the report
degenerated (it echoed the evidence tail instead of writing); the budget now reserves tokens
for the prompt scaffolding and converts the remainder to chars, keeping the full cap when the
context is unknown.

Add opt-in web grounding for auto-read: read the top search results, ingest them into an
ephemeral RAG scope, hybrid-retrieve the passages most relevant to the question with the
existing knowledge-base retriever, and fold those chunks into the step evidence. The scope is
per call and deleted afterwards, so a user's knowledge base is never touched.

Off by default; enable with UNSLOTH_RESEARCH_AUTO_SCRAPE=1. Gated per run by
budgets["maxAutoScrape"], so runs created without it keep legacy snippet-only behavior, and
grounding is skipped when the loaded context is too small for the prompt.

Add tests for the adaptive evidence budget, scraped-text cleaning, the ephemeral web-RAG
retrieval and scope cleanup, and the auto-read evidence path.
This commit is contained in:
danielhanchen 2026-07-21 05:46:14 +00:00
commit 3c658e4666
5 changed files with 839 additions and 9 deletions

View file

@ -0,0 +1,129 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Ephemeral web-RAG for deep research auto-read.
Deep research auto-reads the top search results so synthesis is grounded in page text rather
than short snippets. Whole pages make a small local model loop on boilerplate, so the scraped
pages go through the *same* retrieval pipeline the knowledge base uses and only the most
relevant passages are folded into the evidence.
Nothing here re-implements chunking, embedding, retrieval, ranking, or rendering; it wires
Studio's existing KB components (``chunk_pages``, ``embeddings.encode``, ``store.add_chunks``,
``retrieval.retrieve_hybrid``, ``retrieval.filter_min_score``, ``tool._format``) to the live
scrape. The only difference from a persisted KB is the corpus: pages are ingested under a
unique throwaway scope deleted in a ``finally`` block, so an auto-read never pollutes a user's
knowledge base, exactly like Studio's per-thread attachment RAG on the same store.
"""
from __future__ import annotations
import hashlib
import uuid
from loggers import get_logger
from storage import rag_db
from . import config, embeddings, retrieval, store, tool
from .chunking import chunk_pages
from .parsers import Page
logger = get_logger(__name__)
def _fit_to_budget(hits, rows, char_budget):
"""Keep the best (already ranked) hits whose cumulative chunk text fits ``char_budget``,
always keeping at least the top hit so a single long passage is not dropped whole."""
if char_budget is None:
return hits
kept = []
used = 0
for hit in hits:
row = rows.get(hit.chunk_id)
text = (row["text"] if row else "") or ""
if kept and used + len(text) > char_budget:
break
kept.append(hit)
used += len(text)
return kept
def retrieve_web_chunks(
pages: list[dict],
query: str,
*,
top_n: int,
min_score: float,
char_budget: int | None = None,
max_tokens: int | None = None,
overlap: int | None = None,
model_name: str | None = None,
) -> tuple[str, list[dict]]:
"""Ingest scraped pages into an ephemeral RAG scope, hybrid-retrieve the passages most
relevant to ``query``, and return ``(rendered_chunks, sources)`` using Studio's KB
formatter.
``pages`` is a list of dicts with ``text`` (required) and optional ``title`` / ``url``
(``title`` becomes the ``<chunk source>``). Returns ``("", [])`` when there is nothing
usable or RAG is unavailable, so the caller can fall back to snippet evidence. The scope
is always deleted before returning, so nothing is left in the store."""
query = (query or "").strip()
if not query or top_n <= 0 or not pages or not rag_db.RAG_AVAILABLE:
return "", []
model = model_name or config.effective_embedding_model()
max_tokens = max_tokens or config.CHUNK_TOKENS
overlap = config.CHUNK_OVERLAP if overlap is None else overlap
count = embeddings.token_counter(model)
conn = rag_db.get_connection()
scope = f"research_scrape_{uuid.uuid4().hex}"
doc_ids: list[str] = []
try:
for page in pages:
text = str(page.get("text") or "").strip()
if not text:
continue
source = str(page.get("title") or page.get("url") or "web").strip() or "web"
chunks = chunk_pages(
[Page(text = text, page_number = None, char_count = len(text))],
max_tokens = max_tokens,
overlap = overlap,
count = count,
)
if not chunks:
continue
vectors = embeddings.encode(
[chunk.text for chunk in chunks], model_name = model, normalize = True
)
doc_id = store.create_document(
conn,
scope = scope,
filename = source,
sha256 = hashlib.sha256(text.encode("utf-8", "ignore")).hexdigest(),
status = "ready",
embedding_model = model,
)
doc_ids.append(doc_id)
store.add_chunks(conn, scope, doc_id, chunks, vectors)
if not doc_ids:
return "", []
hits = retrieval.retrieve_hybrid(
conn, scope, query, k = top_n, model_name = model, mode = "hybrid"
)
hits = retrieval.filter_min_score(hits, min_score)
if not hits:
return "", []
rows = store.chunks_by_id(conn, [hit.chunk_id for hit in hits])
hits = _fit_to_budget(hits, rows, char_budget)
return tool._format(rows, hits)
except Exception:
logger.warning("research.web_rank_failed", exc_info = True)
return "", []
finally:
for doc_id in doc_ids:
try:
store.delete_document(conn, doc_id)
except Exception:
logger.warning("research.web_rank_cleanup_failed doc_id=%s", doc_id)
conn.close()

View file

@ -8,6 +8,7 @@ from __future__ import annotations
import asyncio
import ipaddress
import json
import os
import re
import sqlite3
import threading
@ -81,6 +82,78 @@ _MAX_ERROR_CHARS = 500
_MAX_CONTEXT_CHARS = 12_000
_MAX_CONTEXT_MESSAGE_CHARS = 4_000
_MAX_SYNTHESIS_EVIDENCE_CHARS = 32_000
# The synthesis prompt must fit the loaded context or it is silently truncated and the report
# degenerates (echoes the evidence tail). Studio defaults context to 2048 tokens, far below the
# cap above, so the evidence budget adapts to the loaded context: reserve tokens for the prompt
# scaffolding, then convert the remainder to chars. Unknown context keeps the full cap.
_MIN_SYNTHESIS_EVIDENCE_CHARS = 1_500
_SYNTHESIS_EVIDENCE_CHARS_PER_TOKEN = 3.0
_SYNTHESIS_CONTEXT_RESERVE_TOKENS = 2_048
# Below this loaded context the prompt scaffolding alone fills the window and the grounded
# report degenerates, so grounding is skipped (snippet-only) for smaller loads.
_AUTO_SCRAPE_MIN_CONTEXT_TOKENS = 8_192
# Optionally read the top search results so synthesis is grounded in page text, not just
# snippets: each scraped page is ingested into an ephemeral RAG scope (deleted after, so a
# user's knowledge base is untouched), the passages most relevant to the question are
# hybrid-retrieved reusing the KB retriever, and the resulting <chunk> blocks replace the raw
# search text (staying under the existing 12k per-note cap). OFF by default, opt in via
# UNSLOTH_RESEARCH_AUTO_SCRAPE=1: benchmarking showed no reliable factoid-accuracy gain over
# snippets on a local model (snippets usually already carry the fact) while adding latency.
# Gated per run by budgets["maxAutoScrape"] (absent/0 means no scrape, so existing runs keep
# legacy behavior). Safe only with the context gate in _research and the adaptive budget in
# _synthesis_evidence_budget; without them, denser evidence overflows a small context.
_AUTO_SCRAPE_TOP_K = 3
_AUTO_SCRAPE_TOTAL_CHARS = 6_000
_WEB_RAG_TOP_N = 6
_WEB_RAG_MIN_SCORE = 0.30
def _auto_scrape_default() -> int:
"""Server default for ``budgets["maxAutoScrape"]``: 0 (off) unless
``UNSLOTH_RESEARCH_AUTO_SCRAPE`` enables it (``1``/``true`` -> ``_AUTO_SCRAPE_TOP_K``, or an
explicit count clamped to ``[0, _AUTO_SCRAPE_TOP_K]``)."""
raw = os.environ.get("UNSLOTH_RESEARCH_AUTO_SCRAPE", "").strip().lower()
if not raw:
return 0
if raw in ("0", "false", "no", "off"):
return 0
if raw in ("1", "true", "yes", "on"):
return _AUTO_SCRAPE_TOP_K
try:
return max(0, min(int(raw), _AUTO_SCRAPE_TOP_K))
except ValueError:
return 0
# Nav menus, language sidebars, and percent-encoded link lists are not evidence and derail
# retrieval; drop link-dominated and encoded-URL lines.
_MD_LINK = re.compile(r"\[([^\]]*)\]\([^)]*\)")
_PERCENT_ESCAPE = re.compile(r"%[0-9A-Fa-f]{2}")
_LIST_PREFIX = re.compile(r"^(?:[\*\-\+•]|\d+[.)])\s")
_BLANK_RUN = re.compile(r"\n{3,}")
# Bare tracking/redirect URLs arrive as one unbroken token (prose never has an 80-char word);
# not evidence, and a small model will latch onto and echo it.
_LONG_TOKEN = re.compile(r"\S{80,}")
def _clean_scraped_text(text: str) -> str:
kept: list[str] = []
for line in text.splitlines():
stripped = line.strip()
if not stripped:
kept.append("")
continue
if len(_PERCENT_ESCAPE.findall(stripped)) >= 4:
continue
if _LONG_TOKEN.search(stripped):
continue
prose = _MD_LINK.sub(r"\1", stripped).strip()
if "](" in stripped and (
_LIST_PREFIX.match(stripped) or len(prose) <= max(30, len(stripped) // 3)
):
continue
kept.append(line)
return _BLANK_RUN.sub("\n\n", "\n".join(kept)).strip()
_REPORT_SYSTEM_PROMPT = """You are writing a rigorous, self-contained research report.
@ -370,13 +443,42 @@ def _research_question_context(thread_id: str, user_message_id: str) -> tuple[st
return question, json.dumps(turns, ensure_ascii = False)
def _bounded_synthesis_evidence(notes: list[str]) -> str:
def _loaded_context_length() -> int | None:
"""Best-effort read of the active model's context window in tokens, or None if unknown."""
try:
from core.inference.inference import get_inference_backend
backend = get_inference_backend()
name = getattr(backend, "active_model_name", None)
if name:
ctx = (getattr(backend, "models", {}).get(name) or {}).get("context_length")
if isinstance(ctx, int) and not isinstance(ctx, bool) and ctx > 0:
return ctx
except Exception:
logger.debug("research.context_probe_failed", exc_info = True)
return None
def _synthesis_evidence_budget() -> int:
"""Char budget for synthesis evidence, sized to fit the loaded context (falls back to the
full cap when the context is unknown)."""
ctx = _loaded_context_length()
if not ctx:
return _MAX_SYNTHESIS_EVIDENCE_CHARS
usable_tokens = max(0, ctx - _SYNTHESIS_CONTEXT_RESERVE_TOKENS)
budget = int(usable_tokens * _SYNTHESIS_EVIDENCE_CHARS_PER_TOKEN)
return max(_MIN_SYNTHESIS_EVIDENCE_CHARS, min(budget, _MAX_SYNTHESIS_EVIDENCE_CHARS))
def _bounded_synthesis_evidence(
notes: list[str], max_chars: int = _MAX_SYNTHESIS_EVIDENCE_CHARS
) -> str:
if not notes:
return "(none)"
separator = "\n\n"
per_note = max(
1000,
(_MAX_SYNTHESIS_EVIDENCE_CHARS - len(separator) * (len(notes) - 1)) // len(notes),
min(1000, max_chars),
(max_chars - len(separator) * (len(notes) - 1)) // len(notes),
)
bounded = []
for note in notes:
@ -384,7 +486,7 @@ def _bounded_synthesis_evidence(notes: list[str]) -> str:
bounded.append(note)
else:
bounded.append(note[: per_note - 24].rstrip() + "\n[Evidence truncated]")
return separator.join(bounded)[:_MAX_SYNTHESIS_EVIDENCE_CHARS]
return separator.join(bounded)[:max_chars]
def _parse_json_object(text: str) -> dict:
@ -749,6 +851,87 @@ class ResearchSupervisor:
if self._cancel_event(run_id).is_set():
raise RunCancelled()
async def _auto_scrape_sources(
self,
run: dict,
question: str,
step_sources: list[dict],
fetched_urls: set[str],
*,
tool_timeout: int,
website_policy: dict | None,
) -> tuple[str, list[str]]:
"""Concurrently read the top few of this step's accepted source URLs, rank their
content against the research question with the knowledge-base embedding model, and
return the most relevant chunks as ``<chunk>`` evidence plus the URLs actually read.
URLs are already access checked and deduplicated by the caller, so no new sources are
created. Failures, timeouts, unreadable pages, and low-relevance chunks are dropped;
the caller enforces cancellation."""
targets = []
for source in step_sources:
url = str(source.get("url") or "")
if url and url not in fetched_urls:
targets.append(source)
if len(targets) >= _AUTO_SCRAPE_TOP_K:
break
if not targets:
return "", []
cancel_event = self._cancel_event(run["id"])
results = await asyncio.gather(
*(
asyncio.to_thread(
execute_tool,
"web_search",
{"url": source["url"]},
cancel_event = cancel_event,
timeout = tool_timeout,
website_policy = website_policy,
)
for source in targets
),
return_exceptions = True,
)
pages = []
fetched = []
for source, result in zip(targets, results):
if isinstance(result, BaseException) or not isinstance(result, str):
continue
body = strip_result_for_model(result)
if is_tool_error(body):
continue
body = _clean_scraped_text(body)
if not body:
continue
fetched.append(source["url"])
pages.append(
{
"text": body,
"title": source.get("title") or source["url"],
"url": source["url"],
}
)
if not pages:
return "", []
# Reuse Studio's knowledge-base RAG pipeline (ingest -> hybrid retrieve -> <chunk>
# render) over an ephemeral scope; runs off the event loop since embedding and the
# sqlite/vec index work are CPU/GPU bound.
from core.rag import web_rank
section, _sources = await asyncio.to_thread(
web_rank.retrieve_web_chunks,
pages,
question,
top_n = _WEB_RAG_TOP_N,
min_score = _WEB_RAG_MIN_SCORE,
char_budget = _AUTO_SCRAPE_TOTAL_CHARS,
)
if not section:
return "", []
return (
"Relevant passages retrieved from the top results (already read):\n\n" + section,
fetched,
)
async def _check_worker_write(self, run_id: str, written: bool) -> None:
if written:
return
@ -1281,6 +1464,20 @@ class ResearchSupervisor:
max_steps = int(budgets["maxSteps"])
max_sources = int(budgets["maxSources"])
tool_timeout = int(budgets["toolTimeoutSeconds"])
# Absent for runs created before auto-scrape: default 0 keeps their behavior unchanged.
max_auto_scrape = int(budgets.get("maxAutoScrape", 0))
# Grounding needs the synthesis prompt to fit the loaded context; on a tiny context the
# prompt overhead alone fills the window and the report degenerates, so fall back to
# snippet-only when the context is too small.
if max_auto_scrape > 0:
loaded_ctx = _loaded_context_length()
if loaded_ctx is not None and loaded_ctx < _AUTO_SCRAPE_MIN_CONTEXT_TOKENS:
logger.info(
"research.auto_scrape_disabled_small_context run_id=%s context=%s",
run["id"],
loaded_ctx,
)
max_auto_scrape = 0
website_policy = run["config"].get("websitePolicy")
policy_prompt = website_policy_prompt(website_policy)
notes: list[str] = []
@ -1571,6 +1768,29 @@ class ResearchSupervisor:
self.worker_id,
)
await self._check_worker_write(run["id"], written)
tool_failed = is_tool_error(result)
step_failed = _research_step_failed(result, rag_sources)
scraped_section = ""
if (
action["action"] == "search"
and step_sources
and not tool_failed
and max_auto_scrape > 0
):
scraped_section, scraped_urls = await self._auto_scrape_sources(
run,
question,
step_sources,
fetched_urls,
tool_timeout = tool_timeout,
website_policy = website_policy,
)
fetched_urls.update(scraped_urls)
await self._check_active(run["id"])
if scraped_section:
# Replace raw search text with the retrieved chunks; sources are already
# cataloged above, so nothing citable is lost.
result = scraped_section
note = (
f"### {action['title']} ({action['action']})\n"
f"Input: {argument}\nResult:\n{result[:12000]}\n\n"
@ -1581,8 +1801,6 @@ class ResearchSupervisor:
f"### {action['title']} ({action['action']})\n"
f"Input: {argument}\nResult:\n{result[:12000]}"
)
tool_failed = is_tool_error(result)
step_failed = _research_step_failed(result, rag_sources)
clean_result = strip_result_for_model(result)
step_result = {
"action": action["action"],
@ -1590,7 +1808,11 @@ class ResearchSupervisor:
"sourceCount": len(step_sources) + len(rag_sources),
"sourceUrls": [source["url"] for source in step_sources],
"evidenceSources": rag_sources,
**({"excerpt": clean_result[:12000]} if action["action"] == "fetch" else {}),
**(
{"excerpt": clean_result[:12000]}
if action["action"] == "fetch" or scraped_section
else {}
),
**({"error": clean_result[:500]} if tool_failed else {}),
}
await self._check_active(run["id"])
@ -1633,7 +1855,7 @@ class ResearchSupervisor:
f" Chunk ID: {source.get('chunkId') or '(unknown)'}"
for index, source in enumerate(document_sources, 1)
)
evidence_text = _bounded_synthesis_evidence(notes)
evidence_text = _bounded_synthesis_evidence(notes, _synthesis_evidence_budget())
report, synthesis_reasoning, synthesis_finish_reason = await self._stream_completion(
run,
[

View file

@ -252,6 +252,14 @@ def _sanitize_config(payload: CreateResearchRun, thread: dict) -> dict:
raise HTTPException(
status_code = 400, detail = f"{key} must be between {minimum} and {maximum}"
)
# Server-controlled, not client tunable. OFF by default; opt in via
# UNSLOTH_RESEARCH_AUTO_SCRAPE=1. Injected only when enabled, so a default run's budgets stay
# byte-identical to legacy.
from core.research_runs import _auto_scrape_default
_auto_scrape = _auto_scrape_default()
if _auto_scrape > 0:
budgets["maxAutoScrape"] = _auto_scrape
try:
website_policy = normalize_website_policy(payload.websitePolicy)
except ValueError as exc:

View file

@ -55,6 +55,7 @@ def _create(
user_message_id = "user-1",
rag_scope = None,
instructions = "",
budgets = None,
):
return research_db.create_run(
run_id = run_id,
@ -67,7 +68,7 @@ def _create(
"inferenceRequest": {"model": "local-model"},
"ragScope": rag_scope,
"instructions": instructions,
"budgets": {
"budgets": budgets or {
"maxSteps": 5,
"maxSources": 15,
"modelTimeoutSeconds": 30,
@ -178,6 +179,31 @@ def test_synthesis_evidence_is_bounded_across_all_steps():
assert all(f"### Step {index}" in evidence for index in range(12))
def test_synthesis_evidence_budget_tracks_loaded_context(monkeypatch):
from core import research_runs as worker
# Unknown context keeps the full cap (backwards compatible).
monkeypatch.setattr(worker, "_loaded_context_length", lambda: None)
assert worker._synthesis_evidence_budget() == worker._MAX_SYNTHESIS_EVIDENCE_CHARS
# A small context (Studio's 2048 default) shrinks the budget so evidence fits.
monkeypatch.setattr(worker, "_loaded_context_length", lambda: 2048)
small = worker._synthesis_evidence_budget()
assert worker._MIN_SYNTHESIS_EVIDENCE_CHARS <= small < worker._MAX_SYNTHESIS_EVIDENCE_CHARS
# A large context uses (and clamps to) the full cap.
monkeypatch.setattr(worker, "_loaded_context_length", lambda: 32768)
assert worker._synthesis_evidence_budget() == worker._MAX_SYNTHESIS_EVIDENCE_CHARS
def test_bounded_synthesis_evidence_respects_small_budget():
from core import research_runs as worker
notes = ["### Step\n" + "x" * 20_000 for _ in range(6)]
evidence = worker._bounded_synthesis_evidence(notes, 3_072)
assert len(evidence) <= 3_072
def test_report_is_recovered_from_substantial_synthesis_reasoning():
from core import research_runs as worker
@ -1027,6 +1053,7 @@ def test_research_budget_defaults_support_long_runs():
{"modelId": "local-model"},
)
# auto-scrape (page grounding) is off by default, so budgets stay byte-identical to legacy
assert config["budgets"] == {
"maxSteps": 12,
"maxSources": 40,
@ -1275,6 +1302,334 @@ def test_supervisor_planning_and_research_are_durable_with_mocked_io(research_ho
)
_SCRAPE_BUDGETS = {
"maxSteps": 5,
"maxSources": 15,
"modelTimeoutSeconds": 30,
"toolTimeoutSeconds": 10,
"maxAutoScrape": 3,
}
def _patch_web_rank(monkeypatch, *, retrieve = None):
"""Stub the ephemeral web-RAG so loop-integration tests need no sqlite/vec store: by
default each scraped page renders as one ``<chunk>`` block, mirroring the real
``retrieve_web_chunks`` output (whose retrieval/ranking is covered in test_web_rank.py)."""
from core.rag import web_rank
def default_retrieve(pages, query, *, top_n, min_score, char_budget = None, **kwargs):
blocks, sources = [], []
for i, page in enumerate(pages, 1):
text = page.get("text") or ""
src = page.get("title") or page.get("url") or "web"
blocks.append(f'<chunk id="{i}" source="{src}">\n{text}\n</chunk>')
sources.append({"citationId": i, "text": text})
rendered = "\n\n".join(blocks)
if char_budget is not None:
rendered = rendered[:char_budget]
return rendered, sources
monkeypatch.setattr(web_rank, "retrieve_web_chunks", retrieve or default_retrieve)
def _bare_supervisor(monkeypatch):
from core import research_runs as worker
supervisor = worker.ResearchSupervisor(
SimpleNamespace(state = SimpleNamespace(server_port = 1))
)
return worker, supervisor
def _run_search_then_finish(monkeypatch, fake_tool, *, retrieve = None):
"""Drive one search step (which auto-scrapes) followed by finish, and return the
completed run plus the synthesis prompts the model was given."""
from core import research_runs as worker
_patch_web_rank(monkeypatch, retrieve = retrieve)
supervisor = worker.ResearchSupervisor(
SimpleNamespace(state = SimpleNamespace(server_port = 1))
)
decisions = iter(
(
json.dumps({"action": "search", "title": "Find", "query": "grounding evidence"}),
json.dumps({"action": "finish", "title": "Enough evidence"}),
)
)
synthesis_prompts = []
report = "# Report\n\nGrounded finding [source](https://a.example.com)."
async def fake_stream_completion(run, messages, *, json_mode = False, report_progress = True, **kwargs):
system = messages[0]["content"]
if "rigorous web research plan" in system:
return json.dumps(_plan()), "planned", "stop"
if "iterative research process" in system:
return next(decisions), "decided", "stop"
synthesis_prompts.append(messages[1]["content"])
research_db.set_report_progress(run["id"], report)
return report, "synthesized", "stop"
monkeypatch.setattr(supervisor, "_stream_completion", fake_stream_completion)
monkeypatch.setattr(worker, "execute_tool", fake_tool)
asyncio.run(supervisor._process(research_db.claim_next(supervisor.worker_id)))
planned = research_db.get_run("run-1")
research_db.approve("run-1", planned["planRevision"], planned["planHash"])
asyncio.run(supervisor._process(research_db.claim_next(supervisor.worker_id)))
return research_db.get_run("run-1"), synthesis_prompts
def _two_source_search():
return (
"Title: Alpha\nURL: https://a.example.com\nSnippet: alpha snippet.\n\n---\n\n"
"Title: Beta\nURL: https://b.example.com\nSnippet: beta snippet."
)
def test_auto_scrape_retrieves_page_chunks_into_synthesis_evidence(research_home, monkeypatch):
_create(budgets = _SCRAPE_BUDGETS)
url_calls = []
def fake_tool(name, arguments, *args, **kwargs):
url = arguments.get("url")
if url:
url_calls.append(url)
return {"https://a.example.com": "ALPHA_PAGE_BODY", "https://b.example.com": "BETA_PAGE_BODY"}[url]
return _two_source_search()
completed, synthesis_prompts = _run_search_then_finish(monkeypatch, fake_tool)
assert completed["status"] == "completed"
assert sorted(url_calls) == ["https://a.example.com", "https://b.example.com"]
assert synthesis_prompts, "synthesis must have run"
# the retrieved page chunks reach synthesis, rendered in the <chunk> format
assert "<chunk" in synthesis_prompts[0]
assert "ALPHA_PAGE_BODY" in synthesis_prompts[0]
assert "BETA_PAGE_BODY" in synthesis_prompts[0]
def test_auto_scrape_persists_chunk_excerpt_for_resume(research_home, monkeypatch):
_create(budgets = _SCRAPE_BUDGETS)
def fake_tool(name, arguments, *args, **kwargs):
url = arguments.get("url")
if url:
return {"https://a.example.com": "ALPHA_PAGE_BODY", "https://b.example.com": "BETA_PAGE_BODY"}[url]
return _two_source_search()
completed, _ = _run_search_then_finish(monkeypatch, fake_tool)
search_step = completed["steps"][0]
result = search_step["result"]
assert result["action"] == "search"
assert result["sourceUrls"] == ["https://a.example.com", "https://b.example.com"]
assert result["sourceCount"] == 2
# the durable excerpt carries the chunks so a resumed run reconstructs the same evidence
assert "<chunk" in result["excerpt"]
assert "ALPHA_PAGE_BODY" in result["excerpt"]
def test_auto_scrape_ignores_fetch_failures(research_home, monkeypatch):
_create(budgets = _SCRAPE_BUDGETS)
url_calls = []
def fake_tool(name, arguments, *args, **kwargs):
url = arguments.get("url")
if url:
url_calls.append(url)
return "Error: boom" if url == "https://a.example.com" else "BETA_PAGE_BODY"
return _two_source_search()
completed, synthesis_prompts = _run_search_then_finish(monkeypatch, fake_tool)
assert completed["status"] == "completed"
assert completed["steps"][0]["status"] == "completed"
assert len(url_calls) == 2
# the failed fetch is never chunked; only the good page's content appears
assert "BETA_PAGE_BODY" in synthesis_prompts[0]
assert "Error: boom" not in synthesis_prompts[0]
def test_auto_scrape_skipped_for_legacy_config_without_key(research_home, monkeypatch):
# Existing/legacy runs persisted no maxAutoScrape; they must never gain scraping on resume
# or new steps, regardless of the current server default.
_create() # legacy budgets, no maxAutoScrape
url_calls = []
def fake_tool(name, arguments, *args, **kwargs):
if arguments.get("url"):
url_calls.append(arguments["url"])
return "SHOULD_NOT_BE_FETCHED"
return _two_source_search()
completed, synthesis_prompts = _run_search_then_finish(monkeypatch, fake_tool)
assert completed["status"] == "completed"
assert url_calls == []
assert "SHOULD_NOT_BE_FETCHED" not in synthesis_prompts[0]
assert "excerpt" not in completed["steps"][0]["result"]
def test_auto_scrape_skipped_on_small_context(research_home, monkeypatch):
# A context too small for the grounded synthesis prompt would degenerate the report, so
# grounding is skipped (snippet-only) even when maxAutoScrape is set.
from core import research_runs as worker
monkeypatch.setattr(worker, "_loaded_context_length", lambda: 2048)
_create(budgets = _SCRAPE_BUDGETS)
def fake_tool(name, arguments, *args, **kwargs):
if arguments.get("url"):
return "SHOULD_NOT_BE_FETCHED"
return _two_source_search()
completed, synthesis_prompts = _run_search_then_finish(monkeypatch, fake_tool)
assert completed["status"] == "completed"
assert "<chunk" not in synthesis_prompts[0]
assert "SHOULD_NOT_BE_FETCHED" not in synthesis_prompts[0]
assert "excerpt" not in completed["steps"][0]["result"]
def test_synthesis_pass_runs_at_synthesis_phase(research_home, monkeypatch):
# The report pass runs at phase "synthesis" and with default sampling: no repetition
# penalty is injected (an aggressive one degenerates small local models into a word-salad).
from core import research_runs as worker
_create(budgets = _SCRAPE_BUDGETS)
_patch_web_rank(monkeypatch)
supervisor = worker.ResearchSupervisor(
SimpleNamespace(state = SimpleNamespace(server_port = 1))
)
decisions = iter(
(
json.dumps({"action": "search", "title": "Find", "query": "q"}),
json.dumps({"action": "finish", "title": "done"}),
)
)
captured = {}
async def fake_stream_completion(run, messages, *, json_mode = False, report_progress = True, **kwargs):
system = messages[0]["content"]
if "rigorous web research plan" in system:
return json.dumps(_plan()), "p", "stop"
if "iterative research process" in system:
return next(decisions), "d", "stop"
captured.update(kwargs)
research_db.set_report_progress(run["id"], "# Report\n\nGrounded text.")
return "# Report\n\nGrounded text.", "s", "stop"
def fake_tool(name, arguments, *a, **k):
return "page body" if arguments.get("url") else _two_source_search()
monkeypatch.setattr(supervisor, "_stream_completion", fake_stream_completion)
monkeypatch.setattr(worker, "execute_tool", fake_tool)
asyncio.run(supervisor._process(research_db.claim_next(supervisor.worker_id)))
planned = research_db.get_run("run-1")
research_db.approve("run-1", planned["planRevision"], planned["planHash"])
asyncio.run(supervisor._process(research_db.claim_next(supervisor.worker_id)))
assert captured.get("phase") == "synthesis"
assert "repetition_penalty" not in captured
def test_auto_scrape_respects_char_budgets(research_home, monkeypatch):
worker, supervisor = _bare_supervisor(monkeypatch)
_patch_web_rank(monkeypatch)
# space-separated so page cleaning keeps it (a single 50k-char token is stripped as junk)
monkeypatch.setattr(worker, "execute_tool", lambda *a, **k: "yy " * 20_000)
step_sources = [
{"url": f"https://s{i}.example.com", "title": f"S{i}"} for i in range(3)
]
section, fetched = asyncio.run(
supervisor._auto_scrape_sources(
{"id": "run-x"}, "question", step_sources, set(),
tool_timeout = 10, website_policy = None,
)
)
# the folded evidence is bounded chunks, not the 150k of raw page bodies
# (the retrieved chunk section is capped at _AUTO_SCRAPE_TOTAL_CHARS; a short fixed
# header is prepended on top)
assert "<chunk" in section
assert len(section) <= worker._AUTO_SCRAPE_TOTAL_CHARS + 200
assert len(fetched) == worker._AUTO_SCRAPE_TOP_K
notes = [f"### Step\nInput: q\nResult:\n{section[:12_000]}"]
assert len(worker._bounded_synthesis_evidence(notes)) <= worker._MAX_SYNTHESIS_EVIDENCE_CHARS
def test_auto_scrape_falls_back_when_no_relevant_chunks(research_home, monkeypatch):
# When hybrid retrieval surfaces nothing above the floor (covered in test_web_rank.py),
# the step yields no scraped section and the caller keeps the snippet evidence.
worker, supervisor = _bare_supervisor(monkeypatch)
_patch_web_rank(monkeypatch, retrieve = lambda *a, **k: ("", []))
monkeypatch.setattr(worker, "execute_tool", lambda *a, **k: "unrelated boilerplate content")
step_sources = [{"url": "https://s.example.com", "title": "S"}]
section, fetched = asyncio.run(
supervisor._auto_scrape_sources(
{"id": "run-x"}, "find the special token", step_sources, set(),
tool_timeout = 10, website_policy = None,
)
)
assert section == ""
assert fetched == []
def test_clean_scraped_text_strips_nav_and_encoded_links():
from core import research_runs as worker
raw = (
"# Qwen\n"
"* [العربية](https://ar.wikipedia.org/wiki/%D9%83%D9%88%D9%8A%D9%86_%D9%86%D9%85)\n"
"* [Deutsch](https://de.wikipedia.org/wiki/Qwen)\n"
"[Qwen](/Qwen) 's Collections\n"
"[Qwen-AgentWorld](/collections/Qwen/qwen-agentworld)\n"
"BaseModelAndInstructionTuning.html?q=base%2Cmodels&sa=D&sntz=1&usg=AOvVaw2JZPpIYwRrXNjGnFtOuS-H\n"
"Qwen2.5 is released under the [Apache 2.0](https://apache.org/licenses) license, "
"which permits commercial use and redistribution.\n"
"The maximum context length is 131072 tokens.\n"
)
cleaned = worker._clean_scraped_text(raw)
# nav sidebars, encoded-URL lists, bare link menus, and tracking-URL tokens are gone
assert "العربية" not in cleaned
assert "ar.wikipedia" not in cleaned
assert "AgentWorld" not in cleaned
assert "'s Collections" not in cleaned
assert "AOvVaw2" not in cleaned
# real prose with an inline link survives
assert "Apache 2.0" in cleaned
assert "131072 tokens" in cleaned
def test_auto_scrape_skips_already_fetched_urls(research_home, monkeypatch):
worker, supervisor = _bare_supervisor(monkeypatch)
_patch_web_rank(monkeypatch)
called = []
def fake_tool(name, arguments, *args, **kwargs):
called.append(arguments["url"])
return "body for " + arguments["url"]
monkeypatch.setattr(worker, "execute_tool", fake_tool)
step_sources = [
{"url": "https://x.example.com", "title": "X"},
{"url": "https://y.example.com", "title": "Y"},
]
section, fetched = asyncio.run(
supervisor._auto_scrape_sources(
{"id": "run-x"},
"question",
step_sources,
{"https://x.example.com"},
tool_timeout = 10,
website_policy = None,
)
)
assert called == ["https://y.example.com"]
assert fetched == ["https://y.example.com"]
assert "https://x.example.com" not in section
def test_recovered_running_research_resumes_durable_progress(research_home, monkeypatch):
from core import research_runs as worker

View file

@ -0,0 +1,116 @@
"""Unit tests for the ephemeral web-RAG used by deep research auto-read.
These run the *real* Studio RAG store + hybrid retrieval + formatter against a temporary
rag.db (so the ingest -> retrieve -> render reuse chain is exercised end to end) with a fake
deterministic embedding so no model is downloaded. They also assert the ephemeral scope is
deleted, i.e. an auto-read leaves nothing behind in the store."""
import numpy as np
import pytest
from core.rag import web_rank
@pytest.fixture
def rag_home(tmp_path, monkeypatch):
"""Point rag.db at a throwaway file and rebuild its schema there."""
from storage import rag_db
db_file = tmp_path / "rag.db"
monkeypatch.setattr(rag_db, "rag_db_path", lambda: db_file)
monkeypatch.setattr(rag_db, "_schema_ready", False, raising = False)
return db_file
@pytest.fixture(autouse = True)
def fake_embeddings(monkeypatch):
"""Token counter = word count; embedding = 3-d bag over 'lora'/'license' (+ tiny bias),
so relevance is deterministic and independent of any downloaded model."""
from core.rag import embeddings as rag_embeddings
monkeypatch.setattr(
rag_embeddings,
"token_counter",
lambda model_name = None: (lambda text: max(1, len(text.split()))),
)
def encode(texts, *, model_name = None, normalize = True):
rows = []
for text in texts:
low = text.lower()
vec = np.array(
[float(low.count("lora")), float(low.count("license")), 0.001],
dtype = "float32",
)
norm = np.linalg.norm(vec)
rows.append(vec / norm if (normalize and norm) else vec)
return np.stack(rows)
monkeypatch.setattr(rag_embeddings, "encode", encode)
def _scope_rows(db_file):
"""Count leftover ephemeral documents/chunks in the store."""
import sqlite3
conn = sqlite3.connect(str(db_file))
try:
docs = conn.execute(
"SELECT count(*) FROM documents WHERE scope LIKE 'research_scrape_%'"
).fetchone()[0]
chunks = conn.execute(
"SELECT count(*) FROM chunks WHERE scope LIKE 'research_scrape_%'"
).fetchone()[0]
return docs, chunks
finally:
conn.close()
def test_retrieves_relevant_passages_as_chunks(rag_home):
pages = [
{"text": "LoRA is a low-rank adapter method for fine tuning.", "title": "LoRA", "url": "https://a"},
{"text": "The Apache license governs redistribution terms.", "title": "License", "url": "https://b"},
]
rendered, sources = web_rank.retrieve_web_chunks(pages, "what is lora", top_n = 5, min_score = 0.0)
assert "<chunk" in rendered
assert "LoRA" in rendered
assert sources and sources[0]["citationId"] == 1
# source attribution is the page title, via Studio's formatter
assert 'source="LoRA"' in rendered
def test_min_score_floor_drops_irrelevant(rag_home):
pages = [
{"text": "LoRA adapters reduce trainable parameters for fine tuning.", "url": "https://a"},
{"text": "Completely separate cooking recipe with onions and garlic.", "url": "https://b"},
]
rendered, _ = web_rank.retrieve_web_chunks(pages, "lora fine tuning", top_n = 5, min_score = 0.5)
assert "cooking" not in rendered.lower()
assert "lora" in rendered.lower()
def test_char_budget_caps_kept_chunks(rag_home):
# ~2000 words -> several ~500-word chunks; a tight budget keeps a bounded subset.
pages = [{"text": " ".join(["lora"] * 2000), "url": "https://a"}]
full, _ = web_rank.retrieve_web_chunks(pages, "lora", top_n = 10, min_score = 0.0)
capped, _ = web_rank.retrieve_web_chunks(
pages, "lora", top_n = 10, min_score = 0.0, char_budget = 3000
)
assert full.count("<chunk id") >= 2
assert 1 <= capped.count("<chunk id") < full.count("<chunk id")
def test_empty_and_invalid_inputs_return_empty(rag_home):
assert web_rank.retrieve_web_chunks([], "lora", top_n = 5, min_score = 0.1) == ("", [])
assert web_rank.retrieve_web_chunks([{"text": " "}], "lora", top_n = 5, min_score = 0.1) == ("", [])
assert web_rank.retrieve_web_chunks([{"text": "lora"}], "", top_n = 5, min_score = 0.1) == ("", [])
assert web_rank.retrieve_web_chunks([{"text": "lora"}], "lora", top_n = 0, min_score = 0.1) == ("", [])
def test_ephemeral_scope_is_cleaned_up(rag_home):
pages = [{"text": "LoRA low-rank adaptation fine tuning.", "title": "LoRA", "url": "https://a"}]
rendered, _ = web_rank.retrieve_web_chunks(pages, "lora", top_n = 5, min_score = 0.0)
assert "<chunk" in rendered
# nothing from the auto-read is left in the store
assert _scope_rows(rag_home) == (0, 0)