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
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:
@@ -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())
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user