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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:08:55 +08:00
commit 7a0da7932b
2985 changed files with 1049377 additions and 0 deletions
View File
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""
Dump the Flask url_map to a file as one absolute URL per line.
Used by the Nuclei DAST workflow to seed a URL list so the scanner
probes the actual application surface (authenticated routes, API
endpoints, blueprints) instead of just the index page.
Parameterized routes (`/research/<string:research_id>/status`) are
emitted with a converter-appropriate placeholder so Nuclei still
exercises the path. The substituted URL will usually 404 (the resource
doesn't exist for the test user), but that is fine — Nuclei probes path
traversal, parameter injection, and SQLi templates against the URL
pattern, not against a specific resource.
Skips:
- The Flask `static` endpoint (asset serving, no app logic).
- Routes that don't accept GET — Nuclei templates almost exclusively
issue GET probes, so POST-only endpoints just generate 405s.
Usage:
python scripts/ci/dump_url_map.py http://127.0.0.1:5000 > urls.txt
"""
import re
import sys
# Map Flask URL converters to a placeholder that satisfies the converter
# so Flask routes the request to the handler instead of 404-ing at the
# converter stage. Anything not listed falls back to a plain string.
_PLACEHOLDERS = {
"int": "1",
"float": "1",
"uuid": "00000000-0000-0000-0000-000000000000",
}
_DEFAULT_PLACEHOLDER = "nuclei"
_PARAM_RE = re.compile(
r"<(?:(?P<conv>[a-zA-Z_][a-zA-Z0-9_]*)(?:\([^)]*\))?:)?(?P<name>[a-zA-Z_][a-zA-Z0-9_]*)>"
)
def _substitute(match: "re.Match[str]") -> str:
return _PLACEHOLDERS.get(match.group("conv") or "", _DEFAULT_PLACEHOLDER)
def main() -> int:
if len(sys.argv) != 2:
print(f"Usage: {sys.argv[0]} BASE_URL", file=sys.stderr)
return 2
base_url = sys.argv[1].rstrip("/")
from local_deep_research.web.app_factory import create_app
app, _ = create_app()
seen: set[str] = set()
for rule in app.url_map.iter_rules():
if rule.endpoint == "static":
continue
if "GET" not in (rule.methods or set()):
continue
path = _PARAM_RE.sub(_substitute, rule.rule)
url = f"{base_url}{path}"
if url in seen:
continue
seen.add(url)
print(url)
return 0
if __name__ == "__main__":
sys.exit(main())
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""
Initialize test database with pre-created test user for CI.
This script creates the test_admin user BEFORE tests run, avoiding the slow
registration process (2+ min) which:
1. Creates encrypted SQLCipher database
2. Derives encryption keys from password
3. Creates 58 database tables
4. Imports 500+ settings from JSON files
Usage:
python scripts/ci/init_test_database.py
Environment variables:
LDR_DATA_DIR: Directory for database files (required for path consistency)
TEST_ENV: Should be "true" for test environment
Note: Test user credentials must match CI_TEST_USER in tests/ui_tests/auth_helper.js
"""
import os
from pathlib import Path
def main():
"""Create test database and test_admin user."""
# Test user credentials - must match CI_TEST_USER in auth_helper.js
TEST_USERNAME = "test_admin"
TEST_PASSWORD = "testpass123" # pragma: allowlist secret
# Use LDR_DATA_DIR if set, otherwise default
data_dir = Path(
os.environ.get(
"LDR_DATA_DIR",
Path.home() / ".local" / "share" / "local-deep-research",
)
)
print(f"Using data directory: {data_dir}")
data_dir.mkdir(parents=True, exist_ok=True)
(data_dir / "encrypted_databases").mkdir(parents=True, exist_ok=True)
# Import after setting up paths
from local_deep_research.database.auth_db import (
get_auth_db_session,
init_auth_database,
)
from local_deep_research.database.encrypted_db import db_manager
from local_deep_research.database.models.auth import User
# Initialize auth database
init_auth_database()
# Create test user in auth database (no password stored)
session = get_auth_db_session()
user = User(username=TEST_USERNAME)
session.add(user)
session.commit()
session.close()
# Create user's encrypted database with password
db_manager.create_user_database(TEST_USERNAME, TEST_PASSWORD)
print("✅ Database initialized successfully")
print(f"✅ Test user '{TEST_USERNAME}' created")
print(f" Encrypted databases in: {data_dir / 'encrypted_databases'}")
if __name__ == "__main__":
main()
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Seed link-analytics test data for the `link-analytics` UI shard.
The /metrics/links page derives its domain cards entirely from
``ResearchResource`` rows (see ``get_link_analytics`` in
``web/routes/metrics_routes.py``). A freshly-initialized CI database
(``scripts/ci/init_test_database.py``) has none, so the page renders the
"No domain data available" placeholder and ``test_link_analytics_full.js``
times out waiting for ``#domain-list .ldr-domain-item-expanded``.
This script inserts a small, deterministic fixture (2 researches, 7 resources
across 4 domains) for the ``test_admin`` user so the page renders real domain
cards with frequency/diversity badges and a populated "Recent Researches
(N total)" header.
It is wired into ``.github/workflows/docker-tests.yml`` **only** for the
``link-analytics`` shard. Other shards (and the responsive / empty-state
screenshot suites that assert the placeholder on /metrics/links) keep seeing
an empty database.
Usage:
python scripts/ci/seed_link_analytics.py
Environment variables (must match init_test_database.py / the server):
LDR_DATA_DIR: Directory for database files.
LDR_TEST_MODE / LDR_DB_CONFIG_KDF_ITERATIONS: SQLCipher key-derivation
parameters — must match the values used to create the DB or the
encryption key will not match.
"""
import os
import uuid
from datetime import UTC, datetime, timedelta
from pathlib import Path
from sqlalchemy.orm import Session
# Credentials must match CI_TEST_USER in tests/ui_tests/auth_helper.js and the
# user created by scripts/ci/init_test_database.py.
TEST_USERNAME = "test_admin"
TEST_PASSWORD = "testpass123" # pragma: allowlist secret
# A stable namespace UUID so re-running on a fresh DB always produces the same
# ids (keeps the fixture idempotent and easy to reason about).
_SEED_NS = uuid.UUID("00000000-0000-0000-0000-00000000a11a")
def main():
data_dir = Path(
os.environ.get(
"LDR_DATA_DIR",
Path.home() / ".local" / "share" / "local-deep-research",
)
)
print(f"Using data directory: {data_dir}")
# Imported after the env is set so path/SQLCipher config is picked up.
from local_deep_research.database.encrypted_db import db_manager
from local_deep_research.database.models.research import (
ResearchHistory,
ResearchResource,
)
engine = db_manager.open_user_database(TEST_USERNAME, TEST_PASSWORD)
if engine is None:
raise RuntimeError(
f"Could not open encrypted database for user '{TEST_USERNAME}'. "
"Was init_test_database.py run first with matching "
"LDR_TEST_MODE / LDR_DB_CONFIG_KDF_ITERATIONS?"
)
research_a = str(uuid.uuid5(_SEED_NS, "research-a"))
research_b = str(uuid.uuid5(_SEED_NS, "research-b"))
# created_at lands a day ago so it falls inside the default 30d window the
# /metrics/links page queries with. Stored as ISO strings to match the
# rest of the codebase (created_at columns are Text/String, not DateTime).
created = (datetime.now(UTC) - timedelta(days=1)).isoformat()
researches = [
ResearchHistory(
id=research_a,
query="Seed: deep learning survey for link analytics",
mode="quick_summary",
status="completed",
created_at=created,
completed_at=created,
),
ResearchHistory(
id=research_b,
query="Seed: transformer architectures for link analytics",
mode="quick_summary",
status="completed",
created_at=created,
completed_at=created,
),
]
# (research_id, url, title, source_type). Spread across 4 domains with
# uneven counts so frequency_rank / usage_percentage vary, and across both
# researches so research_diversity is >1 for the busiest domains.
resources = [
(
research_a,
"https://en.wikipedia.org/wiki/Deep_learning",
"Deep learning - Wikipedia",
"web",
),
(
research_a,
"https://en.wikipedia.org/wiki/Neural_network",
"Neural network - Wikipedia",
"web",
),
(
research_a,
"https://arxiv.org/abs/1706.03762",
"Attention Is All You Need",
"academic",
),
(
research_a,
"https://www.nature.com/articles/nature14539",
"Deep learning - Nature",
"academic",
),
(
research_b,
"https://en.wikipedia.org/wiki/Transformer_(deep_learning_architecture)",
"Transformer - Wikipedia",
"web",
),
(research_b, "https://arxiv.org/abs/1810.04805", "BERT", "academic"),
(
research_b,
"https://github.com/huggingface/transformers",
"huggingface/transformers",
"code",
),
]
with Session(engine) as session:
# Idempotent: a fresh CI DB is created per attempt, but guard anyway so
# a manual re-run does not duplicate the fixture.
already = (
session.query(ResearchHistory)
.filter(ResearchHistory.id == research_a)
.first()
)
if already:
print("Link analytics fixture already present — nothing to do.")
return
session.add_all(researches)
for research_id, url, title, source_type in resources:
session.add(
ResearchResource(
research_id=research_id,
url=url,
title=title,
source_type=source_type,
created_at=created,
)
)
session.commit()
domains = {url.split("/")[2] for _, url, _, _ in resources}
print(
f"✅ Seeded {len(researches)} researches and {len(resources)} resources "
f"across {len(domains)} domains for '{TEST_USERNAME}'"
)
if __name__ == "__main__":
main()
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""
Passthrough analysis: catalogue every paper that fell through every
local tier of the journal filter and ended up at the passthrough score
(== threshold). Helps decide whether the LLM tier is still worth keeping.
Reuses the same arxiv/openalex/pubmed/s2 dataset as
test_journal_filter_arxiv.py but routes results through __score_journal
directly so we can also see *which* tier actually scored each paper.
"""
import argparse
import re
import sys
from collections import Counter, defaultdict
from unittest.mock import Mock
from local_deep_research.advanced_search_system.filters import (
journal_reputation_filter as _jrf,
)
# Stub SearXNG so the filter can be instantiated standalone
_jrf.create_search_engine = lambda *a, **kw: None
from local_deep_research.utilities.thread_context import search_context # noqa: E402
from local_deep_research.web_search_engines.engines.search_engine_arxiv import ( # noqa: E402
ArXivSearchEngine,
)
from local_deep_research.web_search_engines.engines.search_engine_openalex import ( # noqa: E402
OpenAlexSearchEngine,
)
from local_deep_research.web_search_engines.engines.search_engine_pubmed import ( # noqa: E402
PubMedSearchEngine,
)
from local_deep_research.web_search_engines.engines.search_engine_semantic_scholar import ( # noqa: E402
SemanticScholarSearchEngine,
)
DOMAIN_QUERIES = {
"fusion": "tokamak plasma confinement",
"llm": "large language model alignment",
"graph_nn": "graph neural networks",
"astro": "exoplanet atmosphere spectroscopy",
"biomed": "CRISPR gene editing therapy",
"condmat": "high temperature superconductor cuprate",
"climate": "climate model ocean heat content",
"quantum": "quantum error correction surface code",
"math": "riemann hypothesis zeta function",
"robotics": "reinforcement learning robotic manipulation",
}
ENGINES = {
"arxiv": ArXivSearchEngine,
"openalex": OpenAlexSearchEngine,
"pubmed": PubMedSearchEngine,
"s2": SemanticScholarSearchEngine,
}
def categorize(journal_ref: str, cleaned: str) -> str:
"""Bucket a passthrough journal_ref into a category."""
j = journal_ref.strip()
c = cleaned.strip()
# Citation-like strings (author initials, "et al.", quotes around title)
if re.search(r"^[A-Z]\.\s*[A-Z]", j):
return "citation_author"
if '"' in j or "" in j:
return "citation_quoted"
if re.search(r"\bet al\b", j, re.I):
return "citation_et_al"
# Cleaning debris (bare year/page leftover)
if re.search(r"\b(19|20)\d{2}\b", c):
return "cleaning_debris"
if re.search(r"\d", c[-10:]):
return "cleaning_trailing_num"
# Conference (the regex tier already handles these via score=5)
if re.search(
r"(proc(eedings|\.)?|conference|symp|workshop|colloq)", c, re.I
):
return "conference_uncaught"
# Truncated long names
if len(j) > 50 and j.endswith(("", "...")):
return "truncated"
# Looks like a real journal name we just don't have
return "real_journal_unknown"
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("-n", type=int, default=15)
ap.add_argument("-t", "--threshold", type=int, default=4)
args = ap.parse_args()
snapshot = {
"search.journal_reputation.threshold": args.threshold,
"search.journal_reputation.exclude_non_published": False,
"search.journal_reputation.max_context": 3000,
"search.journal_reputation.reanalysis_period": 365,
"search.engine.web.arxiv.journal_reputation.enabled": True,
"search.engine.web.openalex.journal_reputation.enabled": True,
"search.engine.web.pubmed.journal_reputation.enabled": True,
"search.engine.web.semantic_scholar.journal_reputation.enabled": True,
}
passthroughs: list[tuple[str, str, str, str, str]] = []
# (engine, domain, raw_journal_ref, cleaned, category)
totals: Counter = Counter()
for engine_name, cls in ENGINES.items():
for domain, q in DOMAIN_QUERIES.items():
print(f"{engine_name:<9} {domain:<10} {q!r} ...", flush=True)
kwargs = {
"max_results": args.n,
"llm": Mock(),
"settings_snapshot": snapshot,
}
if engine_name in ("pubmed", "s2"):
kwargs["optimize_queries"] = False
try:
with search_context({"username": None, "user_password": None}):
engine = cls(**kwargs)
out = engine.run(q)
except Exception as e:
print(f" ! {e}")
continue
for r in out:
jref = r.get("journal_ref")
if not jref:
totals["no_journal_ref"] += 1
continue
qual = r.get("journal_quality")
if qual == args.threshold:
# Passthrough — local tiers couldn't score
cleaned = jref # Filter caches private; approximate
cat = categorize(jref, cleaned)
passthroughs.append(
(engine_name, domain, jref, cleaned, cat)
)
totals[f"passthrough_{cat}"] += 1
elif qual is not None:
totals[f"scored_{qual}"] += 1
print()
print("=" * 90)
print("OVERALL")
print("=" * 90)
for k in sorted(totals):
print(f" {k:<35} {totals[k]:>5}")
print()
print("=" * 90)
print(f"PASSTHROUGH BY CATEGORY ({len(passthroughs)} total)")
print("=" * 90)
by_cat: dict[str, list] = defaultdict(list)
for row in passthroughs:
by_cat[row[4]].append(row)
for cat in sorted(by_cat, key=lambda c: -len(by_cat[c])):
rows = by_cat[cat]
print(f"\n[{cat}] ({len(rows)} entries)")
for engine_name, domain, jref, cleaned, _ in rows[:8]:
print(f" {engine_name:<9} {domain:<10} {jref[:65]}")
if len(rows) > 8:
print(f" ... and {len(rows) - 8} more")
return 0
if __name__ == "__main__":
sys.exit(main())
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""Debug script to run pytest with CI settings and see what's failing.
Do not remove — developer utility for reproducing CI failures locally.
"""
import os
import subprocess
import sys
# Set CI environment to mimic CI behavior
os.environ["CI"] = "true"
os.environ["USE_FALLBACK_LLM"] = "true"
cmd = [
sys.executable,
"run_tests.py",
"-m",
"not slow",
"--ignore=tests/searxng/",
"--ignore=tests/unit/test_config.py",
"--ignore=tests/api_tests/",
"--ignore=tests/health_check/test_endpoints_health.py",
"-v",
"--tb=short",
"--timeout=30",
"-x", # Stop on first failure to debug
]
print(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd)
sys.exit(result.returncode)
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Regenerate the golden master settings snapshot.
Usage:
python scripts/dev/regenerate_golden_master.py
NOTE: This script is called by the pre-commit hook
.pre-commit-hooks/check-golden-master-settings.py — do not delete.
"""
import json
import sys
from pathlib import Path
# ---------------------------------------------------------------------------
# Resolve paths
# ---------------------------------------------------------------------------
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
SRC_DIR = PROJECT_ROOT / "src"
GOLDEN_MASTER_PATH = (
PROJECT_ROOT / "tests" / "settings" / "golden_master_settings.json"
)
# Ensure the package is importable
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
def main() -> int:
from local_deep_research.settings.manager import SettingsManager
manager = SettingsManager(db_session=None)
defaults = manager.default_settings
current = {key: dict(defaults[key]) for key in sorted(defaults.keys())}
output = (
json.dumps(
current, indent=2, sort_keys=True, default=str, ensure_ascii=False
)
+ "\n"
)
GOLDEN_MASTER_PATH.write_text(output, encoding="utf-8")
print(f"Wrote {len(current)} settings to {GOLDEN_MASTER_PATH}")
return 0
if __name__ == "__main__":
sys.exit(main())
+134
View File
@@ -0,0 +1,134 @@
#!/bin/bash
# Restart the LDR dev server.
#
# Usage:
# scripts/dev/restart_server.sh [PORT] [--debug] [--tmp]
#
# PORT Optional port (default: 5000). Passed via LDR_WEB_PORT.
# --debug DEBUG logging (LDR_APP_DEBUG=true, LDR_LOG_SETTINGS=summary).
# WARNING: debug logs may contain sensitive data (queries,
# answers, API responses) — local dev / feature testing only,
# never production or real user data.
# --tmp Disposable instance: point LDR_DATA_DIR at a throwaway dir
# (default /tmp/ldr-test) so encrypted user DBs, the auth DB,
# library, logs, research_outputs etc. land OUTSIDE your real
# data dir (~/.local/share/local-deep-research). Override the
# location by exporting LDR_DATA_DIR before running. Reuses one
# dir (doesn't accumulate throwaway databases). To start from a
# clean slate, wipe LDR_DATA_DIR yourself first (e.g.
# `rm -rf "$LDR_DATA_DIR"`) — this script never deletes data.
#
# Only the instance listening on the target PORT is stopped, so multiple
# instances on different ports (e.g. several agents each on their own port)
# coexist — restarting one does not kill the others.
#
# Referenced in:
# - tests/api_tests_with_login/README.md
# - examples/api_usage/http/README.md
# - examples/api_usage/http/advanced/simple_http_example.py
# Do not delete without updating those references.
set -u
usage() {
cat <<'EOF'
Restart the LDR dev server.
Usage:
scripts/dev/restart_server.sh [PORT] [--debug] [--tmp]
PORT Optional port (default: 5000).
--debug DEBUG logging (may log sensitive data; local dev only).
--tmp Disposable instance: LDR_DATA_DIR -> /tmp/ldr-test (override by
exporting LDR_DATA_DIR). Keeps test data out of your real dir.
This script never deletes data; wipe LDR_DATA_DIR yourself to
start from a clean slate.
--test Alias for --tmp.
Only the instance on the target PORT is stopped; instances on other ports
keep running.
EOF
}
PORT=5000
debug=0
use_tmp=0
for arg in "$@"; do
case "$arg" in
--debug) debug=1 ;;
--tmp|--test) use_tmp=1 ;;
-h|--help) usage; exit 0 ;;
''|*[!0-9]*) echo "Unknown argument: $arg" >&2; usage >&2; exit 2 ;;
*) PORT="$arg" ;;
esac
done
echo "Stopping existing LDR server on port ${PORT} (if any)..."
# Port-scoped stop: kill only the process LISTENing on PORT, leaving
# instances on other ports running. The port isn't in the process argv
# (it comes from LDR_WEB_PORT), so a broad pkill can't distinguish them.
if command -v fuser >/dev/null 2>&1; then
fuser -k "${PORT}/tcp" 2>/dev/null || echo "No existing server on port ${PORT}"
elif command -v lsof >/dev/null 2>&1; then
pids=$(lsof -ti "tcp:${PORT}" -sTCP:LISTEN 2>/dev/null)
if [ -n "${pids}" ]; then
# $pids may be several PIDs; word-splitting is intended here.
# shellcheck disable=SC2086
kill ${pids} 2>/dev/null
else
echo "No existing server on port ${PORT}"
fi
else
echo "Warning: neither fuser nor lsof found; cannot port-scope the stop." >&2
echo "Skipping stop to avoid killing instances on other ports." >&2
fi
sleep 1
# Change to the project root.
cd "$(dirname "$0")/../.." || exit 1
# --- environment for the launched server -------------------------------------
env_args=(LDR_WEB_PORT="${PORT}")
if [ "$use_tmp" -eq 1 ]; then
: "${LDR_DATA_DIR:=/tmp/ldr-test}"
mkdir -p "$LDR_DATA_DIR"
echo "Using disposable LDR_DATA_DIR=$LDR_DATA_DIR"
env_args+=(LDR_DATA_DIR="${LDR_DATA_DIR}")
fi
if [ "$debug" -eq 1 ]; then
env_args+=(LDR_APP_DEBUG=true LDR_LOG_SETTINGS=summary)
fi
LOG_FILE="/tmp/ldr_server_${PORT}.log"
if [ "$debug" -eq 1 ]; then
echo "Starting LDR server on port ${PORT} (DEBUG)..."
else
echo "Starting LDR server on port ${PORT}..."
fi
# Start server in background and detach from terminal. Backgrounding nohup
# directly (rather than wrapping in an extra `( ... & ) &` subshell) means
# $! captures the real server PID, not a transient subshell that has already
# exited. nohup ignores SIGHUP, so the server survives the launcher exiting
# and is reparented to init.
nohup env "${env_args[@]}" pdm run python -m local_deep_research.web.app > "${LOG_FILE}" 2>&1 &
SERVER_PID=$!
sleep 2
echo "Server started. PID: ${SERVER_PID}"
echo "Logs: ${LOG_FILE}"
echo "URL: http://127.0.0.1:${PORT}"
if [ "$debug" -eq 1 ]; then
echo ""
echo "WARNING: DEBUG mode — logs may contain sensitive data; local dev only."
fi
echo ""
echo "To view logs: tail -f ${LOG_FILE}"
echo "To stop server: fuser -k ${PORT}/tcp"
exit 0
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""
Run tests for Local Deep Research.
This script runs pytest with appropriate configuration for the project.
Referenced in CONTRIBUTING.md — do not delete without updating that file.
"""
import os
import subprocess
import sys
from pathlib import Path
def main():
"""Run the test suite with appropriate settings."""
# Get the project root directory
project_root = Path(__file__).parent.absolute()
# Ensure PYTHONPATH includes the project root for proper imports
os.environ["PYTHONPATH"] = str(project_root)
# Configure pytest arguments
pytest_args = [
sys.executable, # Use Python interpreter
"-m", # Run module
"pytest", # Call pytest
"--verbose", # Verbose output
"--color=yes", # Force colored output
"--cov=src", # Measure coverage for src directory
"--cov-report=term", # Report coverage in terminal
"--cov-report=html:coverage_html", # Also generate HTML report
"--cov-config=.coveragerc", # Use the coverage configuration file
]
# Add any command line arguments passed to this script
pytest_args.extend(sys.argv[1:])
# Print the command being run
print(f"Running: {' '.join(pytest_args)}")
# Run pytest and capture the return code
result = subprocess.run(pytest_args)
# Return the pytest exit code
return result.returncode
if __name__ == "__main__":
sys.exit(main())
+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
# Script to stop the LDR server.
# Do not remove — companion to restart_server.sh; handles graceful
# and forced shutdown of the dev server.
echo "Stopping LDR server..."
# Kill the main server process
if pkill -f "python -m local_deep_research.web.app" 2>/dev/null; then
echo "✓ Server stopped successfully"
# Wait a moment for the process to fully terminate
sleep 1
# Check if any processes are still running
if pgrep -f "python -m local_deep_research.web.app" > /dev/null; then
echo "Warning: Some server processes may still be running"
echo "Attempting force stop..."
pkill -9 -f "python -m local_deep_research.web.app" 2>/dev/null
sleep 1
fi
else
echo "No running LDR server found"
fi
# Also stop any orphaned Flask dev servers
pkill -f "flask run" 2>/dev/null
# Check final status
if pgrep -f "python -m local_deep_research.web.app" > /dev/null; then
echo "Error: Server processes still running:"
pgrep -af "python -m local_deep_research.web.app"
exit 1
else
echo "All server processes stopped"
exit 0
fi
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""
Probe the journal reputation filter against live arXiv results.
Runs the *full* ArXivSearchEngine pipeline (including the journal filter
as a preview filter) and prints which papers passed/were dropped, along
with the journal_quality score the filter attached.
Usage:
python scripts/dev/test_journal_filter_arxiv.py "graph neural networks" -n 25
"""
import argparse
import sys
from unittest.mock import Mock
from local_deep_research.utilities.thread_context import search_context
from local_deep_research.web_search_engines.engines.search_engine_arxiv import (
ArXivSearchEngine,
)
from local_deep_research.web_search_engines.engines.search_engine_openalex import (
OpenAlexSearchEngine,
)
from local_deep_research.web_search_engines.engines.search_engine_pubmed import (
PubMedSearchEngine,
)
from local_deep_research.web_search_engines.engines.search_engine_semantic_scholar import (
SemanticScholarSearchEngine,
)
ENGINES = {
"arxiv": ArXivSearchEngine,
"openalex": OpenAlexSearchEngine,
"pubmed": PubMedSearchEngine,
"s2": SemanticScholarSearchEngine,
}
DOMAIN_QUERIES = {
"fusion": "tokamak plasma confinement",
"llm": "large language model alignment",
"graph_nn": "graph neural networks",
"astro": "exoplanet atmosphere spectroscopy",
"biomed": "CRISPR gene editing therapy",
"condmat": "high temperature superconductor cuprate",
"climate": "climate model ocean heat content",
"quantum": "quantum error correction surface code",
"math": "riemann hypothesis zeta function",
"robotics": "reinforcement learning robotic manipulation",
}
def run_one(
query: str,
n: int,
threshold: int,
snapshot: dict,
engine_name: str = "arxiv",
) -> dict:
cls = ENGINES[engine_name]
kwargs = {"max_results": n, "llm": Mock(), "settings_snapshot": snapshot}
# PubMed/S2 use the LLM for query optimization — Mock would corrupt
# the optimized query string. Disable optimization for the probe so
# we send the raw query verbatim.
if engine_name in ("pubmed", "s2"):
kwargs["optimize_queries"] = False
with search_context({"username": None, "user_password": None}):
try:
engine = cls(**kwargs)
out = engine.run(query)
except Exception as e:
return {
"query": query,
"n_total": 0,
"n_no_jref": 0,
"n_scored": 0,
"avg_q": 0,
"high": 0,
"passthrough": 0,
"results": [],
"error": str(e)[:80],
}
scored = [r for r in out if "journal_quality" in r]
no_jref = [r for r in out if not r.get("journal_ref")]
qualities = [r["journal_quality"] for r in scored]
return {
"query": query,
"n_total": len(out),
"n_no_jref": len(no_jref),
"n_scored": len(scored),
"avg_q": sum(qualities) / len(qualities) if qualities else 0,
"high": sum(1 for q in qualities if q >= 7),
"passthrough": sum(1 for q in qualities if q == threshold),
"results": out,
}
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument(
"query", nargs="?", help="arXiv search query (omit to run dataset)"
)
ap.add_argument("-n", type=int, default=20, help="number of papers")
ap.add_argument("-t", "--threshold", type=int, default=4)
ap.add_argument(
"--dataset",
action="store_true",
help="run the multi-domain dataset of queries",
)
ap.add_argument(
"--engines",
default="arxiv",
help="comma-separated list: arxiv,openalex,pubmed,s2 (or 'all')",
)
args = ap.parse_args()
if args.engines == "all":
engine_names = list(ENGINES)
else:
engine_names = [e.strip() for e in args.engines.split(",")]
# Minimal settings snapshot — defaults are returned for missing keys.
# Threshold and arxiv journal-filter enable flag are wired explicitly.
snapshot = {
"search.journal_reputation.threshold": args.threshold,
"search.journal_reputation.exclude_non_published": False,
"search.journal_reputation.max_context": 3000,
"search.journal_reputation.reanalysis_period": 365,
"search.engine.web.arxiv.journal_reputation.enabled": True,
"search.engine.web.openalex.journal_reputation.enabled": True,
"search.engine.web.pubmed.journal_reputation.enabled": True,
"search.engine.web.semantic_scholar.journal_reputation.enabled": True,
}
if args.dataset:
print(
f"Running dataset of {len(DOMAIN_QUERIES)} domain queries × "
f"{len(engine_names)} engines (n={args.n}, threshold={args.threshold})\n"
)
all_rows = []
for engine_name in engine_names:
for domain, q in DOMAIN_QUERIES.items():
print(
f"{engine_name:<9} {domain:<10} {q!r} ...", flush=True
)
s = run_one(q, args.n, args.threshold, snapshot, engine_name)
all_rows.append((engine_name, domain, s))
print(
f"\n{'engine':<9} {'domain':<10} {'tot':>4} {'noJ':>4} "
f"{'scor':>4} {'avg':>5} {'≥7':>3} {'pass':>4} err"
)
print("-" * 80)
for engine_name, domain, s in all_rows:
err = s.get("error", "")
print(
f"{engine_name:<9} {domain:<10} "
f"{s['n_total']:>4} {s['n_no_jref']:>4} {s['n_scored']:>4} "
f"{s['avg_q']:>5.1f} {s['high']:>3} {s['passthrough']:>4} {err}"
)
return 0
if not args.query:
ap.error("query required (or use --dataset)")
with search_context({"username": None, "user_password": None}):
engine = ArXivSearchEngine(
max_results=args.n, llm=Mock(), settings_snapshot=snapshot
)
print(f"Running ArXivSearchEngine for: {args.query!r}\n")
out = engine.run(args.query)
print(f"{'#':<3} {'qual':<5} {'journal_ref':<45} title")
print("-" * 120)
for i, r in enumerate(out, 1):
q = r.get("journal_quality", "")
jref = (r.get("journal_ref") or "")[:43]
title = (r.get("title") or "")[:60]
print(f"{i:<3} {str(q):<5} {jref:<45} {title}")
print(
f"\nReturned {len(out)} results from pipeline "
f"(threshold={args.threshold})."
)
return 0
if __name__ == "__main__":
sys.exit(main())
+379
View File
@@ -0,0 +1,379 @@
#!/usr/bin/env python3
"""
Generate CONFIGURATION.md from default settings JSON files and env_definitions.
Usage:
python scripts/generate_config_docs.py # Write to docs/CONFIGURATION.md
python scripts/generate_config_docs.py --output /tmp/out # Write to custom location
python scripts/generate_config_docs.py --check # Exit 1 if docs are stale
"""
import argparse
import ast
import json
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
def get_project_root() -> Path:
"""Return the project root directory."""
return Path(__file__).resolve().parent.parent
def get_env_var_name(key: str) -> str:
"""Convert setting key to environment variable name."""
return f"LDR_{key.replace('.', '_').upper()}"
def format_value(value: Any) -> str:
"""Format default value for markdown."""
if value is None:
return "null"
if isinstance(value, bool):
return str(value).lower()
if isinstance(value, (dict, list)):
return f"`{json.dumps(value)}`"
return str(value)
def _discover_env_definition_files(env_defs_dir: Path) -> List[Path]:
"""Auto-discover env_definitions modules, excluding __init__.py and env_settings.py."""
if not env_defs_dir.is_dir():
return []
return sorted(
p
for p in env_defs_dir.glob("*.py")
if p.name not in ("__init__.py", "env_settings.py")
)
def _category_from_filename(filename: str) -> str:
"""Derive a human-readable category name from a filename.
Example: 'db_config.py' -> 'Db Config'
"""
stem = filename.removesuffix(".py")
return stem.replace("_", " ").title()
def _extract_setting_from_call(node: ast.Call) -> Optional[Dict[str, Any]]:
"""Extract a setting dict from a *Setting() AST call node."""
if not (
isinstance(node.func, ast.Name) and node.func.id.endswith("Setting")
):
return None
keywords = {k.arg: k.value for k in node.keywords if k.arg}
if "key" not in keywords:
return None
key_node = keywords["key"]
if not isinstance(key_node, ast.Constant):
return None
key = key_node.value # gitleaks:allow
# Description — may be a simple string or a parenthesised concatenation
description = ""
if "description" in keywords:
desc_node = keywords["description"]
if isinstance(desc_node, ast.Constant):
description = desc_node.value
else:
description = ast.unparse(desc_node)
# Default
default_val = "None"
if "default" in keywords:
default_val = ast.unparse(keywords["default"])
# Env var (auto-generated unless explicitly overridden)
if "env_var" in keywords and isinstance(keywords["env_var"], ast.Constant):
env_var = keywords["env_var"].value
else:
env_var = get_env_var_name(key)
# Type from the class name (e.g. BooleanSetting -> Boolean)
setting_type = node.func.id.replace("Setting", "")
# Required
required = False
if "required" in keywords and isinstance(
keywords["required"], ast.Constant
):
required = bool(keywords["required"].value)
# Min/max value
min_value = None
if "min_value" in keywords and isinstance(
keywords["min_value"], ast.Constant
):
min_value = keywords["min_value"].value
max_value = None
if "max_value" in keywords and isinstance(
keywords["max_value"], ast.Constant
):
max_value = keywords["max_value"].value
# Allowed values (ast.Set of ast.Constant)
allowed_values = None
if "allowed_values" in keywords and isinstance(
keywords["allowed_values"], ast.Set
):
allowed_values = sorted(
elt.value
for elt in keywords["allowed_values"].elts
if isinstance(elt, ast.Constant)
)
# Deprecated env var
deprecated_env_var = None
if "deprecated_env_var" in keywords and isinstance(
keywords["deprecated_env_var"], ast.Constant
):
deprecated_env_var = keywords["deprecated_env_var"].value
return {
"key": key,
"env_var": env_var,
"description": description,
"default": default_val,
"type": setting_type,
"required": required,
"min_value": min_value,
"max_value": max_value,
"allowed_values": allowed_values,
"deprecated_env_var": deprecated_env_var,
}
def get_env_only_settings(
root_dir: Optional[Path] = None,
) -> List[Dict[str, Any]]:
"""
Extract env-only settings from env_definitions/ by auto-discovering modules.
These are settings required before database initialization.
"""
root_dir = root_dir or get_project_root()
env_defs_dir = (
root_dir
/ "src"
/ "local_deep_research"
/ "settings"
/ "env_definitions"
)
env_only: List[Dict[str, Any]] = []
for filepath in _discover_env_definition_files(env_defs_dir):
category = _category_from_filename(filepath.name)
try:
content = filepath.read_text(encoding="utf-8")
tree = ast.parse(content)
except Exception as e:
print(f"Warning: Could not parse {filepath}: {e}")
continue
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
setting = _extract_setting_from_call(node)
if setting is None:
continue
setting["category"] = category
env_only.append(setting)
return env_only
def _format_constraints(setting: Dict[str, Any]) -> str:
"""Build a human-readable constraints string."""
parts = []
if (
setting.get("min_value") is not None
and setting.get("max_value") is not None
):
parts.append(f"{setting['min_value']}..{setting['max_value']}")
elif setting.get("min_value") is not None:
parts.append(f">={setting['min_value']}")
elif setting.get("max_value") is not None:
parts.append(f"<={setting['max_value']}")
if setting.get("allowed_values"):
parts.append(", ".join(setting["allowed_values"]))
return " | ".join(parts) if parts else ""
def generate_docs_content(root_dir: Optional[Path] = None) -> str:
"""Generate the full CONFIGURATION.md content as a string."""
root_dir = root_dir or get_project_root()
defaults_dir = root_dir / "src" / "local_deep_research" / "defaults"
settings: Dict[str, Any] = {}
# Recursively find all JSON files
for json_file in sorted(defaults_dir.rglob("*.json")):
try:
with open(json_file, "r", encoding="utf-8") as f:
data = json.load(f)
settings.update(data)
except Exception as e:
print(f"Warning: Could not load {json_file}: {e}")
sorted_keys = sorted(settings.keys())
# Get env-only settings
env_only_settings = get_env_only_settings(root_dir)
# Build markdown
content = [
"# Configuration Reference",
"",
"This document is automatically generated from the application's default settings.",
"All settings can be configured via the Web UI (Settings page), or overridden via Environment Variables.",
"",
"## Environment Variables",
"",
"To override a setting using an environment variable, convert the key to uppercase, replace dots with underscores, and prefix with `LDR_`.",
"For example, `app.debug` becomes `LDR_APP_DEBUG`.",
"",
"Configuration Priority: Web UI Config > Environment Variables > Default Values",
"> Environmental Variables are used to override default values, easing installation, while allowing for adjustments to configuration via Web UI.",
"",
"### System Locking",
"There is a special environment variable `LDR_LOCKED_SETTINGS` that allows administrators to strictly enforce specific settings.",
"",
"* **Variable**: `LDR_LOCKED_SETTINGS`",
"* **Format**: Comma-separated list of setting keys (e.g., `llm.model,app.port`)",
"* **Behavior**:",
" 1. Any setting listed here **MUST** have a corresponding value defined in the environment variables (e.g., `LDR_LLM_MODEL`). If not, the application will fail to start.",
" 2. The setting becomes **read-only** in the Web UI.",
" 3. The **Environment Variable** value takes absolute precedence, ignoring any value in the database.",
"",
"**Priority for Locked Settings**: Environment Variable > Database (Ignored) > Default (Ignored)",
"",
"",
]
# Env-only section with expanded columns
if env_only_settings:
content.extend(
[
"## Pre-Database (Env-Only) Settings",
"",
"These settings are **required before database initialization** and can only be set via environment variables.",
"They are not available in the Web UI because they are needed to start the application.",
"",
"| Environment Variable | Type | Default | Required | Constraints | Description | Category | Deprecated Alias |",
"|----------------------|------|---------|----------|-------------|-------------|----------|------------------|",
]
)
for setting in sorted(env_only_settings, key=lambda x: x["env_var"]):
env_var = setting["env_var"]
stype = setting["type"]
default = setting["default"]
required = "Yes" if setting.get("required") else "No"
constraints = _format_constraints(setting).replace("|", "\\|")
desc = setting["description"].replace("|", "\\|").replace("\n", " ")
category = setting["category"]
deprecated = setting.get("deprecated_env_var") or ""
row = (
f"| `{env_var}` | {stype} | `{default}` | {required} "
f"| {constraints} | {desc} | {category} | {deprecated} |"
)
content.append(row)
content.extend(["", ""])
# Main settings list
content.extend(
[
"## Settings List",
"",
"| Key | Environment Variable | Default Value | Description | Type |",
"|-----|----------------------|---------------|-------------|------|",
]
)
for key in sorted_keys:
setting = settings[key]
env_var = setting.get("env_var") or get_env_var_name(key)
default_val = format_value(setting.get("value"))
description = (
setting.get("description", "")
.replace("\n", " ")
.replace("|", "\\|")
)
setting_type = setting.get("type", "UNKNOWN")
row = f"| `{key}` | `{env_var}` | `{default_val}` | {description} | {setting_type} |"
content.append(row)
content.append("")
content.append("*Generated by scripts/generate_config_docs.py*")
return "\n".join(content) + "\n"
def generate_docs(
output_path: Optional[Path] = None,
check: bool = False,
) -> int:
"""Generate (or check) CONFIGURATION.md.
Returns 0 on success, 1 if check finds stale docs.
"""
root_dir = get_project_root()
output_file = output_path or (root_dir / "docs" / "CONFIGURATION.md")
new_content = generate_docs_content(root_dir)
if check:
if not output_file.exists():
print(
f"FAIL: {output_file} does not exist. "
"Run 'python scripts/generate_config_docs.py' to generate it."
)
return 1
existing = output_file.read_text(encoding="utf-8")
if existing == new_content:
print("OK: Configuration docs are up to date.")
return 0
print(
f"FAIL: {output_file} is out of date. "
"Run 'python scripts/generate_config_docs.py' to regenerate it."
)
return 1
output_file.parent.mkdir(parents=True, exist_ok=True)
output_file.write_text(new_content, encoding="utf-8")
print(f"Wrote {output_file}")
return 0
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Generate CONFIGURATION.md from defaults"
)
parser.add_argument(
"--output",
"-o",
type=Path,
help="Output file path (default: docs/CONFIGURATION.md)",
)
parser.add_argument(
"--check",
action="store_true",
help="Check if docs are up to date (exit 1 if stale)",
)
args = parser.parse_args()
sys.exit(generate_docs(output_path=args.output, check=args.check))
File diff suppressed because it is too large Load Diff
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""
LDR Research Script
Reads a research query from stdin and uses Local Deep Research to find
relevant documentation, sources, and context. Returns JSON with the
research output.
Usage:
# Pipe a query from stdin
echo "What is RAG?" | python scripts/ldr-research.py
# Or pass a file
python scripts/ldr-research.py < query.txt
# With CLI arguments (easier local testing)
python scripts/ldr-research.py --provider openrouter --model gpt-4o < query.txt
Output: JSON with research results, sources, and findings.
Environment variables (can be overridden by CLI args):
OPENROUTER_API_KEY - API key for OpenRouter
SERPER_API_KEY - API key for Serper.dev search
LDR_PROVIDER - LLM provider (default: openrouter)
LDR_SEARCH_TOOL - Search tool (default: serper)
LDR_RESEARCH_MODEL - Model name (default: google/gemini-2.0-flash-001 for openrouter)
LDR_STRATEGY - Search strategy (default: langgraph-agent)
Note: This uses the programmatic API and does NOT require a running LDR server.
"""
import argparse
import faulthandler
import json
import os
import sys
# Dump a Python traceback to stderr on SIGABRT/SIGSEGV/SIGFPE/SIGBUS/SIGILL.
faulthandler.enable()
def make_serializable(obj):
"""Convert objects to JSON-serializable format."""
if obj is None:
return None
if isinstance(obj, (str, int, float, bool)):
return obj
if isinstance(obj, dict):
return {k: make_serializable(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [make_serializable(item) for item in obj]
# Handle LangChain Document objects
if hasattr(obj, "page_content") and hasattr(obj, "metadata"):
return {
"content": obj.page_content,
"metadata": make_serializable(obj.metadata),
}
# Handle other objects with __dict__
if hasattr(obj, "__dict__"):
return make_serializable(obj.__dict__)
# Fallback to string representation
return str(obj)
def parse_args():
parser = argparse.ArgumentParser(
description="Run LDR research on a query read from stdin"
)
parser.add_argument(
"--provider",
default=os.environ.get("LDR_PROVIDER", "openrouter"),
help="LLM provider (default: openrouter)",
)
parser.add_argument(
"--search-tool",
default=os.environ.get("LDR_SEARCH_TOOL", "serper"),
help="Search tool (default: serper)",
)
parser.add_argument(
"--model",
default=os.environ.get("LDR_RESEARCH_MODEL"),
help="Model name (default: provider's default)",
)
parser.add_argument(
"--iterations",
type=int,
default=None,
help=(
"Number of research iterations. If unset, the strategy uses "
"its own default (e.g. langgraph-agent reads "
"langgraph_agent.max_iterations from settings)."
),
)
parser.add_argument(
"--strategy",
default=os.environ.get("LDR_STRATEGY", "langgraph-agent"),
help="Search strategy name (default: langgraph-agent)",
)
return parser.parse_args()
def main():
# Flush in finally: SIGABRT during interpreter shutdown won't drain the stdout buffer.
try:
args = parse_args()
# Read query from stdin
query = sys.stdin.read().strip()
if not query:
print(json.dumps({"error": "No query provided on stdin"}))
sys.exit(1)
# Default model for OpenRouter if not specified
model_name = args.model
if not model_name and args.provider == "openrouter":
model_name = "google/gemini-2.0-flash-001"
# Check required API keys
if args.provider == "openrouter" and not os.environ.get(
"OPENROUTER_API_KEY"
):
print(json.dumps({"error": "OPENROUTER_API_KEY not set"}))
sys.exit(1)
if args.search_tool == "serper" and not os.environ.get(
"SERPER_API_KEY"
):
print(json.dumps({"error": "SERPER_API_KEY not set"}))
sys.exit(1)
try:
from local_deep_research.api import quick_summary
from local_deep_research.api.settings_utils import (
create_settings_snapshot,
)
# Build settings overrides
overrides = {
"search.tool": args.search_tool,
"llm.provider": args.provider,
}
if model_name:
overrides["llm.model"] = model_name
# Add API keys from environment
if os.environ.get("OPENROUTER_API_KEY"):
overrides["llm.openrouter.api_key"] = os.environ[
"OPENROUTER_API_KEY"
]
if os.environ.get("SERPER_API_KEY"):
overrides["search.engine.web.serper.api_key"] = os.environ[
"SERPER_API_KEY"
]
settings = create_settings_snapshot(overrides=overrides)
# Build kwargs
kwargs = {
"query": query,
"provider": args.provider,
"search_tool": args.search_tool,
"settings_snapshot": settings,
"programmatic_mode": True,
"search_strategy": args.strategy,
}
if model_name:
kwargs["model_name"] = model_name
if args.iterations is not None:
kwargs["iterations"] = args.iterations
result = quick_summary(**kwargs)
# Use formatted_findings if available (already properly formatted with sources)
# Fall back to summary if not
research_output = result.get("formatted_findings") or result.get(
"summary", str(result)
)
# Build output - make sure everything is JSON serializable
output = {
"research": research_output,
"sources": make_serializable(result.get("sources", [])),
"findings": make_serializable(result.get("findings", [])),
"iterations": result.get("iterations"),
}
print(json.dumps(output))
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
finally:
try:
sys.stdout.flush()
except Exception: # noqa: silent-exception
pass
if __name__ == "__main__":
main()
+57
View File
@@ -0,0 +1,57 @@
#!/bin/bash
set -e
# This entrypoint handles volume permissions for the LDR container.
# Docker volumes are created with root ownership, but we need them
# accessible to the ldruser (UID 1000) that runs the application.
echo "Setting up /data directory permissions..."
# Create required subdirectories under /data if they don't exist
mkdir -p /data/logs
mkdir -p /data/cache
mkdir -p /data/cache/rag_indices
mkdir -p /data/research_outputs
mkdir -p /data/encrypted_databases
# Set permissions to 700 (owner-only access for security)
chmod 700 /data/logs
chmod 700 /data/cache
chmod 700 /data/cache/rag_indices
chmod 700 /data/research_outputs
chmod 700 /data/encrypted_databases
# Fix ownership of /data and all subdirectories
# This is safe because we're still root at this point (before USER directive takes effect)
chown -R ldruser:ldruser /data
# Create matplotlib cache directory for ldruser
echo "Setting up matplotlib cache directory..."
mkdir -p /home/ldruser/.config/matplotlib
chown -R ldruser:ldruser /home/ldruser/.config
chmod -R 700 /home/ldruser/.config
echo "Starting LDR application as ldruser..."
# Switch to ldruser and execute the command.
# setpriv needs CAP_SETUID and CAP_SETGID to call setuid()/setgid() syscalls.
# These are granted via cap_add in docker-compose.yml, but in restricted
# environments (e.g. Proxmox LXC) the outer container may block them.
if ! setpriv --reuid=ldruser --regid=ldruser --init-groups -- true 2>/dev/null; then
echo ""
echo "ERROR: Failed to switch to non-root user 'ldruser'."
echo ""
echo " setpriv requires CAP_SETUID and CAP_SETGID Linux capabilities."
echo " This typically happens in LXC containers (e.g. Proxmox) that"
echo " restrict these capabilities."
echo ""
echo " To fix, ensure your LXC container allows SETUID/SETGID:"
echo " - Proxmox: check 'Features' -> 'Nesting' is enabled"
echo " - Or add to LXC config: lxc.cap.keep = setuid setgid"
echo ""
echo " See: https://github.com/LearningCircuit/local-deep-research/issues"
echo ""
exit 1
fi
export HOME=/home/ldruser
exec setpriv --reuid=ldruser --regid=ldruser --init-groups -- "$@"
+43
View File
@@ -0,0 +1,43 @@
#!/bin/bash
# MCP Server Smoke Test
# Verifies that the MCP server module loads correctly and basic tools work
set -e
echo "=== MCP Server Smoke Test ==="
echo ""
# Test 1: Verify MCP module loads
echo "1. Testing MCP module loading..."
python -c "
from local_deep_research.mcp.server import mcp, list_strategies, get_configuration
print(' MCP server module loaded successfully')
print(f' Server name: {mcp.name}')
# Test discovery tools
result = list_strategies()
assert result['status'] == 'success', f'list_strategies failed: {result}'
print(f' list_strategies: {len(result[\"strategies\"])} strategies available')
config = get_configuration()
assert config['status'] == 'success', f'get_configuration failed: {config}'
print(' get_configuration: works correctly')
"
echo " PASSED"
echo ""
# Test 2: Verify MCP server startup
echo "2. Testing MCP server startup..."
timeout 5 python -m local_deep_research.mcp 2>&1 || {
exit_code=$?
if [ $exit_code -eq 124 ]; then
echo " Server started correctly (timed out waiting for STDIO input as expected)"
echo " PASSED"
else
echo " FAILED: MCP server failed to start with exit code $exit_code"
exit 1
fi
}
echo ""
echo "=== All MCP smoke tests passed ==="
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
set -e
# Function to display usage information
usage() {
echo "Usage: $0 <model_name>"
exit 1
}
# Check if a model name is provided as an argument
if [ "$#" -ne 1 ]; then
usage
fi
MODEL_NAME=$1
# Validate model name to prevent command injection
if ! echo "$MODEL_NAME" | grep -qE '^[a-zA-Z0-9._:/-]+$'; then
echo "ERROR: Invalid model name: $MODEL_NAME"
exit 1
fi
# Start the main Ollama application
ollama serve &
# Wait for the Ollama application to be ready (optional, if necessary)
while ! ollama ls; do
echo "Waiting for Ollama service to be ready..."
sleep 10
done
echo "Ollama service is ready."
# Pull the model using ollama pull
echo "Pulling the $MODEL_NAME with ollama pull..."
# Check if the model was pulled successfully
if ollama pull "$MODEL_NAME"; then
echo "Model pulled successfully."
else
echo "Failed to pull model."
exit 1
fi
# Run ollama forever.
sleep infinity
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""Pre-commit hook: ensure DateTime columns in models and migrations use UtcDateTime.
Scans files under ``src/local_deep_research/database/models/`` and
``src/local_deep_research/database/migrations/versions/`` for
``Column(...)`` / ``sa.Column(...)`` calls whose type argument is a bare
``DateTime`` (with or without ``timezone=True``). Flags them and hints at
the ``UtcDateTime`` replacement from ``sqlalchemy_utc``.
Limitations (accepted gaps, not caught by this hook):
- Raw SQL inside ``op.execute("... DATETIME ...")`` — the hook cannot
parse SQL strings.
- Type-alias indirection: ``dt = sa.DateTime(); sa.Column("x", dt)``.
- Fully-qualified imports without the ``sa`` alias
(e.g. ``import sqlalchemy; sqlalchemy.Column(...)``).
- ``sa.TIMESTAMP`` columns.
- Walrus expressions: ``Column((dt := DateTime()))`` wraps the call in
``ast.NamedExpr``, which the helper does not traverse.
- Import-order variations beyond the two hardcoded substring forms.
"""
import ast
import re
import sys
from pathlib import Path
from typing import List, Tuple
def _callable_name(func_node):
"""Return the callable's short name regardless of ``X`` or ``sa.X`` form."""
if isinstance(func_node, ast.Name):
return func_node.id
if isinstance(func_node, ast.Attribute):
return func_node.attr
return None
def _resolve_type_arg(arg):
"""Return list of ('call', Call) or ('name', str) entries for all
type-like nodes in arg's subtree. Returns [] when arg is not a type
reference.
For ast.IfExp, BOTH branches are included — returning only the
first-resolved branch would silently pass a violation that lives
in the other branch.
"""
if isinstance(arg, ast.Call):
return [("call", arg)]
if isinstance(arg, ast.Name) and arg.id in {"UtcDateTime", "DateTime"}:
return [("name", arg.id)]
if isinstance(arg, ast.IfExp):
return _resolve_type_arg(arg.body) + _resolve_type_arg(arg.orelse)
return []
def check_datetime_columns(file_path: Path) -> List[Tuple[int, str, str]]:
"""Check a Python file for DateTime columns that should use UtcDateTime.
Returns a list of (line_number, line_content, error_message) tuples for violations.
"""
violations = []
try:
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
lines = content.split("\n")
except Exception as e:
print(f"Error reading {file_path}: {e}", file=sys.stderr)
return violations
has_utc_datetime_import = (
"from sqlalchemy_utc import UtcDateTime" in content
or "from sqlalchemy_utc import utcnow, UtcDateTime" in content
)
try:
tree = ast.parse(content)
except SyntaxError:
return violations
fix_hint = (
"Use UtcDateTime() instead of DateTime() — "
"import: from sqlalchemy_utc import UtcDateTime"
)
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
if _callable_name(node.func) != "Column":
continue
type_entries = []
for arg in node.args:
type_entries = _resolve_type_arg(arg)
if type_entries:
break
for kind, payload in type_entries:
if kind == "call":
inner_name = _callable_name(payload.func)
if inner_name == "DateTime":
line_num = node.lineno
if 0 <= line_num - 1 < len(lines):
violations.append(
(line_num, lines[line_num - 1].strip(), fix_hint)
)
elif inner_name == "UtcDateTime":
if not has_utc_datetime_import:
line_num = node.lineno
if 0 <= line_num - 1 < len(lines):
violations.append(
(
line_num,
lines[line_num - 1].strip(),
"Missing import: from sqlalchemy_utc import UtcDateTime",
)
)
elif kind == "name":
if payload == "DateTime":
line_num = node.lineno
if 0 <= line_num - 1 < len(lines):
violations.append(
(line_num, lines[line_num - 1].strip(), fix_hint)
)
elif payload == "UtcDateTime" and not has_utc_datetime_import:
line_num = node.lineno
if 0 <= line_num - 1 < len(lines):
violations.append(
(
line_num,
lines[line_num - 1].strip(),
"Missing import: from sqlalchemy_utc import UtcDateTime",
)
)
for i, line in enumerate(lines, 1):
if "func.now()" in line and "Column" in line:
violations.append(
(
i,
line.strip(),
"Use utcnow() instead of func.now() for timezone-aware defaults",
)
)
if re.search(
r"default\s*=\s*(lambda:\s*)?datetime\.(utcnow|now)", line
):
violations.append(
(
i,
line.strip(),
"Use utcnow() from sqlalchemy_utc instead of datetime functions for defaults",
)
)
return violations
def main():
"""Main entry point for the pre-commit hook."""
files_to_check = sys.argv[1:]
if not files_to_check:
print("No files to check")
return 0
all_violations = []
for file_path_str in files_to_check:
file_path = Path(file_path_str)
path_str = str(file_path)
in_scope = file_path.suffix == ".py" and (
"src/local_deep_research/database/models/" in path_str
or "src/local_deep_research/database/migrations/versions/"
in path_str
)
if not in_scope:
continue
violations = check_datetime_columns(file_path)
if violations:
all_violations.append((file_path, violations))
if all_violations:
print("\nDateTime column issues found:\n")
for file_path, violations in all_violations:
print(f" {file_path}:")
for line_num, line_content, error_msg in violations:
print(f" Line {line_num}: {error_msg}")
print(f" > {line_content}")
print(
"\n Fix: use UtcDateTime from sqlalchemy_utc for all datetime columns"
)
print(
" (applies to both database/models/ and database/migrations/versions/)"
)
print(" Example: ")
print(" from sqlalchemy_utc import UtcDateTime, utcnow")
print(" Column(UtcDateTime, default=utcnow(), ...)\n")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())