chore: import upstream snapshot with attribution
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:08:55 +08:00
commit 7a0da7932b
2985 changed files with 1049377 additions and 0 deletions
View File
@@ -0,0 +1,310 @@
"""Build an evaluation dataset: cross-product of queries × search engines × judge LLMs.
For each (query, engine, model) cell, runs `quick_summary` end-to-end and
saves (a) the formatted report and (b) a verbose log capturing the
filter's KEPT/REMOVED decisions. Writes a summary CSV so you can rank
runs by source count, timing, and visually inspect outliers.
Resumable: cells whose report file already exists are skipped unless
--force is passed. Use this to recover from interrupted runs or to
extend an existing dataset with new rows.
Usage (defaults produce a ~12-cell grid ≈ 60-90min total on qwen3.5:9b):
LDR_BOOTSTRAP_ALLOW_UNENCRYPTED=true LDR_TESTING_WITH_MOCKS=false \\
pdm run python tests/performance/_shared/build_eval_dataset.py \\
--output-dir ./ldr_eval_output
Override any axis from the CLI:
... --queries "q1|q2" --engines "arxiv,openalex" --models "qwen3.5:9b,gemma3:12b"
Be conservative with --parallel — each parallel slot loads an LLM on the
Ollama endpoint; running 2+ different model tags concurrently may OOM
the server. Same-model parallel is usually fine (Ollama queues).
"""
from __future__ import annotations
import argparse
import csv
import os
import re
import subprocess
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path
DEFAULT_QUERIES = [
"LLM interpretability latest research",
"mechanistic interpretability of transformer language models",
"sparse autoencoders for neural network feature discovery",
"safety alignment and refusal in large language models",
]
DEFAULT_ENGINES = ["arxiv", "openalex"]
DEFAULT_MODELS = ["qwen3.5:9b", "gemma3:12b", "ministral-3:14b"]
def slugify(text: str, max_len: int = 50) -> str:
s = re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_")
return s[:max_len]
@dataclass
class Cell:
query: str
engine: str
model: str
def key(self) -> str:
return f"{self.engine}__{slugify(self.model)}__{slugify(self.query)}"
def already_done(cell: Cell, out_dir: Path) -> bool:
return (out_dir / "reports" / f"{cell.key()}.md").exists()
def run_cell(cell: Cell, out_dir: Path, ollama_url: str) -> dict:
"""Invoke run_full_search.py as a subprocess for isolation between cells."""
report_path = out_dir / "reports" / f"{cell.key()}.md"
log_path = out_dir / "logs" / f"{cell.key()}.log"
script = Path(__file__).parent / "run_full_search.py"
cmd = [
sys.executable,
str(script),
"--query",
cell.query,
"--engine",
cell.engine,
"--model",
cell.model,
"--ollama-url",
ollama_url,
"--verbose",
"--output",
str(report_path),
]
env = os.environ.copy()
env.setdefault("LDR_BOOTSTRAP_ALLOW_UNENCRYPTED", "true")
env.setdefault("LDR_TESTING_WITH_MOCKS", "false")
t0 = time.monotonic()
try:
with log_path.open("w") as logf:
proc = subprocess.run(
cmd,
env=env,
stdout=logf,
stderr=subprocess.STDOUT,
timeout=60 * 30, # 30-min hard cap per cell
check=False,
)
elapsed = time.monotonic() - t0
exit_code = proc.returncode
except subprocess.TimeoutExpired:
elapsed = time.monotonic() - t0
exit_code = -1
# A partially-written report would otherwise cause ``already_done``
# to skip this cell on the next run even though it never finished.
report_path.unlink(missing_ok=True)
except Exception as exc:
elapsed = time.monotonic() - t0
exit_code = -2
log_path.write_text(f"Exception: {exc!r}\n")
report_path.unlink(missing_ok=True)
result = {
"query": cell.query,
"engine": cell.engine,
"model": cell.model,
"elapsed_s": round(elapsed, 1),
"exit_code": exit_code,
"report_path": str(report_path) if report_path.exists() else "",
"sources": 0,
"log_path": str(log_path),
}
if report_path.exists():
text = report_path.read_text()
m = re.search(r"^\s*-\s*\*\*Sources:\*\*\s*(\d+)", text, re.M)
if m:
result["sources"] = int(m.group(1))
return result
def parse_list(
s: str, sep_primary: str = "|", sep_fallback: str = ","
) -> list[str]:
"""Parse a delimiter-separated list — '|' preferred for queries with commas."""
sep = sep_primary if sep_primary in s else sep_fallback
return [x.strip() for x in s.split(sep) if x.strip()]
def main() -> int:
p = argparse.ArgumentParser(
description="Build a cross-product eval dataset (query × engine × model).",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
p.add_argument(
"--queries",
default=None,
help="Queries separated by '|'. Default: a 4-query interpretability set.",
)
p.add_argument(
"--engines",
default=",".join(DEFAULT_ENGINES),
help="Comma-separated engine names.",
)
p.add_argument(
"--models",
default=",".join(DEFAULT_MODELS),
help="Comma-separated Ollama model tags.",
)
p.add_argument(
"--output-dir",
default="./ldr_eval_output",
help="Where reports/, logs/, summary.csv land.",
)
p.add_argument(
"--ollama-url",
default=os.environ.get(
"LDR_TEST_OLLAMA_BASE_URL", "http://localhost:11434"
),
)
p.add_argument(
"--parallel",
type=int,
default=1,
help="Cells to run concurrently. 1 = sequential. 2+ may OOM Ollama if different models are loaded simultaneously.",
)
p.add_argument(
"--force",
action="store_true",
help="Re-run cells even if their report file already exists.",
)
args = p.parse_args()
queries = (
parse_list(args.queries) if args.queries else list(DEFAULT_QUERIES)
)
engines = parse_list(args.engines)
models = parse_list(args.models)
out_dir = Path(args.output_dir).resolve()
(out_dir / "reports").mkdir(parents=True, exist_ok=True)
(out_dir / "logs").mkdir(parents=True, exist_ok=True)
cells = [
Cell(query=q, engine=e, model=m)
for q in queries
for e in engines
for m in models
]
total = len(cells)
todo = [c for c in cells if args.force or not already_done(c, out_dir)]
skipped = total - len(todo)
print(
f"Grid: {len(queries)} queries × {len(engines)} engines × {len(models)} models = {total} cells",
file=sys.stderr,
)
print(f" {skipped} already done (use --force to re-run)", file=sys.stderr)
print(f" {len(todo)} to run, parallel={args.parallel}", file=sys.stderr)
print(f" Output: {out_dir}", file=sys.stderr)
summary_path = out_dir / "summary.csv"
summary_rows: list[dict] = []
if summary_path.exists():
with summary_path.open() as f:
summary_rows = list(csv.DictReader(f))
def run_and_log(cell: Cell) -> dict:
print(
f" >>> {cell.engine} | {cell.model} | {cell.query[:60]}",
file=sys.stderr,
)
r = run_cell(cell, out_dir, args.ollama_url)
status = "ok" if r["exit_code"] == 0 else f"exit={r['exit_code']}"
print(
f" {status} {r['elapsed_s']}s sources={r['sources']} {cell.key()}",
file=sys.stderr,
)
return r
t0 = time.monotonic()
if args.parallel <= 1:
for cell in todo:
summary_rows = _upsert(summary_rows, run_and_log(cell))
_write_summary(summary_path, summary_rows)
else:
with ThreadPoolExecutor(max_workers=args.parallel) as pool:
futures = {pool.submit(run_and_log, c): c for c in todo}
for fut in as_completed(futures):
summary_rows = _upsert(summary_rows, fut.result())
_write_summary(summary_path, summary_rows)
elapsed = time.monotonic() - t0
print(
f"\nDataset build complete in {elapsed / 60:.1f}min.", file=sys.stderr
)
print(f" Summary: {summary_path}", file=sys.stderr)
_print_table(summary_rows)
return 0
def _upsert(rows: list[dict], new: dict) -> list[dict]:
"""Replace a row with the same (query, engine, model) or append."""
out = [
r
for r in rows
if not (
r.get("query") == new["query"]
and r.get("engine") == new["engine"]
and r.get("model") == new["model"]
)
]
out.append({k: str(v) for k, v in new.items()})
return out
def _write_summary(path: Path, rows: list[dict]) -> None:
if not rows:
return
fieldnames = [
"query",
"engine",
"model",
"sources",
"elapsed_s",
"exit_code",
"report_path",
"log_path",
]
with path.open("w", newline="") as f:
w = csv.DictWriter(f, fieldnames=fieldnames)
w.writeheader()
for r in rows:
w.writerow({k: r.get(k, "") for k in fieldnames})
def _print_table(rows: list[dict]) -> None:
if not rows:
return
print("\n=== Dataset summary ===")
print(
f"{'engine':10s} {'model':20s} {'sources':>8s} {'elapsed_s':>10s} query"
)
for r in rows:
print(
f"{r.get('engine', ''):10s} {r.get('model', ''):20s} "
f"{str(r.get('sources', '')):>8s} {str(r.get('elapsed_s', '')):>10s} "
f"{r.get('query', '')[:60]}"
)
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,175 @@
"""End-to-end LDR pipeline test — runs a real Quick Summary via programmatic API.
Produces the same style of output as the "Quick Summary" mode in the web UI,
but from a single command so we can diff reports against one another (e.g.
same query with arxiv vs openalex). Useful for evaluating the relevance-
filter prompt and synthesis quality in situ.
Usage:
LDR_BOOTSTRAP_ALLOW_UNENCRYPTED=true LDR_TESTING_WITH_MOCKS=false \\
pdm run python tests/performance/_shared/run_full_search.py \\
--query "LLM interpretability latest research" \\
--engine openalex \\
--output /tmp/ldr_report_openalex.md
Default model / URL mirror the other dev scripts so a bare invocation with
just --query also works.
"""
from __future__ import annotations
import argparse
import os
import sys
import tempfile
import time
from pathlib import Path
import requests
PROJECT_ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from local_deep_research.api import quick_summary # noqa: E402
from local_deep_research.api.settings_utils import ( # noqa: E402
create_settings_snapshot,
)
from local_deep_research.security import safe_get # noqa: E402
from local_deep_research.security.file_write_verifier import ( # noqa: E402
write_file_verified,
)
DEFAULT_OLLAMA_URL = os.environ.get(
"LDR_TEST_OLLAMA_BASE_URL", "http://localhost:11434"
)
DEFAULT_OLLAMA_MODEL = os.environ.get("LDR_TEST_OLLAMA_MODEL", "qwen3.5:9b")
VALID_ENGINES = [
"arxiv",
"openalex",
"wikipedia",
"searxng",
]
def check_ollama(url: str) -> bool:
try:
resp = safe_get(
f"{url.rstrip('/')}/api/tags",
timeout=5,
allow_private_ips=True,
)
return resp.status_code == 200
except requests.RequestException:
return False
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description="Run a Quick Summary end-to-end against a chosen search engine.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
p.add_argument("--query", required=True, help="Research query.")
p.add_argument(
"--engine",
default="arxiv",
choices=VALID_ENGINES,
help="Search engine (search.tool).",
)
p.add_argument("--model", default=DEFAULT_OLLAMA_MODEL)
p.add_argument("--ollama-url", default=DEFAULT_OLLAMA_URL)
p.add_argument(
"--iterations",
type=int,
default=1,
help="search.iterations — 1 matches the REST API default for Quick Summary.",
)
p.add_argument(
"--output",
default=None,
help="Path for the generated report. Defaults to /tmp/ldr_report_<engine>.md",
)
p.add_argument(
"--verbose",
action="store_true",
help="Enable DEBUG logging (LDR_APP_DEBUG=true) — shows filter KEPT/REMOVED decisions.",
)
args = p.parse_args()
if not args.output:
args.output = str(
Path(tempfile.gettempdir()) / f"ldr_report_{args.engine}.md"
)
return args
def main() -> int:
args = parse_args()
if args.verbose:
os.environ["LDR_APP_DEBUG"] = "true"
if not check_ollama(args.ollama_url):
print(
f"Ollama endpoint {args.ollama_url} not reachable. Start it or pass --ollama-url.",
file=sys.stderr,
)
return 1
settings = create_settings_snapshot(
overrides={
"llm.provider": "ollama",
"llm.model": args.model,
"llm.ollama.url": args.ollama_url,
"search.tool": args.engine,
"search.iterations": args.iterations,
"api.allow_file_output": True,
}
)
print(
f"Running Quick Summary — engine={args.engine} model={args.model} "
f"iterations={args.iterations}",
file=sys.stderr,
)
print(f" Query: {args.query!r}", file=sys.stderr)
print(f" Ollama: {args.ollama_url}", file=sys.stderr)
t0 = time.monotonic()
result = quick_summary(
args.query,
settings_snapshot=settings,
programmatic_mode=True,
)
elapsed = time.monotonic() - t0
body = result.get("formatted_findings") or result.get("summary") or ""
sources = result.get("sources", []) or []
header = (
f"# Research Results: {args.query}\n\n"
f"- **Engine:** {args.engine}\n"
f"- **Model:** {args.model}\n"
f"- **Iterations:** {args.iterations}\n"
f"- **Sources:** {len(sources)}\n"
f"- **Elapsed:** {elapsed:.1f}s\n"
f"- **Generated:** {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
"---\n\n"
)
write_file_verified(
args.output,
header + body,
"api.allow_file_output",
context="dev Quick Summary eval",
settings_snapshot=settings,
)
print(
f"\nDone in {elapsed:.1f}s. {len(sources)} sources. Wrote: {args.output}",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
sys.exit(main())
+98
View File
@@ -0,0 +1,98 @@
"""
Pytest configuration and shared fixtures for API tests
"""
import json
import subprocess
import uuid
import pytest
import requests
from pathlib import Path
import tempfile
import os
# Base URL for tests
BASE_URL = os.environ.get("LDR_TEST_BASE_URL", "http://127.0.0.1:5000")
# Use UUID for unique usernames to avoid collisions in parallel tests
TEST_USERNAME = f"testuser_{uuid.uuid4().hex[:12]}"
TEST_PASSWORD = "TestPass123"
class AuthHelper:
"""Helper class to handle Puppeteer authentication"""
@staticmethod
def get_auth_cookies():
"""Use Puppeteer to authenticate and get cookies"""
# Create a temporary file for cookie storage
cookie_file = tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False
)
cookie_file.close()
# Run the Node.js auth helper. The JS file still lives under
# tests/api_tests_with_login/ — it's shared with other Puppeteer
# suites, so we reference it in place rather than duplicate.
auth_script = (
Path(__file__).parents[2]
/ "api_tests_with_login"
/ "auth_helper.js"
)
cmd = [
"node",
str(auth_script),
BASE_URL,
TEST_USERNAME,
TEST_PASSWORD,
cookie_file.name,
]
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=90
)
if result.returncode != 0:
raise Exception(f"Auth failed: {result.stderr}")
# Read cookies from file
with open(cookie_file.name, "r") as f:
cookies = json.load(f)
# Convert to requests format
cookie_dict = {c["name"]: c["value"] for c in cookies}
# Extract CSRF token
csrf_token = None
for cookie in cookies:
if cookie["name"] == "csrf_token":
csrf_token = cookie["value"]
break
return cookie_dict, csrf_token
finally:
# Clean up temp file
if Path(cookie_file.name).exists():
os.unlink(cookie_file.name)
@pytest.fixture(scope="session")
def auth_session():
"""Session-wide fixture for authenticated requests"""
cookies, csrf_token = AuthHelper.get_auth_cookies()
session = requests.Session()
session.cookies.update(cookies)
session.headers.update(
{"X-CSRFToken": csrf_token, "Accept": "application/json"}
)
yield session, csrf_token
session.close()
@pytest.fixture
def base_url():
"""Fixture to provide base URL"""
return BASE_URL
@@ -0,0 +1,68 @@
"""
Test research API validation
These tests require a running server instance and use Puppeteer for
authentication. They are integration tests that cannot run in CI
where no server is available.
"""
import pytest
@pytest.mark.integration
def test_research_without_required_fields(auth_session, base_url):
"""Test validation of required fields"""
session, csrf_token = auth_session
# Only test truly invalid requests
# Note: Empty model is now allowed and uses defaults from database
invalid_requests = [
{}, # Empty request - missing query
{"model": "gemma3n:e2b"}, # Missing query
{"query": "", "model": "gemma3n:e2b"}, # Empty query
]
for data in invalid_requests:
response = session.post(f"{base_url}/api/start_research", json=data)
assert response.status_code in [400, 422], (
f"Expected validation error for {data}, got {response.status_code}"
)
print(f"✓ Correctly rejected invalid request: {data}")
# Test that requests with query but no model are accepted (uses defaults)
valid_requests_with_defaults = [
{"query": "test"}, # Missing model - uses default
{"query": "test", "model": ""}, # Empty model - uses default
]
for data in valid_requests_with_defaults:
response = session.post(f"{base_url}/api/start_research", json=data)
# Requests with query but missing/empty model should succeed (200 or research started)
assert response.status_code == 200, (
f"Expected success for {data}, got {response.status_code}"
)
print(f"✓ Correctly accepted request with default model: {data}")
@pytest.mark.integration
def test_research_requires_authentication(base_url):
"""Test that research API requires authentication"""
import requests
# Try without any auth
response = requests.post(
f"{base_url}/api/start_research",
json={
"query": "test",
"model": "gemma3n:e2b",
"model_provider": "OLLAMA",
},
)
# Accept both 400 (bad request) and 401 (unauthorized) as both indicate the request was rejected
assert response.status_code in [400, 401], (
f"Expected 400 or 401 for unauthenticated request, got {response.status_code}"
)
print("✓ API correctly requires authentication")
@@ -0,0 +1,611 @@
"""Benchmark tests for HTML content extraction pipeline.
Tests extraction quality across 200+ real-world pages from diverse domains.
Skipped in CI (requires network). Run manually with:
pytest tests/research_library/downloaders/test_extraction_benchmark.py -v -s
Results are printed as a comparison table with content length, boilerplate
count, and timing for each downloader mode.
"""
import time
from dataclasses import dataclass
from typing import Dict, List, Tuple
import pytest
from local_deep_research.content_fetcher import ContentFetcher
from local_deep_research.research_library.downloaders.html import HTMLDownloader
from local_deep_research.research_library.downloaders.playwright_html import (
AutoHTMLDownloader,
)
# Skip entire module in CI — these hit the network and need a browser.
# `integration` is the marker CI excludes via `-m 'not integration'`; `slow` is
# kept so the tests can be selected/deselected independently when run locally.
pytestmark = [pytest.mark.slow, pytest.mark.integration]
BOILERPLATE_KEYWORDS = [
"cookie",
"sign up",
"newsletter",
"skip to content",
"subscribe",
"accept all",
"privacy policy",
"terms of service",
"log in",
"sign in",
"add to cart",
"checkout",
"wishlist",
]
# ---------------------------------------------------------------------------
# URL corpus — 200+ pages across 17 categories
# Each entry: (label, url)
# Grouped by expected fetch behaviour: static-friendly vs JS-heavy
# ---------------------------------------------------------------------------
NEWS = [
("BBC front", "https://www.bbc.com/news"),
("Reuters", "https://www.reuters.com/"),
("NYTimes", "https://www.nytimes.com/"),
("Guardian", "https://www.theguardian.com/international"),
("CNN", "https://www.cnn.com/"),
("AP News", "https://apnews.com/"),
("Al Jazeera", "https://www.aljazeera.com/"),
("DW", "https://www.dw.com/en/"),
("France24", "https://www.france24.com/en/"),
("NBC News", "https://www.nbcnews.com/"),
("USA Today", "https://www.usatoday.com/"),
("Forbes", "https://www.forbes.com/"),
("Bloomberg", "https://www.bloomberg.com/"),
("Politico", "https://www.politico.com/"),
("HuffPost", "https://www.huffpost.com/"),
("ABC News", "https://abcnews.go.com/"),
("Time", "https://time.com/"),
("NPR", "https://www.npr.org/"),
("BBC Tech", "https://www.bbc.com/news/technology"),
("Ars Technica", "https://arstechnica.com/"),
]
TECH = [
("GitHub README", "https://github.com/AmiGandhi/WordPredict"),
("GH LDR", "https://github.com/LearningCircuit/local-deep-research"),
(
"GH Issue",
"https://github.com/LearningCircuit/local-deep-research/issues/1",
),
(
"StackOverflow",
"https://stackoverflow.com/questions/231767/what-does-the-yield-keyword-do-in-python",
),
("SO tagged", "https://stackoverflow.com/questions/tagged/python"),
("HackerNews", "https://news.ycombinator.com/item?id=40956541"),
("HN front", "https://news.ycombinator.com/"),
("TechCrunch", "https://techcrunch.com/"),
("Wired", "https://www.wired.com/"),
("The Verge", "https://www.theverge.com/"),
("ZDNet", "https://www.zdnet.com/"),
("Slashdot", "https://slashdot.org/"),
("Dev.to", "https://dev.to/"),
("Lobsters", "https://lobste.rs/"),
("InfoQ", "https://www.infoq.com/"),
]
REFERENCE = [
("Wiki EN", "https://en.wikipedia.org/wiki/Python_(programming_language)"),
("Wiki JA", "https://ja.wikipedia.org/wiki/Python"),
("Wiki DE", "https://de.wikipedia.org/wiki/Python_(Programmiersprache)"),
("Wiki FR", "https://fr.wikipedia.org/wiki/Intelligence_artificielle"),
("Wiki ZH", "https://zh.wikipedia.org/wiki/Python"),
(
"Wiki AR",
"https://ar.wikipedia.org/wiki/%D8%A8%D8%A7%D9%8A%D8%AB%D9%88%D9%86",
),
("Wiki ES", "https://es.wikipedia.org/wiki/Python"),
(
"Britannica",
"https://www.britannica.com/technology/artificial-intelligence",
),
("M-W Dict", "https://www.merriam-webster.com/dictionary/algorithm"),
("W3Schools", "https://www.w3schools.com/python/python_lists.asp"),
]
DOCS = [
("Python docs", "https://docs.python.org/3/library/asyncio.html"),
(
"MDN Docs",
"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Functions",
),
(
"ReadTheDocs",
"https://requests.readthedocs.io/en/latest/user/quickstart/",
),
(
"Rust Book",
"https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html",
),
("Go Docs", "https://go.dev/doc/effective_go"),
("Django Docs", "https://docs.djangoproject.com/en/5.0/topics/http/views/"),
("React Docs", "https://react.dev/learn"),
("Vue Docs", "https://vuejs.org/guide/introduction.html"),
(
"MS Learn",
"https://learn.microsoft.com/en-us/dotnet/csharp/tour-of-csharp/",
),
("Kubernetes", "https://kubernetes.io/docs/concepts/overview/"),
]
ACADEMIC = [
("ArXiv", "https://arxiv.org/abs/2301.07507"),
("ArXiv ICL", "https://arxiv.org/abs/2301.00234"),
("ArXiv LLM", "https://arxiv.org/abs/2303.08774"),
("ArXiv HTML", "https://arxiv.org/html/2301.07507"),
("PubMed", "https://pubmed.ncbi.nlm.nih.gov/37828879/"),
("PubMed CRISPR", "https://pubmed.ncbi.nlm.nih.gov/26553966/"),
("PubMed COVID", "https://pubmed.ncbi.nlm.nih.gov/32015507/"),
("PMC", "https://www.ncbi.nlm.nih.gov/pmc/articles/PMC7095418/"),
(
"SemScholar",
"https://www.semanticscholar.org/paper/Attention-Is-All-You-Need-Vaswani-Shazeer/204e3073870fae3d05bcbc2f6a8e263d9b72e776",
),
(
"SemScholar2",
"https://www.semanticscholar.org/paper/BERT%3A-Pre-training-of-Deep-Bidirectional-Devlin-Chang/df2b0e26d0599ce3e70df8a9da02e51594e0e992",
),
("OpenAlex", "https://openalex.org/works/W2741809807"),
("OpenAlex2", "https://openalex.org/works/W2963403868"),
("Nature", "https://www.nature.com/articles/s41586-024-07487-w"),
("Science", "https://www.science.org/"),
("ResearchGate", "https://www.researchgate.net/"),
("Springer", "https://link.springer.com/"),
("IEEE", "https://www.ieee.org/"),
("JSTOR", "https://www.jstor.org/"),
("ACM DL", "https://dl.acm.org/"),
("bioRxiv", "https://www.biorxiv.org/content/10.1101/2024.01.01.573841v1"),
]
GOVERNMENT = [
("WhiteHouse", "https://www.whitehouse.gov/"),
("NASA", "https://www.nasa.gov/"),
("NIH", "https://www.nih.gov/"),
("CDC", "https://www.cdc.gov/"),
("Europa EU", "https://europa.eu/"),
("WHO", "https://www.who.int/"),
("UN", "https://www.un.org/en/"),
("GOV UK", "https://www.gov.uk/"),
("USA.gov", "https://www.usa.gov/"),
("Data.gov", "https://data.gov/"),
]
EDUCATION = [
("MIT", "https://www.mit.edu/"),
("Stanford", "https://www.stanford.edu/"),
("Harvard", "https://www.harvard.edu/"),
("Berkeley", "https://www.berkeley.edu/"),
("Cornell", "https://www.cornell.edu/"),
("Oxford", "https://www.ox.ac.uk/"),
("Cambridge", "https://www.cam.ac.uk/"),
("Coursera", "https://www.coursera.org/"),
("Khan Academy", "https://www.khanacademy.org/"),
("edX", "https://www.edx.org/"),
]
SHOPPING = [
("Amazon", "https://www.amazon.com/"),
("eBay", "https://www.ebay.com/"),
("Walmart", "https://www.walmart.com/"),
("Target", "https://www.target.com/"),
("Etsy", "https://www.etsy.com/"),
("BestBuy", "https://www.bestbuy.com/"),
("Newegg", "https://www.newegg.com/"),
("IKEA", "https://www.ikea.com/"),
("HomeDepot", "https://www.homedepot.com/"),
("AliExpress", "https://www.aliexpress.com/"),
("Temu", "https://www.temu.com/"),
("Idealo", "https://www.idealo.de/"),
]
SOCIAL = [
("Reddit", "https://www.reddit.com/r/Python/"),
("Reddit old", "https://old.reddit.com/r/Python/"),
("LinkedIn", "https://www.linkedin.com/"),
("Pinterest", "https://www.pinterest.com/"),
("Quora", "https://www.quora.com/"),
("Tumblr", "https://www.tumblr.com/"),
]
ENTERTAINMENT = [
("YouTube", "https://www.youtube.com/"),
("IMDb", "https://www.imdb.com/title/tt0111161/"),
("Rotten Tom", "https://www.rottentomatoes.com/"),
("Spotify", "https://open.spotify.com/"),
("Goodreads", "https://www.goodreads.com/"),
("Letterboxd", "https://letterboxd.com/"),
]
FINANCE = [
("Yahoo Fin", "https://finance.yahoo.com/"),
("Investing", "https://www.investing.com/"),
("MarketWatch", "https://www.marketwatch.com/"),
("SeekingAlpha", "https://seekingalpha.com/"),
("CoinGecko", "https://www.coingecko.com/"),
("Investopedia", "https://www.investopedia.com/"),
]
PACKAGES = [
("PyPI justext", "https://pypi.org/project/justext/"),
("PyPI trafila", "https://pypi.org/project/trafilatura/"),
("npm readab", "https://www.npmjs.com/package/readability"),
("npm express", "https://www.npmjs.com/package/express"),
("crates serde", "https://crates.io/crates/serde"),
("RubyGems rails", "https://rubygems.org/gems/rails"),
("Docker Hub", "https://hub.docker.com/"),
]
INTERNATIONAL = [
("Baidu", "https://www.baidu.com/"),
("Yandex", "https://yandex.ru/"),
("Naver", "https://www.naver.com/"),
("Rakuten", "https://www.rakuten.co.jp/"),
("MercadoLibre", "https://www.mercadolibre.com/"),
("Allegro PL", "https://allegro.pl/"),
("Bol NL", "https://www.bol.com/"),
("Lemonde FR", "https://www.lemonde.fr/"),
("Spiegel DE", "https://www.spiegel.de/"),
("Corriere IT", "https://www.corriere.it/"),
]
BLOGS = [
(
"Medium",
"https://medium.com/@natassha6789/if-i-had-to-start-learning-data-science-again-how-would-i-do-it-78a02b1b56d2",
),
("Substack", "https://substack.com/"),
("S. Willison", "https://simonwillison.net/2024/Mar/8/gpt-4-barrier/"),
("WordPress", "https://wordpress.com/"),
("Ghost", "https://ghost.org/"),
("Hashnode", "https://hashnode.com/"),
]
HEALTH = [
("WebMD", "https://www.webmd.com/"),
("Mayo Clinic", "https://www.mayoclinic.org/"),
("Healthline", "https://www.healthline.com/"),
("MedNews", "https://www.medicalnewstoday.com/"),
("Cleveland", "https://my.clevelandclinic.org/"),
]
TRAVEL = [
("Booking", "https://www.booking.com/"),
("TripAdvisor", "https://www.tripadvisor.com/"),
("Airbnb", "https://www.airbnb.com/"),
("Expedia", "https://www.expedia.com/"),
("Lonely Planet", "https://www.lonelyplanet.com/"),
]
FOOD = [
("AllRecipes", "https://www.allrecipes.com/"),
("Epicurious", "https://www.epicurious.com/"),
("SeriousEats", "https://www.seriouseats.com/"),
("Bon Appetit", "https://www.bonappetit.com/"),
("Food Network", "https://www.foodnetwork.com/"),
]
MISC = [
("Craigslist", "https://www.craigslist.org/"),
("Archive.org", "https://archive.org/"),
("Wayback", "https://web.archive.org/"),
("Pastebin", "https://pastebin.com/"),
("Regex101", "https://regex101.com/"),
]
# Combine all categories
ALL_CATEGORIES: Dict[str, List[Tuple[str, str]]] = {
"news": NEWS,
"tech": TECH,
"reference": REFERENCE,
"docs": DOCS,
"academic": ACADEMIC,
"government": GOVERNMENT,
"education": EDUCATION,
"shopping": SHOPPING,
"social": SOCIAL,
"entertainment": ENTERTAINMENT,
"finance": FINANCE,
"packages": PACKAGES,
"international": INTERNATIONAL,
"blogs": BLOGS,
"health": HEALTH,
"travel": TRAVEL,
"food": FOOD,
"misc": MISC,
}
# Flat list of all pages
ALL_PAGES = []
for cat, pages in ALL_CATEGORIES.items():
for label, url in pages:
ALL_PAGES.append((cat, label, url))
# Pages expected to work with static fetch (no JS rendering needed)
STATIC_PAGES = []
for cat, label, url in ALL_PAGES:
if cat not in ("shopping", "social", "entertainment", "travel"):
STATIC_PAGES.append((label, url))
@dataclass
class FetchResult:
"""Result of a single fetch attempt."""
category: str = ""
page: str = ""
mode: str = ""
length: int = 0
boilerplate: int = 0
time_s: float = 0.0
sample: str = ""
def _count_boilerplate(data: bytes | None) -> int:
if not data:
return 0
text = data.decode("utf-8", errors="replace").lower()
return sum(1 for kw in BOILERPLATE_KEYWORDS if kw in text)
def _get_sample(data: bytes | None, n: int = 80) -> str:
if not data:
return "(no content)"
text = data.decode("utf-8", errors="replace")
idx = text.find("\n\n")
start = text[idx + 2 :] if idx > 0 else text
return start[:n].replace("\n", " ").strip()
def _run_downloader(
dl, category: str, name: str, url: str, mode: str
) -> FetchResult:
t0 = time.time()
try:
data = dl.download(url)
except Exception:
data = None
elapsed = time.time() - t0
return FetchResult(
category=category,
page=name,
mode=mode,
length=len(data) if data else 0,
boilerplate=_count_boilerplate(data),
time_s=round(elapsed, 2),
sample=_get_sample(data),
)
def _print_category_summary(results: list[FetchResult], mode: str):
"""Print per-category success rates."""
from collections import defaultdict
by_cat: Dict[str, list[FetchResult]] = defaultdict(list)
for r in results:
if r.mode == mode:
by_cat[r.category].append(r)
print(f"\n Per-category breakdown ({mode}):")
for cat in ALL_CATEGORIES:
cat_results = by_cat.get(cat, [])
if not cat_results:
continue
success = sum(1 for r in cat_results if r.length > 100)
total = len(cat_results)
avg_len = sum(r.length for r in cat_results if r.length > 100) // max(
success, 1
)
avg_bp = sum(r.boilerplate for r in cat_results) / max(total, 1)
bar = "" * success + "" * (total - success)
print(
f" {cat:<15} {bar} {success}/{total} "
f"avg_len={avg_len:>6} avg_bp={avg_bp:.1f}"
)
class TestExtractionBenchmark:
"""Benchmark extraction quality across downloader modes.
Tests 200+ real-world pages from 17 categories.
Not purely assertions-based — prints a comparison table for human review.
"""
@pytest.fixture(autouse=True)
def _setup(self):
self.static_dl = HTMLDownloader(timeout=20)
# Benchmark exercises the JS-rendering fallback path explicitly,
# so opt in regardless of the disable-by-default ctor.
self.auto_dl = AutoHTMLDownloader(timeout=20, enable_js_rendering=True)
yield
self.static_dl.close()
self.auto_dl.close()
def test_static_pages_return_content(self):
"""Static downloader should extract content from most static pages."""
failures = []
for name, url in STATIC_PAGES[:30]: # Test first 30 for speed
result = _run_downloader(self.static_dl, "", name, url, "static")
if result.length < 100:
failures.append(f"{name}: {result.length} chars")
# Allow up to 20% failure rate (bot protection, geo-blocks, etc.)
max_failures = len(STATIC_PAGES[:30]) * 0.2
assert len(failures) <= max_failures, (
f"Static downloader failed on too many pages "
f"({len(failures)}/{len(STATIC_PAGES[:30])}): "
f"{', '.join(failures[:10])}"
)
def test_full_benchmark(self):
"""Full benchmark across all pages with Auto downloader.
Prints detailed results table grouped by category.
"""
results: list[FetchResult] = []
total = len(ALL_PAGES)
print(f"\n{'=' * 90}")
print(
f" EXTRACTION BENCHMARK: {total} pages across "
f"{len(ALL_CATEGORIES)} categories"
)
print(f"{'=' * 90}")
for i, (cat, name, url) in enumerate(ALL_PAGES):
r = _run_downloader(self.auto_dl, cat, name, url, "Auto")
results.append(r)
# Progress indicator
status = "" if r.length > 100 else ""
print(
f" [{i + 1:>3}/{total}] {status} {cat:<14} "
f"{name:<16} {r.length:>7} chars "
f"{r.time_s:>5.1f}s bp={r.boilerplate}"
)
# Summary table by category
print(f"\n{'=' * 90}")
print(" SUMMARY")
print(f"{'=' * 90}")
total_success = sum(1 for r in results if r.length > 100)
total_chars = sum(r.length for r in results)
total_bp = sum(r.boilerplate for r in results)
avg_time = sum(r.time_s for r in results) / len(results)
print(
f"\n Overall: {total_success}/{total} pages extracted "
f"({total_success / total * 100:.0f}%)"
)
print(f" Total chars: {total_chars:,}")
print(f" Total boilerplate hits: {total_bp}")
print(f" Avg time per page: {avg_time:.1f}s")
_print_category_summary(results, "Auto")
# Show failures
failures = [r for r in results if r.length <= 100]
if failures:
print(f"\n Failed pages ({len(failures)}):")
for r in failures:
print(
f" {r.category:<14} {r.page:<16} "
f"{r.length} chars {r.sample[:60]}"
)
# Show high-boilerplate pages (potential quality issues)
high_bp = [r for r in results if r.boilerplate >= 3 and r.length > 100]
if high_bp:
print(f"\n High boilerplate pages ({len(high_bp)}):")
for r in sorted(high_bp, key=lambda x: -x.boilerplate):
print(
f" {r.category:<14} {r.page:<16} "
f"bp={r.boilerplate} {r.length} chars"
)
# Soft assertion: at least 60% of pages should extract content
# (many sites have bot protection, geo-blocks, paywalls)
assert total_success >= total * 0.6, (
f"Too many failures: {total_success}/{total} "
f"({total_success / total * 100:.0f}%) — expected at least 60%"
)
# URLs that ContentFetcher should route to specialized downloaders
CONTENT_FETCHER_URLS = [
("ArXiv abs", "https://arxiv.org/abs/2301.07507", "arXiv"),
("ArXiv GPT-4", "https://arxiv.org/abs/2303.08774", "arXiv"),
("PubMed", "https://pubmed.ncbi.nlm.nih.gov/37828879/", "PubMed"),
("PubMed CRISPR", "https://pubmed.ncbi.nlm.nih.gov/26553966/", "PubMed"),
("PubMed COVID", "https://pubmed.ncbi.nlm.nih.gov/32015507/", "PubMed"),
("PMC", "https://www.ncbi.nlm.nih.gov/pmc/articles/PMC7095418/", "PMC"),
(
"SemScholar",
"https://www.semanticscholar.org/paper/Attention-Is-All-You-Need-Vaswani-Shazeer/204e3073870fae3d05bcbc2f6a8e263d9b72e776",
"Semantic Scholar",
),
("OpenAlex", "https://openalex.org/works/W2741809807", "OpenAlex"),
(
"bioRxiv",
"https://www.biorxiv.org/content/10.1101/2024.01.01.573841v1",
"bioRxiv",
),
(
"HTML page",
"https://en.wikipedia.org/wiki/Python_(programming_language)",
"Web Page",
),
]
class TestContentFetcherRouting:
"""Test that ContentFetcher routes academic URLs to specialized downloaders.
Verifies the new fetch_batch path used by FullSearchResults actually
works end-to-end with real URLs.
"""
def test_academic_urls_via_content_fetcher(self):
"""ContentFetcher.fetch_batch returns content for academic URLs."""
urls = [url for _, url, _ in CONTENT_FETCHER_URLS]
with ContentFetcher(timeout=30) as fetcher:
results = fetcher.fetch_batch(urls)
print(f"\n{'=' * 90}")
print(" CONTENT FETCHER ROUTING BENCHMARK")
print(f"{'=' * 90}")
failures = []
for label, url, expected_source in CONTENT_FETCHER_URLS:
content = results.get(url)
length = len(content) if content else 0
sample = (
content[:80].replace("\n", " ") if content else "(no content)"
)
# Check URL classification
info = fetcher.get_url_info(url)
detected = info["source_name"]
status = "" if length > 50 else ""
match = "" if detected == expected_source else ""
print(
f" {status} {label:<16} "
f"route={match} {detected:<18} "
f"{length:>7} chars {sample[:50]}"
)
if length <= 50:
failures.append(f"{label} ({detected}): {length} chars")
print(
f"\n Results: {len(CONTENT_FETCHER_URLS) - len(failures)}"
f"/{len(CONTENT_FETCHER_URLS)} URLs returned content"
)
if failures:
print(f" Failures: {', '.join(failures)}")
# At least 60% should work (some may be rate-limited or paywalled)
max_failures = len(CONTENT_FETCHER_URLS) * 0.4
assert len(failures) <= max_failures, (
f"ContentFetcher failed on too many URLs: "
f"{len(failures)}/{len(CONTENT_FETCHER_URLS)}: "
f"{', '.join(failures)}"
)
@@ -0,0 +1 @@
# Scripts for database backwards compatibility testing
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""
Script to create a database using the installed local-deep-research package.
This script is used by test_backwards_compatibility.py to create a database
with a previous version of the package, which is then tested for compatibility
with the current version.
Usage:
python create_compat_db.py <db_dir> <username> <password>
"""
import sys
from pathlib import Path
# Set up paths
db_dir = Path(sys.argv[1])
username = sys.argv[2]
password = sys.argv[3]
# Patch the data directory before importing
import local_deep_research.config.paths as paths_module # noqa: E402
original_get_data_directory = paths_module.get_data_directory
paths_module.get_data_directory = lambda: db_dir
from local_deep_research.database.encrypted_db import DatabaseManager # noqa: E402
# Create a fresh manager with patched path
manager = DatabaseManager()
manager.data_dir = db_dir / "encrypted_databases"
manager.data_dir.mkdir(parents=True, exist_ok=True)
# Create the database
engine = manager.create_user_database(username, password)
# Add some test data
session = manager.get_session(username)
from local_deep_research.database.models import UserSettings # noqa: E402
setting = UserSettings(
key="test.backwards_compat",
value={"version": "previous", "test": True},
category="test",
)
session.add(setting)
session.commit()
session.close()
manager.close_user_database(username)
print("Database created successfully")
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""
Test backwards compatibility for encrypted databases.
This test ensures that databases created with previous versions can still
be opened with the current version. This prevents breaking changes like
salt modifications from going undetected.
The key tests are:
1. TestSaltStability - Fast tests that verify encryption constants haven't changed
2. TestBackwardsCompatibility - Slow test that actually installs previous PyPI version
The salt stability tests run in CI and catch 99% of breaking changes.
The full backwards compatibility test can be run manually with:
pytest tests/performance/database/test_backwards_compatibility.py -m slow --run-slow
"""
import os
import subprocess
import sys
import tempfile
from pathlib import Path
import pytest
# Path to the script that creates a database using the installed package
# This is in a separate file for better IDE support (syntax highlighting, linting)
CREATE_DB_SCRIPT_PATH = (
Path(__file__).parent / "scripts" / "create_compat_db.py"
)
class TestBackwardsCompatibility:
"""Test that current version can open databases from previous versions."""
@pytest.fixture
def temp_dir(self):
"""Create a temporary directory for test databases."""
with tempfile.TemporaryDirectory() as tmpdir:
yield Path(tmpdir)
def get_previous_version(self) -> str:
"""Get the previous version number from PyPI."""
result = subprocess.run(
[
sys.executable,
"-m",
"pip",
"index",
"versions",
"local-deep-research",
],
capture_output=True,
text=True,
)
if result.returncode != 0:
# Fallback: try to get from pip show
result = subprocess.run(
[sys.executable, "-m", "pip", "show", "local-deep-research"],
capture_output=True,
text=True,
)
# Can't determine previous version
pytest.skip("Could not determine previous version from PyPI")
# Parse versions from output
# Output format: "local-deep-research (1.3.21)\nAvailable versions: 1.3.21, 1.3.20, ..."
for line in result.stdout.split("\n"):
if "Available versions:" in line:
versions = line.split(":")[1].strip().split(", ")
if len(versions) >= 2:
return versions[1] # Second version is the previous one
pytest.skip("Could not find previous version")
@pytest.mark.slow
@pytest.mark.timeout(600) # 10 minutes - installing PyPI package takes time
@pytest.mark.skipif(
os.environ.get("RUN_SLOW_TESTS") != "true",
reason="Slow test - set RUN_SLOW_TESTS=true to run",
)
def test_open_database_from_previous_version(self, temp_dir, monkeypatch):
"""
Test that we can open a database created with the previous PyPI version.
This test:
1. Installs the previous version in an isolated venv
2. Creates a database using that version
3. Verifies the current version can open it and read data
"""
# Get previous version
try:
previous_version = self.get_previous_version()
except Exception as e:
pytest.skip(f"Could not get previous version: {e}")
# Create isolated venv for previous version
venv_dir = temp_dir / "prev_venv"
db_dir = temp_dir / "databases"
db_dir.mkdir()
# Create venv
result = subprocess.run(
[sys.executable, "-m", "venv", str(venv_dir)],
capture_output=True,
text=True,
)
if result.returncode != 0:
pytest.skip(f"Could not create venv: {result.stderr}")
# Get venv python
if sys.platform == "win32":
venv_python = venv_dir / "Scripts" / "python.exe"
else:
venv_python = venv_dir / "bin" / "python"
# Install previous version (with timeout)
# Package has many dependencies, needs sufficient time
result = subprocess.run(
[
str(venv_python),
"-m",
"pip",
"install",
f"local-deep-research=={previous_version}",
"--quiet",
],
capture_output=True,
text=True,
timeout=600, # 10 minute timeout - package has many dependencies
)
if result.returncode != 0:
pytest.skip(f"Could not install previous version: {result.stderr}")
# Create database with previous version
username = "compat_test_user"
password = "CompatTestPass123!"
# Copy the script to temp dir (script is in separate file for IDE support)
script_file = temp_dir / "create_db.py"
script_file.write_text(CREATE_DB_SCRIPT_PATH.read_text())
result = subprocess.run(
[
str(venv_python),
str(script_file),
str(db_dir),
username,
password,
],
capture_output=True,
text=True,
timeout=60,
)
if result.returncode != 0:
pytest.fail(
f"Could not create database with previous version: {result.stderr}"
)
# Now test opening with current version
monkeypatch.setattr(
"local_deep_research.database.encrypted_db.get_data_directory",
lambda: db_dir,
)
from local_deep_research.database.encrypted_db import (
DatabaseManager,
)
from local_deep_research.database.models import UserSettings
manager = DatabaseManager()
manager.data_dir = db_dir / "encrypted_databases"
# Try to open the database
engine = manager.open_user_database(username, password)
assert engine is not None, (
f"Failed to open database created with version {previous_version}. "
"This likely means a breaking change was introduced (e.g., salt change)."
)
# Verify we can read the data
session = manager.get_session(username)
setting = (
session.query(UserSettings)
.filter_by(key="test.backwards_compat")
.first()
)
assert setting is not None, (
"Could not read data from previous version database"
)
assert setting.value["version"] == "previous"
assert setting.value["test"] is True
session.close()
manager.close_user_database(username)
# Note: Salt stability tests have been moved to test_encryption_constants.py
# This file now only contains the slow PyPI backwards compatibility test.
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,210 @@
"""Compare multiple local LLMs as relevance-filter judges on the same arXiv results.
Companion to ``eval_relevance_prompt.py`` — that script varies the prompt
with one model held constant; this one varies the model with the prompt
held constant (the prompt currently on the PR).
Usage:
LDR_BOOTSTRAP_ALLOW_UNENCRYPTED=true \\
LDR_TEST_OLLAMA_BASE_URL=http://localhost:11434 \\
pdm run python tests/performance/relevance_filter/eval_models.py
Override the model list via env var:
LDR_TEST_OLLAMA_MODELS="qwen3:4b,qwen3.5:9b,gemma3:12b"
Fetches arXiv results once per query and runs the filter with each model.
Prints per-model keep counts and the consensus / disagreement breakdown so
a human can judge whether the prompt generalises across families and sizes.
"""
from __future__ import annotations
import os
import sys
import time
from pathlib import Path
from typing import Dict, List
import arxiv
import requests
PROJECT_ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from local_deep_research.security import safe_get # noqa: E402
from local_deep_research.web_search_engines.relevance_filter import ( # noqa: E402
filter_previews_for_relevance,
)
OLLAMA_URL = os.environ.get(
"LDR_TEST_OLLAMA_BASE_URL", "http://localhost:11434"
)
ARXIV_MAX = int(os.environ.get("LDR_TEST_ARXIV_MAX", "40"))
# Default model set — picked for family / size diversity so we can see
# whether prompt quality transfers. Override via LDR_TEST_OLLAMA_MODELS.
DEFAULT_MODELS = [
"qwen3:4b", # tiny — test the floor
"qwen3.5:9b", # current default
"gemma3:12b", # Gemma family
"ministral-3:14b", # Mistral family
"gpt-oss:20b", # OpenAI-origin
"qwen3.5:27b", # larger qwen — does scaling help
]
QUERIES = [
"LLM interpretability latest research",
"sparse autoencoders for mechanistic interpretability",
]
def fetch_previews(query: str, max_results: int) -> List[Dict]:
client = arxiv.Client(page_size=max_results)
search = arxiv.Search(
query=query,
max_results=max_results,
sort_by=arxiv.SortCriterion.Relevance,
)
return [
{
"title": p.title.strip(),
"snippet": (p.summary or "").strip(),
"url": p.entry_id,
}
for p in client.results(search)
]
def check_ollama(url: str) -> bool:
try:
resp = safe_get(
f"{url.rstrip('/')}/api/tags",
timeout=3,
allow_private_ips=True,
)
return resp.status_code == 200
except requests.RequestException:
return False
def installed_models(url: str) -> set:
try:
resp = safe_get(
f"{url.rstrip('/')}/api/tags",
timeout=3,
allow_private_ips=True,
)
return {m["name"] for m in resp.json().get("models", [])}
except requests.RequestException:
return set()
def run_model(model: str, llm, previews, query) -> List[Dict]:
return filter_previews_for_relevance(
llm=llm,
previews=previews,
query=query,
max_filtered_results=None,
engine_name=f"Eval[{model}]",
batch_size=10,
max_parallel_batches=4,
)
def summarise(
kept_by_model: Dict[str, List[Dict]],
previews: List[Dict],
timings: Dict[str, float],
) -> None:
all_titles = {p["title"] for p in previews}
kept_titles = {
name: {p["title"] for p in kept} for name, kept in kept_by_model.items()
}
print("\n=== Per-model keep counts ===")
for name in kept_by_model:
n = len(kept_titles[name])
print(
f" {name:24s} kept {n:3d} / {len(previews)} ({timings[name]:.1f}s)"
)
if len(kept_by_model) < 2:
return
consensus = set.intersection(*kept_titles.values())
print(
f"\n=== Consensus (kept by ALL {len(kept_by_model)} models): {len(consensus)} ==="
)
for t in sorted(consensus):
print(f" = {t[:110]}")
rejected_by_all = all_titles - set.union(*kept_titles.values())
print(f"\n=== Rejected by ALL models: {len(rejected_by_all)} ===")
for t in sorted(rejected_by_all):
print(f" × {t[:110]}")
print("\n=== Disagreements (kept by some, rejected by others) ===")
for title in sorted(all_titles - consensus - rejected_by_all):
kept_in = [
name for name, titles in kept_titles.items() if title in titles
]
tag = ",".join(m.split(":")[0][:6] for m in kept_in)
print(f" [{tag:40s}] {title[:100]}")
def main() -> int:
if not check_ollama(OLLAMA_URL):
print(f"Ollama endpoint {OLLAMA_URL} not reachable.", file=sys.stderr)
return 1
override = os.environ.get("LDR_TEST_OLLAMA_MODELS")
if override:
models = [m.strip() for m in override.split(",") if m.strip()]
else:
models = list(DEFAULT_MODELS)
installed = installed_models(OLLAMA_URL)
missing = [m for m in models if m not in installed]
if missing:
print(f"Skipping models not installed on {OLLAMA_URL}: {missing}")
models = [m for m in models if m in installed]
if not models:
print("No models to test.", file=sys.stderr)
return 1
from langchain_ollama import ChatOllama
print(f"Testing {len(models)} models: {models}")
for query in QUERIES:
print("\n" + "=" * 78)
print(f"QUERY: {query!r}")
print(f"Endpoint: {OLLAMA_URL}")
print("=" * 78)
previews = fetch_previews(query, ARXIV_MAX)
print(f"Fetched {len(previews)} arXiv previews.\n")
kept_by_model: Dict[str, List[Dict]] = {}
timings: Dict[str, float] = {}
for model in models:
llm = ChatOllama(model=model, base_url=OLLAMA_URL, num_ctx=8192)
t0 = time.monotonic()
try:
kept = run_model(model, llm, previews, query)
except Exception as exc:
print(f" {model:24s}: ERROR {exc!r}")
continue
elapsed = time.monotonic() - t0
kept_by_model[model] = kept
timings[model] = elapsed
print(f" {model:24s}: kept {len(kept):3d} in {elapsed:.1f}s")
summarise(kept_by_model, previews, timings)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,214 @@
"""Compare multiple relevance-filter prompt variants on the same arXiv results.
Usage:
LDR_BOOTSTRAP_ALLOW_UNENCRYPTED=true \\
LDR_TEST_OLLAMA_BASE_URL=http://localhost:11434 \\
LDR_TEST_OLLAMA_MODEL=qwen3.5:9b \\
pdm run python tests/performance/relevance_filter/eval_prompt.py
Fetches arXiv results ONCE per query and then runs the filter with each
candidate prompt variant. Prints a side-by-side comparison so a human can
judge which variant keeps the right papers and drops the right ones.
This is a dev tool — not a pytest test — because the right answer is a
human judgment, not a hard assertion.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from typing import Dict, List
import arxiv
import requests
PROJECT_ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from local_deep_research.security import safe_get # noqa: E402
from local_deep_research.web_search_engines.relevance_filter import ( # noqa: E402
filter_previews_for_relevance,
)
OLLAMA_URL = os.environ.get(
"LDR_TEST_OLLAMA_BASE_URL", "http://localhost:11434"
)
OLLAMA_MODEL = os.environ.get("LDR_TEST_OLLAMA_MODEL", "qwen3.5:9b")
ARXIV_MAX = int(os.environ.get("LDR_TEST_ARXIV_MAX", "40"))
QUERIES = [
"LLM interpretability latest research",
"sparse autoencoders for mechanistic interpretability",
]
# The shared scaffolding around the guidance block. The variants below only
# swap the middle paragraph — query block, date, results block, and output
# contract stay identical so the comparison is clean.
def _make_template(guidance: str) -> str:
return (
"This is a relevance-filtering step. Kept results move forward and may be used in the final answer; dropped ones are excluded from further processing.\n\n"
'Query: "{query}"\n'
"Current date: {current_date}\n\n"
"Search results:\n"
"{preview_text}\n\n"
f"{guidance}\n\n"
"Output ONLY the 0-based indices of relevant results as a comma-separated list, nothing else.\n"
"Example: 0, 2, 5"
)
VARIANTS: Dict[str, str] = {
# V0 — the prompt currently shipping on main after the selectivity-
# bias fix. Run against this as the baseline to validate new variants.
"V0_current": _make_template(
"Direct topic match matters more than keyword match — results that just mention the query terms usually don't help."
),
# V1 — explicit "prefer smaller" selectivity bias (the version that
# was on an earlier commit of this PR before we dropped it). Kept as
# a variant so we can quantify the regression if someone re-adds it.
"V1_prefer_smaller": _make_template(
"Direct topic match matters more than keyword match — results that just mention the query terms usually don't help. "
"Prefer a smaller high-confidence selection over a broader one."
),
# V2 — explicitly allow related/adjacent work. Targets the observed
# false negatives on mech-interp papers that share only one of two query terms.
"V2_inclusive_adjacent": _make_template(
"Keep results whose main subject is the query topic or closely relates to it. "
"Reject results that share keywords with the query but whose main subject is clearly something else."
),
# V3 — flip the framing to emphasise what to keep rather than what to reject.
"V3_keep_framing": _make_template(
"A result is relevant if reading it would help someone answering the query. "
"Borderline-relevant work is often worth keeping; reject only when the main subject is clearly elsewhere."
),
# V4 — terser, closer to the historical "MUST directly address" line.
"V4_terse": _make_template(
"Keep results whose main subject directly addresses the query. "
"Mere keyword overlap with off-topic work is not relevant."
),
}
def fetch_previews(query: str, max_results: int) -> List[Dict]:
client = arxiv.Client(page_size=max_results)
search = arxiv.Search(
query=query,
max_results=max_results,
sort_by=arxiv.SortCriterion.Relevance,
)
return [
{
"title": p.title.strip(),
"snippet": (p.summary or "").strip(),
"url": p.entry_id,
}
for p in client.results(search)
]
def check_ollama(url: str) -> bool:
try:
resp = safe_get(
f"{url.rstrip('/')}/api/tags",
timeout=3,
allow_private_ips=True,
)
return resp.status_code == 200
except requests.RequestException:
return False
def run_one(
variant_name: str, template: str, llm, previews, query
) -> List[Dict]:
return filter_previews_for_relevance(
llm=llm,
previews=previews,
query=query,
max_filtered_results=None,
engine_name=f"Eval[{variant_name}]",
batch_size=10,
max_parallel_batches=4,
prompt_template=template,
)
def summarise(
kept_by_variant: Dict[str, List[Dict]], previews: List[Dict]
) -> None:
all_titles = {p["title"] for p in previews}
kept_titles_by_variant = {
name: {p["title"] for p in kept}
for name, kept in kept_by_variant.items()
}
print("\n=== Per-variant counts ===")
# Iterate over the collected results rather than VARIANTS so a
# variant that raised (and was skipped in main) doesn't KeyError.
for name, titles in kept_titles_by_variant.items():
print(f" {name:24s} kept {len(titles):3d} / {len(previews)}")
if len(kept_titles_by_variant) < 2:
return
consensus = set.intersection(*kept_titles_by_variant.values())
print(f"\n=== Consensus (kept by ALL variants): {len(consensus)} ===")
for t in sorted(consensus):
print(f" = {t[:110]}")
rejected_by_all = all_titles - set.union(*kept_titles_by_variant.values())
print(f"\n=== Rejected by ALL variants: {len(rejected_by_all)} ===")
for t in sorted(rejected_by_all):
print(f" × {t[:110]}")
print("\n=== Disagreements (kept by some, rejected by others) ===")
for title in sorted(all_titles - consensus - rejected_by_all):
kept_in = [
name
for name, titles in kept_titles_by_variant.items()
if title in titles
]
print(
f" [{','.join(v.split('_')[0] for v in kept_in):14s}] {title[:100]}"
)
def main() -> int:
if not check_ollama(OLLAMA_URL):
print(f"Ollama endpoint {OLLAMA_URL} not reachable.", file=sys.stderr)
return 1
from langchain_ollama import ChatOllama
llm = ChatOllama(model=OLLAMA_MODEL, base_url=OLLAMA_URL, num_ctx=8192)
for query in QUERIES:
print("\n" + "=" * 78)
print(f"QUERY: {query!r}")
print(f"Judge: {OLLAMA_MODEL} @ {OLLAMA_URL}")
print("=" * 78)
previews = fetch_previews(query, ARXIV_MAX)
print(f"Fetched {len(previews)} arXiv previews.\n")
kept_by_variant: Dict[str, List[Dict]] = {}
for name, template in VARIANTS.items():
try:
kept = run_one(name, template, llm, previews, query)
except Exception as exc:
print(f" {name:24s}: ERROR {exc!r}")
continue
kept_by_variant[name] = kept
print(f" {name:24s}: kept {len(kept)}")
summarise(kept_by_variant, previews)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,168 @@
"""Live end-to-end check of the LLM relevance filter against real arXiv results.
Hits arXiv for previews and a real Ollama endpoint for the judge, then runs
``filter_previews_for_relevance`` on the pair. Prints KEPT/REMOVED so a human
can eyeball whether the filter is doing the right thing for the chosen local
LLM and prompt.
Excluded from CI via the ``integration`` marker. Skips cleanly if Ollama is
unreachable so running the performance suite locally without a running
endpoint does not fail.
Environment variables (all optional — defaults target a typical local setup):
LDR_TEST_OLLAMA_BASE_URL Ollama endpoint (default http://localhost:11434)
LDR_TEST_OLLAMA_MODEL Model tag to load (default qwen3.5:9b)
LDR_TEST_ARXIV_MAX How many arXiv results to request (default 40)
"""
from __future__ import annotations
import os
from typing import Any, Dict, List
from urllib.parse import urlparse
import pytest
import requests
pytestmark = [
pytest.mark.integration,
pytest.mark.requires_llm,
pytest.mark.slow,
]
DEFAULT_OLLAMA_URL = "http://localhost:11434"
DEFAULT_OLLAMA_MODEL = "qwen3.5:9b"
def _ollama_url() -> str:
return os.environ.get("LDR_TEST_OLLAMA_BASE_URL", DEFAULT_OLLAMA_URL)
def _ollama_model() -> str:
return os.environ.get("LDR_TEST_OLLAMA_MODEL", DEFAULT_OLLAMA_MODEL)
def _ollama_reachable(url: str, timeout: float = 2.0) -> bool:
# Use safe_get with allow_private_ips=True — the reachability probe
# targets a user-configured Ollama endpoint on a local/private
# network, same pattern as the sibling eval scripts.
from local_deep_research.security import safe_get
try:
resp = safe_get(
f"{url.rstrip('/')}/api/tags",
timeout=timeout,
allow_private_ips=True,
)
return resp.status_code == 200
except requests.RequestException:
return False
@pytest.fixture(scope="module")
def ollama_llm():
"""Real ChatOllama bound to the configured endpoint, or skip the test."""
url = _ollama_url()
if not _ollama_reachable(url):
pytest.skip(
f"Ollama endpoint {url} not reachable. Set "
"LDR_TEST_OLLAMA_BASE_URL to a running endpoint or run this "
"test on a machine that can reach it."
)
# Import late so the module itself can be collected even if
# langchain_ollama isn't installed (it is, via the main deps).
from langchain_ollama import ChatOllama
host = urlparse(url).netloc or url
return ChatOllama(model=_ollama_model(), base_url=url, num_ctx=8192), host
def _arxiv_previews(query: str, max_results: int) -> List[Dict[str, Any]]:
"""Fetch arXiv previews in the dict shape the relevance filter expects.
Uses the ``arxiv`` package directly (not ArXivSearchEngine) to avoid the
rate-limiter, journal-reputation-filter, and thread-context machinery —
we just want raw title+abstract pairs.
"""
import arxiv
client = arxiv.Client(page_size=max_results)
search = arxiv.Search(
query=query,
max_results=max_results,
sort_by=arxiv.SortCriterion.Relevance,
)
previews = []
for paper in client.results(search):
previews.append(
{
"title": paper.title.strip(),
"snippet": (paper.summary or "").strip(),
"url": paper.entry_id,
}
)
return previews
# Queries chosen to exercise the keyword-bait failure mode. Each one has
# plausibly-matching noise on arXiv: papers that share tokens with the query
# but whose primary topic is something else.
_LIVE_QUERIES = [
"LLM interpretability latest research",
"sparse autoencoders for mechanistic interpretability",
]
@pytest.mark.parametrize("query", _LIVE_QUERIES, ids=lambda q: q.split()[0])
def test_relevance_filter_against_real_arxiv(ollama_llm, query, capsys):
"""Run arXiv → relevance filter → Ollama end-to-end and report decisions."""
from local_deep_research.web_search_engines.relevance_filter import (
filter_previews_for_relevance,
)
llm, host = ollama_llm
max_results = int(os.environ.get("LDR_TEST_ARXIV_MAX", "40"))
previews = _arxiv_previews(query, max_results)
assert previews, "arXiv returned no results — check connectivity"
with capsys.disabled():
print(f"\n=== Query: {query!r} ===")
print(f" Judge: {_ollama_model()} @ {host}")
print(f" arXiv previews fetched: {len(previews)}")
kept = filter_previews_for_relevance(
llm=llm,
previews=previews,
query=query,
max_filtered_results=None, # let the judge decide
engine_name="LiveArxivTest",
batch_size=10,
max_parallel_batches=4,
)
kept_titles = {p["title"] for p in kept}
removed = [p for p in previews if p["title"] not in kept_titles]
with capsys.disabled():
print(f" KEPT ({len(kept)}):")
for p in kept:
print(f" + {p['title'][:110]}")
print(f" REMOVED ({len(removed)}):")
for p in removed[:25]:
print(f" - {p['title'][:110]}")
if len(removed) > 25:
print(f" ... and {len(removed) - 25} more")
# Soft sanity checks — the test exists primarily to surface output for
# a human to read, but fail loudly if the filter collapses entirely.
assert kept, (
f"Filter rejected ALL {len(previews)} results for '{query}'. "
"Either the judge is broken or the prompt over-rejects."
)
assert len(kept) < len(previews), (
f"Filter kept ALL {len(previews)} results for '{query}'. "
"The prompt is no longer filtering — likely broken."
)
@@ -0,0 +1,504 @@
"""
Integration tests for new search adapters with real API calls.
These tests verify that the adapters work correctly against live APIs.
They are marked with @pytest.mark.integration and make real network requests.
The TestFactoryIntegration class verifies that engines can be created through
the production factory path (create_search_engine → get_safe_module_class),
which requires correct entries in the security whitelist.
"""
import pytest
from local_deep_research.web_search_engines.search_engine_base import (
BaseSearchEngine,
)
from local_deep_research.web_search_engines.search_engine_factory import (
create_search_engine,
)
@pytest.mark.integration
class TestOpenLibraryIntegration:
"""Integration tests for Open Library with real API calls."""
def test_create_engine(self):
"""Test that Open Library engine can be instantiated."""
from local_deep_research.web_search_engines.engines.search_engine_openlibrary import (
OpenLibrarySearchEngine,
)
engine = OpenLibrarySearchEngine(max_results=5)
assert engine is not None
assert engine.max_results == 5
def test_search_and_get_results(self):
"""Test full search flow with real API."""
from local_deep_research.web_search_engines.engines.search_engine_openlibrary import (
OpenLibrarySearchEngine,
)
engine = OpenLibrarySearchEngine(max_results=5)
results = engine.run("lord of the rings")
assert len(results) > 0, "Should return search results"
for r in results:
assert "title" in r, "Each result should have a title"
assert "link" in r, "Each result should have a link"
assert r.get("source") == "Open Library"
titles = [r["title"].lower() for r in results]
assert any("lord" in t or "ring" in t for t in titles), (
f"Should find Lord of the Rings, got: {titles}"
)
def test_search_returns_authors(self):
"""Test that search returns author information."""
from local_deep_research.web_search_engines.engines.search_engine_openlibrary import (
OpenLibrarySearchEngine,
)
engine = OpenLibrarySearchEngine(max_results=3)
results = engine.run("1984 orwell")
assert len(results) > 0
has_authors = any(r.get("authors") for r in results)
assert has_authors, "At least one result should have authors"
@pytest.mark.integration
class TestGutenbergIntegration:
"""Integration tests for Gutenberg with real API calls."""
def test_create_engine(self):
"""Test that Gutenberg engine can be instantiated."""
from local_deep_research.web_search_engines.engines.search_engine_gutenberg import (
GutenbergSearchEngine,
)
engine = GutenbergSearchEngine(max_results=5)
assert engine is not None
assert engine.max_results == 5
def test_search_and_get_results(self):
"""Test full search flow with real API."""
from local_deep_research.web_search_engines.engines.search_engine_gutenberg import (
GutenbergSearchEngine,
)
engine = GutenbergSearchEngine(max_results=5)
results = engine.run("sherlock holmes")
assert len(results) > 0, "Should return search results"
for r in results:
assert "title" in r, "Each result should have a title"
assert "link" in r, "Each result should have a link"
assert r.get("source") == "Project Gutenberg"
assert "gutenberg.org" in r["link"]
titles = [r["title"].lower() for r in results]
assert any("sherlock" in t or "holmes" in t for t in titles), (
f"Should find Sherlock Holmes, got: {titles}"
)
def test_search_returns_authors(self):
"""Test that search returns author information."""
from local_deep_research.web_search_engines.engines.search_engine_gutenberg import (
GutenbergSearchEngine,
)
engine = GutenbergSearchEngine(max_results=3)
results = engine.run("pride and prejudice")
assert len(results) > 0
found_austen = False
for r in results:
for author in r.get("authors", []):
if "austen" in author.lower():
found_austen = True
break
assert found_austen, (
f"Should find Austen, got: {[r.get('authors') for r in results]}"
)
@pytest.mark.integration
class TestZenodoIntegration:
"""Integration tests for Zenodo with real API calls.
Note: These tests depend on external Zenodo API availability.
They handle empty results gracefully since API may be rate-limited in CI.
"""
def test_create_engine(self):
"""Test that Zenodo engine can be instantiated."""
from local_deep_research.web_search_engines.engines.search_engine_zenodo import (
ZenodoSearchEngine,
)
engine = ZenodoSearchEngine(max_results=5)
assert engine is not None
assert engine.max_results == 5
def test_search_and_get_results(self):
"""Test full search flow with real API."""
from local_deep_research.web_search_engines.engines.search_engine_zenodo import (
ZenodoSearchEngine,
)
engine = ZenodoSearchEngine(max_results=5)
results = engine.run("climate data")
# API may be rate-limited; verify structure if results returned
if len(results) > 0:
for r in results:
assert "title" in r, "Each result should have a title"
assert "link" in r, "Each result should have a link"
assert r.get("source") == "Zenodo"
assert "zenodo.org" in r["link"]
def test_search_returns_doi(self):
"""Test that search returns DOI information."""
from local_deep_research.web_search_engines.engines.search_engine_zenodo import (
ZenodoSearchEngine,
)
engine = ZenodoSearchEngine(max_results=5)
results = engine.run("neural network dataset")
# API may be rate-limited; verify DOI if results returned
if len(results) > 0:
has_doi = any(r.get("doi") for r in results)
assert has_doi, "At least one result should have a DOI"
@pytest.mark.integration
class TestStackExchangeIntegration:
"""Integration tests for Stack Exchange with real API calls."""
def test_create_engine(self):
"""Test that Stack Exchange engine can be instantiated."""
from local_deep_research.web_search_engines.engines.search_engine_stackexchange import (
StackExchangeSearchEngine,
)
engine = StackExchangeSearchEngine(max_results=5)
assert engine is not None
assert engine.max_results == 5
def test_search_and_get_results(self):
"""Test full search flow with real API."""
from local_deep_research.web_search_engines.engines.search_engine_stackexchange import (
StackExchangeSearchEngine,
)
engine = StackExchangeSearchEngine(max_results=5)
results = engine.run("python list comprehension")
assert len(results) > 0, "Should return search results"
for r in results:
assert "title" in r, "Each result should have a title"
assert "link" in r, "Each result should have a link"
assert r.get("source") == "Stack Overflow"
assert "stackoverflow.com" in r["link"]
def test_search_returns_scores(self):
"""Test that search returns score information."""
from local_deep_research.web_search_engines.engines.search_engine_stackexchange import (
StackExchangeSearchEngine,
)
engine = StackExchangeSearchEngine(max_results=5)
results = engine.run("javascript async await")
assert len(results) > 0
for r in results:
assert "score" in r, "Each result should have a score"
assert isinstance(r["score"], int), "Score should be integer"
def test_search_returns_tags(self):
"""Test that search returns tag information."""
from local_deep_research.web_search_engines.engines.search_engine_stackexchange import (
StackExchangeSearchEngine,
)
engine = StackExchangeSearchEngine(max_results=5)
results = engine.run("react hooks useState")
assert len(results) > 0
has_tags = any(r.get("tags") for r in results)
assert has_tags, "At least one result should have tags"
@pytest.mark.integration
class TestPubChemIntegration:
"""Integration tests for PubChem with real API calls.
Note: These tests depend on external PubChem API availability.
They handle empty results gracefully since API may be rate-limited in CI.
"""
def test_create_engine(self):
"""Test that PubChem engine can be instantiated."""
from local_deep_research.web_search_engines.engines.search_engine_pubchem import (
PubChemSearchEngine,
)
engine = PubChemSearchEngine(max_results=5)
assert engine is not None
assert engine.max_results == 5
def test_search_and_get_results(self):
"""Test full search flow with real API."""
from local_deep_research.web_search_engines.engines.search_engine_pubchem import (
PubChemSearchEngine,
)
engine = PubChemSearchEngine(max_results=3)
results = engine.run("caffeine")
# API may be rate-limited; verify structure if results returned
if len(results) > 0:
first = results[0]
assert "title" in first, "Should have title"
assert "link" in first, "Should have link"
assert first.get("source") == "PubChem"
assert "pubchem.ncbi.nlm.nih.gov" in first["link"]
def test_search_returns_molecular_properties(self):
"""Test that search returns molecular properties."""
from local_deep_research.web_search_engines.engines.search_engine_pubchem import (
PubChemSearchEngine,
)
engine = PubChemSearchEngine(max_results=1)
results = engine.run("ibuprofen")
# API may be rate-limited; verify properties if results returned
if len(results) > 0:
first = results[0]
assert first.get("molecular_formula"), (
"Should have molecular formula"
)
assert first.get("molecular_weight"), "Should have molecular weight"
assert first.get("cid"), "Should have PubChem CID"
def test_search_returns_smiles(self):
"""Test that search returns SMILES notation."""
from local_deep_research.web_search_engines.engines.search_engine_pubchem import (
PubChemSearchEngine,
)
engine = PubChemSearchEngine(max_results=1)
results = engine.run("glucose")
# API may be rate-limited; verify SMILES if results returned
if len(results) > 0:
first = results[0]
assert first.get("smiles"), "Should have SMILES notation"
@pytest.mark.integration
class TestAllAdaptersRoundTrip:
"""Test all adapters in a single parametrized test.
Note: These tests depend on external API availability.
They handle empty results gracefully since APIs may be rate-limited in CI.
"""
@pytest.mark.parametrize(
"engine_class_path,engine_class_name,query,expected_source",
[
(
"local_deep_research.web_search_engines.engines.search_engine_openlibrary",
"OpenLibrarySearchEngine",
"frankenstein shelley",
"Open Library",
),
(
"local_deep_research.web_search_engines.engines.search_engine_gutenberg",
"GutenbergSearchEngine",
"moby dick",
"Project Gutenberg",
),
(
"local_deep_research.web_search_engines.engines.search_engine_zenodo",
"ZenodoSearchEngine",
"astronomy dataset",
"Zenodo",
),
(
"local_deep_research.web_search_engines.engines.search_engine_stackexchange",
"StackExchangeSearchEngine",
"docker container",
"Stack Overflow",
),
(
"local_deep_research.web_search_engines.engines.search_engine_pubchem",
"PubChemSearchEngine",
"ethanol",
"PubChem",
),
],
)
def test_adapter_full_flow(
self, engine_class_path, engine_class_name, query, expected_source
):
"""Test each adapter's full flow."""
import importlib
module = importlib.import_module(engine_class_path)
engine_class = getattr(module, engine_class_name)
engine = engine_class(max_results=3)
assert engine is not None, f"Failed to create {engine_class_name}"
results = engine.run(query)
# API may be rate-limited; verify structure if results returned
if len(results) > 0:
for r in results:
assert r.get("source") == expected_source, (
f"Source should be {expected_source}, got {r.get('source')}"
)
assert "title" in r, (
f"{engine_class_name} results should have title"
)
assert "link" in r, (
f"{engine_class_name} results should have link"
)
@pytest.mark.parametrize(
"engine_class_path,engine_class_name,query",
[
(
"local_deep_research.web_search_engines.engines.search_engine_openlibrary",
"OpenLibrarySearchEngine",
"frankenstein shelley",
),
(
"local_deep_research.web_search_engines.engines.search_engine_gutenberg",
"GutenbergSearchEngine",
"moby dick",
),
(
"local_deep_research.web_search_engines.engines.search_engine_zenodo",
"ZenodoSearchEngine",
"astronomy dataset",
),
(
"local_deep_research.web_search_engines.engines.search_engine_stackexchange",
"StackExchangeSearchEngine",
"docker container",
),
(
"local_deep_research.web_search_engines.engines.search_engine_pubchem",
"PubChemSearchEngine",
"ethanol",
),
],
)
def test_adapter_full_content(
self, engine_class_path, engine_class_name, query
):
"""Test each adapter's _get_full_content() path (search_snippets_only=False)."""
import importlib
module = importlib.import_module(engine_class_path)
engine_class = getattr(module, engine_class_name)
engine = engine_class(max_results=2, search_snippets_only=False)
results = engine.run(query)
# API may be rate-limited; verify content field if results returned
if len(results) > 0:
for r in results:
assert "content" in r, (
f"{engine_class_name} full content results should have 'content' field"
)
assert "_raw" not in r, (
f"{engine_class_name} should clean up _raw in full content"
)
def _make_snapshot(engine_key: str, module_path: str, class_name: str) -> dict:
"""Build a minimal flat settings snapshot for a single engine.
Uses ``ui_element: "checkbox"`` for boolean fields so that
``get_typed_setting_value`` preserves the boolean type.
"""
return {
f"search.engine.web.{engine_key}.module_path": {
"value": module_path,
},
f"search.engine.web.{engine_key}.class_name": {
"value": class_name,
},
f"search.engine.web.{engine_key}.requires_api_key": {
"value": False,
"ui_element": "checkbox",
},
}
class TestFactoryIntegration:
"""Verify that create_search_engine() can instantiate each new engine.
These tests exercise the full production path: settings snapshot →
search_config() → get_safe_module_class() (security whitelist) →
engine class instantiation. No mocking of the whitelist or factory.
"""
@pytest.mark.parametrize(
"engine_key,module_path,class_name",
[
(
"openlibrary",
".engines.search_engine_openlibrary",
"OpenLibrarySearchEngine",
),
(
"gutenberg",
".engines.search_engine_gutenberg",
"GutenbergSearchEngine",
),
(
"zenodo",
".engines.search_engine_zenodo",
"ZenodoSearchEngine",
),
(
"stackexchange",
".engines.search_engine_stackexchange",
"StackExchangeSearchEngine",
),
(
"pubchem",
".engines.search_engine_pubchem",
"PubChemSearchEngine",
),
],
)
def test_factory_creates_engine(self, engine_key, module_path, class_name):
"""Engine is created via the factory without SecurityError.
If this fails with ``engine is None``, the most likely cause is a
missing entry in ``module_whitelist.py`` (ALLOWED_MODULE_PATHS or
ALLOWED_CLASS_NAMES).
"""
snapshot = _make_snapshot(engine_key, module_path, class_name)
engine = create_search_engine(
engine_key,
settings_snapshot=snapshot,
programmatic_mode=True,
)
assert engine is not None, (
f"create_search_engine returned None for '{engine_key}'. "
f"Check that {module_path} and {class_name} are in "
f"security/module_whitelist.py"
)
assert isinstance(engine, BaseSearchEngine)
@@ -0,0 +1,131 @@
"""Live Project Gutenberg via Gutendex API integration tests.
Hit real network APIs — excluded from CI via `-m 'not integration'`.
Run locally with:
pdm run pytest tests/performance/search_engines/test_search_engine_gutenberg_live.py -v
"""
import pytest
@pytest.mark.integration
class TestGutenbergIntegration:
"""Integration tests making real API calls to Gutendex."""
def test_real_search_sherlock_holmes(self):
"""Test real search for Sherlock Holmes."""
from local_deep_research.web_search_engines.engines.search_engine_gutenberg import (
GutenbergSearchEngine,
)
engine = GutenbergSearchEngine(max_results=5)
results = engine._get_previews("sherlock holmes")
# Verify we got results
assert len(results) > 0, "Should find Sherlock Holmes books"
# Verify result structure
for r in results:
assert "title" in r, "Each result should have a title"
assert "link" in r, "Each result should have a link"
assert r["source"] == "Project Gutenberg"
assert "gutenberg.org" in r["link"], "Link should be Gutenberg URL"
# Verify at least one is actually Sherlock Holmes
titles = [r["title"].lower() for r in results]
assert any("sherlock" in t or "holmes" in t for t in titles), (
f"Should find Sherlock Holmes, got: {titles}"
)
def test_real_search_finds_doyle(self):
"""Test that Sherlock Holmes search finds Arthur Conan Doyle."""
from local_deep_research.web_search_engines.engines.search_engine_gutenberg import (
GutenbergSearchEngine,
)
engine = GutenbergSearchEngine(max_results=5)
results = engine._get_previews("sherlock holmes doyle")
assert len(results) > 0, "Should find results"
# At least one should have Doyle as author
found_doyle = False
for r in results:
for author in r.get("authors", []):
if "doyle" in author.lower():
found_doyle = True
break
assert found_doyle, (
f"Should find Doyle as author, got: {[r.get('authors') for r in results]}"
)
def test_real_search_pride_and_prejudice(self):
"""Test real search for Pride and Prejudice."""
from local_deep_research.web_search_engines.engines.search_engine_gutenberg import (
GutenbergSearchEngine,
)
engine = GutenbergSearchEngine(max_results=5)
results = engine._get_previews("pride and prejudice austen")
assert len(results) > 0, "Should find Pride and Prejudice"
# Check for Jane Austen
found_austen = False
for r in results:
for author in r.get("authors", []):
if "austen" in author.lower():
found_austen = True
break
assert found_austen, (
f"Should find Austen, got: {[r.get('authors') for r in results]}"
)
def test_real_search_returns_download_count(self):
"""Test that real search returns download counts."""
from local_deep_research.web_search_engines.engines.search_engine_gutenberg import (
GutenbergSearchEngine,
)
engine = GutenbergSearchEngine(max_results=3)
results = engine._get_previews("moby dick")
assert len(results) > 0, "Should find Moby Dick"
# Popular books should have download counts
first = results[0]
assert "download_count" in first, "Should have download_count"
assert first["download_count"] > 0, "Popular book should have downloads"
def test_real_search_with_language_filter(self):
"""Test real search with English language filter."""
from local_deep_research.web_search_engines.engines.search_engine_gutenberg import (
GutenbergSearchEngine,
)
engine = GutenbergSearchEngine(max_results=5, languages="en")
results = engine._get_previews("sherlock holmes")
assert len(results) > 0, "Should find English Sherlock Holmes books"
# All results should have English
for r in results:
assert "en" in r.get("languages", []), (
f"Should be English, got languages: {r.get('languages')}"
)
def test_real_search_has_read_url(self):
"""Test that results include read URLs."""
from local_deep_research.web_search_engines.engines.search_engine_gutenberg import (
GutenbergSearchEngine,
)
engine = GutenbergSearchEngine(max_results=3)
results = engine._get_previews("frankenstein")
assert len(results) > 0, "Should find Frankenstein"
# At least one should have a read URL
has_read_url = any(r.get("read_url") for r in results)
assert has_read_url, "At least one result should have read_url"
@@ -0,0 +1,121 @@
"""Live Open Library API integration tests.
Hit real network APIs — excluded from CI via `-m 'not integration'`.
Run locally with:
pdm run pytest tests/performance/search_engines/test_search_engine_openlibrary_live.py -v
"""
import pytest
@pytest.mark.integration
class TestOpenLibraryIntegration:
"""Integration tests making real API calls to Open Library."""
def test_real_search_harry_potter(self):
"""Test real search for Harry Potter books."""
from local_deep_research.web_search_engines.engines.search_engine_openlibrary import (
OpenLibrarySearchEngine,
)
engine = OpenLibrarySearchEngine(max_results=5)
results = engine._get_previews("harry potter")
# Verify we got results
assert len(results) > 0, "Should find Harry Potter books"
# Verify result structure
for r in results:
assert "title" in r, "Each result should have a title"
assert "link" in r, "Each result should have a link"
assert r["source"] == "Open Library", (
"Source should be Open Library"
)
assert r["link"].startswith("https://openlibrary.org"), (
"Link should be valid"
)
# Verify at least one result mentions Harry Potter
titles = [r["title"].lower() for r in results]
assert any("harry potter" in t or "potter" in t for t in titles), (
f"At least one result should contain 'Harry Potter', got: {titles}"
)
def test_real_author_search_stephen_king(self):
"""Test real author search for Stephen King."""
from local_deep_research.web_search_engines.engines.search_engine_openlibrary import (
OpenLibrarySearchEngine,
)
engine = OpenLibrarySearchEngine(max_results=5, search_field="author")
results = engine._get_previews("Stephen King")
# Verify we got results
assert len(results) > 0, "Should find Stephen King books"
# Verify at least one result has Stephen King as author
found_king = False
for r in results:
authors = r.get("authors", [])
for author in authors:
if "king" in author.lower():
found_king = True
break
assert found_king, (
f"At least one result should have King as author, got: {[r.get('authors') for r in results]}"
)
def test_real_title_search_1984(self):
"""Test real title search for 1984."""
from local_deep_research.web_search_engines.engines.search_engine_openlibrary import (
OpenLibrarySearchEngine,
)
engine = OpenLibrarySearchEngine(max_results=5, search_field="title")
results = engine._get_previews("1984")
# Verify we got results
assert len(results) > 0, "Should find books with 1984 in title"
# Verify result has expected fields
first = results[0]
assert "title" in first
assert "authors" in first
assert "link" in first
def test_real_search_returns_metadata(self):
"""Test that real search returns proper metadata."""
from local_deep_research.web_search_engines.engines.search_engine_openlibrary import (
OpenLibrarySearchEngine,
)
engine = OpenLibrarySearchEngine(max_results=3)
results = engine._get_previews("lord of the rings tolkien")
assert len(results) > 0, "Should find Lord of the Rings"
# Check first result has expected metadata
first = results[0]
assert first.get("title"), "Should have title"
assert first.get("authors"), "Should have authors"
assert first.get("link"), "Should have link"
assert first.get("id"), "Should have id"
# Verify it's actually Tolkien
authors = first.get("authors", [])
assert any("tolkien" in a.lower() for a in authors), (
f"Should find Tolkien as author, got: {authors}"
)
def test_real_search_with_language_filter(self):
"""Test real search with language filter."""
from local_deep_research.web_search_engines.engines.search_engine_openlibrary import (
OpenLibrarySearchEngine,
)
engine = OpenLibrarySearchEngine(max_results=5, language="eng")
results = engine._get_previews("science fiction")
assert len(results) > 0, "Should find science fiction books in English"
assert all(r["source"] == "Open Library" for r in results)
@@ -0,0 +1,137 @@
"""Live PubChem API integration tests.
Hit real network APIs — excluded from CI via `-m 'not integration'`.
Run locally with:
pdm run pytest tests/performance/search_engines/test_search_engine_pubchem_live.py -v
"""
import pytest
@pytest.mark.integration
class TestPubChemIntegration:
"""Integration tests making real API calls to PubChem."""
def test_real_search_aspirin(self):
"""Test real search for aspirin."""
from local_deep_research.web_search_engines.engines.search_engine_pubchem import (
PubChemSearchEngine,
)
engine = PubChemSearchEngine(max_results=3)
results = engine._get_previews("aspirin")
# Verify we got results
assert len(results) > 0, "Should find aspirin"
# Verify first result
first = results[0]
assert "title" in first, "Should have title"
assert "link" in first, "Should have link"
assert first["source"] == "PubChem", "Source should be PubChem"
assert "pubchem.ncbi.nlm.nih.gov" in first["link"], (
"Link should be PubChem URL"
)
def test_real_search_returns_molecular_formula(self):
"""Test that real search returns molecular formula."""
from local_deep_research.web_search_engines.engines.search_engine_pubchem import (
PubChemSearchEngine,
)
engine = PubChemSearchEngine(max_results=1)
results = engine._get_previews("caffeine")
assert len(results) > 0, "Should find caffeine"
first = results[0]
assert first.get("molecular_formula"), "Should have molecular_formula"
# Caffeine is C8H10N4O2
assert "C" in first["molecular_formula"], "Formula should contain C"
def test_real_search_returns_molecular_weight(self):
"""Test that real search returns molecular weight."""
from local_deep_research.web_search_engines.engines.search_engine_pubchem import (
PubChemSearchEngine,
)
engine = PubChemSearchEngine(max_results=1)
results = engine._get_previews("glucose")
assert len(results) > 0, "Should find glucose"
first = results[0]
assert first.get("molecular_weight"), "Should have molecular_weight"
# Just verify molecular weight is a positive number
# (PubChem may return different glucose compounds like derivatives)
weight = float(first["molecular_weight"])
assert weight > 0, f"Molecular weight should be positive, got {weight}"
def test_real_search_returns_smiles(self):
"""Test that real search returns SMILES notation."""
from local_deep_research.web_search_engines.engines.search_engine_pubchem import (
PubChemSearchEngine,
)
engine = PubChemSearchEngine(max_results=1)
results = engine._get_previews("ethanol")
assert len(results) > 0, "Should find ethanol"
first = results[0]
assert first.get("smiles"), "Should have SMILES"
# Ethanol SMILES should contain C and O
assert "C" in first["smiles"] and "O" in first["smiles"], (
f"Ethanol SMILES should contain C and O, got: {first['smiles']}"
)
def test_real_search_returns_cid(self):
"""Test that real search returns compound ID."""
from local_deep_research.web_search_engines.engines.search_engine_pubchem import (
PubChemSearchEngine,
)
engine = PubChemSearchEngine(max_results=1)
results = engine._get_previews("water")
assert len(results) > 0, "Should find water"
first = results[0]
assert first.get("cid"), "Should have CID"
assert isinstance(first["cid"], int), "CID should be integer"
def test_real_search_ibuprofen(self):
"""Test real search for ibuprofen."""
from local_deep_research.web_search_engines.engines.search_engine_pubchem import (
PubChemSearchEngine,
)
engine = PubChemSearchEngine(max_results=1)
results = engine._get_previews("ibuprofen")
assert len(results) > 0, "Should find ibuprofen"
first = results[0]
# Ibuprofen CID is 3672
assert first.get("cid"), "Should have CID"
assert first.get("iupac_name"), "Should have IUPAC name"
def test_real_search_partial_name(self):
"""Test real search with partial compound name."""
from local_deep_research.web_search_engines.engines.search_engine_pubchem import (
PubChemSearchEngine,
)
engine = PubChemSearchEngine(max_results=5)
results = engine._get_previews("acet")
# Should find compounds starting with "acet" like acetone, acetamide, etc.
assert len(results) > 0, "Should find compounds starting with 'acet'"
# At least one should have a title starting with "acet"
titles = [r["title"].lower() for r in results]
has_acet = any("acet" in t for t in titles)
assert has_acet, (
f"Should find compound starting with 'acet', got: {titles}"
)
@@ -0,0 +1,136 @@
"""Live Stack Exchange API integration tests.
Hit real network APIs — excluded from CI via `-m 'not integration'`.
Run locally with:
pdm run pytest tests/performance/search_engines/test_search_engine_stackexchange_live.py -v
"""
import pytest
@pytest.mark.integration
class TestStackExchangeIntegration:
"""Integration tests making real API calls to Stack Exchange."""
def test_real_search_python(self):
"""Test real search for Python questions."""
from local_deep_research.web_search_engines.engines.search_engine_stackexchange import (
StackExchangeSearchEngine,
)
engine = StackExchangeSearchEngine(max_results=5)
results = engine._get_previews("python list comprehension")
# Verify we got results
assert len(results) > 0, "Should find Python questions"
# Verify result structure
for r in results:
assert "title" in r, "Each result should have a title"
assert "link" in r, "Each result should have a link"
assert r["source"] == "Stack Overflow", (
"Source should be Stack Overflow"
)
assert "stackoverflow.com" in r["link"], "Link should be SO URL"
def test_real_search_returns_scores(self):
"""Test that real search returns scores."""
from local_deep_research.web_search_engines.engines.search_engine_stackexchange import (
StackExchangeSearchEngine,
)
engine = StackExchangeSearchEngine(max_results=5)
results = engine._get_previews("javascript async await")
assert len(results) > 0, "Should find JavaScript questions"
# All should have score
for r in results:
assert "score" in r, "Should have score"
assert isinstance(r["score"], int), "Score should be int"
def test_real_search_returns_tags(self):
"""Test that real search returns tags."""
from local_deep_research.web_search_engines.engines.search_engine_stackexchange import (
StackExchangeSearchEngine,
)
engine = StackExchangeSearchEngine(max_results=5)
results = engine._get_previews("react hooks")
assert len(results) > 0, "Should find React questions"
# At least some should have tags
has_tags = any(r.get("tags") for r in results)
assert has_tags, (
f"At least one should have tags, got: {[r.get('tags') for r in results]}"
)
def test_real_search_different_site(self):
"""Test real search on different Stack Exchange site."""
from local_deep_research.web_search_engines.engines.search_engine_stackexchange import (
StackExchangeSearchEngine,
)
engine = StackExchangeSearchEngine(max_results=5, site="unix")
results = engine._get_previews("bash script loop")
assert len(results) > 0, "Should find Unix questions"
# Source should reflect the site
assert results[0]["source"] == "Unix & Linux", (
f"Source should be Unix & Linux, got: {results[0]['source']}"
)
assert results[0]["site"] == "unix"
def test_real_search_with_high_score(self):
"""Test real search with minimum score filter."""
from local_deep_research.web_search_engines.engines.search_engine_stackexchange import (
StackExchangeSearchEngine,
)
engine = StackExchangeSearchEngine(
max_results=5, min_score=100, sort="votes"
)
results = engine._get_previews("python")
assert len(results) > 0, "Should find high-score Python questions"
# All should have score >= 100
for r in results:
assert r["score"] >= 100, (
f"Score should be >= 100, got: {r['score']}"
)
def test_real_search_returns_answer_count(self):
"""Test that real search returns answer counts."""
from local_deep_research.web_search_engines.engines.search_engine_stackexchange import (
StackExchangeSearchEngine,
)
engine = StackExchangeSearchEngine(max_results=5, has_answers=True)
results = engine._get_previews("docker container")
assert len(results) > 0, "Should find Docker questions with answers"
# All should have at least one answer
for r in results:
assert r["answer_count"] >= 1, (
f"Should have answers, got: {r['answer_count']}"
)
def test_real_search_returns_author_info(self):
"""Test that real search returns author information."""
from local_deep_research.web_search_engines.engines.search_engine_stackexchange import (
StackExchangeSearchEngine,
)
engine = StackExchangeSearchEngine(max_results=5)
results = engine._get_previews("git merge conflict")
assert len(results) > 0, "Should find Git questions"
# At least some should have author
has_author = any(r.get("author") for r in results)
assert has_author, "At least one should have author"
@@ -0,0 +1,129 @@
"""Live Zenodo API integration tests.
Hit real network APIs — excluded from CI via `-m 'not integration'`.
Run locally with:
pdm run pytest tests/performance/search_engines/test_search_engine_zenodo_live.py -v
"""
import pytest
@pytest.mark.integration
class TestZenodoIntegration:
"""Integration tests making real API calls to Zenodo.
Note: These tests depend on external Zenodo API availability.
They handle empty results gracefully since API may be rate-limited in CI.
"""
def test_real_search_machine_learning(self):
"""Test real search for machine learning datasets."""
from local_deep_research.web_search_engines.engines.search_engine_zenodo import (
ZenodoSearchEngine,
)
engine = ZenodoSearchEngine(max_results=5)
results = engine._get_previews("machine learning")
# API may be rate-limited; verify structure if results returned
if len(results) > 0:
for r in results:
assert "title" in r, "Each result should have a title"
assert "link" in r, "Each result should have a link"
assert r["source"] == "Zenodo", "Source should be Zenodo"
assert "zenodo.org" in r["link"], "Link should be Zenodo URL"
def test_real_search_returns_doi(self):
"""Test that real search returns DOIs."""
from local_deep_research.web_search_engines.engines.search_engine_zenodo import (
ZenodoSearchEngine,
)
engine = ZenodoSearchEngine(max_results=5)
results = engine._get_previews("climate data")
# API may be rate-limited; verify DOI if results returned
if len(results) > 0:
has_doi = any(r.get("doi") for r in results)
assert has_doi, "At least one result should have a DOI"
def test_real_search_datasets(self):
"""Test real search filtered to datasets."""
from local_deep_research.web_search_engines.engines.search_engine_zenodo import (
ZenodoSearchEngine,
)
engine = ZenodoSearchEngine(max_results=5, resource_type="dataset")
results = engine._get_previews("genomics")
# API may be rate-limited; verify type if results returned
if len(results) > 0:
for r in results:
assert r.get("resource_type") in [
"Dataset",
"dataset",
"Other",
], f"Should be dataset type, got: {r.get('resource_type')}"
def test_real_search_software(self):
"""Test real search filtered to software."""
from local_deep_research.web_search_engines.engines.search_engine_zenodo import (
ZenodoSearchEngine,
)
engine = ZenodoSearchEngine(max_results=5, resource_type="software")
results = engine._get_previews("python")
# API may be rate-limited; verify links if results returned
if len(results) > 0:
for r in results:
assert r["link"].startswith("https://zenodo.org"), (
f"Link should be Zenodo URL: {r['link']}"
)
def test_real_search_returns_authors(self):
"""Test that real search returns authors."""
from local_deep_research.web_search_engines.engines.search_engine_zenodo import (
ZenodoSearchEngine,
)
engine = ZenodoSearchEngine(max_results=5)
results = engine._get_previews("neural network")
# API may be rate-limited; verify authors if results returned
if len(results) > 0:
has_authors = any(r.get("authors") for r in results)
assert has_authors, (
f"At least one should have authors, got: {[r.get('authors') for r in results]}"
)
def test_real_search_open_access(self):
"""Test real search for open access records."""
from local_deep_research.web_search_engines.engines.search_engine_zenodo import (
ZenodoSearchEngine,
)
engine = ZenodoSearchEngine(max_results=5, access_right="open")
results = engine._get_previews("biology")
# API may be rate-limited; verify access_right if results returned
if len(results) > 0:
for r in results:
assert r.get("access_right") == "open", (
f"Should be open access, got: {r.get('access_right')}"
)
def test_real_search_returns_publication_date(self):
"""Test that real search returns publication dates."""
from local_deep_research.web_search_engines.engines.search_engine_zenodo import (
ZenodoSearchEngine,
)
engine = ZenodoSearchEngine(max_results=5)
results = engine._get_previews("astronomy")
# API may be rate-limited; verify publication_date if results returned
if len(results) > 0:
has_date = any(r.get("publication_date") for r in results)
assert has_date, "At least one should have publication_date"